diff --git a/.github/contributor/welcome.md b/.github/contributor/welcome.md new file mode 100644 index 000000000000..c34690a0f7dc --- /dev/null +++ b/.github/contributor/welcome.md @@ -0,0 +1,10 @@ +Thanks for opening your first cmux pull request! + +We're a small team and the outside-PR queue is long, so a reply can take a while — sometimes longer than we'd like. If this one goes quiet and you'd like eyes on it, comment here and we'll pick it up. + +A few things that help: + +- The PR template has a "Review Trigger" block of review-bot mentions. Pasting it as a comment after your latest commit is the quickest way to get automated review. +- If the CLA check asks, reply with the sentence it gives you. +- The [verification ladder](https://github.com/manaflow-ai/cmux/blob/main/docs/contributor-verification.md) shows which checks fit your change. Say in the description which ones you ran. +- If we end up fixing the same problem another way, we'll credit you with a `Co-authored-by` trailer and link the fix here. diff --git a/.github/review-bot-rules/test-determinism.md b/.github/review-bot-rules/test-determinism.md index 38ee953d6a3a..ad5dca891093 100644 --- a/.github/review-bot-rules/test-determinism.md +++ b/.github/review-bot-rules/test-determinism.md @@ -10,6 +10,7 @@ This gate enforces two principles: Report a failure when the changed test code introduces or materially expands any of these: - A fixed `sleep`/`usleep`/`Task.sleep`/`setTimeout`/`Thread.sleep`/`time.sleep` used to wait for async readiness before an assertion (the `sleep(0.3); assert` shape that fails on correct code under load). +- A poll of a condition bounded by an iteration count of `Task.yield()` (or any other reschedule) instead of a deadline, such as `for _ in 0..<100 { if ready { break }; await Task.yield() }`. A yield waits for nothing, so N yields shrinks to microseconds on an idle machine and gives no fixed budget under load: a busy runner turns a slow pass into a failure. Bound the poll by a clock deadline, or await the real signal. - An assertion on a measured wall-clock duration, or a hard absolute latency ceiling on shared CI. - Reading `Date()` / `Date.now` / `CACurrentMediaTime()` / `perf_counter` / `performance.now()` in an assertion. - Binding a fixed non-zero port, or hitting a live network host instead of a local fake or ephemeral server. diff --git a/.github/test-determinism-allowlist.txt b/.github/test-determinism-allowlist.txt index 575a707e9d7e..1b76939e4b46 100644 --- a/.github/test-determinism-allowlist.txt +++ b/.github/test-determinism-allowlist.txt @@ -17,3 +17,61 @@ Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxRelayCredentialI cmuxTests/MobileHostConnectionEventLaneTests.swift sleep-then-assert upstream event-lane timing tests; replace with event-driven readiness cmuxTests/MobileHostConnectionLifecycleTests.swift sleep-then-assert upstream connection lifecycle timing tests; replace with event-driven readiness web/tests/iroh-dashboard-controller.test.ts sleep-then-assert upstream dashboard render timing tests; replace with event-driven readiness +Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/DiagnosticLogTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903) +Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/TerminalWorkIntervalTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903) +Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/AuthCoordinatorPostSignInTimeoutTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903) +Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/AuthCoordinatorSignInExchangePreflightTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903) +Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/AuthCoordinatorSignInPreflightTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903) +Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/AuthCoordinatorTimeoutTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903) +Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/AuthCoordinatorTokenTouchingPhaseCancellationTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903) +Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxConnectivityEngineTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903) +Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxConnectivityPeerSessionTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903) +Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903) +Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohEndpointServerTests+Capacity.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903) +Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeFailedRestartTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903) +Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimePolicyTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903) +Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohOnlineAdmissionRegistryOfflineTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903) +Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRegistryContextProviderStalenessTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903) +Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohServerSessionTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903) +Packages/Shared/CmuxSentryTelemetry/Tests/CmuxSentryReportingTests/TransportSentryReporterTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903) +Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/CancellationIgnoringTokenProvider.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903) +Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCSessionPipelinedTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903) +Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCTokenTimeoutTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903) +Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileTaskModelCatalogClientTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903) +Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalLaneReplayBarrierTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903) +Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalRawInputOrderingTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903) +Packages/iOS/CmuxMobileToast/Tests/CmuxMobileToastTests/ToastCenterTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903) +Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlPlaneConcurrencyTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903) +Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/Concurrency/MainActorCoalescingDeadlineTimerTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903) +Packages/macOS/CmuxSidebarGit/Tests/CmuxSidebarGitTests/ProbeSchedulingTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903) +Packages/macOS/CmuxSimulator/Tests/CmuxSimulatorTests/SimulatorCameraCleanupDeadlineTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903) +Packages/macOS/CmuxSimulator/Tests/CmuxSimulatorTests/SimulatorWorkerClientCameraCleanupTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903) +Packages/macOS/CmuxSimulator/Tests/CmuxSimulatorTests/SimulatorWorkerClientContainmentTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903) +Packages/macOS/CmuxSimulator/Tests/CmuxSimulatorTests/SimulatorWorkerClientLifecycleTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903) +Packages/macOS/CmuxSimulator/Tests/CmuxSimulatorTests/SimulatorWorkerClientQueueTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903) +Packages/macOS/CmuxSimulator/Tests/CmuxSimulatorTests/SimulatorWorkerClientRecoveryTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903) +Packages/macOS/CmuxSimulator/Tests/CmuxSimulatorTests/SimulatorWorkerClientStagedReplayTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903) +Packages/macOS/CmuxSimulator/Tests/CmuxSimulatorTests/SimulatorWorkerClientTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903) +Packages/macOS/CmuxSimulator/Tests/CmuxSimulatorTests/SimulatorWorkerClientWheelRecoveryTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903) +Packages/macOS/CmuxSimulator/Tests/CmuxSimulatorUITests/SimulatorAccessibilityPresentationTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903) +Packages/macOS/CmuxSimulator/Tests/CmuxSimulatorUITests/SimulatorLiveStatusWatcherTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903) +Packages/macOS/CmuxSimulator/Tests/CmuxSimulatorUITests/SimulatorPaneCoordinatorCancellationTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903) +Packages/macOS/CmuxSimulator/Tests/CmuxSimulatorUITests/SimulatorPaneCoordinatorLocationLifecycleTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903) +Packages/macOS/CmuxSimulator/Tests/CmuxSimulatorUITests/SimulatorPaneCoordinatorOverflowTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903) +Packages/macOS/CmuxSimulator/Tests/CmuxSimulatorUITests/SimulatorPaneCoordinatorTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903) +Packages/macOS/CmuxSimulator/Tests/CmuxSimulatorUITests/SimulatorProcessSessionTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903) +Packages/macOS/CmuxSimulator/Tests/CmuxSimulatorUITests/SimulatorWebInspectorCoordinatorTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903) +Packages/macOS/CmuxSimulator/Tests/CmuxSimulatorWorkerTests/SimulatorToolOperationSchedulingTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903) +Packages/macOS/CmuxSimulator/Tests/CmuxSimulatorWorkerTests/SimulatorWebInspectorServiceFailureTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903) +Packages/macOS/CmuxSudoBroker/Tests/CmuxSudoBrokerTests/SudoReviewRegressionTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903) +cmuxTests/CloudWireGuardHubTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903) +cmuxTests/DockShortcutRoutingTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903) +cmuxTests/FilePreviewKindResolverTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903) +cmuxTests/MobileHostAuthorizationTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903) +cmuxTests/MobileHostConnectionEventLaneTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903) +cmuxTests/MobileHostOrderedInputTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903) +cmuxTests/RemoteTmuxPaneSeedTransportTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903) +cmuxTests/SidebarScrollViewConfiguratorTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903) +cmuxTests/SidebarWorkspaceTableTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903) +cmuxTests/TextBoxInlineAttachmentRenderingTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903) +cmuxTests/WindowAndDragTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903) diff --git a/.github/workflows/ci-artifact-transport.yml b/.github/workflows/ci-artifact-transport.yml index 7d4e8a76bd5a..b490d6812f35 100644 --- a/.github/workflows/ci-artifact-transport.yml +++ b/.github/workflows/ci-artifact-transport.yml @@ -62,7 +62,7 @@ concurrency: jobs: transport: - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} timeout-minutes: 10 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 diff --git a/.github/workflows/ci-cache-receipts.yml b/.github/workflows/ci-cache-receipts.yml index 8ebd3005f985..fe37ece70a81 100644 --- a/.github/workflows/ci-cache-receipts.yml +++ b/.github/workflows/ci-cache-receipts.yml @@ -36,7 +36,7 @@ concurrency: jobs: receipt-contract: - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} timeout-minutes: 5 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd diff --git a/.github/workflows/ci-guards.yml b/.github/workflows/ci-guards.yml index e90310ab6b39..992009985e4c 100644 --- a/.github/workflows/ci-guards.yml +++ b/.github/workflows/ci-guards.yml @@ -30,7 +30,7 @@ jobs: fail-fast: false matrix: group: ${{ fromJSON(inputs.linux_guard_test_groups) }} - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} # Its three sibling guard jobs are bounded at 15; 65 recent runs peaked at 0.8 min. timeout-minutes: 15 # This job executes scripts from the pull request. It needs only read @@ -159,6 +159,10 @@ jobs: if: ${{ matrix.group == 'preflight' }} run: python3 tests/test_write_sidebar_extension_point.py + - name: Validate settings rows advertise supported cmux.json paths + if: ${{ matrix.group == 'preflight' }} + run: python3 tests/test_settings_configuration_review_paths.py + - name: Validate Localizable.xcstrings catalog structure if: ${{ matrix.group == 'preflight' }} run: python3 tests/test_localizable_xcstrings_structure.py @@ -335,6 +339,10 @@ jobs: if: ${{ matrix.group == 'ci' }} run: python3 tests/test_ci_health_report.py + - name: Validate runner label policy + if: ${{ matrix.group == 'ci' }} + run: python3 tests/test_runner_label_policy.py + - name: Validate the trusted web complexity workflow if: ${{ matrix.group == 'ci' }} run: python3 tests/test_web_complexity_trusted_workflow.py @@ -469,6 +477,10 @@ jobs: if: ${{ matrix.group == 'app-host-process' }} run: python3 tests/test_ci_app_host_failure_census.py + - name: Validate app-host verdict replay + if: ${{ matrix.group == 'app-host-process' }} + run: python3 tests/test_ci_replay_app_host_verdict.py + - name: Validate app-host result ratchet if: ${{ matrix.group == 'app-host-process' }} env: @@ -545,6 +557,10 @@ jobs: if: ${{ matrix.group == 'release-ios' }} run: python3 tests/test_ios_appstore_lane_identity.py + - name: Validate the cmux.app upload marker follows the receipt + if: ${{ matrix.group == 'release-ios' }} + run: python3 tests/test_ios_appstore_upload_marker.py + - name: Validate TestFlight upload argument expansion if: ${{ matrix.group == 'release-ios' }} run: python3 tests/test_ios_upload_array_expansion.py @@ -703,6 +719,10 @@ jobs: python3 tests/test_ci_change_areas.py python3 tests/test_ci_linux_guard_routing.py + - name: Validate fork runner routing + if: ${{ matrix.group == 'ci' }} + run: python3 tests/test_ci_fork_runner_routing.py + - name: Validate evidence collection reporting if: ${{ matrix.group == 'ci' }} run: python3 tests/test_ci_evidence_outcomes.py @@ -717,6 +737,7 @@ jobs: python3 tests/test_ci_guard_workflow_structure.py python3 tests/test_app_host_test_products.py python3 tests/test_reuse_app_host_products.py + python3 tests/test_e2e_warm_derived_data.py python3 tests/test_ci_product_publication.py - name: Validate Python R2 appcast upload guard @@ -790,7 +811,7 @@ jobs: # The lockfile policy compares the candidate with the exact base parent of # GitHub's synthetic PR/merge-group commit. Depth 2 contains HEAD and that # parent; the policy does not need the rest of repository history. - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} timeout-minutes: 15 permissions: contents: read @@ -839,7 +860,7 @@ jobs: group: [tui-resolution, profiling] # The two slow CLI contracts are independent; run them beside each other # and let the reusable-workflow job aggregate their result. - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} timeout-minutes: 15 permissions: contents: read @@ -874,7 +895,7 @@ jobs: group: [sidebar-layout, dispatch-ownership] # These source-policy scans are independent and each dominates wall time; # run them concurrently and aggregate through the reusable-workflow job. - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} timeout-minutes: 15 permissions: contents: read @@ -918,7 +939,7 @@ jobs: - workflow-guard-cli-scripts - workflow-guard-source-lints if: ${{ always() }} - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} timeout-minutes: 5 steps: - name: Check routed guard jobs diff --git a/.github/workflows/ci-health-report.yml b/.github/workflows/ci-health-report.yml index 6a11599d524a..9eeaabad1376 100644 --- a/.github/workflows/ci-health-report.yml +++ b/.github/workflows/ci-health-report.yml @@ -73,4 +73,29 @@ jobs: # whenever b is falsy, so the skip has to be written as the negative: # only a dispatch that did not ask to skip passes the issue through. CI_HEALTH_REPORT_ISSUE: ${{ (inputs.skip_issue != true) && vars.CI_HEALTH_REPORT_ISSUE || '' }} + # Every other number here is measured from jobs that already ran, so + # it can only show a runner variable drifting after the minutes are + # spent. This is the configuration itself. The expression context + # serves variable values without any token scope, which is what lets + # a report whose token is `actions: read` see them at all. + # + # Named one by one rather than `toJSON(vars)`: a step's env is printed + # at the top of its public log, and only runner labels belong there. + # tests/test_runner_label_policy.py fails if a workflow reads a + # runner variable this list leaves out. The background lane carries + # its fallback because test_ci_self_hosted_guard.sh requires it on + # every read; `macos-15` is an approved label either way. + CMUX_CI_RUNNER_VARIABLES: | + LINUX_ARM64_RUNNER=${{ vars.LINUX_ARM64_RUNNER }} + LINUX_RUNNER=${{ vars.LINUX_RUNNER }} + MACOS_RUNNER_15=${{ vars.MACOS_RUNNER_15 }} + MACOS_RUNNER_26=${{ vars.MACOS_RUNNER_26 }} + MACOS_RUNNER_26_LARGE=${{ vars.MACOS_RUNNER_26_LARGE }} + MACOS_RUNNER_BACKGROUND=${{ vars.MACOS_RUNNER_BACKGROUND || 'macos-15' }} + MACOS_RUNNER_DISPLAY=${{ vars.MACOS_RUNNER_DISPLAY }} + MACOS_RUNNER_DUAL_XCODE=${{ vars.MACOS_RUNNER_DUAL_XCODE }} + MACOS_RUNNER_IOS=${{ vars.MACOS_RUNNER_IOS }} + MACOS_RUNNER_PR=${{ vars.MACOS_RUNNER_PR }} + MACOS_RUNNER_TESTS=${{ vars.MACOS_RUNNER_TESTS }} + WINDOWS_RUNNER=${{ vars.WINDOWS_RUNNER }} run: python3 scripts/ci/ci_health_report.py diff --git a/.github/workflows/ci-macos-compat.yml b/.github/workflows/ci-macos-compat.yml index f73269fa5632..d1f4a993f945 100644 --- a/.github/workflows/ci-macos-compat.yml +++ b/.github/workflows/ci-macos-compat.yml @@ -27,7 +27,7 @@ jobs: skip_zig: false expected_arch: x86_64 expected_os_major: "15" - - os: ${{ vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15' }} + - os: ${{ vars.CI_PAID_MACOS_OVERFLOW == '1' && vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15' }} timeout: 120 run_unit_tests: false run_mobile_transport_tests: true diff --git a/.github/workflows/ci-macos.yml b/.github/workflows/ci-macos.yml index a14576c8d34f..4dec6d4ec3ba 100644 --- a/.github/workflows/ci-macos.yml +++ b/.github/workflows/ci-macos.yml @@ -18,6 +18,16 @@ on: required: false default: "" type: string + # Label-derived selection for `app-host unit tests`. The full suite + # already implies it; this lets `unit-ci` ask for compile admission plus + # that one job, which reads the product admission already built, without + # waking the package, lag, release-admission and Release-build lanes. + # Optional and defaulted for the same reason as swift_packages: a caller + # that predates this input leaves it empty, which reads as "not routed". + unit_suite: + required: false + default: "" + type: string product_artifacts: required: false default: "layered" @@ -63,7 +73,7 @@ jobs: # cannot fan out across every macOS worker and a successful build is not # repeated six times. if: ${{ inputs.macos == 'true' && inputs.compile_admitted != 'true' }} - runs-on: ${{ github.event_name == 'pull_request' && (vars.MACOS_RUNNER_PR || 'blacksmith-6vcpu-macos-15') || vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15' }} + runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'macos-15' || (github.event_name == 'pull_request' && (vars.MACOS_RUNNER_PR || 'blacksmith-6vcpu-macos-15') || vars.CI_PAID_MACOS_OVERFLOW == '1' && vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15') }} timeout-minutes: 75 permissions: contents: read @@ -91,7 +101,7 @@ jobs: # Part of the compiled product contract, so it has to name the pool this # job actually ran on: two pools lay the workspace out differently, and a # product built under one cannot be relocated into the other. - CMUX_PRODUCT_RUNNER: ${{ github.event_name == 'pull_request' && (vars.MACOS_RUNNER_PR || 'blacksmith-6vcpu-macos-15') || vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15' }} + CMUX_PRODUCT_RUNNER: ${{ github.repository_owner != 'manaflow-ai' && 'macos-15' || (github.event_name == 'pull_request' && (vars.MACOS_RUNNER_PR || 'blacksmith-6vcpu-macos-15') || vars.CI_PAID_MACOS_OVERFLOW == '1' && vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15') }} steps: - name: Reject stale pull request rerun if: ${{ github.event_name == 'pull_request' }} @@ -596,6 +606,7 @@ jobs: id: publish-products env: PRODUCT_FULL_SUITE: ${{ inputs.full_suite }} + PRODUCT_UNIT_SUITE: ${{ inputs.unit_suite }} PRODUCT_EVENT: ${{ github.event_name }} PRODUCT_HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }} PRODUCT_REPOSITORY: ${{ github.repository }} @@ -604,8 +615,12 @@ jobs: import os head = os.environ.get("PRODUCT_HEAD_REPOSITORY", "").strip() repository = os.environ.get("PRODUCT_REPOSITORY", "").strip() + # `unit-ci` makes app-host unit tests a consumer of this product + # under the compile-only policy, so the fork product stops being + # unused and must still be packaged and uploaded. unused_fork_product = ( os.environ.get("PRODUCT_FULL_SUITE") == "false" + and os.environ.get("PRODUCT_UNIT_SUITE") != "true" and os.environ.get("PRODUCT_EVENT") == "pull_request" and bool(head) and bool(repository) and head.casefold() != repository.casefold() @@ -950,14 +965,14 @@ jobs: # jobs that legitimately skip (web/go/agent-session paths), and that # transitive skip otherwise marks every macOS job skipped even when # linux-preflight itself succeeds. Require the direct needs explicitly. - if: ${{ !cancelled() && needs.macos-compile-admission.result == 'success' && inputs.macos == 'true' && inputs.full_suite == 'true' }} + if: ${{ !cancelled() && needs.macos-compile-admission.result == 'success' && inputs.macos == 'true' && (inputs.full_suite == 'true' || inputs.unit_suite == 'true') }} name: app-host unit tests (${{ matrix.shard }}/7) # App-host XCTest needs a runner that can broker testmanagerd control # sessions, so route through the shared MACOS_RUNNER_15 var like the other # macOS jobs. The fallback is what fork pull requests get, because # repository variables are not exposed to them, so it names the same # Blacksmith pool main uses and never the paid overflow provider. - runs-on: ${{ github.event_name == 'pull_request' && (vars.MACOS_RUNNER_PR || 'blacksmith-6vcpu-macos-15') || vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15' }} + runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'macos-15' || (github.event_name == 'pull_request' && (vars.MACOS_RUNNER_PR || 'blacksmith-6vcpu-macos-15') || vars.CI_PAID_MACOS_OVERFLOW == '1' && vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15') }} timeout-minutes: 75 strategy: # A pull request wants every shard's failures in one run. A merge group @@ -1691,9 +1706,35 @@ jobs: LOGICAL_SHARDS=("$PHYSICAL_SHARD" "$((PHYSICAL_SHARD + PHYSICAL_SHARD_TOTAL))") # Each worker runs two balanced batches sequentially. Every invocation - # writes to a regular file; a separately-owned tail process mirrors - # that file into the Actions log. Detached test descendants therefore - # cannot retain the CI capture pipe after xcodebuild exits. + # writes to a regular file, and this script mirrors that file into the + # Actions log itself. Detached test descendants therefore cannot retain + # the CI capture pipe after xcodebuild exits. + # + # Mirroring in-process rather than from a background `tail -f` is what + # makes the last write observable. A tail had to be given some margin + # to deliver it before being killed, and a loaded runner could miss + # that margin and drop the final lines -- the ones saying why a batch + # died. Tracking the byte offset here has no margin to miss. + stream_offset=0 + emit_batch_output() { + local file="$1" size + size="$(wc -c <"$file" 2>/dev/null || echo 0)" + size="${size//[[:space:]]/}" + if [ "${size:-0}" -gt "$stream_offset" ]; then + # Read exactly the byte range included in the size snapshot. + # Reading to EOF here would race a concurrent writer: bytes + # appended after wc(1) could be emitted now while stream_offset + # advances only to the old size, duplicating them next poll. + python3 -c ' + import sys + path, start, end = sys.argv[1], int(sys.argv[2]), int(sys.argv[3]) + with open(path, "rb") as stream: + stream.seek(start) + sys.stdout.buffer.write(stream.read(end - start)) + ' "$file" "$stream_offset" "$size" + stream_offset="$size" + fi + } run_unit_test_batch() { local logical_shard="$1" local execution_attempt="$2" @@ -1744,6 +1785,7 @@ jobs: echo "Running app-host unit-test batch ${logical_shard}/${LOGICAL_SHARD_TOTAL}, execution ${execution_attempt}" : >"$batch_output" + stream_offset=0 CMUX_TAG="$batch_tag" \ scripts/ci/run-in-console-session.sh \ scripts/ci/run-app-host-xcodebuild.sh \ @@ -1757,8 +1799,6 @@ jobs: test-without-building >"$batch_output" 2>&1 & local xcodebuild_pid=$! - tail -n +1 -f "$batch_output" & - local stream_pid=$! local timeout_seconds="${CMUX_UNIT_TEST_TIMEOUT_SECONDS:-900}" local deadline=$((SECONDS + timeout_seconds)) local timed_out=0 @@ -1793,6 +1833,7 @@ jobs: timed_out=1 break fi + emit_batch_output "$batch_output" sleep 5 done @@ -1805,9 +1846,7 @@ jobs: batch_status=124 fi - sleep 0.2 - kill "$stream_pid" 2>/dev/null || true - wait "$stream_pid" 2>/dev/null || true + emit_batch_output "$batch_output" shopt -s nullglob local typed_results=("$result_bundle_root"/cmux-app-host-xcodebuild-"$batch_tag"-pid-*.tests.json) @@ -2193,7 +2232,7 @@ jobs: ghostty_helper_toolchain_sha256: ${{ steps.ghostty-helper-identity.outputs.toolchain_sha256 }} ghostty_helper_sdk: ${{ steps.ghostty-helper-identity.outputs.sdk }} # Build the release helper with SDK 15, then run package tests with SDK 26. - runs-on: ${{ vars.MACOS_RUNNER_DUAL_XCODE || 'blacksmith-6vcpu-macos-15' }} + runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'macos-15' || (vars.CI_PAID_MACOS_OVERFLOW == '1' && vars.MACOS_RUNNER_DUAL_XCODE || 'blacksmith-6vcpu-macos-15') }} timeout-minutes: 40 env: CMUX_CI_XCODE_APP: ${{ vars.CMUX_CI_XCODE_APP_MACOS_15 }} @@ -2651,7 +2690,7 @@ jobs: if: ${{ !cancelled() && needs.macos-compile-admission.result == 'success' && inputs.macos == 'true' && inputs.full_suite == 'true' }} # Reuse compile admission's app and UI test products; this runner only # performs display and runtime verification. - runs-on: ${{ github.event_name == 'pull_request' && (vars.MACOS_RUNNER_PR || 'blacksmith-6vcpu-macos-15') || vars.MACOS_RUNNER_DISPLAY || 'blacksmith-6vcpu-macos-15' }} + runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'macos-15' || (github.event_name == 'pull_request' && (vars.MACOS_RUNNER_PR || 'blacksmith-6vcpu-macos-15') || vars.CI_PAID_MACOS_OVERFLOW == '1' && vars.MACOS_RUNNER_DISPLAY || 'blacksmith-6vcpu-macos-15') }} timeout-minutes: 75 env: CMUX_NODE_PRODUCT_CACHE_ROOT: ${{ vars.CMUX_NODE_PRODUCT_CACHE_ROOT }} @@ -2664,7 +2703,7 @@ jobs: steps: - name: Validate display runner identity env: - REQUESTED_RUNNER: ${{ github.event_name == 'pull_request' && (vars.MACOS_RUNNER_PR || 'blacksmith-6vcpu-macos-15') || vars.MACOS_RUNNER_DISPLAY || 'blacksmith-6vcpu-macos-15' }} + REQUESTED_RUNNER: ${{ github.repository_owner != 'manaflow-ai' && 'macos-15' || (github.event_name == 'pull_request' && (vars.MACOS_RUNNER_PR || 'blacksmith-6vcpu-macos-15') || vars.CI_PAID_MACOS_OVERFLOW == '1' && vars.MACOS_RUNNER_DISPLAY || 'blacksmith-6vcpu-macos-15') }} RUNNER_CONTEXT_NAME: ${{ runner.name }} run: | set -euo pipefail @@ -3007,7 +3046,7 @@ jobs: - swift-package-tests - macos-compile-admission if: ${{ !cancelled() && needs.swift-package-tests.result == 'success' && needs.macos-compile-admission.result == 'success' && inputs.release_build == 'true' && inputs.full_suite == 'true' }} - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} timeout-minutes: 20 steps: - name: Wait for routed Linux preflight @@ -3087,9 +3126,9 @@ jobs: # Keep the app build on macOS 26 so SDK-gated SwiftUI Liquid Glass code # compiles into the same artifact shape as nightly and stable releases. # Release builds need enough disk for a universal Release build plus the - # restored SwiftPM cache. Default to a clean paid macOS 26 runner instead - # of the generic persistent macOS 26 pool. - runs-on: ${{ vars.MACOS_RUNNER_26_RELEASE || 'blacksmith-6vcpu-macos-26' }} + # restored SwiftPM cache, which the macOS 26 image already carries. The + # variable names that image, not this lane. + runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'macos-15' || (vars.MACOS_RUNNER_26 || 'blacksmith-6vcpu-macos-26') }} timeout-minutes: 60 permissions: actions: read @@ -3099,7 +3138,7 @@ jobs: CMUX_CI_XCODE_APP: ${{ vars.CMUX_CI_XCODE_APP_MACOS_26 }} CMUX_CI_REQUIRED_MACOS_SDK_MAJOR: "26" CMUX_SKIP_ZIG_BUILD: "1" - CMUX_PRODUCT_RUNNER: ${{ vars.MACOS_RUNNER_26_RELEASE || 'blacksmith-6vcpu-macos-26' }} + CMUX_PRODUCT_RUNNER: ${{ github.repository_owner != 'manaflow-ai' && 'macos-15' || (vars.MACOS_RUNNER_26 || 'blacksmith-6vcpu-macos-26') }} CMUX_RELEASE_SOURCE_REVISION: ${{ github.event.pull_request.head.sha || github.sha }} steps: - name: Clear stale git locks (self-hosted reused workspace) @@ -3478,7 +3517,7 @@ jobs: - release-admission - release-build if: ${{ always() }} - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} timeout-minutes: 5 steps: - name: Check routed macOS jobs @@ -3494,7 +3533,7 @@ jobs: inputs = json.loads(os.environ["MACOS_INPUTS"]) needs = json.loads(os.environ["MACOS_NEEDS"]) # The caller leaves compile_admitted unset when its build-input reuse - # steps are skipped (full suite) or fail; unset means "compile", the + # steps are skipped (full suite or unit-ci) or fail; unset means "compile", the # same reading the macos-compile-admission job condition uses. if inputs.get("compile_admitted") == "": inputs["compile_admitted"] = "false" @@ -3512,9 +3551,14 @@ jobs: # The package lane is routed from changed paths, so it is required # whenever the router selected it even under the compile-only suite. swift_packages = inputs["swift_packages"] == "true" + # `unit-ci` asks for app-host unit tests without the full suite, and + # clears suite-coverage on the strength of them, so a skip here must + # fail rather than read as a job nobody routed. Empty or missing + # means a caller that predates the input. + unit_suite = inputs.get("unit_suite", "") == "true" required = { "macos-compile-admission": macos and inputs["compile_admitted"] != "true", - "app-host-unit-tests": macos and full_suite, + "app-host-unit-tests": macos and (full_suite or unit_suite), "swift-package-tests": (macos and full_suite) or swift_packages, "tests-build-and-lag": macos and full_suite, "release-admission": macos and full_suite and inputs["release_build"] == "true", diff --git a/.github/workflows/ci-web.yml b/.github/workflows/ci-web.yml index e10536e3623e..a4ff9e577d4f 100644 --- a/.github/workflows/ci-web.yml +++ b/.github/workflows/ci-web.yml @@ -19,7 +19,7 @@ permissions: jobs: web-subarea-scope: if: ${{ inputs.web == 'true' || inputs.macos == 'true' }} - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} timeout-minutes: 5 outputs: db: ${{ steps.scope.outputs.db }} @@ -45,7 +45,7 @@ jobs: web-typecheck: needs: web-subarea-scope if: ${{ inputs.web == 'true' && needs.web-subarea-scope.outputs.typecheck == 'true' }} - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} # Matches the 10-minute siblings in this workflow. timeout-minutes: 10 defaults: @@ -90,7 +90,7 @@ jobs: web-production-build: needs: web-subarea-scope if: ${{ inputs.web == 'true' && needs.web-subarea-scope.outputs.production_build == 'true' }} - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} timeout-minutes: 15 defaults: run: @@ -129,7 +129,7 @@ jobs: fail-fast: false matrix: shard: ["1/4", "2/4", "3/4", "4/4"] - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} timeout-minutes: 10 defaults: run: @@ -154,7 +154,7 @@ jobs: web-instant-navigation: needs: web-subarea-scope if: ${{ inputs.web == 'true' && needs.web-subarea-scope.outputs.instant == 'true' }} - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} timeout-minutes: 10 defaults: run: @@ -195,7 +195,7 @@ jobs: react-apps-check: needs: web-subarea-scope if: ${{ inputs.web == 'true' && needs.web-subarea-scope.outputs.react_apps == 'true' }} - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} # Matches the 10-minute siblings in this workflow. timeout-minutes: 10 steps: @@ -235,7 +235,7 @@ jobs: # Generated protocol and streaming benchmarks only run for their owned inputs. needs: web-subarea-scope if: ${{ (inputs.macos == 'true' || inputs.web == 'true') && needs.web-subarea-scope.outputs.diff_sidecar == 'true' }} - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} timeout-minutes: 15 steps: - name: Checkout @@ -274,7 +274,7 @@ jobs: web-db-migrations: needs: web-subarea-scope if: ${{ inputs.web == 'true' && needs.web-subarea-scope.outputs.db == 'true' }} - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} # Matches web-production-build and diff-sidecar-check, the other 15s here. timeout-minutes: 15 defaults: @@ -327,7 +327,7 @@ jobs: agent-session-web-resources: if: ${{ inputs.agent_session_web == 'true' }} - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} timeout-minutes: 10 steps: - name: Checkout @@ -359,7 +359,7 @@ jobs: - web-db-migrations - agent-session-web-resources if: ${{ always() }} - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} timeout-minutes: 5 steps: - name: Check routed web jobs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6a294afa87d7..97ccd03bdb37 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,7 +47,7 @@ concurrency: jobs: changes: - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} timeout-minutes: 5 outputs: macos: ${{ steps.detect.outputs.macos }} @@ -69,6 +69,7 @@ jobs: linux_guard_source: ${{ steps.linux_guards.outputs.linux_guard_source }} ghosttykit_release: ${{ steps.unchanged_inputs.outputs.compile_admitted == 'true' && 'false' || steps.linux_guards.outputs.ghosttykit_release }} full_suite: ${{ steps.suite.outputs.full_suite }} + unit_suite: ${{ steps.suite.outputs.unit_suite }} coverage_gap: ${{ steps.suite.outputs.coverage_gap }} compile_admitted: ${{ steps.unchanged_inputs.outputs.compile_admitted == 'true' && 'true' || steps.admitted.outputs.compile_admitted }} source_tree: ${{ steps.source-identity.outputs.tree }} @@ -572,7 +573,9 @@ jobs: # compile. Each pull request run publishes its build-input fingerprint as # an artifact name, and a compile-only run whose fingerprint an earlier run # of the same in-org branch already compiled skips compile admission. Runs - # with the full suite always compile, because the shards need the product. + # with the full suite or unit-ci always compile, because the app-host + # shards need the product and only run behind a compile admission that + # succeeded in this run. # The artifact name carries the run attempt, so a pass only vouches for the # fingerprint published in its own attempt. These steps are an optimization: # any of them failing leaves compile_admitted unset, which compiles. @@ -595,7 +598,7 @@ jobs: - name: Skip compile when build inputs are unchanged id: unchanged_inputs continue-on-error: true - if: ${{ steps.inputs.outputs.fingerprint != '' && steps.suite.outputs.full_suite == 'false' }} + if: ${{ steps.inputs.outputs.fingerprint != '' && steps.suite.outputs.full_suite == 'false' && steps.suite.outputs.unit_suite != 'true' }} env: # Both steps are pull-request-only, and the xcode= extra exists so the # fingerprint moves when the pinned toolchain does. Read the same lane @@ -634,7 +637,7 @@ jobs: - name: Look for an earlier run that compiled these inputs id: admitted continue-on-error: true - if: ${{ steps.inputs.outputs.fingerprint != '' && steps.suite.outputs.full_suite == 'false' && steps.unchanged_inputs.outputs.compile_admitted != 'true' }} + if: ${{ steps.inputs.outputs.fingerprint != '' && steps.suite.outputs.full_suite == 'false' && steps.suite.outputs.unit_suite != 'true' && steps.unchanged_inputs.outputs.compile_admitted != 'true' }} env: GH_TOKEN: ${{ github.token }} BRANCH: ${{ github.head_ref }} @@ -698,7 +701,7 @@ jobs: name: Fast static checks # No package installs, submodules or app build: reject malformed inputs # before starting the longer guard, web and macOS stages. - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} timeout-minutes: 5 permissions: contents: read @@ -760,7 +763,7 @@ jobs: permissions: contents: read # Check the pinned framework before using a Mac, and on provenance changes. - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} timeout-minutes: 20 steps: - name: Checkout repository @@ -817,7 +820,7 @@ jobs: name: Claude wrapper regressions needs: [changes, static-preflight] if: ${{ !cancelled() && needs.changes.result == 'success' && needs.static-preflight.result == 'success' && (needs.changes.outputs.claude_wrapper == 'true' || (needs.changes.outputs.macos == 'true' && needs.changes.outputs.full_suite == 'true')) }} - runs-on: ${{ github.event_name == 'pull_request' && (vars.MACOS_RUNNER_PR || 'blacksmith-6vcpu-macos-15') || vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15' }} + runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'macos-15' || (github.event_name == 'pull_request' && (vars.MACOS_RUNNER_PR || 'blacksmith-6vcpu-macos-15') || vars.CI_PAID_MACOS_OVERFLOW == '1' && vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15') }} timeout-minutes: 10 steps: - name: Checkout wrapper and test inputs @@ -860,16 +863,18 @@ jobs: # the previous run through the concurrency group, but by then its macOS jobs # have already been billed. Holding macOS admission briefly on a Linux runner # lets a quick follow-up push cancel the run before any Mac starts. A stale - # head SHA after the wait fails this run instead of admitting it. Merge groups - # and dispatches are not debounced. CI_MACOS_ADMISSION_DEBOUNCE_SECONDS=0 - # turns the wait off. + # head SHA after the wait fails this run instead of admitting it, and so does + # a run that has already failed a job. Merge groups and dispatches are not + # debounced. A re-run attempt and CI_MACOS_ADMISSION_DEBOUNCE_SECONDS=0 both + # skip the wait and with it both checks. macos-debounce: name: macOS admission debounce needs: changes if: ${{ github.event_name == 'pull_request' && needs.changes.outputs.macos != 'false' && (needs.changes.outputs.full_suite == 'true' || needs.changes.outputs.compile_admitted != 'true') }} - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} timeout-minutes: 15 permissions: + actions: read pull-requests: read steps: - name: Wait for follow-up pushes @@ -894,6 +899,28 @@ jobs: echo "::error::PR head moved from $HEAD_SHA to $current; the newer run owns macOS admission." exit 1 fi + # A job that has already failed has settled this run: the pull request + # is red on this head whatever a Mac finds, and the fix push opens a + # new run. Declining costs only work nobody was going to read. + # + # Decline the same way a moved head does, by failing. A job output + # would read better here, but `macos` is skipped either way and only a + # *failed* dependency makes "Re-run failed jobs" re-run it -- an + # output would strand the run red until someone re-ran everything. + # + # Only a concluded `failure` counts: `cancelled` means the run is + # already going away. An unreadable reply, an error body, or a first + # page that misses a later job all admit, like the check above. + if failed="$(gh api \ + "repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID/jobs?per_page=100" \ + --jq '[.jobs[] | select(.conclusion == "failure")] | length')" \ + && [ -n "$failed" ] && [ "$failed" != "0" ]; then + echo "::error::$failed job(s) in this run already failed; not admitting macOS. Re-run failed jobs to collect macOS results anyway." + exit 1 + fi + if [ -z "${failed:-}" ]; then + echo "Could not read this run's jobs; admitting macOS." + fi echo "Head $HEAD_SHA is still current; admitting macOS." macos: @@ -910,7 +937,7 @@ jobs: # already admitted or the macOS area is neutral (a Packages/iOS package # outside the desktop closure). Every other job in the called workflow stays # gated on inputs.macos, so nothing else wakes up with it. - if: ${{ !cancelled() && needs.changes.result == 'success' && needs.static-preflight.result == 'success' && (needs.macos-debounce.result == 'success' || needs.macos-debounce.result == 'skipped') && ((needs.changes.outputs.macos != 'false' && (needs.changes.outputs.full_suite == 'true' || needs.changes.outputs.compile_admitted != 'true')) || needs.changes.outputs.swift_packages == 'true') }} + if: ${{ !cancelled() && needs.changes.result == 'success' && needs.static-preflight.result == 'success' && (needs.macos-debounce.result == 'success' || needs.macos-debounce.result == 'skipped') && ((needs.changes.outputs.macos != 'false' && (needs.changes.outputs.full_suite == 'true' || needs.changes.outputs.unit_suite == 'true' || needs.changes.outputs.compile_admitted != 'true')) || needs.changes.outputs.swift_packages == 'true') }} permissions: actions: read id-token: write @@ -921,6 +948,7 @@ jobs: with: macos: ${{ needs.changes.outputs.macos }} full_suite: ${{ needs.changes.outputs.full_suite }} + unit_suite: ${{ needs.changes.outputs.unit_suite }} swift_packages: ${{ needs.changes.outputs.swift_packages }} compile_admitted: ${{ needs.changes.outputs.compile_admitted }} release_build: ${{ needs.changes.outputs.release_build }} @@ -938,7 +966,7 @@ jobs: - macos - web if: ${{ always() }} - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} timeout-minutes: 5 steps: - name: Check platform workflow routing @@ -978,9 +1006,16 @@ jobs: # and of the macOS area, so it can require the called workflow on its # own. Keep this in sync with the `macos` job's own `if:`. swift_packages = outputs.get("swift_packages") == "true" + # `unit-ci` asks for app-host unit tests without the full suite, so a + # routed macOS run is required even when compile admission reused a + # product and full_suite stayed false. + unit_suite = outputs.get("unit_suite") == "true" macos_work_required = swift_packages or ( macos_route == "true" - and not (full_suite == "false" and compile_admitted == "true") + and ( + unit_suite + or not (full_suite == "false" and compile_admitted == "true") + ) ) if macos_work_required: if macos_result != "success": @@ -1016,7 +1051,7 @@ jobs: # router explicitly says macOS is irrelevant, skip the runner allocation. # Missing/invalid route output fails open by running the preflight. if: ${{ always() && needs.changes.outputs.macos != 'false' }} - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} timeout-minutes: 5 steps: - name: Check routed Linux results @@ -1116,20 +1151,25 @@ jobs: # that edits cmuxTests/ and skips the full suite runs none of the code it # changed. The policy that allows the skip is written for a merge queue # that no longer gates main, so refuse the run here instead of reporting - # green on a diff nothing observed. Add "full-ci" to run the suite, or - # "no-full-ci" to record that skipping it is deliberate. + # green on a diff nothing observed. A cmuxTests/ diff selects + # `app-host unit tests` by itself (choose_ci_suite.py), so it never lands + # here. What does is cmuxUITests/, which no pull request job executes, and + # a diff that could not be read: "full-ci" runs everything, and + # "no-full-ci" records that skipping it is deliberate. needs: - changes if: ${{ !cancelled() && needs.changes.result == 'success' && needs.changes.outputs.coverage_gap == 'true' }} - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} steps: - name: Require the suite that judges this diff run: | - echo "This pull request changes cmuxTests/ or cmuxUITests/, and this run" >&2 - echo "skipped the macOS suite, so none of the changed tests executed." >&2 + echo "This pull request changes cmuxUITests/, or its diff could not be" >&2 + echo "read, and this run skipped the macOS suite, so nothing observed it." >&2 + echo "(A cmuxTests/ change runs 'app-host unit tests' automatically.)" >&2 echo >&2 - echo "Add the 'full-ci' label to run the suite on a fresh run, or add" >&2 - echo "'no-full-ci' to record that skipping it is deliberate." >&2 + echo "No pull request job executes cmuxUITests/. Add the 'full-ci' label" >&2 + echo "to run the suite on a fresh run, or 'no-full-ci' to record that" >&2 + echo "skipping it is deliberate." >&2 exit 1 ci-status: @@ -1149,7 +1189,7 @@ jobs: - macos - tests if: ${{ always() }} - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} # One step that reads `needs` and decides. Its siblings are bounded at 5 # to 20 minutes; this one was inheriting the six-hour default, as the # required check every pull request waits on. diff --git a/.github/workflows/cli-pipe-regressions.yml b/.github/workflows/cli-pipe-regressions.yml index 75d2efb6b481..065b454c4cd9 100644 --- a/.github/workflows/cli-pipe-regressions.yml +++ b/.github/workflows/cli-pipe-regressions.yml @@ -13,7 +13,7 @@ concurrency: jobs: cli-pipe-regressions: - runs-on: ${{ github.event_name == 'pull_request' && (vars.MACOS_RUNNER_PR || 'blacksmith-6vcpu-macos-15') || vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15' }} + runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'macos-15' || (github.event_name == 'pull_request' && (vars.MACOS_RUNNER_PR || 'blacksmith-6vcpu-macos-15') || vars.CI_PAID_MACOS_OVERFLOW == '1' && vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15') }} timeout-minutes: 30 env: CMUX_CI_XCODE_APP: ${{ github.event_name == 'pull_request' && (vars.CMUX_CI_XCODE_APP_PR || vars.CMUX_CI_XCODE_APP_MACOS_15) || vars.CMUX_CI_XCODE_APP_MACOS_15 }} diff --git a/.github/workflows/cloud-command-deadlines.yml b/.github/workflows/cloud-command-deadlines.yml index 788abc0f9d56..18eceda5b803 100644 --- a/.github/workflows/cloud-command-deadlines.yml +++ b/.github/workflows/cloud-command-deadlines.yml @@ -38,7 +38,7 @@ concurrency: cancel-in-progress: true jobs: command-regressions: - runs-on: ${{ inputs.runner || vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15' }} + runs-on: ${{ inputs.runner || github.repository_owner != 'manaflow-ai' && 'macos-15' || (vars.CI_PAID_MACOS_OVERFLOW == '1' && vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15') }} timeout-minutes: 5 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd diff --git a/.github/workflows/cloud-machine-tests.yml b/.github/workflows/cloud-machine-tests.yml index 683c6b05a60e..e562dc253f12 100644 --- a/.github/workflows/cloud-machine-tests.yml +++ b/.github/workflows/cloud-machine-tests.yml @@ -33,7 +33,7 @@ jobs: ref: ${{ inputs.ref }} changes: - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} timeout-minutes: 2 outputs: should_run: ${{ steps.route.outputs.should_run }} @@ -99,7 +99,7 @@ jobs: lifecycle: needs: [changes, resolve-ref] if: ${{ needs.changes.outputs.should_run == 'true' }} - runs-on: ${{ inputs.runner || vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15' }} + runs-on: ${{ inputs.runner || github.repository_owner != 'manaflow-ai' && 'macos-15' || (vars.CI_PAID_MACOS_OVERFLOW == '1' && vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15') }} timeout-minutes: 10 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 diff --git a/.github/workflows/cloud-vm-image-contract.yml b/.github/workflows/cloud-vm-image-contract.yml index 928091bb65bb..87bb1a1c3c08 100644 --- a/.github/workflows/cloud-vm-image-contract.yml +++ b/.github/workflows/cloud-vm-image-contract.yml @@ -50,7 +50,7 @@ concurrency: jobs: contract: - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} defaults: run: working-directory: web diff --git a/.github/workflows/cloud-vm-image-reachability.yml b/.github/workflows/cloud-vm-image-reachability.yml index 49a28cfc6397..23f079f99d1a 100644 --- a/.github/workflows/cloud-vm-image-reachability.yml +++ b/.github/workflows/cloud-vm-image-reachability.yml @@ -45,7 +45,7 @@ concurrency: jobs: reachable: - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} # The provider key lives in this environment, not in a repo-wide secret, # so only a job that names it can read it. No reviewers and no branch # policy, or the daily run and PR runs could not use it; fork PRs get no diff --git a/.github/workflows/cmux-skill-contract.yml b/.github/workflows/cmux-skill-contract.yml index ed3e6c9b72a2..dcc352e172ed 100644 --- a/.github/workflows/cmux-skill-contract.yml +++ b/.github/workflows/cmux-skill-contract.yml @@ -40,7 +40,7 @@ concurrency: jobs: browser-skill: - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} timeout-minutes: 5 steps: - name: Checkout diff --git a/.github/workflows/cmux-tui-build-package.yml b/.github/workflows/cmux-tui-build-package.yml index 83e480945812..6f5081a9e97d 100644 --- a/.github/workflows/cmux-tui-build-package.yml +++ b/.github/workflows/cmux-tui-build-package.yml @@ -62,6 +62,11 @@ on: required: false default: false type: boolean + build_agent_plugin: + description: "Build and upload the Unix userland agent screen-detection plugin" + required: false + default: false + type: boolean permissions: {} @@ -80,7 +85,7 @@ jobs: TARGET_SET: ${{ inputs.target_set }} 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' }} + MACOS_RUNNER: ${{ inputs.macos_runner != '' && inputs.macos_runner || vars.CI_PAID_MACOS_OVERFLOW == '1' && vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15' }} LINUX_RUNNER: ${{ inputs.linux_runner != '' && inputs.linux_runner || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} LINUX_ARM64_RUNNER: ${{ vars.LINUX_ARM64_RUNNER || 'ubuntu-24.04-arm' }} VERIFY_LINUX_ARM64: ${{ inputs.verify_linux_arm64 }} @@ -279,7 +284,34 @@ jobs: cargo build -p cmux-relay --bin cmux-relay --release --locked --target ${{ matrix.build_target }} cargo build -p chatmux-relay --bin chatmux-relay --release --locked --target ${{ matrix.build_target }} + - name: Build agent screen-detection plugin + if: inputs.build_agent_plugin && runner.os != 'Windows' + env: + AGENT_PLUGIN_CROSS: ${{ matrix.cross }} + AGENT_PLUGIN_TARGET: ${{ matrix.build_target }} + RUNNER_OS: ${{ runner.os }} + working-directory: cmux-tui + shell: bash + run: | + set -euo pipefail + plugin_manifest="bindings/examples/rust-agent-screen-detection/Cargo.toml" + # The reference plugin is its own Cargo workspace. Use the common + # target directory so staging has one predictable path and the + # plugin can reuse SDK dependencies from the TUI build. + export CARGO_TARGET_DIR="$GITHUB_WORKSPACE/cmux-tui/target" + if [[ "$AGENT_PLUGIN_CROSS" == "true" && "$RUNNER_OS" == "Linux" ]]; then + cargo zigbuild \ + --manifest-path "$plugin_manifest" \ + --release --locked --target "$AGENT_PLUGIN_TARGET" + else + cargo build \ + --manifest-path "$plugin_manifest" \ + --release --locked --target "$AGENT_PLUGIN_TARGET" + fi + - name: Stage binary + env: + BUILD_AGENT_PLUGIN: ${{ inputs.build_agent_plugin }} shell: bash run: | mkdir -p dist @@ -290,6 +322,10 @@ jobs: cp "cmux-tui/target/${{ matrix.target }}/release/cmux-tui-hook${{ matrix.ext }}" "$hook_binary" cp "cmux-tui/target/${{ matrix.target }}/release/cmux-relay${{ matrix.ext }}" "$relay_binary" cp "cmux-tui/target/${{ matrix.target }}/release/chatmux-relay${{ matrix.ext }}" "dist/chatmux-relay-${{ matrix.target }}${{ matrix.ext }}" + if [[ "$BUILD_AGENT_PLUGIN" == "true" && "${{ runner.os }}" != "Windows" ]]; then + cp "cmux-tui/target/${{ matrix.target }}/release/cmux-agent-screen-detection${{ matrix.ext }}" \ + "dist/cmux-agent-screen-detection-${{ matrix.target }}${{ matrix.ext }}" + fi if [[ -n "${{ matrix.compatibility_target }}" ]]; then cp "$binary" "dist/cmux-tui-${{ matrix.compatibility_target }}${{ matrix.ext }}" cp "$hook_binary" "dist/cmux-tui-hook-${{ matrix.compatibility_target }}${{ matrix.ext }}" @@ -356,6 +392,14 @@ jobs: path: dist/chatmux-relay-*${{ matrix.ext }} if-no-files-found: error + - name: Upload agent screen-detection plugin artifact + if: inputs.build_agent_plugin && runner.os != 'Windows' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: cmux-agent-screen-detection-${{ matrix.target }} + path: dist/cmux-agent-screen-detection-*${{ matrix.ext }} + if-no-files-found: error + build-windows: name: build x86_64-pc-windows-gnu if: inputs.include_windows diff --git a/.github/workflows/cmux-tui-sdks.yml b/.github/workflows/cmux-tui-sdks.yml index 66dff216abea..7d87859f1016 100644 --- a/.github/workflows/cmux-tui-sdks.yml +++ b/.github/workflows/cmux-tui-sdks.yml @@ -79,7 +79,7 @@ permissions: jobs: contract: name: protocol contract - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} timeout-minutes: 8 outputs: sdk_inputs: ${{ steps.sdk-inputs.outputs.changed }} @@ -182,7 +182,7 @@ jobs: name: ${{ matrix.language }} package needs: contract if: ${{ needs.contract.outputs.sdk_inputs == 'true' }} - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} timeout-minutes: 25 strategy: fail-fast: false @@ -356,7 +356,7 @@ jobs: name: ${{ matrix.language }} consumer needs: contract if: ${{ needs.contract.outputs.sdk_inputs == 'true' }} - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} timeout-minutes: 25 strategy: fail-fast: false @@ -515,7 +515,7 @@ jobs: name: Rust SDK MSRV (1.88) needs: contract if: ${{ needs.contract.outputs.sdk_inputs == 'true' }} - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} timeout-minutes: 25 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -530,6 +530,9 @@ jobs: cargo +1.88.0 fmt --manifest-path cmux-tui/bindings/rust/Cargo.toml -- --check cargo +1.88.0 test --manifest-path cmux-tui/bindings/rust/Cargo.toml --locked cargo +1.88.0 test --manifest-path cmux-tui/bindings/rust-sidebar/Cargo.toml --locked + cargo +1.88.0 test \ + --manifest-path cmux-tui/bindings/examples/rust-agent-screen-detection/Cargo.toml \ + --locked cargo +1.88.0 clippy \ --manifest-path cmux-tui/bindings/examples/rust-agent-dashboard/Cargo.toml \ --locked \ @@ -544,7 +547,7 @@ jobs: conformance: name: seven-language live conformance needs: contract - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-8vcpu-ubuntu-2404' }} + runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-8vcpu-ubuntu-2404' }} 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 5b11f65a044c..6c10e7a8f3fc 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: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} 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 0f1db7083c07..f63d8de85b9d 100644 --- a/.github/workflows/cmux-tui.yml +++ b/.github/workflows/cmux-tui.yml @@ -466,6 +466,19 @@ jobs: - name: Test isolated TUI test-runner policy run: python3 .github/scripts/test_run_cmux_tui_core_tests_isolated.py + - name: cargo fmt + id: rustfmt-check + working-directory: cmux-tui + run: cargo fmt --check + + - name: userland agent plugin package tests + working-directory: cmux-tui + # The reference plugin is an independent Cargo workspace, so the + # daemon workspace test does not compile it. Keep this explicit check + # in both hosted OS lanes. + run: cargo test --manifest-path bindings/examples/rust-agent-screen-detection/Cargo.toml --locked + + - name: focused Linux journal process-fence test if: inputs.mode == 'focused' && runner.os == 'Linux' working-directory: cmux-tui @@ -819,6 +832,8 @@ jobs: package_pypi: ${{ inputs.mode == 'full' }} include_windows: ${{ inputs.mode == 'full' }} target_set: ${{ inputs.mode == 'full' && 'all' || 'macos-arm64' }} + # Build the exact reference plugin beside the daemon artifact. + build_agent_plugin: true verify_linux_arm64: ${{ inputs.mode == 'full' }} macos_runner: blacksmith-6vcpu-macos-15 linux_runner: blacksmith-4vcpu-ubuntu-2404 diff --git a/.github/workflows/contributor-welcome.yml b/.github/workflows/contributor-welcome.yml new file mode 100644 index 000000000000..b32134358628 --- /dev/null +++ b/.github/workflows/contributor-welcome.yml @@ -0,0 +1,70 @@ +name: Contributor welcome + +# First-time contributors used to hear only from bots. This posts one short, +# human-written note (.github/contributor/welcome.md) when their first pull +# request opens. It never checks out or runs PR code. +on: + pull_request_target: + types: [opened] + branches: [main] + +permissions: {} + +jobs: + welcome: + if: >- + github.event.pull_request.user.type == 'User' && + contains(fromJSON('["FIRST_TIME_CONTRIBUTOR","FIRST_TIMER","NONE"]'), github.event.pull_request.author_association) + runs-on: ubuntu-24.04 # github-hosted-required: trusted pull-request-write token + timeout-minutes: 5 + permissions: + contents: read + pull-requests: write + steps: + - name: Post welcome note + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + PR: ${{ github.event.pull_request.number }} + AUTHOR: ${{ github.event.pull_request.user.login }} + WORKFLOW_SHA: ${{ github.workflow_sha }} + run: | + set -euo pipefail + + # `author_association` answers "has no merged commit here", not "this + # is their first PR". cmux merges few outside PRs, so a persistent + # contributor stays FIRST_TIME_CONTRIBUTOR indefinitely and would be + # greeted again on every PR. Count their PRs instead; this one is + # always included, so more than one means it is not their first. + # + # Treat an unusable answer as "skip", not as an error: the search API + # can rate-limit or 422, and a non-numeric reply would otherwise abort + # the step under `set -e` and leave a red X on a newcomer's first PR. + # A missed greeting is recoverable; a wrong one or a red check is not. + if ! count=$(gh api -X GET search/issues \ + -f q="repo:$GH_REPO is:pr author:$AUTHOR" --jq '.total_count' 2>/dev/null) \ + || ! [ "$count" -eq "$count" ] 2>/dev/null; then + echo "Could not count pull requests for $AUTHOR; skipping." + exit 0 + fi + if [ "$count" -gt 1 ]; then + echo "$AUTHOR has $count pull requests here; not a first PR, skipping." + exit 0 + fi + + # Search is eventually consistent, so it can undercount a burst of + # PRs opened together. The marker makes a second greeting impossible + # even when the count above is stale. + marker='' + if gh api "repos/$GH_REPO/issues/$PR/comments" --paginate --jq '.[].body' \ + | grep -qF "$marker"; then + echo "Already welcomed on #$PR, skipping." + exit 0 + fi + + # Read the note from the commit this workflow was loaded from, so a + # pull request cannot rewrite the text it gets welcomed with. + gh api "repos/$GH_REPO/contents/.github/contributor/welcome.md?ref=$WORKFLOW_SHA" \ + -H "Accept: application/vnd.github.raw" > welcome.md + printf '\n%s\n' "$marker" >> welcome.md + gh pr comment "$PR" --body-file welcome.md diff --git a/.github/workflows/ios-appstore-upload.yml b/.github/workflows/ios-appstore-upload.yml index 82312b338685..d7341bb329f5 100644 --- a/.github/workflows/ios-appstore-upload.yml +++ b/.github/workflows/ios-appstore-upload.yml @@ -352,18 +352,62 @@ jobs: echo "Shipped CFBundleVersion: $FINAL_BN" - name: Record completed upload before group assignment + id: record_upload + # The next scheduled poll skips this revision only if this marker + # exists. The upload step can fail after Apple accepted the IPA (the + # notes step did, hourly, in #13690), so a failed step still records + # the upload when asc's receipt says "uploaded": true. The build number + # file alone is not evidence: the script writes it before archiving. + if: ${{ always() && steps.upload.outcome != 'skipped' }} env: + UPLOAD_OUTCOME: ${{ steps.upload.outcome }} BUILD_NUMBER: ${{ steps.upload.outputs.final_build_number }} + BUILD_NUMBER_FILE: ${{ runner.temp }}/cmux-final-build-number.txt + UPLOAD_RECEIPT: ${{ runner.temp }}/cmux-ios-upload/upload.log run: | mkdir -p "$RUNNER_TEMP/cmux-app-upload-marker" python3 - <<'PY' - import json, os + import json, os, re from pathlib import Path - marker = {'sha': os.environ['GITHUB_SHA'], 'app_id': '6783338052', 'build_number': os.environ['BUILD_NUMBER']} + + def accepted(path): + try: + text = Path(path).read_text(encoding='utf-8', errors='replace') + except OSError: + return False + # asc prints one JSON object; tolerate it pretty-printed or + # surrounded by other lines. + decoder = json.JSONDecoder() + for start in (m.start() for m in re.finditer(r'\{', text)): + try: + value, _ = decoder.raw_decode(text, start) + except ValueError: + continue + if isinstance(value, dict) and value.get('uploaded') is True: + return True + return False + + build_number = os.environ.get('BUILD_NUMBER', '').strip() + if os.environ['UPLOAD_OUTCOME'] != 'success': + if not accepted(os.environ['UPLOAD_RECEIPT']): + print('No App Store Connect upload receipt; not recording an upload.') + raise SystemExit(0) + try: + build_number = Path(os.environ['BUILD_NUMBER_FILE']).read_text().strip() + except OSError: + build_number = '' + if not re.fullmatch(r'[0-9]+', build_number): + print(f'No numeric build number ({build_number!r}); not recording an upload.') + raise SystemExit(0) + marker = {'sha': os.environ['GITHUB_SHA'], 'app_id': '6783338052', 'build_number': build_number} Path(os.environ['RUNNER_TEMP'], 'cmux-app-upload-marker', 'upload.json').write_text(json.dumps(marker)) + with open(os.environ['GITHUB_OUTPUT'], 'a', encoding='utf-8') as output: + output.write('recorded=true\n') + print(f'Recorded upload of build {build_number} for {os.environ["GITHUB_SHA"]}.') PY - name: Retain completed upload for assignment retries + if: ${{ always() && steps.record_upload.outputs.recorded == 'true' }} uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: cmux-app-testflight-upload diff --git a/.github/workflows/ios-streamed-validate.yml b/.github/workflows/ios-streamed-validate.yml index 4d04b64ac12a..eb287fdef67b 100644 --- a/.github/workflows/ios-streamed-validate.yml +++ b/.github/workflows/ios-streamed-validate.yml @@ -19,10 +19,10 @@ permissions: jobs: validate: # This end-to-end validation is expensive and is run only on explicit - # dispatch. Default to the Blacksmith image the iOS simulator tests use; the - # selector below picks its Xcode 26 toolchain. Keep an override for a - # future isolated runner migration. - runs-on: ${{ vars.MACOS_RUNNER_STREAMED_VALIDATION || 'blacksmith-6vcpu-macos-26' }} + # dispatch. It wants the image the iOS simulator tests use, so it reads the + # variable that names that pool rather than one named after this lane; the + # selector below picks its Xcode 26 toolchain. + runs-on: ${{ vars.MACOS_RUNNER_IOS || 'blacksmith-6vcpu-macos-26' }} timeout-minutes: 120 env: CMUX_PORT: "3000" diff --git a/.github/workflows/iroh-release-gate.yml b/.github/workflows/iroh-release-gate.yml index c0c3314cb0b1..a7230ced6e44 100644 --- a/.github/workflows/iroh-release-gate.yml +++ b/.github/workflows/iroh-release-gate.yml @@ -32,7 +32,7 @@ jobs: tailscale-version-skew: needs: resolve-ref name: Tailscale version-skew compatibility - runs-on: ${{ vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15' }} + runs-on: ${{ vars.CI_PAID_MACOS_OVERFLOW == '1' && vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15' }} timeout-minutes: 75 env: CMUX_CI_XCODE_APP: ${{ vars.CMUX_CI_XCODE_APP_MACOS_15 }} @@ -77,7 +77,7 @@ jobs: max-parallel: 1 matrix: mode: ${{ fromJSON(inputs.mode == 'all' && '["automatic","relay-only","relay-expiry","direct-only","private-path"]' || format('["{0}"]', inputs.mode)) }} - runs-on: ${{ vars.MACOS_RUNNER_STREAMED_VALIDATION || 'blacksmith-6vcpu-macos-15' }} + runs-on: ${{ vars.CI_PAID_MACOS_OVERFLOW == '1' && vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15' }} timeout-minutes: 120 steps: - name: Checkout diff --git a/.github/workflows/localization-catalog.yml b/.github/workflows/localization-catalog.yml index 86c16e870573..510f10538251 100644 --- a/.github/workflows/localization-catalog.yml +++ b/.github/workflows/localization-catalog.yml @@ -23,7 +23,7 @@ concurrency: jobs: catalog-structure: - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} timeout-minutes: 5 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 568156772ee8..4cd8a35c114c 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -393,7 +393,7 @@ jobs: needs: decide if: github.event_name == 'schedule' && github.event.schedule == '17 */6 * * *' || github.event_name == 'workflow_dispatch' && inputs.seed_only # Match the PR Release-build runner and Xcode so its compilation cache is reusable. - runs-on: ${{ vars.MACOS_RUNNER_26_RELEASE || 'blacksmith-6vcpu-macos-26' }} + runs-on: ${{ vars.MACOS_RUNNER_26 || 'blacksmith-6vcpu-macos-26' }} # This job rebuilds the cache after the store drops it, so it runs cold # by design; scheduled run 35134963192 was cancelled at 45 minutes. timeout-minutes: 90 @@ -711,7 +711,7 @@ jobs: if: needs.decide.outputs.should_build == 'true' && (github.event_name != 'schedule' || github.event.schedule == '47 8 * * *') && needs.decide.outputs.build_only != 'true' # Zig 0.15.2 cannot link the real helper on macOS 26. Match the stable # release workflow by building and verifying it on macOS 15 instead. - runs-on: ${{ needs.decide.outputs.fast_build == 'true' && 'blacksmith-6vcpu-macos-15' || vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15' }} + runs-on: ${{ needs.decide.outputs.fast_build == 'true' && 'blacksmith-6vcpu-macos-15' || vars.CI_PAID_MACOS_OVERFLOW == '1' && vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15' }} timeout-minutes: 20 env: NIGHTLY_FAST_BUILD: ${{ needs.decide.outputs.fast_build }} @@ -768,11 +768,13 @@ jobs: daemon_build: ${{ steps.remote_daemon.outputs.build }} daemon_version: ${{ steps.remote_daemon.outputs.version }} if: needs.decide.outputs.should_build == 'true' && (github.event_name != 'schedule' || github.event.schedule == '47 8 * * *') - # Full runs match the cache warmer and stable release lane. Fast branch - # dogfood always uses the dedicated Blacksmith image. A repository-wide - # runner override may point at a slower shared builder, which defeats the - # purpose of the one-architecture path. - runs-on: ${{ needs.decide.outputs.fast_build == 'true' && 'blacksmith-12vcpu-macos-26' || vars.MACOS_RUNNER_26_NIGHTLY_BUILD || 'blacksmith-6vcpu-macos-26' }} + # Full runs share the cache warmer's and stable release lane's image and + # toolchain, which is what the compilation cache is keyed on — OS, arch and + # toolchain, never instance size, which is deliberately larger here. Fast + # branch dogfood always uses the dedicated Blacksmith image. A + # repository-wide runner override may point at a slower shared builder, + # which defeats the purpose of the one-architecture path. + runs-on: ${{ needs.decide.outputs.fast_build == 'true' && 'blacksmith-12vcpu-macos-26' || vars.CI_PAID_MACOS_OVERFLOW == '1' && vars.MACOS_RUNNER_26_LARGE || 'blacksmith-12vcpu-macos-26' }} # The Blacksmith cache is scoped per branch and also drops main's own # entry (runs 35179030871 and 35182663752 restored nothing and were # cancelled at 45 minutes mid-compile), so the budget must fit a cold diff --git a/.github/workflows/perf-activation.yml b/.github/workflows/perf-activation.yml index e6af815412b0..c0e30d0fd2af 100644 --- a/.github/workflows/perf-activation.yml +++ b/.github/workflows/perf-activation.yml @@ -111,7 +111,7 @@ jobs: activation-session-benchmark: needs: activation_changes if: ${{ needs.activation_changes.outputs.macos == 'true' }} - runs-on: ${{ ((!inputs.runner || inputs.runner == 'auto') && (vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15') || inputs.runner) }} + runs-on: ${{ ((!inputs.runner || inputs.runner == 'auto') && (vars.CI_PAID_MACOS_OVERFLOW == '1' && vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15') || inputs.runner) }} timeout-minutes: 45 env: PERF_TAG: perf-${{ github.run_id }}-${{ github.run_attempt }} diff --git a/.github/workflows/plain-paste-worker.yml b/.github/workflows/plain-paste-worker.yml index 41dd46f5e1cd..1e18897bada0 100644 --- a/.github/workflows/plain-paste-worker.yml +++ b/.github/workflows/plain-paste-worker.yml @@ -15,7 +15,7 @@ permissions: contents: read jobs: macos-15: - runs-on: ${{ vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15' }} + runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'macos-15' || (vars.CI_PAID_MACOS_OVERFLOW == '1' && vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15') }} timeout-minutes: 10 defaults: run: diff --git a/.github/workflows/r2-upload-tests.yml b/.github/workflows/r2-upload-tests.yml index cc823c420115..83f0de2643f0 100644 --- a/.github/workflows/r2-upload-tests.yml +++ b/.github/workflows/r2-upload-tests.yml @@ -23,7 +23,7 @@ concurrency: jobs: r2-upload-tests: - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} timeout-minutes: 5 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 diff --git a/.github/workflows/relay-tls.yml b/.github/workflows/relay-tls.yml index 84444dd03657..dd18689b14f7 100644 --- a/.github/workflows/relay-tls.yml +++ b/.github/workflows/relay-tls.yml @@ -20,7 +20,7 @@ permissions: jobs: diagnostic-presentation: - runs-on: ${{ vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15' }} + runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'macos-15' || (vars.CI_PAID_MACOS_OVERFLOW == '1' && vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15') }} timeout-minutes: 15 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -33,7 +33,7 @@ jobs: swift test --package-path Packages/Shared/CmuxIrxTransport --filter IrxEndpointDiagnosticTests system-keychain: - runs-on: ${{ vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15' }} + runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'macos-15' || (vars.CI_PAID_MACOS_OVERFLOW == '1' && vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15') }} timeout-minutes: 15 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b3bd71211738..bf25a034a73b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,7 +39,7 @@ jobs: upload: false build-ghostty-cli-helper: - runs-on: ${{ vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15' }} + runs-on: ${{ vars.CI_PAID_MACOS_OVERFLOW == '1' && vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15' }} timeout-minutes: 20 steps: - name: Clear stale git locks (self-hosted reused workspace) diff --git a/.github/workflows/remote-daemon.yml b/.github/workflows/remote-daemon.yml index 083475cda0d0..b566853c6c74 100644 --- a/.github/workflows/remote-daemon.yml +++ b/.github/workflows/remote-daemon.yml @@ -28,7 +28,7 @@ concurrency: jobs: remote-daemon-admission: - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} timeout-minutes: 5 permissions: pull-requests: read @@ -53,7 +53,7 @@ jobs: remote-daemon-tests: needs: remote-daemon-admission - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} timeout-minutes: 15 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -77,7 +77,7 @@ jobs: # request only Linux packaging validation for publishing-only changes. if: ${{ github.event_name != 'pull_request' || inputs.native_tests }} needs: remote-daemon-admission - runs-on: ${{ vars.MACOS_RUNNER_26 || 'blacksmith-6vcpu-macos-26' }} + runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'macos-15' || (vars.MACOS_RUNNER_26 || 'blacksmith-6vcpu-macos-26') }} timeout-minutes: 15 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 diff --git a/.github/workflows/terminal-hang-diagnostics.yml b/.github/workflows/terminal-hang-diagnostics.yml index 7fb8ed6e1f38..5f95ce671b0f 100644 --- a/.github/workflows/terminal-hang-diagnostics.yml +++ b/.github/workflows/terminal-hang-diagnostics.yml @@ -32,7 +32,7 @@ concurrency: jobs: portal-reconciliation: - runs-on: ${{ github.event_name == 'pull_request' && (vars.MACOS_RUNNER_PR || 'blacksmith-6vcpu-macos-15') || vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15' }} + runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'macos-15' || (github.event_name == 'pull_request' && (vars.MACOS_RUNNER_PR || 'blacksmith-6vcpu-macos-15') || vars.CI_PAID_MACOS_OVERFLOW == '1' && vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15') }} timeout-minutes: 10 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -44,7 +44,7 @@ jobs: run: bash tests/run_terminal_portal_reconciliation_tests.sh release-gate: - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} timeout-minutes: 5 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -53,7 +53,7 @@ jobs: - run: python3 tests/test_terminal_hang_release_gate.py phase-attribution: - runs-on: ${{ github.event_name == 'pull_request' && (vars.MACOS_RUNNER_PR || 'blacksmith-6vcpu-macos-15') || vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15' }} + runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'macos-15' || (github.event_name == 'pull_request' && (vars.MACOS_RUNNER_PR || 'blacksmith-6vcpu-macos-15') || vars.CI_PAID_MACOS_OVERFLOW == '1' && vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15') }} timeout-minutes: 20 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 diff --git a/.github/workflows/test-depot.yml b/.github/workflows/test-depot.yml index 661d32f8908b..39f94bab6ee2 100644 --- a/.github/workflows/test-depot.yml +++ b/.github/workflows/test-depot.yml @@ -42,7 +42,7 @@ on: default: false type: boolean unit_test_suites: - description: "Comma-separated unit suites; empty runs the full unit target" + description: "Comma-separated unit suites, or Suite/testName (XCTest) / Suite/testName() (Swift Testing) for one test; empty runs the full unit target" required: false default: "" test_filter: @@ -235,12 +235,18 @@ jobs: IFS=',' read -r -a suites <<< "$UNIT_TEST_SUITES" local failed=0 for suite in "${suites[@]}"; do - if [[ ! "$suite" =~ ^[A-Za-z0-9_]+$ ]]; then + # A suite, or one test in it: `Suite/testName` for XCTest, + # `Suite/testName()` for Swift Testing. Without the parens a + # Swift Testing selector matches nothing, and the positive-summary + # check below fails the run rather than reporting a pass. + if [[ ! "$suite" =~ ^[A-Za-z0-9_]+(/[A-Za-z0-9_]+(\(\))?)?$ ]]; then echo "Invalid unit suite identifier" >&2 return 1 fi local suite_status=0 - local output_path="$TEST_RESULTS_ROOT/$suite.log" + local log_name="${suite//\//.}" + log_name="${log_name%()}" + local output_path="$TEST_RESULTS_ROOT/$log_name.log" set +e run_unit_suite "$output_path" "-only-testing:cmuxTests/$suite" suite_status=$? diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml index 03b94ffa9e52..7c625b2f94a8 100644 --- a/.github/workflows/test-e2e.yml +++ b/.github/workflows/test-e2e.yml @@ -191,6 +191,11 @@ jobs: build: needs: [resolve-ref, filter] runs-on: ${{ (!inputs.runner || inputs.runner == 'auto') && (vars.MACOS_RUNNER_TESTS || 'blacksmith-6vcpu-macos-26') || inputs.runner }} + # Reuse lists this contract's artifacts and downloads one from an earlier + # run. Nothing else in this lane reads the Actions API, and nothing writes. + permissions: + contents: read + actions: read timeout-minutes: ${{ fromJSON(inputs.job_timeout || '45') }} outputs: artifact_id: ${{ steps.upload-product.outputs.artifact-id }} @@ -281,28 +286,42 @@ jobs: run: | ./scripts/install-rust-ci.sh - - name: Identify the compiled product this revision needs - id: product-key - run: python3 scripts/ci/reuse_app_host_products.py key "$CMUX_DERIVED_DATA_PATH" - - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: "1.3.14" + - name: Install zig + run: | + ./scripts/install-zig-ci.sh + + # Every tool `contract()` fingerprints is installed by now. Computing the + # key earlier recorded some of them as absent, which made the published + # artifact's name disagree with the receipt sealed inside it, so nothing + # could ever adopt it. + - name: Identify the compiled product this revision needs + id: product-key + run: python3 scripts/ci/reuse_app_host_products.py key "$CMUX_DERIVED_DATA_PATH" + + - name: Reuse a compiled product instead of building one + id: reuse + continue-on-error: true + env: + GH_TOKEN: ${{ github.token }} + run: python3 scripts/ci/reuse_app_host_products.py restore "$CMUX_DERIVED_DATA_PATH" + + # Only an input to compiling, and not part of the product contract, so an + # adopted product never needs it. - name: Download pre-built GhosttyKit.xcframework + if: ${{ steps.reuse.outputs.hit != 'true' }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail ./scripts/download-prebuilt-ghosttykit.sh - - name: Install zig - run: | - ./scripts/install-zig-ci.sh - - - name: Restore E2E compilation cache + if: ${{ steps.reuse.outputs.hit != 'true' }} id: compilation-cache-restore continue-on-error: true uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 @@ -312,7 +331,7 @@ jobs: restore-keys: e2e-compilation-v1-${{ runner.os }}-${{ runner.arch }}-all-${{ steps.compilation-cache-key.outputs.fingerprint }}- - name: Discard incomplete E2E compilation cache - if: ${{ steps.compilation-cache-restore.outcome == 'failure' }} + if: ${{ steps.reuse.outputs.hit != 'true' && steps.compilation-cache-restore.outcome == 'failure' }} run: | set -euo pipefail workspace_path="$(cd "$GITHUB_WORKSPACE" && pwd -P)" @@ -325,6 +344,7 @@ jobs: echo "::warning::E2E cache restore failed; compiling with an empty cache" - name: Cache Swift packages + if: ${{ steps.reuse.outputs.hit != 'true' }} uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: .ci-source-packages @@ -332,9 +352,11 @@ jobs: restore-keys: spm- - name: Sanitize Swift package cache + if: ${{ steps.reuse.outputs.hit != 'true' }} run: python3 scripts/ci/sanitize-xcode-source-packages-cache.py .ci-source-packages - name: Resolve Swift packages + if: ${{ steps.reuse.outputs.hit != 'true' }} run: | set -euo pipefail SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages" @@ -360,8 +382,32 @@ jobs: sleep $((attempt * 5)) done + # A fresh checkout makes every file newer than any earlier build, so + # without this even a one-line test change recompiles the app host. + - name: Adopt main's DerivedData + id: warm + if: ${{ steps.reuse.outputs.hit != 'true' }} + continue-on-error: true + timeout-minutes: 10 + env: + GH_TOKEN: ${{ github.token }} + WARM_KEY: ${{ steps.compilation-cache-key.outputs.fingerprint }} + run: | + set -euo pipefail + # A fresh checkout gives every file a new inode; without this the + # build system reruns every task whose inputs merely moved. + defaults write com.apple.dt.XCBuild IgnoreFileSystemDeviceInodeChanges -bool YES + python3 scripts/ci/e2e_warm_derived_data.py restore "$GITHUB_WORKSPACE" "$CMUX_DERIVED_DATA_PATH" "$WARM_KEY" + + - name: Record build input times + id: record-inputs + if: ${{ steps.reuse.outputs.hit != 'true' }} + continue-on-error: true + run: python3 scripts/ci/e2e_warm_derived_data.py record "$GITHUB_WORKSPACE" "$CMUX_DERIVED_DATA_PATH/cmux-e2e-input-mtimes.json" + - name: Build the app-host and UI test product id: compile + if: ${{ steps.reuse.outputs.hit != 'true' }} run: | set -euo pipefail # Builds cmux, cmux-unit and cmux-numeric-locale, so one product @@ -383,7 +429,10 @@ jobs: python3 scripts/ci/app_host_test_products.py stamp "$CMUX_DERIVED_DATA_PATH" python3 scripts/ci/reuse_app_host_products.py seal "$CMUX_DERIVED_DATA_PATH" archive="$RUNNER_TEMP/app-host-products.tar.gz" - tar -chzf "$archive" -C "$CMUX_DERIVED_DATA_PATH" Build/Products + # Still a gzip stream, so every consumer reads it unchanged. The + # default level spends about a minute squeezing Mach-O binaries that + # travel between two jobs on the same fleet and are deleted after. + tar -chf - -C "$CMUX_DERIVED_DATA_PATH" Build/Products | gzip -1 > "$archive" echo "sha256=$(shasum -a 256 "$archive" | awk '{print $1}')" >> "$GITHUB_OUTPUT" echo "bytes=$(wc -c < "$archive" | tr -d ' ')" >> "$GITHUB_OUTPUT" @@ -402,12 +451,32 @@ jobs: env: COMPILE_SECONDS: ${{ steps.compile.outputs.seconds }} ARCHIVE_BYTES: ${{ steps.package.outputs.bytes }} + REUSE_HIT: ${{ steps.reuse.outputs.hit }} + REUSE_MISSES: ${{ steps.reuse.outputs.miss_reasons }} + REUSE_PRODUCER: ${{ steps.reuse.outputs.producer_run_id }} + REUSE_SAVED: ${{ steps.reuse.outputs.macos_runner_minutes_saved }} + REUSE_TRANSFER: ${{ steps.reuse.outputs.transfer_seconds }} + WARM_HIT: ${{ steps.warm.outputs.hit }} + WARM_PRODUCER: ${{ steps.warm.outputs.producer_run_id }} + WARM_CHANGED: ${{ steps.warm.outputs.changed_inputs }} + WARM_REASON: ${{ steps.warm.outputs.reason }} run: | set -euo pipefail { echo "### Compiled test product" echo - echo "Compiled from source in ${COMPILE_SECONDS:-unknown} s." + if [ "$REUSE_HIT" = "true" ]; then + echo "Adopted the product run $REUSE_PRODUCER compiled; transferred in ${REUSE_TRANSFER:-unknown} s." + echo "Avoided ${REUSE_SAVED:-unknown} macOS runner-minutes of compilation." + else + echo "Compiled from source in ${COMPILE_SECONDS:-unknown} s." + echo "No product to adopt (${REUSE_MISSES:-none})." + if [ "$WARM_HIT" = "true" ]; then + echo "Started from the DerivedData run $WARM_PRODUCER built; ${WARM_CHANGED:-unknown} inputs differed." + else + echo "Started from empty DerivedData (${WARM_REASON:-not attempted})." + fi + fi echo "Archive: ${ARCHIVE_BYTES:-unknown} bytes." } >> "$GITHUB_STEP_SUMMARY" @@ -423,6 +492,36 @@ jobs: echo "contained=$contained" >> "$GITHUB_OUTPUT" echo "Selected revision contained in main: $contained" + # Same trust rule as the compilation cache: only code main already + # contains may seed builds of other revisions. + - name: Package DerivedData for later builds + id: warm-package + continue-on-error: true + if: ${{ !cancelled() && github.ref == 'refs/heads/main' && steps.compile.outcome == 'success' && steps.record-inputs.outcome == 'success' && steps.revision-on-main.outputs.contained == 'true' }} + run: | + set -euo pipefail + archive="$RUNNER_TEMP/derived-data.tar.gz" + tar -cf - -C "$CMUX_DERIVED_DATA_PATH" --exclude ./Logs --exclude ./Index.noindex . | gzip -1 > "$archive" + bytes="$(wc -c < "$archive" | tr -d ' ')" + echo "DerivedData archive: $bytes bytes" + if [ "$bytes" -gt $((12 * 1024 * 1024 * 1024)) ]; then + echo "::warning::DerivedData archive exceeds 12 GiB; not publishing" + exit 0 + fi + echo "bytes=$bytes" >> "$GITHUB_OUTPUT" + echo "publish=true" >> "$GITHUB_OUTPUT" + + - name: Publish DerivedData for later builds + continue-on-error: true + if: ${{ !cancelled() && steps.warm-package.outputs.publish == 'true' }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: e2e-derived-data-v1-${{ steps.compilation-cache-key.outputs.fingerprint }} + path: ${{ runner.temp }}/derived-data.tar.gz + if-no-files-found: error + retention-days: 3 + compression-level: 0 + - name: Bound E2E compilation cache id: compilation-cache-bound continue-on-error: true diff --git a/.github/workflows/testbox-broker-guard.yml b/.github/workflows/testbox-broker-guard.yml index 177473a2f9bb..1f06eeaea38f 100644 --- a/.github/workflows/testbox-broker-guard.yml +++ b/.github/workflows/testbox-broker-guard.yml @@ -19,7 +19,7 @@ concurrency: jobs: guard: name: Testbox broker trust boundary - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} timeout-minutes: 10 permissions: contents: read diff --git a/.github/workflows/tmux-corpus.yml b/.github/workflows/tmux-corpus.yml index fee7709b2782..1834e09591c6 100644 --- a/.github/workflows/tmux-corpus.yml +++ b/.github/workflows/tmux-corpus.yml @@ -15,7 +15,7 @@ concurrency: jobs: terminal-nightly: if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' - runs-on: ${{ vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15' }} + runs-on: ${{ vars.CI_PAID_MACOS_OVERFLOW == '1' && vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15' }} timeout-minutes: 30 env: # XCTest app-host crashes can leave xcodebuild waiting in Swift's crash diff --git a/.github/workflows/web-validation.yml b/.github/workflows/web-validation.yml index 7d24a0b71b0a..36c29cb824f6 100644 --- a/.github/workflows/web-validation.yml +++ b/.github/workflows/web-validation.yml @@ -23,7 +23,7 @@ concurrency: jobs: changes: if: ${{ github.event_name != 'pull_request' && github.event_name != 'merge_group' }} - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} timeout-minutes: 5 outputs: required: ${{ steps.route.outputs.required }} @@ -46,7 +46,7 @@ jobs: name: web-build needs: changes if: needs.changes.outputs.required == 'true' && github.event_name != 'pull_request' && github.event_name != 'merge_group' - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} timeout-minutes: 15 defaults: run: @@ -76,7 +76,7 @@ jobs: name: web-tests needs: changes if: needs.changes.outputs.required == 'true' && github.event_name != 'pull_request' && github.event_name != 'merge_group' - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} timeout-minutes: 20 defaults: run: @@ -100,7 +100,7 @@ jobs: name: web-database-tests needs: changes if: needs.changes.outputs.required == 'true' && github.event_name != 'pull_request' && github.event_name != 'merge_group' - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} timeout-minutes: 15 defaults: run: @@ -138,7 +138,7 @@ jobs: name: web-validation needs: [changes, build, tests, database] if: always() - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} timeout-minutes: 5 steps: - name: Accept CI-owned pull-request validation diff --git a/Assets.xcassets/AgentIcons/ATTRIBUTIONS.md b/Assets.xcassets/AgentIcons/ATTRIBUTIONS.md new file mode 100644 index 000000000000..0f5418c1b525 --- /dev/null +++ b/Assets.xcassets/AgentIcons/ATTRIBUTIONS.md @@ -0,0 +1,5 @@ +# Agent icons + +Cursor, Gemini, Kiro, GitHub Copilot, CodeBuddy, Qoder, Kimi and Ollama SVGs come from [Lobe Icons](https://github.com/lobehub/lobe-icons/tree/a94750e3f5f8fc33757b839d85030e742284e43a/packages/static-svg/icons) under the MIT license in `LOBE-LICENSE.txt`. Original SVG paths are unchanged; currentColor is replaced with black and white for light and dark appearances. + +Factory uses its [published site icon](https://factory.ai/icon.svg), retrieved on 2026-09-17. The redundant outer SVG wrapper is removed for asset-catalog compatibility. diff --git a/Assets.xcassets/AgentIcons/CodeBuddy.imageset/CodeBuddy.svg b/Assets.xcassets/AgentIcons/CodeBuddy.imageset/CodeBuddy.svg new file mode 100644 index 000000000000..ec353e26418e --- /dev/null +++ b/Assets.xcassets/AgentIcons/CodeBuddy.imageset/CodeBuddy.svg @@ -0,0 +1 @@ +CodeBuddy \ No newline at end of file diff --git a/Assets.xcassets/AgentIcons/CodeBuddy.imageset/Contents.json b/Assets.xcassets/AgentIcons/CodeBuddy.imageset/Contents.json new file mode 100644 index 000000000000..4700385540b8 --- /dev/null +++ b/Assets.xcassets/AgentIcons/CodeBuddy.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images": [ + { + "filename": "CodeBuddy.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true + } +} diff --git a/Assets.xcassets/AgentIcons/Copilot.imageset/Contents.json b/Assets.xcassets/AgentIcons/Copilot.imageset/Contents.json new file mode 100644 index 000000000000..29834aaeedf3 --- /dev/null +++ b/Assets.xcassets/AgentIcons/Copilot.imageset/Contents.json @@ -0,0 +1,25 @@ +{ + "images": [ + { + "filename": "Copilot.svg", + "idiom": "universal" + }, + { + "filename": "Copilot-dark.svg", + "idiom": "universal", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true + } +} diff --git a/Assets.xcassets/AgentIcons/Copilot.imageset/Copilot-dark.svg b/Assets.xcassets/AgentIcons/Copilot.imageset/Copilot-dark.svg new file mode 100644 index 000000000000..ef5e74f4541f --- /dev/null +++ b/Assets.xcassets/AgentIcons/Copilot.imageset/Copilot-dark.svg @@ -0,0 +1 @@ +GithubCopilot \ No newline at end of file diff --git a/Assets.xcassets/AgentIcons/Copilot.imageset/Copilot.svg b/Assets.xcassets/AgentIcons/Copilot.imageset/Copilot.svg new file mode 100644 index 000000000000..fd0bc9ed7aa2 --- /dev/null +++ b/Assets.xcassets/AgentIcons/Copilot.imageset/Copilot.svg @@ -0,0 +1 @@ +GithubCopilot \ No newline at end of file diff --git a/Assets.xcassets/AgentIcons/Cursor.imageset/Contents.json b/Assets.xcassets/AgentIcons/Cursor.imageset/Contents.json new file mode 100644 index 000000000000..fefa738bedee --- /dev/null +++ b/Assets.xcassets/AgentIcons/Cursor.imageset/Contents.json @@ -0,0 +1,25 @@ +{ + "images": [ + { + "filename": "Cursor.svg", + "idiom": "universal" + }, + { + "filename": "Cursor-dark.svg", + "idiom": "universal", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true + } +} diff --git a/Assets.xcassets/AgentIcons/Cursor.imageset/Cursor-dark.svg b/Assets.xcassets/AgentIcons/Cursor.imageset/Cursor-dark.svg new file mode 100644 index 000000000000..4b6f0ed522cc --- /dev/null +++ b/Assets.xcassets/AgentIcons/Cursor.imageset/Cursor-dark.svg @@ -0,0 +1 @@ +Cursor \ No newline at end of file diff --git a/Assets.xcassets/AgentIcons/Cursor.imageset/Cursor.svg b/Assets.xcassets/AgentIcons/Cursor.imageset/Cursor.svg new file mode 100644 index 000000000000..f0cb42874a75 --- /dev/null +++ b/Assets.xcassets/AgentIcons/Cursor.imageset/Cursor.svg @@ -0,0 +1 @@ +Cursor \ No newline at end of file diff --git a/Assets.xcassets/AgentIcons/Factory.imageset/Contents.json b/Assets.xcassets/AgentIcons/Factory.imageset/Contents.json new file mode 100644 index 000000000000..efc8a52ab5a1 --- /dev/null +++ b/Assets.xcassets/AgentIcons/Factory.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images": [ + { + "filename": "Factory.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true + } +} diff --git a/Assets.xcassets/AgentIcons/Factory.imageset/Factory.svg b/Assets.xcassets/AgentIcons/Factory.imageset/Factory.svg new file mode 100644 index 000000000000..38aad643a75e --- /dev/null +++ b/Assets.xcassets/AgentIcons/Factory.imageset/Factory.svg @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file diff --git a/Assets.xcassets/AgentIcons/Gemini.imageset/Contents.json b/Assets.xcassets/AgentIcons/Gemini.imageset/Contents.json new file mode 100644 index 000000000000..e237b2d5b5fd --- /dev/null +++ b/Assets.xcassets/AgentIcons/Gemini.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images": [ + { + "filename": "Gemini.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true + } +} diff --git a/Assets.xcassets/AgentIcons/Gemini.imageset/Gemini.svg b/Assets.xcassets/AgentIcons/Gemini.imageset/Gemini.svg new file mode 100644 index 000000000000..e15f1f7efae4 --- /dev/null +++ b/Assets.xcassets/AgentIcons/Gemini.imageset/Gemini.svg @@ -0,0 +1 @@ +Gemini CLI \ No newline at end of file diff --git a/Assets.xcassets/AgentIcons/Kimi.imageset/Contents.json b/Assets.xcassets/AgentIcons/Kimi.imageset/Contents.json new file mode 100644 index 000000000000..1eb219aa99fc --- /dev/null +++ b/Assets.xcassets/AgentIcons/Kimi.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images": [ + { + "filename": "Kimi.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true + } +} diff --git a/Assets.xcassets/AgentIcons/Kimi.imageset/Kimi.svg b/Assets.xcassets/AgentIcons/Kimi.imageset/Kimi.svg new file mode 100644 index 000000000000..83878fa28476 --- /dev/null +++ b/Assets.xcassets/AgentIcons/Kimi.imageset/Kimi.svg @@ -0,0 +1 @@ +Kimi \ No newline at end of file diff --git a/Assets.xcassets/AgentIcons/Kiro.imageset/Contents.json b/Assets.xcassets/AgentIcons/Kiro.imageset/Contents.json new file mode 100644 index 000000000000..c5437ff6ad83 --- /dev/null +++ b/Assets.xcassets/AgentIcons/Kiro.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images": [ + { + "filename": "Kiro.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true + } +} diff --git a/Assets.xcassets/AgentIcons/Kiro.imageset/Kiro.svg b/Assets.xcassets/AgentIcons/Kiro.imageset/Kiro.svg new file mode 100644 index 000000000000..0c651b9747d0 --- /dev/null +++ b/Assets.xcassets/AgentIcons/Kiro.imageset/Kiro.svg @@ -0,0 +1 @@ +Kiro \ No newline at end of file diff --git a/Assets.xcassets/AgentIcons/LOBE-LICENSE.txt b/Assets.xcassets/AgentIcons/LOBE-LICENSE.txt new file mode 100644 index 000000000000..1dd53d2a9d99 --- /dev/null +++ b/Assets.xcassets/AgentIcons/LOBE-LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 LobeHub + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Assets.xcassets/AgentIcons/Ollama.imageset/Contents.json b/Assets.xcassets/AgentIcons/Ollama.imageset/Contents.json new file mode 100644 index 000000000000..53532f35516e --- /dev/null +++ b/Assets.xcassets/AgentIcons/Ollama.imageset/Contents.json @@ -0,0 +1,25 @@ +{ + "images": [ + { + "filename": "Ollama.svg", + "idiom": "universal" + }, + { + "filename": "Ollama-dark.svg", + "idiom": "universal", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true + } +} diff --git a/Assets.xcassets/AgentIcons/Ollama.imageset/Ollama-dark.svg b/Assets.xcassets/AgentIcons/Ollama.imageset/Ollama-dark.svg new file mode 100644 index 000000000000..a758e0f55587 --- /dev/null +++ b/Assets.xcassets/AgentIcons/Ollama.imageset/Ollama-dark.svg @@ -0,0 +1 @@ +Ollama \ No newline at end of file diff --git a/Assets.xcassets/AgentIcons/Ollama.imageset/Ollama.svg b/Assets.xcassets/AgentIcons/Ollama.imageset/Ollama.svg new file mode 100644 index 000000000000..06947b8d698a --- /dev/null +++ b/Assets.xcassets/AgentIcons/Ollama.imageset/Ollama.svg @@ -0,0 +1 @@ +Ollama \ No newline at end of file diff --git a/Assets.xcassets/AgentIcons/Qoder.imageset/Contents.json b/Assets.xcassets/AgentIcons/Qoder.imageset/Contents.json new file mode 100644 index 000000000000..14cc6097d0ab --- /dev/null +++ b/Assets.xcassets/AgentIcons/Qoder.imageset/Contents.json @@ -0,0 +1,25 @@ +{ + "images": [ + { + "filename": "Qoder.svg", + "idiom": "universal" + }, + { + "filename": "Qoder-dark.svg", + "idiom": "universal", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true + } +} diff --git a/Assets.xcassets/AgentIcons/Qoder.imageset/Qoder-dark.svg b/Assets.xcassets/AgentIcons/Qoder.imageset/Qoder-dark.svg new file mode 100644 index 000000000000..1301a9de4582 --- /dev/null +++ b/Assets.xcassets/AgentIcons/Qoder.imageset/Qoder-dark.svg @@ -0,0 +1 @@ +Qoder \ No newline at end of file diff --git a/Assets.xcassets/AgentIcons/Qoder.imageset/Qoder.svg b/Assets.xcassets/AgentIcons/Qoder.imageset/Qoder.svg new file mode 100644 index 000000000000..36ffff45c37d --- /dev/null +++ b/Assets.xcassets/AgentIcons/Qoder.imageset/Qoder.svg @@ -0,0 +1 @@ +Qoder \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index cca8b1373490..e1991829a6e4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -196,6 +196,16 @@ Three things about it are easy to get wrong: A callsign is attribution, never authority. The worker attempt is identified by `callsign + run ID + session ID + lease generation`; that tuple records who acted and grants nothing. Do not gate an action on a callsign, and do not treat a comment bearing one as authenticated — marker text is not an authenticated principal, which is the defect `teamleaderleo/quarry` #1103 tracks. +## Outside contributors + +Most open PRs from people outside the team never got a human reply: of 810 open on 2026-09-23, 765 had only bot comments. Several were fixed on `main` by a maintainer PR while the contributor's PR sat open, and the contributor found out on their own. + +Before fixing a bug or building a feature, run `gh search prs --repo manaflow-ai/cmux --state open ''` and look for an outside PR (author not on the team). If one exists: + +- Prefer landing theirs. Push fixups to their branch when "Allow edits by maintainers" is on, and say what you changed. +- If you write your own fix instead, add `Co-authored-by: Name ` for them to every commit that uses their approach, using the email from their commits (`git log --format='%an <%ae>'` on their branch). Then comment on their PR with a link to yours and a plain thank-you, and let a human close it. +- Never close an outside PR without a human-written comment saying why. + ## Choosing CI coverage `full-ci` requests the expensive full macOS suite policy. It is not shorthand @@ -227,6 +237,35 @@ The main agent owns dogfood, approval, mergeability, and every pushed fix. Mergi Notify through `cmux notify` so the user can leave and return. Handoff: `--title "Dogfood ready: " --subtitle " · " --body "Was: . Now: . . PR: "`. Later closeout notifications use `"CI green: "` or `"CI blocked: "` with a one-line cause and the next decision. Titles carry outcome and branch, bodies carry the single next action. Skip notify if there is no cmux socket. +## Reading CI cost + +Three measurements that are routinely read wrong, each established against +`test-e2e.yml` on 2026-09-23 over a 98-run window. + +**A cancelled job's duration is usually queue, not spend.** GitHub sets a +queued job's `started_at` to when it entered the queue, so a run that waited 45 +minutes for a runner and was then cancelled reports a 45-minute job. Check +`runner_name` and `steps`: both empty means no runner was ever assigned and the +job burned nothing. Of 20 cancelled runs totalling an apparent 239 macOS +runner-minutes, 15 never got a runner and the real spend was 46. All 15 were +waiting on `blacksmith-6vcpu-macos-15`, whose queue then ran a 26-minute median +against 0.6 minutes for the macOS 26 pool. + +**Compiling fewer schemes saves almost nothing.** `build-for-testing` over +`cmux`, `cmux-unit` and `cmux-numeric-locale` costs 691 s, 28 s and 16 s. The +app scheme is 94% of it and is the test host every app-host test needs, so +selecting schemes per test target is not a lever. What the schemes cost is +worth re-measuring before any plan depends on splitting them. + +**The compile is close to binary, and one file decides it.** Against the same +restored compilation cache, a revision with no changed native sources compiled +in 280 s; a revision differing by a single file in `Sources/` took 737 s. The +cause is not established (Debug builds are not whole-module), but "small diff" +does not mean "short build", +and a cache seeded from a commit that has since drifted is worth much less than +its hit rate suggests. Prefer adopting an already-compiled product over +reasoning about cache warmth. + ## Pitfalls Each of these has full detail in the skill named in parentheses. diff --git a/CLI/CMUXCLI+AutoNamingSummarizers.swift b/CLI/CMUXCLI+AutoNamingSummarizers.swift index a7d5d884dba4..1439f588e80f 100644 --- a/CLI/CMUXCLI+AutoNamingSummarizers.swift +++ b/CLI/CMUXCLI+AutoNamingSummarizers.swift @@ -66,7 +66,7 @@ extension CMUXCLI { "--verbatim" ] stdinPrompt = "" - case "pi", "omp": + case "pi": guard let promptPath = promptFile() else { return nil } executablePath = executable() arguments = [ @@ -81,6 +81,22 @@ extension CMUXCLI { "Generate a 2-5 word title from the attached conversation excerpt. Output only the title." ] stdinPrompt = "" + case "omp": + guard let promptPath = promptFile() else { return nil } + executablePath = executable() + // OMP uses rules for context isolation and rejects Pi's + // --no-prompt-templates and --no-context-files flags. + arguments = [ + "--print", + "--no-tools", + "--no-session", + "--no-extensions", + "--no-skills", + "--no-rules", + "@\(promptPath)", + "Generate a 2-5 word title from the attached conversation excerpt. Output only the title." + ] + stdinPrompt = "" default: return nil } diff --git a/CLI/CMUXCLI+SSHStartupScripts.swift b/CLI/CMUXCLI+SSHStartupScripts.swift index 0281cbfb398e..470a618c4451 100644 --- a/CLI/CMUXCLI+SSHStartupScripts.swift +++ b/CLI/CMUXCLI+SSHStartupScripts.swift @@ -344,13 +344,14 @@ extension CMUXCLI { scriptLines += ["cmux_ssh_foreground_auth() {", trimmedOneTimeCommand, "}"] scriptLines.append(authRetryPolicy.processTreeTerminationShellFunction()) } - let reconnectConfiguration = retryPTYAttachStatus ? [ - // A missing limit used to mean infinity, which left a corrupt or - // permanently unavailable daemon spinning forever in the pane. - // Keep the supervisor finite even when an old persisted launcher - // omitted CMUX_SSH_RECONNECT_LIMIT. - "cmux_ssh_reconnect_limit=\"${CMUX_SSH_RECONNECT_LIMIT:-20}\"", - "case \"$cmux_ssh_reconnect_limit\" in ''|*[!0-9]*) cmux_ssh_reconnect_limit=20 ;; *) while [ \"${cmux_ssh_reconnect_limit#0}\" != \"$cmux_ssh_reconnect_limit\" ] && [ \"$cmux_ssh_reconnect_limit\" != 0 ]; do cmux_ssh_reconnect_limit=\"${cmux_ssh_reconnect_limit#0}\"; done; case \"$cmux_ssh_reconnect_limit\" in [1-9]|1[0-9]|20) ;; *) cmux_ssh_reconnect_limit=20 ;; esac ;; esac", + // A missing limit used to mean infinity, which left a corrupt or + // permanently unavailable daemon spinning forever in the pane. + // Keep the supervisor finite even when an old persisted launcher + // omitted CMUX_SSH_RECONNECT_LIMIT. + let reconnectLimitLines = SSHReconnectBudget().limitNormalizationShellLines( + variable: "cmux_ssh_reconnect_limit" + ) + let reconnectConfiguration = retryPTYAttachStatus ? reconnectLimitLines + [ "cmux_ssh_reconnect_delay=\"${CMUX_SSH_RECONNECT_DELAY_SECONDS:-2}\"", "case \"$cmux_ssh_reconnect_delay\" in ''|*[!0-9]*|0*) cmux_ssh_reconnect_delay=2 ;; esac", "cmux_ssh_reconnect_max_delay=\"${CMUX_SSH_RECONNECT_MAX_DELAY_SECONDS:-30}\"", diff --git a/CLI/CMUXCLI+VMTui.swift b/CLI/CMUXCLI+VMTui.swift index 783777f88442..fffca7052881 100644 --- a/CLI/CMUXCLI+VMTui.swift +++ b/CLI/CMUXCLI+VMTui.swift @@ -457,7 +457,20 @@ extension CMUXCLI { // create sessions; opening or reconnecting the machine does not. let terminalStartedAt = Date() do { - let catalog = try client.sendV2(method: "surface.catalog", params: ["machine": vmId, "refresh": true], responseTimeout: 180) + // The snapshot contract creates the first remote workspace and + // terminal before the daemon accepts clients, so one link plus + // one graph read is all New Machine needs to find it. + // + // `ensure_linked` is that minimum, and it is required: a machine + // created a moment ago has no provider and no link in this app, + // so a plain cached read returns no graph and the resolver + // reports `.unavailable` ("The machine's sessions are + // unavailable"). That regression shipped once when the flag was + // dropped to "save work". Do not remove it, and do not upgrade it + // to `refresh: true`: a forced pass waits behind the fleet poll's + // in-flight connect and rescans ports for nothing. A reopen of a + // machine that is already linked costs no network at all. + let catalog = try client.sendV2(method: "surface.catalog", params: ["machine": vmId, "ensure_linked": true], responseTimeout: 180) let opened: [String: Any] switch VMRemoteWorkspaceResolver().resolveVMMachineTerminal(machine: vmId, catalog: catalog) { case .resolved(let remoteWorkspaceID, let terminalID, let tabID): diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 78de43ac076d..771838a37ad8 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -11756,7 +11756,7 @@ struct CMUXCLI { } else { let splitAttachCommand = [ "env", - "CMUX_SSH_RECONNECT_LIMIT=${CMUX_SSH_RECONNECT_LIMIT:-86400}", + "CMUX_SSH_RECONNECT_LIMIT=${CMUX_SSH_RECONNECT_LIMIT:-\(SSHReconnectBudget().maximumLimit)}", "CMUX_SSH_RECONNECT_DELAY_SECONDS=${CMUX_SSH_RECONNECT_DELAY_SECONDS:-2}", shellQuote(executablePath), "vm", @@ -11769,7 +11769,7 @@ struct CMUXCLI { sshCommand: splitAttachCommand, shellFeatures: shellFeaturesValue, remoteRelayPort: 0, - reconnectLimitDefault: 86400 + reconnectLimitDefault: SSHReconnectBudget().maximumLimit ) } } else { @@ -13410,7 +13410,7 @@ struct CMUXCLI { let quotedVMID = shellQuote(vmID) let lines = [ "cmux_freestyle_cli=\(quotedCLI)", - "CMUX_SSH_RECONNECT_LIMIT=\"${CMUX_SSH_RECONNECT_LIMIT:-86400}\"", + "CMUX_SSH_RECONNECT_LIMIT=\"${CMUX_SSH_RECONNECT_LIMIT:-\(SSHReconnectBudget().maximumLimit)}\"", "CMUX_SSH_RECONNECT_DELAY_SECONDS=\"${CMUX_SSH_RECONNECT_DELAY_SECONDS:-2}\"", "CMUX_DEFAULT_FREESTYLE_ATTACH_RETRY_LIMIT=\"${CMUX_DEFAULT_FREESTYLE_ATTACH_RETRY_LIMIT:-$CMUX_SSH_RECONNECT_LIMIT}\"", "CMUX_DEFAULT_FREESTYLE_ATTACH_RETRY_DELAY_SECONDS=\"${CMUX_DEFAULT_FREESTYLE_ATTACH_RETRY_DELAY_SECONDS:-$CMUX_SSH_RECONNECT_DELAY_SECONDS}\"", diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+PresenceRouteSync.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+PresenceRouteSync.swift index b88d383e5d39..7d2299dbe5b6 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+PresenceRouteSync.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+PresenceRouteSync.swift @@ -190,9 +190,17 @@ extension MobileShellComposite { macDeviceID: instance.deviceId, instanceTag: instance.tag ) + // An untagged legacy row adopts its device's sole + // route-advertising build; `applyPushedRoutes` checks that + // this instance is that build before writing. + let legacyPairingID = MobilePairedMac.pairingID( + macDeviceID: instance.deviceId, + instanceTag: nil + ) if await self.applyPushedRoutes( from: instance, - pairedMac: pairedMacsByPairingID[pairingID], + pairedMac: pairedMacsByPairingID[pairingID] + ?? pairedMacsByPairingID[legacyPairingID], scope: scope ) { persistedRoutes = true diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index 9aa9706b46e1..8860565d025d 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -6499,8 +6499,11 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { // A renamed/repaired row may be the currently authenticated // identity even though presence still names its historical id. // Let physical-route coalescing choose that authoritative row. + // The alias set holds bare device ids, so match the build too: an + // online sibling build on the same device is not this row's alias. let aliasIDs = physicalAliasIDsByCanonicalID[pairingID] ?? [canonicalID] + let instanceTag = $0.instanceTag return visibleLoadedMacs.contains { candidate in let candidatePairingID = MobilePairedMac.pairingID( macDeviceID: candidate.macDeviceID, @@ -6508,6 +6511,10 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { ) return exactOnlinePairingIDs.contains(candidatePairingID) && aliasIDs.contains(cmxCanonicalDeviceID(candidate.macDeviceID)) + && macInstanceTagAuthority.sameStoredAuthority( + candidate.instanceTag, + instanceTag + ) } } // Sibling builds of one physical Mac are distinct aggregation targets, diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerPendingAttachmentTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerPendingAttachmentTests.swift index 378f8c73df79..f8f41f8e6a19 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerPendingAttachmentTests.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerPendingAttachmentTests.swift @@ -14,8 +14,10 @@ import Testing private static let terminalA = MobileTerminalPreview(id: "term-a", name: "a") private static let terminalB = MobileTerminalPreview(id: "term-b", name: "b") - /// A composite selected on `term-a`. Selection is set by `init` (no `didSet` - /// draft swap fires), so the store contents stay exactly what each test seeds. + /// A composite selected on `term-a`. `init` leaves the selection to the + /// workspace synchronizer, which picks `term-a` and records one + /// `surfaceFocused` event. The draft swap it runs loads `term-a`'s empty + /// draft, so the store contents stay exactly what each test seeds. private static func makeComposite(diagnosticLog: DiagnosticLog? = nil) -> MobileShellComposite { MobileShellComposite( workspaces: [ @@ -55,17 +57,21 @@ import Testing let clock = ContinuousClock() let deadline = clock.now.advanced(by: .seconds(1)) - while await log.processedCount() < 3, clock.now < deadline { + while await log.processedCount() < 4, clock.now < deadline { await Task.yield() } - #expect(await log.processedCount() >= 3) + #expect(await log.processedCount() >= 4) let report = await log.snapshot() + // The first event is construction focusing `term-a` (see + // `makeComposite`), not an attachment mutation. #expect(report.events.map(\.a) == [ + DiagnosticAppEventKind.surfaceFocused.rawValue, DiagnosticAppEventKind.terminalAttachmentStaged.rawValue, DiagnosticAppEventKind.terminalAttachmentRemoved.rawValue, DiagnosticAppEventKind.terminalAttachmentRejected.rawValue, ]) #expect(report.events.map(\.b) == [ + nil, nil, nil, DiagnosticFailureKind.protocolViolation.rawValue, diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacConnectionPoolTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacConnectionPoolTests.swift index a359f7af636e..cf45f11d64b9 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacConnectionPoolTests.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacConnectionPoolTests.swift @@ -318,13 +318,13 @@ import Testing let historicalAlias = paired( id: "mac-before-rename", displayName: "Old Name", - instanceTag: "old-tag", + instanceTag: "renamed-tag", seenAt: .distantPast ) let currentIdentity = paired( id: "mac-after-rename", displayName: "New Name", - instanceTag: "new-tag", + instanceTag: "renamed-tag", seenAt: Date() ) let shell = MobileShellComposite( @@ -343,7 +343,7 @@ import Testing Self.snapshot([ Self.instance( deviceID: historicalAlias.macDeviceID, - tag: "old-tag", + tag: "renamed-tag", online: true ), ]), @@ -394,13 +394,13 @@ import Testing let historicalAlias = paired( id: "mac-auth-before-rename", displayName: "Old Name", - instanceTag: "old-tag", + instanceTag: "renamed-tag", seenAt: .distantPast ) let currentIdentity = paired( id: "mac-auth-after-rename", displayName: "New Name", - instanceTag: "new-tag", + instanceTag: "renamed-tag", seenAt: Date() ) let pairedStore = DelayedTeamPairedMacStore( @@ -473,7 +473,7 @@ import Testing isActive: false, stackUserID: "user-1", teamID: "team-1", - instanceTag: "old-aggregate-tag" + instanceTag: "aggregate-tag" ) let currentIdentity = MobilePairedMac( macDeviceID: "mac-aggregate-after-rename", @@ -484,7 +484,7 @@ import Testing isActive: false, stackUserID: "user-1", teamID: "team-1", - instanceTag: "new-aggregate-tag" + instanceTag: "aggregate-tag" ) let pairedStore = DelayedTeamPairedMacStore( recordsByTeam: [ @@ -1401,6 +1401,7 @@ import Testing ) } store.foregroundMacDeviceID = focused.macDeviceID + store.activeMacInstanceTag = focused.instanceTag store.activeRoute = focusedRoute let candidates = store.secondaryAggregationCandidateMacs( @@ -3621,7 +3622,10 @@ import Testing probeTimeoutNanoseconds: 1_000_000_000 ) let macDeviceID = try #require(shell.foregroundMacDeviceID) - let connection = try #require(shell.connections[macDeviceID]) + let connection = try #require(shell.connections[MacPairingKey( + macDeviceID: macDeviceID, + instanceTag: shell.activeMacInstanceTag + )]) let initialSubscribeCount = await router.count(of: "mobile.events.subscribe") await router.delaySubscribeRequest( diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlCommandCoordinator+Workspace.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlCommandCoordinator+Workspace.swift index cd34ec176c76..a6ed58459d89 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlCommandCoordinator+Workspace.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlCommandCoordinator+Workspace.swift @@ -259,13 +259,118 @@ extension ControlCommandCoordinator { ]) } + /// The id params `workspace.reorder` resolves, in the order a caller should + /// hear about a failure: the subject before the relative target. + private func workspaceReorderIDKeys() -> [String] { + ["workspace_id", "before_workspace_id", "after_workspace_id"] + } + + /// Whether a value could ever name a workspace: a UUID, or a minted + /// `kind:N` handle ref. `uuid(_:_:)` accepts exactly these two spellings, + /// so anything else is a value the registry was never going to resolve — + /// a typo, not an object that went away. Both `workspace.reorder` and + /// `workspace.reorder_many` split on this, so the two methods agree on the + /// same input. + private func isWorkspaceReferenceShaped(_ raw: String) -> Bool { + if UUID(uuidString: raw) != nil { return true } + guard let colon = raw.firstIndex(of: ":") else { return false } + let kind = String(raw[raw.startIndex.. Set? { + guard case .resolved(_, let workspaces, _)? = context?.controlWorkspaceList( + routing: routingSelectors(params) + ) else { return nil } + return Set(workspaces.map(\.id)) + } + + /// Builds one failure reply for a reorder, naming the id that could not be + /// resolved instead of always naming the subject workspace. + /// + /// `workspace` carries the caller's own spelling, so a stale `kind:N` ref + /// comes back verbatim and the caller can see which value to replace; + /// `workspace_id` stays a UUID, or JSON `null` when the value never + /// resolved to one. The planner reports one opaque `notFound` for "subject + /// missing" and "target missing" alike, so the workspace list is re-read — + /// on this error path only — to tell them apart. + private func workspaceReorderResolutionFailure( + _ params: [String: JSONValue], + subject: String + ) -> ControlCallResult { + let strings = context?.controlWorkspaceStrings() + func failure(param: String, value: String, id: UUID?) -> ControlCallResult { + .err(code: "not_found", message: strings?.workspaceNotFound ?? "", data: .object([ + "param": .string(param), + "workspace": .string(value), + "workspace_id": orNull(id?.uuidString), + ])) + } + let supplied = workspaceReorderIDKeys().compactMap { key -> (key: String, raw: String)? in + guard let raw = string(params, key) else { return nil } + return (key, raw) + } + if let unresolvable = supplied.first(where: { uuid(params, $0.key) == nil }) { + // A stale `workspace:7` named a real workspace once, so it reports + // the object as gone. `"potato"` never could, so it stays a param + // error — the same split `workspace.reorder_many` makes. + guard isWorkspaceReferenceShaped(unresolvable.raw) else { + return .err( + code: "invalid_params", + message: strings?.invalidWorkspaceRef ?? "", + data: .object([ + "param": .string(unresolvable.key), + "workspace": .string(unresolvable.raw), + ]) + ) + } + return failure(param: unresolvable.key, value: unresolvable.raw, id: nil) + } + // With no relative target, `supplied` holds only the subject, so the + // list read cannot distinguish anything: the branch below and the + // fallback return byte-identical payloads. Skip it. That is the only + // shape the sidebar sends (`SwiftViewInterpreter` defaults `Reorderable` + // to workspace_id + index), and `controlWorkspaceList` bridges a remote + // status payload and formats timestamps for every workspace on the main + // actor, so this is the difference between one wasted full list read per + // failed drop and none. + let hasRelativeTarget = hasNonNull(params, "before_workspace_id") + || hasNonNull(params, "after_workspace_id") + if hasRelativeTarget, + let live = workspaceReorderLiveIDs(params), + let absent = supplied.first(where: { entry in + guard let id = uuid(params, entry.key) else { return false } + return !live.contains(id) + }) { + return failure(param: absent.key, value: absent.raw, id: uuid(params, absent.key)) + } + return failure(param: "workspace_id", value: subject, id: uuid(params, "workspace_id")) + } + /// `workspace.reorder` — move one workspace to an index/relative target. func workspaceReorder(_ params: [String: JSONValue]) -> ControlCallResult { + let strings = context?.controlWorkspaceStrings() guard context?.controlWorkspaceRoutingResolvesTabManager(routing: routingSelectors(params)) ?? false else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) + return .err(code: "unavailable", message: strings?.tabManagerUnavailable ?? "", data: nil) } + guard let subject = string(params, "workspace_id") else { + return .err(code: "invalid_params", message: strings?.reorderMissingWorkspaceID ?? "", data: nil) + } + // A ref the registry once minted names an object that is gone, which is + // the same failure a stale `before_workspace_id` reports. A value that + // could never have named a workspace stays `invalid_params`, and so + // does a param `string(_:_:)` cannot read at all. guard let workspaceID = uuid(params, "workspace_id") else { - return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil) + return workspaceReorderResolutionFailure(params, subject: subject) } let index = int(params, "index") @@ -277,13 +382,35 @@ extension ControlCommandCoordinator { // must neither look like a missing target nor hide a conflicting one. let targetCount = ["index", "before_workspace_id", "after_workspace_id"] .filter { hasNonNull(params, $0) }.count - if targetCount != 1 || (hasNonNull(params, "index") && index == nil) { + if targetCount != 1 { return .err( code: "invalid_params", - message: "Specify exactly one target: index, before_workspace_id, or after_workspace_id", + message: strings?.reorderTargetRequired ?? "", data: nil ) } + if hasNonNull(params, "index"), index == nil { + return .err( + code: "invalid_params", + message: strings?.reorderIndexNotAnInteger ?? "", + data: .object(["param": .string("index")]) + ) + } + // `hasNonNull` is true for values `uuid` can never read — `""`, + // whitespace, and non-string JSON. There is no id to look up, so this + // is a type error rather than a missing workspace. + if let malformed = ["before_workspace_id", "after_workspace_id"].first(where: { + hasNonNull(params, $0) && string(params, $0) == nil + }) { + // The message stays flat and shared with `workspace.reorder_many`; + // `data.param` already names the param, so interpolating it would + // only make the string untranslatable. + return .err( + code: "invalid_params", + message: strings?.invalidWorkspaceRef ?? "", + data: .object(["param": .string(malformed)]) + ) + } let resolution: ControlWorkspaceReorderResolution if (hasNonNull(params, "before_workspace_id") && beforeID == nil) @@ -301,9 +428,7 @@ extension ControlCommandCoordinator { } switch resolution { case .notFound: - return .err(code: "not_found", message: "Workspace not found", data: .object([ - "workspace_id": .string(workspaceID.uuidString), - ])) + return workspaceReorderResolutionFailure(params, subject: subject) case .resolved(let windowID, let plan): var object: [String: JSONValue] = [ "workspace_id": .string(plan.workspaceID.uuidString), @@ -331,7 +456,7 @@ extension ControlCommandCoordinator { if let invalid = rawOrder.invalidValue { return .err( code: "invalid_params", - message: strings?.reorderManyInvalidWorkspace ?? "", + message: strings?.invalidWorkspaceRef ?? "", data: .object(["workspace": .string(invalid)]) ) } @@ -348,9 +473,25 @@ extension ControlCommandCoordinator { workspaceIDs.reserveCapacity(order.count) for raw in order { guard let workspaceID = uuidAny(.string(raw)) else { + // The registry forgets a ref when its workspace closes, so a + // stale `workspace:7` lands here too. It named something once: + // report it gone, as `workspace.reorder` does for the same ref. + // The id keys stay present, as `null`, so this reply has the + // same shape as the `.workspaceNotFound` one below. + if isWorkspaceReferenceShaped(raw) { + return .err( + code: "not_found", + message: strings?.workspaceNotFound ?? "", + data: .object([ + "workspace": .string(raw), + "workspace_id": .null, + "workspace_ref": .null, + ]) + ) + } return .err( code: "invalid_params", - message: strings?.reorderManyInvalidWorkspace ?? "", + message: strings?.invalidWorkspaceRef ?? "", data: .object(["workspace": .string(raw)]) ) } @@ -382,7 +523,7 @@ extension ControlCommandCoordinator { case .workspaceNotFound(let workspaceID): return .err( code: "not_found", - message: strings?.reorderManyWorkspaceNotFound ?? "", + message: strings?.workspaceNotFound ?? "", data: .object([ "workspace_id": .string(workspaceID.uuidString), "workspace_ref": ref(.workspace, workspaceID), diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlWorkspaceStrings.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlWorkspaceStrings.swift index 5de11528070d..fe8ad43f63f9 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlWorkspaceStrings.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlWorkspaceStrings.swift @@ -17,10 +17,19 @@ public struct ControlWorkspaceStrings: Sendable, Equatable { public let reorderManyMissingOrder: String /// `socket.workspace.reorderMany.duplicateWorkspace`. public let reorderManyDuplicateWorkspace: String - /// `socket.workspace.reorderMany.workspaceNotFound`. - public let reorderManyWorkspaceNotFound: String - /// `socket.workspace.reorderMany.invalidWorkspace`. - public let reorderManyInvalidWorkspace: String + /// `socket.workspace.reorderMany.workspaceNotFound` — shared by + /// `workspace.reorder`, which reports the same failure for the same reason. + public let workspaceNotFound: String + /// `socket.workspace.reorderMany.invalidWorkspace` — shared by + /// `workspace.reorder`, so a value neither method can read reads the same + /// either way. + public let invalidWorkspaceRef: String + /// `socket.workspace.reorder.indexNotAnInteger`. + public let reorderIndexNotAnInteger: String + /// `socket.workspace.reorder.missingWorkspaceID`. + public let reorderMissingWorkspaceID: String + /// `socket.workspace.reorder.targetRequired`. + public let reorderTargetRequired: String /// `socket.workspace.reorderMany.tabManagerUnavailable`. public let reorderManyTabManagerUnavailable: String /// `socket.workspace.list.tabManagerUnavailable`. @@ -35,8 +44,11 @@ public struct ControlWorkspaceStrings: Sendable, Equatable { /// - closeFailed: The `workspace.close` local-teardown failure message. /// - reorderManyMissingOrder: The missing-order message. /// - reorderManyDuplicateWorkspace: The duplicate-workspace message. - /// - reorderManyWorkspaceNotFound: The workspace-not-found message. - /// - reorderManyInvalidWorkspace: The invalid-workspace message. + /// - workspaceNotFound: The workspace-not-found message. + /// - invalidWorkspaceRef: The invalid-workspace message. + /// - reorderIndexNotAnInteger: The unreadable-`index` message. + /// - reorderMissingWorkspaceID: The missing-subject message. + /// - reorderTargetRequired: The wrong-target-count message. /// - reorderManyTabManagerUnavailable: The TabManager-unavailable message. /// - tabManagerUnavailable: The localized workspace-list unavailable message. /// - relayOwnerUnavailable: The stale relay-owner message. @@ -45,8 +57,11 @@ public struct ControlWorkspaceStrings: Sendable, Equatable { closeFailed: String, reorderManyMissingOrder: String, reorderManyDuplicateWorkspace: String, - reorderManyWorkspaceNotFound: String, - reorderManyInvalidWorkspace: String, + workspaceNotFound: String, + invalidWorkspaceRef: String, + reorderIndexNotAnInteger: String, + reorderMissingWorkspaceID: String, + reorderTargetRequired: String, reorderManyTabManagerUnavailable: String, tabManagerUnavailable: String = "TabManager not available", relayOwnerUnavailable: String @@ -55,8 +70,11 @@ public struct ControlWorkspaceStrings: Sendable, Equatable { self.closeFailed = closeFailed self.reorderManyMissingOrder = reorderManyMissingOrder self.reorderManyDuplicateWorkspace = reorderManyDuplicateWorkspace - self.reorderManyWorkspaceNotFound = reorderManyWorkspaceNotFound - self.reorderManyInvalidWorkspace = reorderManyInvalidWorkspace + self.workspaceNotFound = workspaceNotFound + self.invalidWorkspaceRef = invalidWorkspaceRef + self.reorderIndexNotAnInteger = reorderIndexNotAnInteger + self.reorderMissingWorkspaceID = reorderMissingWorkspaceID + self.reorderTargetRequired = reorderTargetRequired self.reorderManyTabManagerUnavailable = reorderManyTabManagerUnavailable self.tabManagerUnavailable = tabManagerUnavailable self.relayOwnerUnavailable = relayOwnerUnavailable diff --git a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlClientAsyncTransportTests.swift b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlClientAsyncTransportTests.swift index 80dfb3126a9a..f730b56148cc 100644 --- a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlClientAsyncTransportTests.swift +++ b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlClientAsyncTransportTests.swift @@ -124,7 +124,6 @@ struct ControlClientAsyncTransportTests { maximumBufferedBytes: 64 * 1024 ) var expected = "" - let deadline = Date().addingTimeInterval(5) var drainedEveryWrite = true for index in 0..<200 { let byte: [UInt8] = [UInt8(65 + (index % 26))] @@ -135,7 +134,10 @@ struct ControlClientAsyncTransportTests { } // Deadline-poll the drain's byte accounting so every write is // drained (one queued chunk each) before the next one, keeping - // the many-short-chunks shape deterministic under load. + // the many-short-chunks shape deterministic under load. The + // deadline is per write: one shared budget across 200 polls ran + // out on a loaded host, where each 1 ms sleep wakes much later. + let deadline = Date().addingTimeInterval(5) while bufferingState(reader).queued < index + 1, Date() < deadline { try await Task.sleep(nanoseconds: 1_000_000) } diff --git a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift index 6439339f9b6c..ebf4fc940def 100644 --- a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift +++ b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift @@ -293,8 +293,11 @@ extension ControlWorkspaceContext { closeProtected: "", closeFailed: "", reorderManyMissingOrder: "", reorderManyDuplicateWorkspace: "", - reorderManyWorkspaceNotFound: "", - reorderManyInvalidWorkspace: "", + workspaceNotFound: "", + invalidWorkspaceRef: "", + reorderIndexNotAnInteger: "", + reorderMissingWorkspaceID: "", + reorderTargetRequired: "", reorderManyTabManagerUnavailable: "", relayOwnerUnavailable: "" ) } diff --git a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlWorkspaceReorderTargetTests.swift b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlWorkspaceReorderTargetTests.swift index 4d9bfb4b0d84..c6745b96ad03 100644 --- a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlWorkspaceReorderTargetTests.swift +++ b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlWorkspaceReorderTargetTests.swift @@ -64,4 +64,271 @@ struct ControlWorkspaceReorderTargetTests { #expect(call.after == (key == "after_workspace_id" ? targetID : nil)) #expect(call.dryRun == dryRun) } + + private func summary(id: UUID) -> ControlWorkspaceSummary { + ControlWorkspaceSummary( + id: id, + title: "Workspace", + customTitle: nil, + customDescription: nil, + isPinned: false, + listeningPorts: [], + remoteStatus: .object([:]), + currentDirectory: nil, + customColor: nil, + latestConversationMessage: nil, + latestSubmittedMessage: nil, + latestSubmittedAt: nil + ) + } + + /// The `not_found` payload must name the id that failed to resolve. Naming + /// the subject tells the caller the one workspace that did resolve is the + /// missing one. + @Test(arguments: ["before_workspace_id", "after_workspace_id"]) + func unresolvedRelativeTargetNamesTheTarget(key: String) throws { + let context = FakeWorkspaceControlCommandContext() + let coordinator = ControlCommandCoordinator(context: context) + let workspaceID = UUID() + let result = coordinator.handle(ControlRequest(id: .int(1), method: "workspace.reorder", params: [ + "workspace_id": .string(workspaceID.uuidString), + key: .string("workspace:999999"), + "dry_run": .bool(true) + ])) + guard case .err(let code, _, .object(let data)) = result else { + Issue.record("An unknown relative target must fail with a payload") + return + } + #expect(code == "not_found") + #expect(data["param"] == .string(key)) + #expect(data["workspace"] == .string("workspace:999999")) + #expect(data["workspace_id"] != .string(workspaceID.uuidString)) + } + + /// A well-formed target id that no live workspace matches is still the + /// target's failure, even though the planner reports one opaque `notFound`. + @Test(arguments: ["before_workspace_id", "after_workspace_id"]) + func relativeTargetThatIsNotLiveNamesTheTarget(key: String) throws { + let context = FakeWorkspaceControlCommandContext() + let coordinator = ControlCommandCoordinator(context: context) + let workspaceID = UUID() + let missingID = UUID() + context.listResolution = .resolved( + windowID: nil, + workspaces: [summary(id: workspaceID)], + selectedIndex: 0 + ) + let result = coordinator.handle(ControlRequest(id: .int(1), method: "workspace.reorder", params: [ + "workspace_id": .string(workspaceID.uuidString), + key: .string(missingID.uuidString), + "dry_run": .bool(true) + ])) + guard case .err(let code, _, .object(let data)) = result else { + Issue.record("A target that is not live must fail with a payload") + return + } + #expect(code == "not_found") + #expect(data["param"] == .string(key)) + #expect(data["workspace_id"] == .string(missingID.uuidString)) + } + + /// `hasNonNull` is true for values `uuid` can never read. A type error is + /// not a missing workspace. + @Test(arguments: [ + JSONValue.string(""), .string(" "), .int(5), .bool(true), .object([:]), .array([]), + ]) + func malformedRelativeTargetIsInvalidParams(value: JSONValue) throws { + for key in ["before_workspace_id", "after_workspace_id"] { + let context = FakeWorkspaceControlCommandContext() + let coordinator = ControlCommandCoordinator(context: context) + let result = coordinator.handle(ControlRequest(id: .int(1), method: "workspace.reorder", params: [ + "workspace_id": .string(UUID().uuidString), + key: value + ])) + guard case .err(let code, _, .object(let data)) = result else { + Issue.record("A malformed \(key) must fail with a payload") + return + } + #expect(code == "invalid_params") + #expect(data["param"] == .string(key)) + #expect(context.reorderCall == nil) + } + } + + /// One target was specified; it was unreadable. "Specify exactly one + /// target" sends the caller after the wrong param. + @Test func unparsableIndexReportsAnInvalidIndex() throws { + let context = FakeWorkspaceControlCommandContext() + let coordinator = ControlCommandCoordinator(context: context) + let result = coordinator.handle(ControlRequest(id: .int(1), method: "workspace.reorder", params: [ + "workspace_id": .string(UUID().uuidString), + "index": .string("abc") + ])) + guard case .err(let code, let message, .object(let data)) = result else { + Issue.record("An unreadable index must fail with a payload") + return + } + #expect(code == "invalid_params") + #expect(data["param"] == .string("index")) + #expect(!message.contains("exactly one target")) + #expect(context.reorderCall == nil) + } + + /// The subject and the relative target are the same kind of reference, so + /// an unresolvable ref reports the same way through either param. + @Test func unresolvedSubjectRefReportsNotFoundEchoingTheRef() throws { + let context = FakeWorkspaceControlCommandContext() + let coordinator = ControlCommandCoordinator(context: context) + let result = coordinator.handle(ControlRequest(id: .int(1), method: "workspace.reorder", params: [ + "workspace_id": .string("workspace:999999"), + "index": .int(0) + ])) + guard case .err(let code, _, .object(let data)) = result else { + Issue.record("An unresolvable subject ref must fail with a payload") + return + } + #expect(code == "not_found") + #expect(data["param"] == .string("workspace_id")) + #expect(data["workspace"] == .string("workspace:999999")) + #expect(context.reorderCall == nil) + } + + /// A missing or unreadable `workspace_id` stays a request-shape error. + @Test(arguments: [JSONValue.string(""), .int(7), .bool(true)]) + func malformedSubjectStaysInvalidParams(value: JSONValue) throws { + let context = FakeWorkspaceControlCommandContext() + let coordinator = ControlCommandCoordinator(context: context) + let result = coordinator.handle(ControlRequest(id: .int(1), method: "workspace.reorder", params: [ + "workspace_id": value, + "index": .int(0) + ])) + guard case .err(let code, _, _) = result else { + Issue.record("A malformed workspace_id must fail") + return + } + #expect(code == "invalid_params") + #expect(context.reorderCall == nil) + } + + /// A value neither `uuid(_:_:)` spelling can read — not a UUID, not a + /// `kind:N` ref — is a typo, not a workspace that went away. Reporting it + /// as `not_found` sends the caller looking for a workspace that never + /// existed under that name. That includes a `kind:N` whose kind the + /// registry never mints (`unknown:1`), and a known kind in the wrong case + /// (`WORKSPACE:1`): refs are minted lowercase and looked up exactly. + @Test(arguments: [ + "potato", "workspace", "workspace:", ":7", "workspace:abc", "7", "workspace 7", + "unknown:1", "WORKSPACE:1", + ]) + func unreadableSubjectIsInvalidParams(raw: String) throws { + let context = FakeWorkspaceControlCommandContext() + let coordinator = ControlCommandCoordinator(context: context) + let result = coordinator.handle(ControlRequest(id: .int(1), method: "workspace.reorder", params: [ + "workspace_id": .string(raw), + "index": .int(0), + "dry_run": .bool(true) + ])) + guard case .err(let code, _, .object(let data)) = result else { + Issue.record("An unreadable workspace_id must fail with a payload") + return + } + #expect(code == "invalid_params") + #expect(data["param"] == .string("workspace_id")) + #expect(data["workspace"] == .string(raw)) + #expect(context.reorderCall == nil) + } + + /// The same split on a relative target. + @Test(arguments: ["before_workspace_id", "after_workspace_id"]) + func unreadableRelativeTargetIsInvalidParams(key: String) throws { + let context = FakeWorkspaceControlCommandContext() + let coordinator = ControlCommandCoordinator(context: context) + let result = coordinator.handle(ControlRequest(id: .int(1), method: "workspace.reorder", params: [ + "workspace_id": .string(UUID().uuidString), + key: .string("potato"), + "dry_run": .bool(true) + ])) + guard case .err(let code, _, .object(let data)) = result else { + Issue.record("An unreadable relative target must fail with a payload") + return + } + #expect(code == "invalid_params") + #expect(data["param"] == .string(key)) + #expect(data["workspace"] == .string("potato")) + #expect(context.reorderCall == nil) + } + + /// The other half of the split: a ref the registry once minted names a + /// workspace that is gone, so it stays `not_found`. + /// `TAB:4` is included because the registry lowercases the `tab:` alias. + @Test(arguments: ["workspace:999999", "tab:4", "TAB:4", "pane:7", "workspace_group:2"]) + func staleRefSubjectStaysNotFound(raw: String) throws { + let context = FakeWorkspaceControlCommandContext() + let coordinator = ControlCommandCoordinator(context: context) + let result = coordinator.handle(ControlRequest(id: .int(1), method: "workspace.reorder", params: [ + "workspace_id": .string(raw), + "index": .int(0), + "dry_run": .bool(true) + ])) + guard case .err(let code, _, .object(let data)) = result else { + Issue.record("A stale ref must fail with a payload") + return + } + #expect(code == "not_found") + #expect(data["workspace"] == .string(raw)) + #expect(context.reorderCall == nil) + } + + /// `workspace.reorder` and `workspace.reorder_many` must answer the same + /// unresolvable value with the same code. They disagreed before this change, + /// so a caller that fell back from one to the other saw the failure change + /// class without the input changing. `workspace:999999` is the shape a + /// closed workspace's ref takes once the registry forgets it. + @Test(arguments: [ + ("potato", "invalid_params"), + ("workspace:abc", "invalid_params"), + ("unknown:1", "invalid_params"), + ("", "invalid_params"), + ("workspace:999999", "not_found"), + ]) + func reorderAgreesWithReorderManyOnUnresolvableValues(raw: String, expected: String) throws { + func code(of result: ControlCallResult?) -> String? { + guard case .err(let code, _, _)? = result else { return nil } + return code + } + // The coordinator holds its context weakly: an inline fake is freed + // before `handle` runs, and `reorder` answers `unavailable`. + let singleContext = FakeWorkspaceControlCommandContext() + let manyContext = FakeWorkspaceControlCommandContext() + let single = ControlCommandCoordinator(context: singleContext) + let many = ControlCommandCoordinator(context: manyContext) + let reorder = single.handle(ControlRequest(id: .int(1), method: "workspace.reorder", params: [ + "workspace_id": .string(raw), "index": .int(0), "dry_run": .bool(true) + ])) + let reorderMany = many.handle(ControlRequest(id: .int(1), method: "workspace.reorder_many", params: [ + "workspace_ids": .array([.string(raw)]), "dry_run": .bool(true) + ])) + #expect(code(of: reorder) == expected) + #expect(code(of: reorderMany) == expected) + withExtendedLifetime((singleContext, manyContext)) {} + } + + /// A stale ref through `workspace.reorder_many` echoes the caller's value + /// and keeps the id keys its UUID `not_found` reply carries, as `null`. + @Test func reorderManyStaleRefEchoesTheRef() throws { + let context = FakeWorkspaceControlCommandContext() + let coordinator = ControlCommandCoordinator(context: context) + let result = coordinator.handle(ControlRequest(id: .int(1), method: "workspace.reorder_many", params: [ + "workspace_ids": .array([.string("workspace:999999")]), "dry_run": .bool(true) + ])) + guard case .err(let code, _, .object(let data))? = result else { + Issue.record("A stale ref must fail with a payload") + return + } + #expect(code == "not_found") + #expect(data["workspace"] == .string("workspace:999999")) + #expect(data["workspace_id"] == .null) + #expect(data["workspace_ref"] == .null) + } + } diff --git a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/FakeWorkspaceControlCommandContext.swift b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/FakeWorkspaceControlCommandContext.swift index 2694f4a9a1a3..63a112c310a0 100644 --- a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/FakeWorkspaceControlCommandContext.swift +++ b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/FakeWorkspaceControlCommandContext.swift @@ -104,8 +104,11 @@ final class FakeWorkspaceControlCommandContext: ControlCommandContext { closeFailed: "close failed", reorderManyMissingOrder: "missing order", reorderManyDuplicateWorkspace: "duplicate workspace", - reorderManyWorkspaceNotFound: "workspace not found", - reorderManyInvalidWorkspace: "invalid workspace", + workspaceNotFound: "workspace not found", + invalidWorkspaceRef: "invalid workspace", + reorderIndexNotAnInteger: "index not an integer", + reorderMissingWorkspaceID: "missing workspace_id", + reorderTargetRequired: "exactly one target", reorderManyTabManagerUnavailable: "tab manager unavailable", relayOwnerUnavailable: "relay owner workspace unavailable" ) diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHPTYAttachRetryScriptBuilder.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHPTYAttachRetryScriptBuilder.swift index f50c961438e4..75de6ac73856 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHPTYAttachRetryScriptBuilder.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHPTYAttachRetryScriptBuilder.swift @@ -74,13 +74,18 @@ public struct SSHPTYAttachRetryScriptBuilder: Sendable { let sessionRunningStatus = SSHPTYAttachExitCode.bridgeClosedSessionRunning.rawValue let transientStatus = SSHPTYAttachExitCode.retryableTransient.rawValue let terminalModeReset = SSHTerminalModeResetSequence().shellPrintfFormat.remoteCommandShellQuoted + // Persisted launchers may predate the retry policy. A missing or + // malformed limit must fail closed to the same finite supervisor used + // by newly generated SSH startup scripts; a well-formed larger budget + // is honored up to the shared ceiling. + let reconnectLimitLines = SSHReconnectBudget().limitNormalizationShellLines( + variable: "cmux_ssh_attach_reconnect_limit" + ) var lines = [ "cmux_ssh_attach_restore_terminal() { cmux_ssh_attach_flush_status=0; if [ \"${cmux_ssh_attach_input_paused:-0}\" = 1 ] && [ -n \"${cmux_ssh_attach_cli:-}\" ]; then \"$cmux_ssh_attach_cli\" __ssh-pty-flush-input <&0 >/dev/null 2>&1; cmux_ssh_attach_flush_status=$?; fi; cmux_ssh_attach_restore_status=0; if [ -n \"${cmux_ssh_attach_terminal_state:-}\" ]; then /bin/stty \"$cmux_ssh_attach_terminal_state\" <&0 2>/dev/null; cmux_ssh_attach_restore_status=$?; fi; cmux_ssh_attach_input_paused=0; if [ \"$cmux_ssh_attach_flush_status\" -ne 0 ] || [ \"$cmux_ssh_attach_restore_status\" -ne 0 ]; then cmux_ssh_attach_terminal_control_failed=1; fi; }", - // Persisted launchers may predate the retry policy. A missing or - // malformed limit must fail closed to the same finite supervisor - // used by newly generated SSH startup scripts. - "cmux_ssh_attach_reconnect_limit=\"${CMUX_SSH_RECONNECT_LIMIT:-20}\"", - "case \"$cmux_ssh_attach_reconnect_limit\" in ''|*[!0-9]*) cmux_ssh_attach_reconnect_limit=20 ;; *) while [ \"${cmux_ssh_attach_reconnect_limit#0}\" != \"$cmux_ssh_attach_reconnect_limit\" ] && [ \"$cmux_ssh_attach_reconnect_limit\" != 0 ]; do cmux_ssh_attach_reconnect_limit=\"${cmux_ssh_attach_reconnect_limit#0}\"; done; case \"$cmux_ssh_attach_reconnect_limit\" in [1-9]|1[0-9]|20) ;; *) cmux_ssh_attach_reconnect_limit=20 ;; esac ;; esac", + ] + lines.append(contentsOf: reconnectLimitLines) + lines.append(contentsOf: [ "cmux_ssh_attach_reconnect_delay=\"${CMUX_SSH_RECONNECT_DELAY_SECONDS:-2}\"", "case \"$cmux_ssh_attach_reconnect_delay\" in ''|*[!0-9]*|0*) cmux_ssh_attach_reconnect_delay=2 ;; esac", "cmux_ssh_attach_reconnect_max_delay=\"${CMUX_SSH_RECONNECT_MAX_DELAY_SECONDS:-30}\"", @@ -89,7 +94,7 @@ public struct SSHPTYAttachRetryScriptBuilder: Sendable { "cmux_ssh_attach_reconnect_initial_delay=\"$cmux_ssh_attach_reconnect_delay\"", "cmux_ssh_attach_retry_reason=\(bridgeClosedReason)", "cmux_ssh_attach_suppress_replay=0", - ] + ]) lines.append(contentsOf: noProgressPolicy.configurationLines) lines.append(contentsOf: [ "cmux_ssh_attach_no_progress_retry=0", diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHReconnectBudget.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHReconnectBudget.swift new file mode 100644 index 000000000000..657cb201af0a --- /dev/null +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHReconnectBudget.swift @@ -0,0 +1,74 @@ +/// Single source of truth for the persistent SSH reconnect budget. +/// +/// `CMUX_SSH_RECONNECT_LIMIT` is read by generated shell supervisors in +/// several entrypoints. Each used to hard-code its own numbers, so one name +/// carried a 20-attempt ceiling in the attach supervisor and an 86400 default +/// in the freestyle supervisors. Both sides now read these constants. +public struct SSHReconnectBudget: Sendable { + /// Environment variable that carries an operator-chosen reconnect budget. + public let limitEnvironmentName: String + + /// Attempts used when the variable is unset or carries unusable text. + /// + /// Failing closed to a small finite budget is deliberate: a persisted + /// launcher that predates the retry policy, or a typo, must not leave a + /// corrupt or permanently unavailable daemon spinning forever in a pane. + public let fallbackLimit: Int + + /// Largest reconnect budget an operator can ask for. + /// + /// This is also what the freestyle supervisors default to, so the largest + /// value an operator may request matches the largest value the app itself + /// requests. Keeping it finite preserves the fail-closed property above; + /// at the capped 30s backoff, 86400 attempts is a supervisor that gives up + /// only after the host has been gone for weeks. + public let maximumLimit: Int + + public init( + limitEnvironmentName: String = "CMUX_SSH_RECONNECT_LIMIT", + fallbackLimit: Int = 20, + maximumLimit: Int = 86400 + ) { + self.limitEnvironmentName = limitEnvironmentName + self.fallbackLimit = fallbackLimit + self.maximumLimit = maximumLimit + } + + /// Shell lines that resolve ``limitEnvironmentName`` into `variable`. + /// + /// A value of 1...``maximumLimit`` is honored, after leading zeros are + /// stripped. Anything else — non-digits, empty, zero, or a count above the + /// ceiling — falls back to `fallback` or the ceiling and prints one line to + /// stderr naming the value it rejected and the value it used. The oversized + /// case is length-tested before it is compared numerically, because a value + /// with more digits than the shell's integer range makes `[ … -gt … ]` + /// error out instead of answering. + /// + /// - Parameters: + /// - variable: Shell variable that receives the resolved budget. The + /// generator also writes `\(variable)_requested` and + /// `\(variable)_rejected`. + /// - fallback: Budget used when the supplied value is unusable. + /// - Returns: POSIX `/bin/sh` lines. + public func limitNormalizationShellLines( + variable: String, + fallback: Int? = nil + ) -> [String] { + let fallback = fallback ?? fallbackLimit + let ceilingDigits = String(maximumLimit).count + return [ + "\(variable)=\"${\(limitEnvironmentName):-\(fallback)}\"", + "\(variable)_requested=\"$\(variable)\"", + "\(variable)_rejected=0", + "case \"$\(variable)\" in ''|*[!0-9]*) \(variable)_rejected=1; \(variable)=\(fallback) ;; *) " + + "while [ \"${\(variable)#0}\" != \"$\(variable)\" ] && [ \"$\(variable)\" != 0 ]; do " + + "\(variable)=\"${\(variable)#0}\"; done; " + + "if [ \"$\(variable)\" = 0 ]; then \(variable)_rejected=1; \(variable)=\(fallback); " + + "elif [ \"${#\(variable)}\" -gt \(ceilingDigits) ] || [ \"$\(variable)\" -gt \(maximumLimit) ]; then " + + "\(variable)_rejected=1; \(variable)=\(maximumLimit); fi ;; esac", + "if [ \"$\(variable)_rejected\" = 1 ]; then printf " + + "'[cmux] \(limitEnvironmentName)=%s is not an attempt count in 1-\(maximumLimit); using %s.\\n' " + + "\"$\(variable)_requested\" \"$\(variable)\" >&2 || true; fi", + ] + } +} diff --git a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHPTYAttachRetryScriptBuilderTests.swift b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHPTYAttachRetryScriptBuilderTests.swift index 0d0c48524d84..cecb8660c3c6 100644 --- a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHPTYAttachRetryScriptBuilderTests.swift +++ b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHPTYAttachRetryScriptBuilderTests.swift @@ -432,8 +432,8 @@ struct SSHPTYAttachRetryScriptBuilderTests { #expect(!transcript.contains("remote PTY bridge closed; reattaching"), Comment(rawValue: transcript)) } - @Test(arguments: ["bad", "21", "999999999999999999999999999999"]) - func malformedOrOversizedReconnectLimitsRemainFinite(_ configuredLimit: String) throws { + @Test(arguments: ["bad", "-5", "0"]) + func unusableReconnectLimitsRemainFinite(_ configuredLimit: String) throws { let logURL = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-ssh-attach-limit-\(UUID().uuidString)") defer { try? FileManager.default.removeItem(at: logURL) } @@ -460,9 +460,46 @@ struct SSHPTYAttachRetryScriptBuilderTests { .count #expect(result.status == 255) - // One initial attach plus at most the 20 reconnects is the hard - // contract, regardless of user-provided limit text. + // One initial attach plus the 20 fallback reconnects is the contract + // for text the supervisor cannot use as an attempt count. #expect(attempts == 21) + #expect( + result.stderr.contains("CMUX_SSH_RECONNECT_LIMIT=\(configuredLimit)"), + Comment(rawValue: result.stderr) + ) + } + + @Test func wellFormedReconnectLimitAboveTwentyIsHonored() throws { + let logURL = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-ssh-attach-limit-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: logURL) } + + let retryLines = SSHPTYAttachRetryScriptBuilder().lines( + command: "cmux_test_attach", + reauthenticates: false + ) + let script = ([ + "cmux_ssh_attach_signal_exit() { exit \"$1\"; }", + "sleep() { :; }", + "cmux_test_attach() { printf '%s\\n' attach >> \"$CMUX_TEST_LOG\"; return 255; }", + ] + retryLines).joined(separator: "\n") + + let result = try run( + script, + environment: [ + "CMUX_TEST_LOG": logURL.path, + "CMUX_SSH_RECONNECT_LIMIT": "25", + ] + ) + let attempts = try String(contentsOf: logURL, encoding: .utf8) + .split(separator: "\n") + .count + + #expect(result.status == 255) + // 25 used to be rewritten to 20 without a word. The supervisor now + // spends the budget it was given, and stays silent about it. + #expect(attempts == 26) + #expect(!result.stderr.contains("CMUX_SSH_RECONNECT_LIMIT="), Comment(rawValue: result.stderr)) } @Test diff --git a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHReconnectBudgetTests.swift b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHReconnectBudgetTests.swift new file mode 100644 index 000000000000..638ffaa083f5 --- /dev/null +++ b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHReconnectBudgetTests.swift @@ -0,0 +1,94 @@ +import Foundation +import Testing + +@testable import CmuxFoundation + +@Suite(.serialized) +struct SSHReconnectBudgetTests { + @Test(arguments: [ + (limit: "1", resolved: "1"), + (limit: "20", resolved: "20"), + // A well-formed budget above the historical 20-attempt ceiling used to + // be discarded without a word. It is honored now. + (limit: "21", resolved: "21"), + (limit: "50", resolved: "50"), + (limit: "86400", resolved: "86400"), + // Leading zeros are normalization, not rejection. + (limit: "007", resolved: "7"), + ]) + func honorsWellFormedBudgetsSilently(_ testCase: (limit: String, resolved: String)) throws { + let result = try resolve(testCase.limit) + + #expect(result.resolved == testCase.resolved) + #expect(result.stderr.isEmpty, Comment(rawValue: result.stderr)) + } + + @Test(arguments: ["abc", "-5", "1e3", "1.5", " 20", "0", "0000"]) + func unusableBudgetsFailClosedAndSaySo(_ limit: String) throws { + let result = try resolve(limit) + + #expect(result.resolved == String(SSHReconnectBudget().fallbackLimit)) + #expect(result.stderr.contains("CMUX_SSH_RECONNECT_LIMIT=\(limit)"), Comment(rawValue: result.stderr)) + #expect(result.stderr.contains("using \(SSHReconnectBudget().fallbackLimit)."), Comment(rawValue: result.stderr)) + } + + @Test(arguments: ["86401", "99999", "999999", "99999999999999999999"]) + func oversizedBudgetsClampToTheCeilingAndSaySo(_ limit: String) throws { + let result = try resolve(limit) + + // A value with more digits than the shell's integer range must be + // rejected by length, before any `[ … -gt … ]` tries to compare it. + #expect(result.resolved == String(SSHReconnectBudget().maximumLimit)) + #expect(!result.stderr.contains("integer expression expected"), Comment(rawValue: result.stderr)) + #expect(result.stderr.contains("using \(SSHReconnectBudget().maximumLimit)."), Comment(rawValue: result.stderr)) + } + + @Test func unsetBudgetUsesTheFallbackWithoutComplaining() throws { + let result = try resolve(nil) + + #expect(result.resolved == String(SSHReconnectBudget().fallbackLimit)) + #expect(result.stderr.isEmpty, Comment(rawValue: result.stderr)) + } + + @Test func callerSuppliedFallbackSurvivesItsOwnNormalization() throws { + let result = try resolve(nil, fallback: SSHReconnectBudget().maximumLimit) + + #expect(result.resolved == String(SSHReconnectBudget().maximumLimit)) + #expect(result.stderr.isEmpty, Comment(rawValue: result.stderr)) + } + + private func resolve( + _ limit: String?, + fallback: Int = SSHReconnectBudget().fallbackLimit + ) throws -> (resolved: String, stderr: String) { + let variable = "cmux_test_limit" + let script = (SSHReconnectBudget().limitNormalizationShellLines( + variable: variable, + fallback: fallback + ) + ["printf '%s' \"$\(variable)\""]).joined(separator: "\n") + + let process = Process() + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + process.executableURL = URL(fileURLWithPath: "/bin/sh") + process.arguments = ["-c", script] + var environment = ProcessInfo.processInfo.environment + environment.removeValue(forKey: SSHReconnectBudget().limitEnvironmentName) + if let limit { + environment[SSHReconnectBudget().limitEnvironmentName] = limit + } + process.environment = environment + process.standardInput = FileHandle.nullDevice + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + + try process.run() + let stdoutData = stdoutPipe.fileHandleForReading.readDataToEndOfFile() + let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + return ( + String(data: stdoutData, encoding: .utf8) ?? "", + String(data: stderrData, encoding: .utf8) ?? "" + ) + } +} diff --git a/Packages/macOS/CmuxRemoteWorkspace/Tests/CmuxRemoteWorkspaceTests/RemoteCLIRelayPolicyTests.swift b/Packages/macOS/CmuxRemoteWorkspace/Tests/CmuxRemoteWorkspaceTests/RemoteCLIRelayPolicyTests.swift index b6a07ef3631e..42eab951ad57 100644 --- a/Packages/macOS/CmuxRemoteWorkspace/Tests/CmuxRemoteWorkspaceTests/RemoteCLIRelayPolicyTests.swift +++ b/Packages/macOS/CmuxRemoteWorkspace/Tests/CmuxRemoteWorkspaceTests/RemoteCLIRelayPolicyTests.swift @@ -154,6 +154,42 @@ struct RemoteCLIRelayPolicyTests { } } + /// `workspace.reorder` has no relay parameter contract, so the method gate + /// denies it before any selector is read. + /// + /// The selectors below are UUIDs on purpose. Ref-form selectors such as + /// `workspace:1` are rejected by the *selector* gate + /// (`RemoteRelayCommandPolicy.malformedSelector`) whether or not the method + /// is allowlisted, so a ref-form payload reports `remote_relay_denied` + /// either way and this test would stay green through exactly the + /// regression it exists to catch. With UUIDs, the method gate is the only + /// thing left denying these, so allowlisting `workspace.reorder` turns them + /// into `ALLOW` and fails the test. + @Test("workspace.reorder has no relay contract") + func workspaceReorderHasNoRelayContract() { + #expect( + RemoteRelayRoutingSchema().parameters(for: "workspace.reorder") == nil, + "workspace.reorder must stay absent from the relay routing schema" + ) + } + + @Test("workspace.reorder is denied through a relay", arguments: [ + #"{"id":"p5r","method":"workspace.reorder","params":{"workspace_id":"1EA7D9C4-0000-4000-8000-00000000A001","index":0}}"#, + #"{"id":"p5r","method":"workspace.reorder","params":{"workspace_id":"1EA7D9C4-0000-4000-8000-00000000A001","before_workspace_id":"1EA7D9C4-0000-4000-8000-00000000A002"}}"#, + #"{"id":"p5r","method":"workspace.reorder","params":{"workspace_id":"1EA7D9C4-0000-4000-8000-00000000A001","after_workspace_id":"1EA7D9C4-0000-4000-8000-00000000A002"}}"#, + ]) + func deniesWorkspaceReorder(commandLine: String) throws { + try withServer { port, unixServer in + let exchange = try runPolicyRelayExchange( + port: port, + relayID: relayID, + tokenHex: tokenHex, + commandLine: commandLine + ) + expectDenial(exchange, unixServer, "workspace.reorder") + } + } + @Test("non-JSON command lines are denied") func deniesNonJSONCommandLine() throws { try withServer { port, unixServer in diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/TerminalCatalogSection.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/TerminalCatalogSection.swift index 96ce135095b5..a67a0758c927 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/TerminalCatalogSection.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/TerminalCatalogSection.swift @@ -43,6 +43,16 @@ public struct TerminalCatalogSection: SettingCatalogSection { userDefaultsKey: "terminal.copyOnSelect" ) + /// Whether macOS text-editing gestures are replayed as their line-editor + /// equivalents: Command and Option arrow motion, and the Command and Option + /// deletion chords. Off by default, because the mode claims chords the + /// running application would otherwise receive. + public let textEditingGestures = DefaultsKey( + id: "terminal.textEditingGestures", + defaultValue: false, + userDefaultsKey: "terminal.textEditingGestures" + ) + /// Whether cmux supplies its appearance-adaptive managed palette for an /// Ghostty config without authored themes or terminal colors. Font and /// behavior settings preserve the managed palette; it is enabled by default. diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift index 83f18d5b0b8f..4300c2129763 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift @@ -170,6 +170,7 @@ extension Array where Element == CuratedSettingEntry { synonyms: "terminal.scrollSpeed scroll speed multiplier wheel mouse trackpad sensitivity faster slower" ), .init(section: .terminal, id: "copy-on-select", title: String(localized: "settings.terminal.copyOnSelect", defaultValue: "Copy on Selection"), synonyms: "Copy on Selection terminal.copyOnSelect copy on selection select clipboard mouse double click triple click iterm"), + .init(section: .terminal, id: "text-editing-gestures", title: String(localized: "settings.terminal.textEditingGestures", defaultValue: "Text Editing Gestures"), synonyms: "Text Editing Gestures terminal.textEditingGestures text editing gestures option alt word line kill readline emacs keybindings command arrow delete"), .init(section: .terminal, id: "agent-auto-resume", title: String(localized: "settings.terminal.agentAutoResume", defaultValue: "Resume Agent Sessions on Reopen"), synonyms: "Resume Agent Sessions on Reopen terminal.autoResumeAgentSessions auto resume restore reopen relaunch quit sessions agents claude code codex opencode rovo dev rovodev toggle"), .init(section: .terminal, id: "agent-hibernation", title: String(localized: "settings.terminal.agentHibernation", defaultValue: "Agent Hibernation"), synonyms: "Agent Hibernation terminal.agentHibernation.enabled idle hibernate suspend background agents claude code codex opencode live terminals"), .init(section: .terminal, id: "agent-hibernation-idle", title: String(localized: "settings.terminal.agentHibernation.idleSeconds", defaultValue: "Hibernate After Idle Seconds"), synonyms: "Hibernate After Idle Seconds terminal.agentHibernation.idleSeconds idle seconds timeout delay hibernate suspend"), diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalSection.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalSection.swift index 18fae3f909f2..32eb08406193 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalSection.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalSection.swift @@ -23,6 +23,7 @@ public struct TerminalSection: View { @State private var sessionContentAlignment: DefaultsValueModel @State private var scrollBar: DefaultsValueModel @State private var copyOnSelect: DefaultsValueModel + @State private var textEditingGestures: DefaultsValueModel @State private var adaptiveDefaultTheme: DefaultsValueModel @State private var autoResume: DefaultsValueModel @State private var hibernation: DefaultsValueModel @@ -50,6 +51,7 @@ public struct TerminalSection: View { _sessionContentAlignment = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.terminal.sessionContentAlignment)) _scrollBar = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.terminal.showScrollBar)) _copyOnSelect = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.terminal.copyOnSelect)) + _textEditingGestures = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.terminal.textEditingGestures)) _adaptiveDefaultTheme = State( initialValue: DefaultsValueModel( store: defaultsStore, @@ -84,6 +86,7 @@ public struct TerminalSection: View { sessionContentAlignment, scrollBar, copyOnSelect, + textEditingGestures, adaptiveDefaultTheme, autoResume, hibernation, @@ -424,6 +427,19 @@ public struct TerminalSection: View { .accessibilityIdentifier("SettingsTerminalCopyOnSelectToggle") } SettingsCardDivider() + SettingsCardRow( + configurationReview: .json("terminal.textEditingGestures"), + String(localized: "settings.terminal.textEditingGestures", defaultValue: "Text Editing Gestures"), + subtitle: textEditingGestures.current + ? String(localized: "settings.terminal.textEditingGestures.subtitleOn", defaultValue: "Command and Option arrow keys move by line and word, and the Command and Option delete keys kill by line and word. Applications receive these chords instead of the gesture, so turn this off before working in a full-screen TUI.") + : String(localized: "settings.terminal.textEditingGestures.subtitleOff", defaultValue: "Command and Option key combinations reach the terminal unchanged.") + ) { + Toggle("", isOn: Binding(get: { textEditingGestures.current }, set: { textEditingGestures.set($0) })) + .labelsHidden() + .controlSize(.small) + .accessibilityIdentifier("SettingsTerminalTextEditingGesturesToggle") + } + SettingsCardDivider() SettingsCardRow( configurationReview: .json("terminal.autoResumeAgentSessions"), String(localized: "settings.terminal.agentAutoResume", defaultValue: "Resume Agent Sessions on Reopen"), diff --git a/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsRowAnchorResolutionTests.swift b/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsRowAnchorResolutionTests.swift index 272a69bdaa01..f5046a222b6c 100644 --- a/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsRowAnchorResolutionTests.swift +++ b/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsRowAnchorResolutionTests.swift @@ -139,6 +139,7 @@ struct SettingsRowAnchorResolutionTests { "terminal.rendererRealization.maxWarmRenderers", "terminal.autoResumeAgentSessions", "terminal.copyOnSelect", + "terminal.textEditingGestures", "terminal.resumeCommands", "terminal.sessionContentAlignment", "terminal.sessionContentMaxWidth", diff --git a/Packages/macOS/CmuxSimulator/Tests/CmuxSimulatorTests/SimulatorBoundedCommandRunnerTests.swift b/Packages/macOS/CmuxSimulator/Tests/CmuxSimulatorTests/SimulatorBoundedCommandRunnerTests.swift index 8345155fa79d..1d15b54af516 100644 --- a/Packages/macOS/CmuxSimulator/Tests/CmuxSimulatorTests/SimulatorBoundedCommandRunnerTests.swift +++ b/Packages/macOS/CmuxSimulator/Tests/CmuxSimulatorTests/SimulatorBoundedCommandRunnerTests.swift @@ -161,8 +161,10 @@ struct SimulatorBoundedCommandRunnerTests { #expect(result.timedOut) let pid = try #require(processIdentifier.value) - #expect(Darwin.kill(pid, 0) != 0) - #expect(errno == ESRCH) + let probeResult = Darwin.kill(pid, 0) + let probeErrno = errno + #expect(probeResult != 0) + #expect(probeErrno == ESRCH) } @Test("The public runner bounds timeout and kills descendants") @@ -184,8 +186,10 @@ struct SimulatorBoundedCommandRunnerTests { #expect(result.status == 124) let descendant = try await requireMarkerPID(marker) await expectProcessExited(descendant) - #expect(Darwin.kill(descendant, 0) != 0) - #expect(errno == ESRCH) + let probeResult = Darwin.kill(descendant, 0) + let probeErrno = errno + #expect(probeResult != 0) + #expect(probeErrno == ESRCH) } @Test("The owned command runner delegates asynchronously to its injected process runner") diff --git a/Packages/macOS/CmuxSimulator/Tests/CmuxSimulatorTests/SimulatorLengthPrefixedMessageChannelTests.swift b/Packages/macOS/CmuxSimulator/Tests/CmuxSimulatorTests/SimulatorLengthPrefixedMessageChannelTests.swift index 1763f3cfee33..ae1298f6a7c8 100644 --- a/Packages/macOS/CmuxSimulator/Tests/CmuxSimulatorTests/SimulatorLengthPrefixedMessageChannelTests.swift +++ b/Packages/macOS/CmuxSimulator/Tests/CmuxSimulatorTests/SimulatorLengthPrefixedMessageChannelTests.swift @@ -84,8 +84,10 @@ struct SimulatorLengthPrefixedMessageChannelTests { await Task.yield() } - #expect(kill(processIdentifier, 0) == -1) - #expect(errno == ESRCH) + let probeResult = kill(processIdentifier, 0) + let probeErrno = errno + #expect(probeResult == -1) + #expect(probeErrno == ESRCH) #expect(throws: SimulatorChannelError.writeFailed) { try connection.send(Data("must not reach the terminated worker".utf8)) } diff --git a/Packages/macOS/CmuxSudoBroker/Tests/CmuxSudoBrokerTests/SudoProcessLifecycleTests.swift b/Packages/macOS/CmuxSudoBroker/Tests/CmuxSudoBrokerTests/SudoProcessLifecycleTests.swift index 3058faac0e84..ae9e4912d5e2 100644 --- a/Packages/macOS/CmuxSudoBroker/Tests/CmuxSudoBrokerTests/SudoProcessLifecycleTests.swift +++ b/Packages/macOS/CmuxSudoBroker/Tests/CmuxSudoBrokerTests/SudoProcessLifecycleTests.swift @@ -121,8 +121,10 @@ struct SudoProcessLifecycleTests { #expect(reapedProcessIdentifier == process.identity.processIdentifier) var status: Int32 = 0 - #expect(waitpid(process.identity.processIdentifier, &status, WNOHANG) == -1) - #expect(errno == ECHILD) + let reapResult = waitpid(process.identity.processIdentifier, &status, WNOHANG) + let reapErrno = errno + #expect(reapResult == -1) + #expect(reapErrno == ECHILD) } @Test("Execution deadline terminates a script PTY tree", .timeLimit(.minutes(1))) diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Input.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Input.swift index b6bc6d2a6b2c..141aef36ca52 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Input.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Input.swift @@ -85,48 +85,60 @@ extension TerminalSurface { /// Sends paste-style text to the surface, queueing on a cold surface. /// + /// - Parameter text: Literal UTF-8 text to paste. /// - Returns: Whether the text was delivered or queued. @MainActor @discardableResult public func sendText(_ text: String) -> Bool { - guard let data = text.data(using: .utf8), !data.isEmpty else { return true } + sendTextResult(text).accepted + } + + /// Sends paste-style text and reports whether it was delivered or queued. + /// + /// Delivery means handed to the live terminal runtime, not consumed by its child process. + /// - Parameter text: Literal UTF-8 text to paste. Empty text succeeds without a write. + /// - Returns: The immediate delivery, queueing, or rejection outcome. + @MainActor + @discardableResult + public func sendTextResult(_ text: String) -> TextSendResult { + guard let data = text.data(using: .utf8), !data.isEmpty else { return .sent } didReceiveExplicitInput() - let accepted = sendTextAfterExplicitInput(data) - if accepted { + let result = sendTextAfterExplicitInput(data) + if result.accepted { hibernationRecorder.recordTerminalInput( workspaceId: tabId, panelId: id ) } - return accepted + return result } @MainActor - private func sendTextAfterExplicitInput(_ data: Data) -> Bool { + private func sendTextAfterExplicitInput(_ data: Data) -> TextSendResult { if deferInputDuringRuntimeClipboardRead( estimatedBytes: data.count, replay: { [weak self] in _ = self?.sendTextAfterExplicitInput(data) } ) { - return true + return .queued } guard surface != nil else { - guard allowsRuntimeSurfaceCreation() else { return false } + guard allowsRuntimeSurfaceCreation() else { return .surfaceUnavailable } let queued = enqueuePendingSocketInput(.pasteText(data)) if queued { requestInputDemandSurfaceStartIfNeeded() didAcceptExplicitInput() } - return queued + return queued ? .queued : .inputQueueFull } guard let liveSurface = liveSurfaceForSocketWrite(reason: "socket.sendText") else { - return false + return .surfaceUnavailable } - guard !ghostty_surface_process_exited(liveSurface) else { return false } + guard !ghostty_surface_process_exited(liveSurface) else { return .processExited } writeTextData(data, to: liveSurface) didAcceptExplicitInput() - return true + return .sent } /// Sends raw key text as a single key event. diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift index 619f5092ddb3..cde17691c997 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift @@ -33,6 +33,7 @@ public final class TerminalSurface: Identifiable, ObservableObject { // nested TerminalSurface.NamedKeySendResult/.InputSendResult names that // other files use. public typealias NamedKeySendResult = CmuxTerminalCore.NamedKeySendResult + public typealias TextSendResult = CmuxTerminalCore.TextSendResult public typealias InputSendResult = CmuxTerminalCore.InputSendResult public typealias AgentCommandShimSet = TerminalSurfaceAgentCommandShimSet public typealias CmuxContextEnvironment = TerminalSurfaceCmuxContextEnvironment diff --git a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceExplicitInputTests.swift b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceExplicitInputTests.swift index 331599512abb..2e1923a1f410 100644 --- a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceExplicitInputTests.swift +++ b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceExplicitInputTests.swift @@ -93,11 +93,46 @@ struct TerminalSurfaceExplicitInputTests { let fixture = makeFixture() defer { fixture.surface.releaseSurfaceForTesting() } - #expect(fixture.surface.sendText("hello")) + #expect(fixture.surface.sendTextResult("hello") == .queued) #expect(fixture.paneHost.explicitInputCount == 1) } + @Test func pasteReportsClipboardDeferralAndRetainsOneReplay() { + let fixture = makeFixture() + defer { fixture.surface.releaseSurfaceForTesting() } + fixture.nativeView.shouldDeferRuntimeInput = true + + #expect(fixture.surface.sendTextResult("literal\n世界") == .queued) + #expect(fixture.nativeView.deferredRuntimeInputs.count == 1) + #expect(fixture.surface.pendingSocketInputBytes == 0) + + fixture.nativeView.shouldDeferRuntimeInput = false + fixture.nativeView.deferredRuntimeInputs.removeFirst()() + #expect(fixture.surface.pendingSocketInputBytes == "literal\n世界".utf8.count) + } + + @Test func pasteReportsQueueFullWithoutAcceptingText() { + let fixture = makeFixture() + defer { fixture.surface.releaseSurfaceForTesting() } + fixture.surface.pendingSocketInputBytes = fixture.surface.maxPendingSocketInputBytes + var accepted = 0 + fixture.surface.onExplicitInput = { accepted += 1 } + + #expect(fixture.surface.sendTextResult("literal\n世界") == .inputQueueFull) + #expect(fixture.surface.pendingSocketInputBytes == fixture.surface.maxPendingSocketInputBytes) + #expect(accepted == 0) + } + + @Test func pasteReportsClosedSurfaceWithoutQueueing() { + let fixture = makeFixture() + defer { fixture.surface.releaseSurfaceForTesting() } + fixture.surface.beginPortalCloseLifecycle(reason: "test.closed") + + #expect(fixture.surface.sendTextResult("literal\n世界") == .surfaceUnavailable) + #expect(fixture.surface.pendingSocketInputBytes == 0) + } + @Test func parsedInputNotifiesPaneHostBeforeQueueingOnAColdSurface() { let fixture = makeFixture() defer { fixture.surface.releaseSurfaceForTesting() } diff --git a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/SurfaceValues/TextSendResult.swift b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/SurfaceValues/TextSendResult.swift new file mode 100644 index 000000000000..63f5fbc081c7 --- /dev/null +++ b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/SurfaceValues/TextSendResult.swift @@ -0,0 +1,23 @@ +/// The outcome of sending literal paste text to a terminal surface. +public enum TextSendResult: Equatable, Sendable { + /// Delivered to the live runtime surface. + case sent + /// Accepted for ordered delivery after surface startup or a clipboard read. + case queued + /// The pending-input queue is at capacity. + case inputQueueFull + /// No runtime surface exists and none is starting. + case surfaceUnavailable + /// The surface's child process already exited. + case processExited + + /// Whether the text was delivered or accepted for ordered delivery. + public var accepted: Bool { + switch self { + case .sent, .queued: + true + case .inputQueueFull, .surfaceUnavailable, .processExited: + false + } + } +} diff --git a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/TextEditing/TerminalTextEditingKeyResolution.swift b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/TextEditing/TerminalTextEditingKeyResolution.swift new file mode 100644 index 000000000000..d21d904f30f1 --- /dev/null +++ b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/TextEditing/TerminalTextEditingKeyResolution.swift @@ -0,0 +1,136 @@ +/// Virtual key codes the text-editing resolver recognises. +/// +/// These mirror the Carbon `kVK_*` constants the app target already uses, kept +/// here so the package stays free of a Carbon dependency. +enum TerminalTextEditingKeyCode { + /// `kVK_Delete` — the Backspace key. + static let backspace: UInt16 = 0x33 + /// `kVK_ForwardDelete` — the forward Delete key. + static let forwardDelete: UInt16 = 0x75 + /// `kVK_LeftArrow`. + static let leftArrow: UInt16 = 0x7B + /// `kVK_RightArrow`. + static let rightArrow: UInt16 = 0x7C +} + +/// The chord a text-editing gesture stands in for. +/// +/// The resolver deliberately names a *chord* rather than the bytes it encodes +/// to. The app target replays the chord through the ordinary key path, so +/// Ghostty performs the encoding and the result stays correct under whichever +/// keyboard protocol the running application negotiated. Emitting raw bytes +/// would bypass that and send legacy control codes to an application expecting +/// `CSI u`. +public struct TerminalTextEditingChord: Equatable, Sendable { + /// The modifier the replayed chord carries. + public enum Modifier: Equatable, Sendable { + /// The Control modifier, as in `Ctrl+A`. + case control + /// The Option/Alt modifier, as in `Alt+b`. + case option + } + + /// The ASCII lowercase letter of the chord. + public let letter: Character + + /// The modifier held with ``letter``. + public let modifier: Modifier + + /// Creates a chord. + /// + /// - Parameters: + /// - letter: The ASCII lowercase letter of the chord. + /// - modifier: The modifier held with `letter`. + public init(letter: Character, modifier: Modifier) { + self.letter = letter + self.modifier = modifier + } + + /// `Ctrl+A` — move to the beginning of the line. + static let beginningOfLine = TerminalTextEditingChord(letter: "a", modifier: .control) + /// `Ctrl+E` — move to the end of the line. + static let endOfLine = TerminalTextEditingChord(letter: "e", modifier: .control) + /// `Alt+b` — move backward one word. + static let backwardWord = TerminalTextEditingChord(letter: "b", modifier: .option) + /// `Alt+f` — move forward one word. + static let forwardWord = TerminalTextEditingChord(letter: "f", modifier: .option) + /// `Ctrl+U` — kill from the cursor to the beginning of the line. + static let killToLineStart = TerminalTextEditingChord(letter: "u", modifier: .control) + /// `Ctrl+K` — kill from the cursor to the end of the line. + static let killToLineEnd = TerminalTextEditingChord(letter: "k", modifier: .control) + /// `Ctrl+W` — kill the word before the cursor. + static let killBackwardWord = TerminalTextEditingChord(letter: "w", modifier: .control) + /// `Alt+d` — kill the word after the cursor. + static let killForwardWord = TerminalTextEditingChord(letter: "d", modifier: .option) +} + +/// Strips modifiers that never participate in gesture matching. +private func terminalTextEditingNormalizedModifiers( + _ modifiers: TerminalTextEditingModifiers +) -> TerminalTextEditingModifiers { + modifiers.subtracting([.numericPad, .function, .capsLock]) +} + +/// Resolves a macOS text-editing gesture into the line-editor chord it stands for. +/// +/// Returns `nil` for anything the mode does not own, which the caller must pass +/// through untouched. In particular this returns `nil` for every event carrying +/// Control, so `Ctrl+C` and friends keep reaching the remote unchanged, and for +/// Shift combinations, because readline and zle have no selection model for a +/// shift-extended gesture to target. +/// +/// `Cmd+A` is deliberately unmapped. In macOS it means select-all, which has no +/// line-editor equivalent, and silently repurposing it as "beginning of line" +/// would give the chord a second meaning users did not ask for. +/// +/// ```swift +/// let chord = terminalTextEditingResolve( +/// keyCode: 0x7B, // Left arrow +/// modifiers: [.option] +/// ) +/// // chord == TerminalTextEditingChord(letter: "b", modifier: .option) +/// ``` +/// +/// - Parameters: +/// - keyCode: The virtual key code of the event. +/// - modifiers: The event modifiers, already mapped off AppKit. +/// - Returns: The chord to replay, or `nil` when the event is not a +/// text-editing gesture and should pass through to the terminal unchanged. +public func terminalTextEditingResolve( + keyCode: UInt16, + modifiers: TerminalTextEditingModifiers +) -> TerminalTextEditingChord? { + let normalized = terminalTextEditingNormalizedModifiers(modifiers) + + // Control-bearing events stay with the remote application, always. + guard !normalized.contains(.control) else { return nil } + + // No selection model downstream, so a shift-extended gesture has nothing to + // resolve to. Pass it through rather than dropping the shift silently. + guard !normalized.contains(.shift) else { return nil } + + let hasCommand = normalized.contains(.command) + let hasOption = normalized.contains(.option) + + // Exactly one of Command or Option selects the gesture family. Both at once + // is ambiguous, and neither means an ordinary keystroke. + guard hasCommand != hasOption else { return nil } + + if hasCommand { + switch keyCode { + case TerminalTextEditingKeyCode.leftArrow: return .beginningOfLine + case TerminalTextEditingKeyCode.rightArrow: return .endOfLine + case TerminalTextEditingKeyCode.backspace: return .killToLineStart + case TerminalTextEditingKeyCode.forwardDelete: return .killToLineEnd + default: return nil + } + } + + switch keyCode { + case TerminalTextEditingKeyCode.leftArrow: return .backwardWord + case TerminalTextEditingKeyCode.rightArrow: return .forwardWord + case TerminalTextEditingKeyCode.backspace: return .killBackwardWord + case TerminalTextEditingKeyCode.forwardDelete: return .killForwardWord + default: return nil + } +} diff --git a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/TextEditing/TerminalTextEditingModifiers.swift b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/TextEditing/TerminalTextEditingModifiers.swift new file mode 100644 index 000000000000..1bcb5692f39d --- /dev/null +++ b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/TextEditing/TerminalTextEditingModifiers.swift @@ -0,0 +1,51 @@ +/// Modifier keys relevant to terminal text-editing gesture resolution. +/// +/// `TerminalTextEditingModifiers` is a small, platform-neutral option set that +/// lets the gesture resolver avoid depending on AppKit event types. The app +/// target maps `NSEvent.ModifierFlags` into this type before calling +/// ``terminalTextEditingResolve(keyCode:modifiers:)``. +/// +/// This is deliberately separate from ``TerminalKeyboardCopyModeModifiers``, +/// which has no Option member because copy mode never needed one. Text editing +/// is built around Option, so it carries its own set rather than widening a +/// type that shipping copy-mode code depends on. +/// +/// ```swift +/// let modifiers: TerminalTextEditingModifiers = [.option] +/// if modifiers.contains(.option) { +/// print("word-wise motion") +/// } +/// ``` +public struct TerminalTextEditingModifiers: OptionSet, Equatable, Sendable { + /// The raw option-set storage. + public let rawValue: UInt8 + + /// Creates a modifier set from raw option bits. + /// + /// - Parameter rawValue: The raw option-set storage. Unknown bits are + /// preserved so callers can round-trip values produced by `OptionSet`. + public init(rawValue: UInt8) { + self.rawValue = rawValue + } + + /// The Command modifier. + public static let command = TerminalTextEditingModifiers(rawValue: 1 << 0) + + /// The Shift modifier. + public static let shift = TerminalTextEditingModifiers(rawValue: 1 << 1) + + /// The Control modifier. + public static let control = TerminalTextEditingModifiers(rawValue: 1 << 2) + + /// The Option modifier. + public static let option = TerminalTextEditingModifiers(rawValue: 1 << 3) + + /// The numeric-pad modifier, ignored during gesture matching. + public static let numericPad = TerminalTextEditingModifiers(rawValue: 1 << 4) + + /// The function-key modifier, ignored during gesture matching. + public static let function = TerminalTextEditingModifiers(rawValue: 1 << 5) + + /// The caps-lock modifier, ignored during gesture matching. + public static let capsLock = TerminalTextEditingModifiers(rawValue: 1 << 6) +} diff --git a/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/SurfaceValueTests.swift b/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/SurfaceValueTests.swift index abc8c7da062f..a76ab43e9a21 100644 --- a/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/SurfaceValueTests.swift +++ b/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/SurfaceValueTests.swift @@ -14,6 +14,16 @@ import GhosttyKit } } +@Suite struct TextSendResultTests { + @Test func acceptedDistinguishesDeliveryFromQueueing() { + #expect(TextSendResult.sent.accepted) + #expect(TextSendResult.queued.accepted) + #expect(!TextSendResult.inputQueueFull.accepted) + #expect(!TextSendResult.surfaceUnavailable.accepted) + #expect(!TextSendResult.processExited.accepted) + } +} + @Suite struct InputSendResultTests { @Test func acceptedReflectsDelivery() { #expect(InputSendResult.sent.accepted) diff --git a/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/TextEditing/TerminalTextEditingKeyResolutionTests.swift b/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/TextEditing/TerminalTextEditingKeyResolutionTests.swift new file mode 100644 index 000000000000..31dd5c054a34 --- /dev/null +++ b/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/TextEditing/TerminalTextEditingKeyResolutionTests.swift @@ -0,0 +1,85 @@ +import CmuxTerminalCore +import Testing + +@Suite("Terminal text-editing gesture resolver") +struct TerminalTextEditingKeyResolutionTests { + private enum Key { + static let backspace: UInt16 = 0x33 + static let forwardDelete: UInt16 = 0x75 + static let leftArrow: UInt16 = 0x7B + static let rightArrow: UInt16 = 0x7C + static let letterC: UInt16 = 0x08 + } + + @Test func commandGesturesResolveToLineWiseEditing() { + let cases: [(keyCode: UInt16, chord: TerminalTextEditingChord)] = [ + (Key.leftArrow, TerminalTextEditingChord(letter: "a", modifier: .control)), + (Key.rightArrow, TerminalTextEditingChord(letter: "e", modifier: .control)), + (Key.backspace, TerminalTextEditingChord(letter: "u", modifier: .control)), + (Key.forwardDelete, TerminalTextEditingChord(letter: "k", modifier: .control)), + ] + for testCase in cases { + let chord = terminalTextEditingResolve(keyCode: testCase.keyCode, modifiers: [.command]) + #expect(chord == testCase.chord, "keyCode \(testCase.keyCode)") + } + } + + @Test func optionGesturesResolveToWordWiseEditing() { + let cases: [(keyCode: UInt16, chord: TerminalTextEditingChord)] = [ + (Key.leftArrow, TerminalTextEditingChord(letter: "b", modifier: .option)), + (Key.rightArrow, TerminalTextEditingChord(letter: "f", modifier: .option)), + (Key.backspace, TerminalTextEditingChord(letter: "w", modifier: .control)), + (Key.forwardDelete, TerminalTextEditingChord(letter: "d", modifier: .option)), + ] + for testCase in cases { + let chord = terminalTextEditingResolve(keyCode: testCase.keyCode, modifiers: [.option]) + #expect(chord == testCase.chord, "keyCode \(testCase.keyCode)") + } + } + + /// Control must always reach the remote, or the mode would eat Ctrl+C. + @Test func controlBearingEventsAlwaysPassThrough() { + let modifierSets: [TerminalTextEditingModifiers] = [ + [.control], + [.control, .command], + [.control, .option], + [.control, .shift], + ] + for modifiers in modifierSets { + #expect(terminalTextEditingResolve(keyCode: Key.letterC, modifiers: modifiers) == nil) + #expect(terminalTextEditingResolve(keyCode: Key.leftArrow, modifiers: modifiers) == nil) + } + } + + /// Readline and zle have no selection model, so shift has nothing to target. + @Test func shiftExtendedGesturesPassThrough() { + #expect(terminalTextEditingResolve(keyCode: Key.leftArrow, modifiers: [.command, .shift]) == nil) + #expect(terminalTextEditingResolve(keyCode: Key.rightArrow, modifiers: [.option, .shift]) == nil) + } + + /// Command+Option is ambiguous; neither family should claim it. + @Test func commandAndOptionTogetherPassThrough() { + #expect(terminalTextEditingResolve(keyCode: Key.leftArrow, modifiers: [.command, .option]) == nil) + } + + /// An unmodified keystroke is ordinary input, not a gesture. + @Test func unmodifiedKeysPassThrough() { + #expect(terminalTextEditingResolve(keyCode: Key.leftArrow, modifiers: []) == nil) + #expect(terminalTextEditingResolve(keyCode: Key.backspace, modifiers: []) == nil) + } + + /// Only the four navigation/deletion keys are owned; Cmd+C must stay a shortcut. + @Test func unmappedKeysPassThroughEvenWithGestureModifiers() { + #expect(terminalTextEditingResolve(keyCode: Key.letterC, modifiers: [.command]) == nil) + #expect(terminalTextEditingResolve(keyCode: Key.letterC, modifiers: [.option]) == nil) + } + + /// Lock and pad modifiers are noise and must not defeat a real gesture. + @Test func ignoredModifiersDoNotBlockResolution() { + let chord = terminalTextEditingResolve( + keyCode: Key.leftArrow, + modifiers: [.option, .capsLock, .numericPad, .function] + ) + #expect(chord == TerminalTextEditingChord(letter: "b", modifier: .option)) + } +} diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index ef326b2dffe2..86980195f4a0 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -374718,6 +374718,183 @@ } } }, + "settings.terminal.textEditingGestures": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Text Editing Gestures" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "テキスト編集ジェスチャー" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "文本编辑手势" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Textbearbeitungsgesten" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Gestes d’édition de texte" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إيماءات تحرير النص" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Gestos de edición de texto" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "文字編輯手勢" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "텍스트 편집 제스처" + } + } + } + }, + "settings.terminal.textEditingGestures.subtitleOn": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Command and Option arrow keys move by line and word, and the Command and Option delete keys kill by line and word. Applications receive these chords instead of the gesture, so turn this off before working in a full-screen TUI." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Command と Option の矢印キーで行単位・単語単位に移動し、Command と Option の削除キーで行単位・単語単位に削除します。アプリケーションにはジェスチャーではなくこれらのキーの組み合わせが送られるため、フルスクリーンの TUI で作業する前にオフにしてください。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Command 和 Option 加方向键按行和按词移动,Command 和 Option 加删除键按行和按词删除。应用程序收到的是这些组合键而不是手势,因此在全屏 TUI 中工作前请关闭此项。" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Befehls- und Wahltaste mit Pfeiltasten bewegen zeilen- und wortweise, Befehls- und Wahltaste mit der Löschtaste löschen zeilen- und wortweise. Programme erhalten diese Tastenkombinationen statt der Geste, deshalb vor der Arbeit in einer Vollbild-TUI ausschalten." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Les flèches avec Commande et Option déplacent par ligne et par mot, et la touche Supprimer avec Commande et Option supprime par ligne et par mot. Les applications reçoivent ces combinaisons au lieu du geste ; désactivez donc cette option avant de travailler dans une TUI plein écran." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تنقل مفاتيح الأسهم مع Command وOption حسب السطر والكلمة، وتحذف مفاتيح الحذف مع Command وOption حسب السطر والكلمة. تتلقى التطبيقات هذه التركيبات بدلاً من الإيماءة، لذا أوقف هذا الخيار قبل العمل في واجهة TUI بملء الشاشة." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Las flechas con Comando y Opción mueven por línea y por palabra, y la tecla Eliminar con Comando y Opción borra por línea y por palabra. Las aplicaciones reciben estas combinaciones en lugar del gesto, así que desactívelo antes de trabajar en una TUI a pantalla completa." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Command 與 Option 加方向鍵可依行與依字移動,Command 與 Option 加刪除鍵可依行與依字刪除。應用程式收到的是這些組合鍵而非手勢,因此在全螢幕 TUI 中工作前請關閉此項。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Command 및 Option 화살표 키로 줄 및 단어 단위로 이동하고, Command 및 Option 삭제 키로 줄 및 단어 단위로 삭제합니다. 애플리케이션은 제스처 대신 이 키 조합을 받으므로 전체 화면 TUI에서 작업하기 전에 이 설정을 끄세요." + } + } + } + }, + "settings.terminal.textEditingGestures.subtitleOff": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Command and Option key combinations reach the terminal unchanged." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Command と Option のキーの組み合わせは変更されずにターミナルに送られます。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Command 和 Option 组合键会原样传递给终端。" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Tastenkombinationen mit Befehls- und Wahltaste erreichen das Terminal unverändert." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Les combinaisons avec Commande et Option parviennent au terminal sans modification." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تصل تركيبات مفاتيح Command وOption إلى الطرفية دون تغيير." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Las combinaciones con Comando y Opción llegan a la terminal sin cambios." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Command 與 Option 組合鍵會原封不動傳送到終端機。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Command 및 Option 키 조합이 변경 없이 터미널로 전달됩니다." + } + } + } + }, "settings.textBox.betaWarning": { "extractionState": "manual", "localizations": { @@ -442240,6 +442417,381 @@ } } }, + "socket.workspace.reorder.indexNotAnInteger": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يجب أن يكون index عددًا صحيحًا" + } + }, + "bs": { + "stringUnit": { + "state": "needs_review", + "value": "index must be an integer" + } + }, + "da": { + "stringUnit": { + "state": "needs_review", + "value": "index must be an integer" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "index muss eine ganze Zahl sein" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "index must be an integer" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "index debe ser un número entero" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "index doit être un entier" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "index must be an integer" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "index は整数である必要があります" + } + }, + "km": { + "stringUnit": { + "state": "needs_review", + "value": "index must be an integer" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "index는 정수여야 합니다" + } + }, + "nb": { + "stringUnit": { + "state": "needs_review", + "value": "index must be an integer" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "index must be an integer" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "index must be an integer" + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "index must be an integer" + } + }, + "th": { + "stringUnit": { + "state": "needs_review", + "value": "index must be an integer" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "index must be an integer" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "index must be an integer" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "index 必须是整数" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "index 必須是整數" + } + } + } + }, + "socket.workspace.reorder.missingWorkspaceID": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "قيمة workspace_id مفقودة أو غير صالحة" + } + }, + "bs": { + "stringUnit": { + "state": "needs_review", + "value": "Missing or invalid workspace_id" + } + }, + "da": { + "stringUnit": { + "state": "needs_review", + "value": "Missing or invalid workspace_id" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Fehlende oder ungültige workspace_id" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Missing or invalid workspace_id" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Falta workspace_id o no es válido" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "workspace_id manquant ou invalide" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Missing or invalid workspace_id" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "workspace_id がないか無効です" + } + }, + "km": { + "stringUnit": { + "state": "needs_review", + "value": "Missing or invalid workspace_id" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "workspace_id가 없거나 유효하지 않습니다" + } + }, + "nb": { + "stringUnit": { + "state": "needs_review", + "value": "Missing or invalid workspace_id" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Missing or invalid workspace_id" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Missing or invalid workspace_id" + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "Missing or invalid workspace_id" + } + }, + "th": { + "stringUnit": { + "state": "needs_review", + "value": "Missing or invalid workspace_id" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Missing or invalid workspace_id" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Missing or invalid workspace_id" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "缺少或无效的 workspace_id" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "缺少或無效的 workspace_id" + } + } + } + }, + "socket.workspace.reorder.targetRequired": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "حدّد هدفًا واحدًا فقط: index أو before_workspace_id أو after_workspace_id" + } + }, + "bs": { + "stringUnit": { + "state": "needs_review", + "value": "Specify exactly one target: index, before_workspace_id, or after_workspace_id" + } + }, + "da": { + "stringUnit": { + "state": "needs_review", + "value": "Specify exactly one target: index, before_workspace_id, or after_workspace_id" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Genau ein Ziel angeben: index, before_workspace_id oder after_workspace_id" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Specify exactly one target: index, before_workspace_id, or after_workspace_id" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Especifica exactamente un destino: index, before_workspace_id o after_workspace_id" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Indiquez exactement une cible : index, before_workspace_id ou after_workspace_id" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Specify exactly one target: index, before_workspace_id, or after_workspace_id" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "index、before_workspace_id、after_workspace_id のいずれか 1 つだけを指定してください" + } + }, + "km": { + "stringUnit": { + "state": "needs_review", + "value": "Specify exactly one target: index, before_workspace_id, or after_workspace_id" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "index, before_workspace_id, after_workspace_id 중 정확히 하나만 지정하세요" + } + }, + "nb": { + "stringUnit": { + "state": "needs_review", + "value": "Specify exactly one target: index, before_workspace_id, or after_workspace_id" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Specify exactly one target: index, before_workspace_id, or after_workspace_id" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Specify exactly one target: index, before_workspace_id, or after_workspace_id" + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "Specify exactly one target: index, before_workspace_id, or after_workspace_id" + } + }, + "th": { + "stringUnit": { + "state": "needs_review", + "value": "Specify exactly one target: index, before_workspace_id, or after_workspace_id" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Specify exactly one target: index, before_workspace_id, or after_workspace_id" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Specify exactly one target: index, before_workspace_id, or after_workspace_id" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "请只指定一个目标:index、before_workspace_id 或 after_workspace_id" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "請只指定一個目標:index、before_workspace_id 或 after_workspace_id" + } + } + } + }, "socket.workspace.reorderMany.duplicateWorkspace": { "extractionState": "manual", "localizations": { diff --git a/Sources/App/AgentHibernationController.swift b/Sources/App/AgentHibernationController.swift index 0e3d4e70e2e9..0d4fadba597f 100644 --- a/Sources/App/AgentHibernationController.swift +++ b/Sources/App/AgentHibernationController.swift @@ -22,7 +22,7 @@ struct AgentHibernationRecord { let panelProcessIDs: Set let processIDs: Set let processIdentities: [Int: AgentPIDProcessIdentity] - let processLiveness: RestorableAgentProcessLiveness + private(set) var processLiveness: RestorableAgentProcessLiveness = .unknown init( key: AgentHibernationPanelKey, workspace: Workspace, diff --git a/Sources/Auth/AuthEnvironment.swift b/Sources/Auth/AuthEnvironment.swift index 4bedbebf341e..28e9e018dbf7 100644 --- a/Sources/Auth/AuthEnvironment.swift +++ b/Sources/Auth/AuthEnvironment.swift @@ -15,9 +15,73 @@ enum AuthEnvironment { private static let productionStackProjectID = "9790718f-14cd-4f7e-824d-eaf527a82b82" private static let productionStackPublishableClientKey = "pck_kzj80gx4mh2jrzn1cx6y5e8jk0kwa01vkevh2p9zd4twr" + /// Debug-only values that may be changed after the app is built. The file + /// is intentionally an allowlist of routing/channel knobs, never secrets. + /// It wins over inherited process variables because launchers commonly + /// inherit a stale port from another tagged cmux process. + private static let debugRuntimeOverrideKeys: [String] = [ + "CMUX_API_BASE_URL", + "CMUX_VM_API_BASE_URL", + "CMUX_WWW_ORIGIN", + "CMUX_AUTH_WWW_ORIGIN", + "CMUX_BILLING_WWW_ORIGIN", + "CMUX_PUSH_API_BASE_URL", + "CMUX_DEVICE_REGISTRY_API_BASE_URL", + "CMUX_IROH_BROKER_BASE_URL", + "CMUX_IROH_V2_BASE_URL", + "CMUX_IROH_V2_ENVIRONMENT", + "CMUX_IROH_V2_FORCE_RELAY", + "CMUX_STACK_BASE_URL", + "CMUX_STACK_PROJECT_ID", + "CMUX_STACK_PUBLISHABLE_CLIENT_KEY", + "CMUX_AUTH_ENVIRONMENT", + "CMUX_AUTH_CALLBACK_SCHEME", + "CMUX_PORT", + "PORT", + "CMUX_DEV_BACKEND_TAILSCALE_HOST", + "CMUX_DEV_BACKEND_TRANSPORT", + ] + + /// The one runtime configuration surface for a Debug app. A file override + /// is explicit operator state and therefore takes precedence over the + /// parent process environment; Release never reads it. + private static var runtimeEnvironment: [String: String] { + #if DEBUG + return mergedRuntimeEnvironment( + environment: ProcessInfo.processInfo.environment, + fileOverrides: debugRuntimeOverrides() + ) + #else + return ProcessInfo.processInfo.environment + #endif + } + + /// Merges only the allowlisted local routing/channel values. Keeping this + /// pure makes the precedence rule testable without touching a developer's + /// actual home directory or process environment. + static func mergedRuntimeEnvironment( + environment: [String: String], + fileOverrides: [String: String] + ) -> [String: String] { + var merged = environment + for key in debugRuntimeOverrideKeys { + if let value = fileOverrides[key]?.trimmingCharacters(in: .whitespacesAndNewlines), + !value.isEmpty { + merged[key] = value + } + } + return merged + } + + private static func debugRuntimeOverrides() -> [String: String] { + Dictionary(uniqueKeysWithValues: debugRuntimeOverrideKeys.compactMap { key in + devOverride(key: key).map { (key, $0) } + }) + } + static var callbackScheme: String { callbackScheme( - environment: ProcessInfo.processInfo.environment, + environment: runtimeEnvironment, bundleIdentifier: Bundle.main.bundleIdentifier ) } @@ -91,16 +155,15 @@ enum AuthEnvironment { } static var websiteOrigin: URL { - appWebOrigin(environment: ProcessInfo.processInfo.environment) + appWebOrigin(environment: runtimeEnvironment) } /// Pricing page used by every "Upgrade to cmux Pro" entrypoint - /// (Settings, command palette, Help menu). Resolution order mirrors - /// ``vmAPIBaseURL``: process env `CMUX_WWW_ORIGIN`, then the DEBUG-only - /// `~/.cmux-dev.env` file (so a deeplink-launched dev build can point at - /// a local web server), then the production website. + /// (Settings, command palette, Help menu). The DEBUG-only + /// `~/.cmux-dev.env` file wins over inherited process values, so a + /// deeplink-launched dev build can point at the current tagged backend. static var pricingURL: URL { - resolvedPricingURL(environment: ProcessInfo.processInfo.environment) + resolvedPricingURL(environment: runtimeEnvironment) } static func resolvedPricingURL(environment: [String: String]) -> URL { @@ -108,11 +171,11 @@ enum AuthEnvironment { } static var appPricingURL: URL { - resolvedAppPricingURL(environment: ProcessInfo.processInfo.environment) + resolvedAppPricingURL(environment: runtimeEnvironment) } static var appWebOrigin: URL { - resolvedAppWebOrigin(environment: ProcessInfo.processInfo.environment) + resolvedAppWebOrigin(environment: runtimeEnvironment) } /// Credential-bearing native-to-web handoffs are pinned to cmux.com in @@ -126,7 +189,7 @@ enum AuthEnvironment { let isDebugBuild = false #endif return resolvedAppSessionHandoffOrigin( - environment: ProcessInfo.processInfo.environment, + environment: runtimeEnvironment, isDebugBuild: isDebugBuild ) } @@ -181,7 +244,7 @@ enum AuthEnvironment { } static var appProWelcomeURL: URL { - resolvedAppProWelcomeURL(environment: ProcessInfo.processInfo.environment) + resolvedAppProWelcomeURL(environment: runtimeEnvironment) } static func resolvedAppProWelcomeURL(environment: [String: String]) -> URL { @@ -195,7 +258,7 @@ enum AuthEnvironment { /// request on the same origin that rendered pricing instead of crossing to /// production. static var billingCheckoutURL: URL { - resolvedBillingCheckoutURL(environment: ProcessInfo.processInfo.environment) + resolvedBillingCheckoutURL(environment: runtimeEnvironment) } static func resolvedBillingCheckoutURL(environment: [String: String]) -> URL { @@ -206,7 +269,7 @@ enum AuthEnvironment { } static var billingPortalURL: URL { - resolvedBillingPortalURL(environment: ProcessInfo.processInfo.environment) + resolvedBillingPortalURL(environment: runtimeEnvironment) } static func resolvedBillingPortalURL(environment: [String: String]) -> URL { @@ -214,12 +277,12 @@ enum AuthEnvironment { } static var signInWebsiteOrigin: URL { - resolvedAuthWebOrigin(environment: ProcessInfo.processInfo.environment) + resolvedAuthWebOrigin(environment: runtimeEnvironment) } static var apiBaseURL: URL { resolvedAPIBaseURL( - environment: ProcessInfo.processInfo.environment, + environment: runtimeEnvironment, isDebugBuild: isDebugBuild ) } @@ -251,13 +314,13 @@ enum AuthEnvironment { /// Base URL for the cmux-owned cloud VM backend (`/api/vm`). /// - /// Resolution order (first hit wins): - /// 1. process env `CMUX_VM_API_BASE_URL` — works when the app is launched from a shell. - /// 2. `~/.cmux-dev.env` file `CMUX_VM_API_BASE_URL=...` line — works regardless of how - /// the app was launched (click-through, Dock, `open`, etc.). Only honored in DEBUG. + /// Resolution order for Debug (first hit wins): + /// 1. `~/.cmux-dev.env` file `CMUX_VM_API_BASE_URL=...` line — explicit + /// operator state that works for click-through, Dock, and `open`. + /// 2. process env `CMUX_VM_API_BASE_URL` — useful for one-off launches. /// 3. VM backend dev origin (`http://localhost:$CMUX_PORT` in Debug, cmux.com in Release). static var vmAPIBaseURL: URL { - let environment = ProcessInfo.processInfo.environment + let environment = runtimeEnvironment #if DEBUG let debugBuild = true #else @@ -295,7 +358,7 @@ enum AuthEnvironment { /// defaults to shared staging (mirroring `irohBrokerBaseURL`); Release /// keeps the production VM-API origin. static var pushAPIBaseURL: URL { - let environment = ProcessInfo.processInfo.environment + let environment = runtimeEnvironment #if DEBUG let debugBuild = true #else @@ -337,7 +400,7 @@ enum AuthEnvironment { /// identity keeps dialing the dead endpoint forever. Mirrors /// `pushAPIBaseURL`; Release keeps the production VM-API origin. static var deviceRegistryAPIBaseURL: URL { - let environment = ProcessInfo.processInfo.environment + let environment = runtimeEnvironment #if DEBUG let debugBuild = true #else @@ -380,7 +443,7 @@ enum AuthEnvironment { /// shared staging in Debug so separately launched processes publish into one /// account-scoped registry. Release keeps the production cmux origin. static var irohBrokerBaseURL: URL? { - let environment = ProcessInfo.processInfo.environment + let environment = runtimeEnvironment #if DEBUG let debugBuild = true #else @@ -453,23 +516,16 @@ enum AuthEnvironment { return canonicalizedLoopbackURL(url) } - /// Look up `key=value` in `~/.cmux-dev.env` for the DEBUG build. Returns nil in Release. - /// Kept tiny on purpose — this is a "drop a file, restart the app, it picks up" override, - /// not a real config system. + /// Look up a Debug runtime override. A tag profile wins over the global + /// file, so several tagged apps can run against different backends without + /// rebuilding or inheriting a stale parent-process port. Returns nil in + /// Release and never reads arbitrary keys as configuration. private static func devOverride(key: String) -> String? { #if DEBUG guard let home = ProcessInfo.processInfo.environment["HOME"] else { return nil } - let path = (home as NSString).appendingPathComponent(".cmux-dev.env") - guard let data = try? String(contentsOfFile: path, encoding: .utf8) else { return nil } - for raw in data.split(separator: "\n") { - let line = raw.trimmingCharacters(in: .whitespaces) - guard !line.hasPrefix("#"), let eq = line.firstIndex(of: "=") else { continue } - let k = String(line[.. [String] { + var paths: [String] = [] + if let tag = ProcessInfo.processInfo.environment["CMUX_TAG"], + tag.range(of: #"^[A-Za-z0-9._-]+$"#, options: .regularExpression) != nil { + paths.append((home as NSString).appendingPathComponent(".config/cmux/dev-profiles/\(tag).env")) + } + paths.append((home as NSString).appendingPathComponent(".cmux-dev.env")) + return paths + } + + static func parseDebugOverride(key: String, contents: String) -> String? { + for raw in contents.split(separator: "\n") { + let line = raw.trimmingCharacters(in: .whitespaces) + guard !line.hasPrefix("#"), let eq = line.firstIndex(of: "=") else { continue } + let parsedKey = String(line[.. URL { @@ -576,7 +656,7 @@ enum AuthEnvironment { } private static func environmentPort(_ key: String) -> String? { - environmentPort(key, environment: ProcessInfo.processInfo.environment) + environmentPort(key, environment: runtimeEnvironment) } private static func environmentPort(_ key: String, environment: [String: String]) -> String? { @@ -591,7 +671,7 @@ enum AuthEnvironment { } private static var defaultWebOrigin: String { - resolvedDefaultWebOrigin(environment: ProcessInfo.processInfo.environment) + resolvedDefaultWebOrigin(environment: runtimeEnvironment) } private static func resolvedDefaultWebOrigin(environment: [String: String]) -> String { @@ -628,7 +708,7 @@ enum AuthEnvironment { } private static var defaultAPIBaseURL: String { - if let url = ProcessInfo.processInfo.environment["CMUX_API_BASE_URL"]? + if let url = runtimeEnvironment["CMUX_API_BASE_URL"]? .trimmingCharacters(in: .whitespacesAndNewlines), !url.isEmpty { return url @@ -650,12 +730,12 @@ enum AuthEnvironment { static var stackProjectID: String { #if DEBUG return resolvedStackProjectID( - environment: ProcessInfo.processInfo.environment, + environment: runtimeEnvironment, isDebugBuild: true ) #else return resolvedStackProjectID( - environment: ProcessInfo.processInfo.environment, + environment: runtimeEnvironment, isDebugBuild: false ) #endif @@ -711,12 +791,12 @@ enum AuthEnvironment { static var stackPublishableClientKey: String { #if DEBUG return resolvedStackPublishableClientKey( - environment: ProcessInfo.processInfo.environment, + environment: runtimeEnvironment, isDebugBuild: true ) #else return resolvedStackPublishableClientKey( - environment: ProcessInfo.processInfo.environment, + environment: runtimeEnvironment, isDebugBuild: false ) #endif @@ -750,7 +830,7 @@ enum AuthEnvironment { /// The website origin used for the after-sign-in handler. static var afterSignInOrigin: URL { - resolvedAfterSignInOrigin(environment: ProcessInfo.processInfo.environment) + resolvedAfterSignInOrigin(environment: runtimeEnvironment) } static func resolvedAfterSignInOrigin(environment: [String: String]) -> URL { @@ -818,7 +898,7 @@ enum AuthEnvironment { resolvedURL( environmentKey: environmentKey, fallback: fallback, - environment: ProcessInfo.processInfo.environment + environment: runtimeEnvironment ) } diff --git a/Sources/Cloud/CloudMachineLinkManager.swift b/Sources/Cloud/CloudMachineLinkManager.swift index 0a458900a7bd..5fbf49d2e46e 100644 --- a/Sources/Cloud/CloudMachineLinkManager.swift +++ b/Sources/Cloud/CloudMachineLinkManager.swift @@ -110,6 +110,16 @@ actor CloudMachineLinkManager { setPrivateAddresses(address.map { [$0] } ?? [], for: machineID) } + /// A create receipt proved the machine's image serves the trusted + /// private-network listener (snapshot-v2), so its first link dials + /// `--carrier` like a machine linked before. Without this, New Machine's + /// first link paid a control-plane attach request (a Mac-to-backend round + /// trip plus a provider status read, ~0.3 s) before its first dial. + func markTrustedCarrier(machineID: String) { + guard paths.deviceFingerprint(for: machineID) == nil else { return } + paths.saveDeviceFingerprint(CloudTuiClientPaths.carrierDeviceMarker, for: machineID) + } + func setPrivateAddresses(_ addresses: [String], for machineID: String) { var seen = Set() let addresses = addresses.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } diff --git a/Sources/Cloud/CloudTreeRowContentView.swift b/Sources/Cloud/CloudTreeRowContentView.swift index a056f58951e5..c969c29ed2dc 100644 --- a/Sources/Cloud/CloudTreeRowContentView.swift +++ b/Sources/Cloud/CloudTreeRowContentView.swift @@ -172,6 +172,7 @@ struct CloudTreeLeafRow: View { let style: CloudTreeStyle let icon: String let tint: Color + var iconAsset: String? = nil let title: String var titleWeight: Font.Weight = .regular var titleDimmed: Bool = false @@ -187,6 +188,7 @@ struct CloudTreeLeafRow: View { style: CloudTreeStyle, icon: String, tint: Color, + iconAsset: String? = nil, title: String, titleWeight: Font.Weight = .regular, titleDimmed: Bool = false, @@ -197,6 +199,7 @@ struct CloudTreeLeafRow: View { self.style = style self.icon = icon self.tint = tint + self.iconAsset = iconAsset self.title = title self.titleWeight = titleWeight self.titleDimmed = titleDimmed @@ -208,7 +211,13 @@ struct CloudTreeLeafRow: View { var body: some View { HStack(alignment: .center, spacing: GlobalFontMagnification.scaledSize(style.iconGap, percent: magnification)) { if style.iconSlot > 0 { - CloudTreeRowIcon(style: style, systemName: icon, tint: tint, dimmed: titleDimmed) + CloudTreeRowIcon( + style: style, + systemName: icon, + tint: tint, + assetName: iconAsset, + dimmed: titleDimmed + ) } switch style.leafLayout { case .twoLine: @@ -274,6 +283,7 @@ extension CloudTreeLeafRow where Accessories == EmptyView { style: CloudTreeStyle, icon: String, tint: Color, + iconAsset: String? = nil, title: String, titleWeight: Font.Weight = .regular, titleDimmed: Bool = false, @@ -284,6 +294,7 @@ extension CloudTreeLeafRow where Accessories == EmptyView { style: style, icon: icon, tint: tint, + iconAsset: iconAsset, title: title, titleWeight: titleWeight, titleDimmed: titleDimmed, @@ -294,7 +305,7 @@ extension CloudTreeLeafRow where Accessories == EmptyView { } } -/// A cmux-tui terminal row: lifecycle glyph and title, with secondary details on hover. +/// A cmux-tui terminal row with its provider mark, title, directory and optional view count. struct CloudTreeTerminalRowContent: View { let row: CloudTreeTerminalRow var style: CloudTreeStyle = CloudTreeStyleStore.current @@ -319,6 +330,7 @@ struct CloudTreeTerminalRowContent: View { style: style, icon: glyph, tint: CloudTreeIconPalette.terminal, + iconAsset: terminal.terminalAgentIconAssetName, title: row.displayTitle.isEmpty ? String(localized: "cloudTree.terminal.untitled", defaultValue: "terminal") : row.displayTitle, titleDimmed: terminal.lifecycle == .exited || showsDetachedState ) diff --git a/Sources/Cloud/CloudTreeRowIcon.swift b/Sources/Cloud/CloudTreeRowIcon.swift index e43440ca8b13..99df661b9ee5 100644 --- a/Sources/Cloud/CloudTreeRowIcon.swift +++ b/Sources/Cloud/CloudTreeRowIcon.swift @@ -1,4 +1,6 @@ import CmuxFoundation +import AppKit +import CmuxAppKitSupportUI import SwiftUI /// A row glyph in the shared icon slot, drawn per the style's icon treatment: @@ -9,12 +11,30 @@ struct CloudTreeRowIcon: View { let style: CloudTreeStyle let systemName: String let tint: Color + var assetName: String? = nil var dimmed: Bool = false var weight: Font.Weight = .regular var size: CGFloat? = nil @Environment(\.cmuxGlobalFontMagnificationPercent) private var magnification var body: some View { + if let assetName { + CmuxResolvedIconImage(request: CmuxResolvedIconRequest( + source: .asset(name: assetName, bundle: .main), + size: NSSize(width: style.iconSize, height: style.iconSize), + fallbackSource: .systemSymbol(name: systemName, accessibilityDescription: nil), + fallbackTintColor: .secondaryLabelColor + )) + .frame(width: style.iconSlot, height: style.iconSize, alignment: .center) + .opacity(dimmed ? 0.45 : 1) + .accessibilityHidden(true) + } else { + systemIcon + } + } + + @ViewBuilder + private var systemIcon: some View { switch style.iconTreatment { case .monochrome: // `Color.tertiary` needs macOS 15; the label colors match the diff --git a/Sources/Cloud/PortForward/CloudHubConnector.swift b/Sources/Cloud/PortForward/CloudHubConnector.swift index 9e78a73dee62..465f029d39f1 100644 --- a/Sources/Cloud/PortForward/CloudHubConnector.swift +++ b/Sources/Cloud/PortForward/CloudHubConnector.swift @@ -5,10 +5,24 @@ import Network /// A family can blackhole independently of the other after a VM joins its VPC. /// Race actual SOCKS CONNECT handshakes, retaining the winning stream and closing /// every loser before returning, so terminal and browser callers share the policy. +/// +/// Each address is also redialed every `redialInterval` until one handshake +/// succeeds or `timeout` passes. A machine created a moment ago is not +/// reachable until its VPC fabric has seen a frame from it; the SYNs of an +/// attempt started before that are lost, and the hub's TCP retransmit backoff +/// (1 s, then 2 s, ...) left New Machine waiting ~3.7 s, or failing at the 15 s +/// deadline, for a daemon that was reachable ~0.4 s after the create response. +/// A fresh attempt costs one local SOCKS connect, so hedging is cheap. struct CloudHubConnector: Sendable { var timeout: Duration = .seconds(15) /// A cancellable head start for the preferred family, driven by the injected clock. var fallbackDelay: Duration = .milliseconds(250) + /// How often a still-unanswered address gets another, independent attempt. + var redialInterval: Duration = .milliseconds(50) + /// Redial rounds after the first. The fresh-machine window is well under a + /// second; after 3 s the in-flight attempts ride normal retransmits, so a + /// blackholed family never holds more than this many sockets per address. + var maxRedials: Int = 60 var clock: any Clock = ContinuousClock() #if compiler(>=6.2) @@ -21,46 +35,107 @@ struct CloudHubConnector: Sendable { target: CloudPortForwardTarget, queue: DispatchQueue ) async throws -> CloudHubConnection { - let candidates = target.hosts.map { host in - CloudHubConnection(connection: NWConnection(to: endpoint, using: .tcp), host: host) - } - return try await withTaskCancellationHandler { - try await withThrowingTaskGroup(of: Result.self) { group in - for (index, candidate) in candidates.enumerated() { + let hosts = target.hosts + return try await Self.hedged( + candidates: hosts.count, + fallbackDelay: fallbackDelay, + redialInterval: redialInterval, + maxRedials: maxRedials, + timeout: timeout, + clock: clock, + attempt: { index in + let candidate = CloudHubConnection(connection: NWConnection(to: endpoint, using: .tcp), host: hosts[index]) + do { + try await handshake(candidate.connection, host: candidate.host, port: target.port, queue: queue) + return candidate + } catch { + candidate.connection.cancel() + throw error + } + }, + discard: { $0.connection.cancel() } + ) + } + + /// Runs `attempt(candidate)` for every candidate (each later one delayed by + /// `fallbackDelay`) and starts a new attempt for every candidate each + /// `redialInterval`, until the first success. Every other in-flight or later + /// success is passed to `discard`. Throws the last failure (or a timeout) + /// when nothing succeeds within `timeout`. + static func hedged( + candidates: Int, + fallbackDelay: Duration, + redialInterval: Duration, + maxRedials: Int, + timeout: Duration, + clock: any Clock, + attempt: @escaping @Sendable (Int) async throws -> Value, + discard: @escaping @Sendable (Value) -> Void + ) async throws -> Value { + guard candidates > 0 else { throw CancellationError() } + return try await withThrowingTaskGroup(of: CloudHubHedgeEvent.self) { group in + func launch(round: Int) { + for index in 0.. 0 && round == 0 ? fallbackDelay : .zero group.addTask { do { - if index > 0 { try await clock.sleep(for: fallbackDelay) } - try Task.checkCancellation() - try await handshake(candidate.connection, host: candidate.host, port: target.port, queue: queue) + if delay > .zero { try await clock.sleep(for: delay) } try Task.checkCancellation() - return .success(candidate) + return .success(try await attempt(index)) } catch { - candidate.connection.cancel() return .failure(error) } } } - defer { group.cancelAll() } - var lastError: any Error = CancellationError() - while let result = try await group.next() { - switch result { - case .success(let connected): - for other in candidates where other.connection !== connected.connection { - other.connection.cancel() - } - if Task.isCancelled { - connected.connection.cancel() - throw CancellationError() - } - return connected - case .failure(let error): - lastError = error + } + launch(round: 0) + group.addTask { + try? await clock.sleep(for: redialInterval) + return .tick + } + group.addTask { + try? await clock.sleep(for: timeout) + return .deadline + } + var round = 1 + var expired = false + var lastError: any Error = CloudPortForwardRelay.RelayError.handshakeTimedOut(timeout) + var winner: Value? + while let event = try await group.next() { + switch event { + case .success(let value): + if winner == nil { + winner = value + group.cancelAll() + } else { + discard(value) } + case .failure(let error): + if !(error is CancellationError) { lastError = error } + case .tick: + guard winner == nil, !expired, round <= maxRedials else { continue } + launch(round: round) + round += 1 + group.addTask { + try? await clock.sleep(for: redialInterval) + return .tick + } + case .deadline: + expired = true + if winner == nil { group.cancelAll() } } - throw lastError } - } onCancel: { - for candidate in candidates { candidate.connection.cancel() } + // The group drains every child before returning, so late winners + // were discarded above and no attempt outlives this call. + if let winner { + if Task.isCancelled { + discard(winner) + throw CancellationError() + } + return winner + } + try Task.checkCancellation() + throw lastError } } @@ -91,3 +166,10 @@ struct CloudHubConnector: Sendable { } } } + +enum CloudHubHedgeEvent: Sendable { + case success(Value) + case failure(any Error) + case tick + case deadline +} diff --git a/Sources/Cloud/VMClient.swift b/Sources/Cloud/VMClient.swift index 02bd9fa27c26..fb8dddb85655 100644 --- a/Sources/Cloud/VMClient.swift +++ b/Sources/Cloud/VMClient.swift @@ -286,6 +286,10 @@ struct VMSummary { /// the WireGuard tunnel); nil for machines created before private networking. var addressIPv4: String? var addressIPv6: String? + /// The image's cmux-tui attach contract from the create receipt + /// (`"snapshot-v2"`: baked daemon, trusted private-network listener). + /// Only the create response carries it; list reads leave it nil. + var cmuxTuiContract: String? /// The name to show people: the label when set, else the generated slug, /// else the machine id. @@ -1234,6 +1238,10 @@ actor VMClient { extraHeaders: headers, timeoutSeconds: Self.createTimeoutSeconds ) + #if DEBUG + // Per-stage server time for the New Machine critical path. + cmuxDebugLog("cloud.vm.create.serverTiming status=\(http.statusCode) \(http.value(forHTTPHeaderField: "Server-Timing") ?? "none")") + #endif try ensureOK(http, data: data) let obj = try decodeJSONObject(data) guard let id = obj["id"] as? String, @@ -1254,6 +1262,14 @@ actor VMClient { summary.capabilities = VMCapabilities(vmResponse: obj) summary.displayName = (obj["displayName"] as? String).flatMap { $0.isEmpty ? nil : $0 } summary.slug = (obj["slug"] as? String).flatMap { $0.isEmpty ? nil : $0 } + // The create receipt names the new machine's private address and + // attach contract, so the app can register and dial it without a + // fleet re-read or an attach request (see createdMachineAttach). + if let address = obj["address"] as? [String: Any] { + summary.addressIPv4 = (address["ipv4"] as? String).flatMap { $0.isEmpty ? nil : $0 } + summary.addressIPv6 = (address["ipv6"] as? String).flatMap { $0.isEmpty ? nil : $0 } + } + summary.cmuxTuiContract = (obj["cmuxTuiContract"] as? String).flatMap { $0.isEmpty ? nil : $0 } machineCache.record(hasAnyMachine: true) return summary } diff --git a/Sources/Cloud/VMClientSocketCommands.swift b/Sources/Cloud/VMClientSocketCommands.swift index bf4f3ca254dd..8513ab1eb716 100644 --- a/Sources/Cloud/VMClientSocketCommands.swift +++ b/Sources/Cloud/VMClientSocketCommands.swift @@ -577,7 +577,23 @@ extension TerminalController { throw CloudMachineLinkManager.ManagerError.wireGuardHubUnsupported } var payload: [String: Any] - if let deviceFingerprint { + var isCreatedReceipt = false + if deviceFingerprint == nil, + let createdRoute = await registry.takeCreatedTrustedCarrierRoute(machineID: vmId) { + isCreatedReceipt = true + // New Machine: the create receipt already proved the + // snapshot-v2 trusted listener and named the private + // address. Skip POST /attach-endpoint (~2 s measured); + // it would return this same route from the same row. + payload = [ + "transport": "cmux-remote", + "route": createdRoute, + "token": "", + "expires_at_unix": 0, + "session": "cmux", + "trusted_carrier": true, + ] + } else if let deviceFingerprint { guard let knownRoute = await registry.privateRoute(machineID: vmId) else { throw CloudMachineLinkManager.ManagerError.privateRouteRequired(vmId) } @@ -627,9 +643,14 @@ extension TerminalController { guard let hub else { throw CloudMachineLinkManager.ManagerError.wireGuardHubMissing } let ready = try await hub.pinForExternalClient() payload["wireguard_hub_socket"] = ready.socketPath - let addresses = payload["network_addresses"] as? [String: Any] ?? [:] - let resolvedRoute = try await registry.resolvedPrivateRoute(machineID: vmId, through: ready, fallbackRoute: route, addresses: ["ipv4", "ipv6"].compactMap { addresses[$0] as? String }) - payload["route"] = resolvedRoute + // A just-created machine keeps the route its receipt declared + // (IPv4 first, like the server). Racing families here would + // dial a machine that is still coming up and double the wait + // the app's own link (already started) is paying. + if !isCreatedReceipt { + let addresses = payload["network_addresses"] as? [String: Any] ?? [:] + payload["route"] = try await registry.resolvedPrivateRoute(machineID: vmId, through: ready, fallbackRoute: route, addresses: ["ipv4", "ipv6"].compactMap { addresses[$0] as? String }) + } return payload } case "vm.sessions": diff --git a/Sources/CmuxSettingsFileStore+SupportedPaths.swift b/Sources/CmuxSettingsFileStore+SupportedPaths.swift index dac7a82b4913..589a61d57b58 100644 --- a/Sources/CmuxSettingsFileStore+SupportedPaths.swift +++ b/Sources/CmuxSettingsFileStore+SupportedPaths.swift @@ -28,6 +28,7 @@ extension CmuxSettingsFileStore { "app.reorderOnNotification", "app.sendAnonymousTelemetry", "app.confirmQuit", + "app.globalFontMagnification", "app.warnBeforeQuit", "app.warnBeforeClosingTab", "app.warnBeforeClosingTabXButton", @@ -39,6 +40,7 @@ extension CmuxSettingsFileStore { "terminal.showScrollBar", "terminal.scrollSpeed", "terminal.copyOnSelect", + "terminal.textEditingGestures", "terminal.autoResumeAgentSessions", "terminal.showTextBoxOnNewTerminals", "terminal.focusTextBoxOnNewTerminals", @@ -153,5 +155,6 @@ extension CmuxSettingsFileStore { "fileEditor.tabWidth", "fileExplorer.doubleClickAction", "shortcuts.bindings", + "shortcuts.showModifierHoldHints", ] } diff --git a/Sources/CmuxSettingsJSONPathSupport.swift b/Sources/CmuxSettingsJSONPathSupport.swift index dc28913d7521..789ea75401b2 100644 --- a/Sources/CmuxSettingsJSONPathSupport.swift +++ b/Sources/CmuxSettingsJSONPathSupport.swift @@ -236,6 +236,11 @@ enum TerminalSettingsFileMapping { defaultsKey: AgentSessionAutoResumeSettings.autoResumeAgentSessionsKey, invalidPath: "terminal.autoResumeAgentSessions" ), + .init( + jsonKey: "textEditingGestures", + defaultsKey: terminal.textEditingGestures.userDefaultsKey, + invalidPath: terminal.textEditingGestures.id + ), ] } diff --git a/Sources/CmuxTaskManagerCodingAgentDefinition+BuiltIns.swift b/Sources/CmuxTaskManagerCodingAgentDefinition+BuiltIns.swift index 771eb9605878..9883b3fc9cc9 100644 --- a/Sources/CmuxTaskManagerCodingAgentDefinition+BuiltIns.swift +++ b/Sources/CmuxTaskManagerCodingAgentDefinition+BuiltIns.swift @@ -10,7 +10,7 @@ extension CmuxTaskManagerCodingAgentDefinition { .init(id: "codex", displayName: "Codex", assetName: "AgentIcons/Codex", launchKinds: ["codex", "omx"], directBasenames: ["codex", "omx"], argumentNeedles: ["codex", "@openai/codex", "oh-my-codex"]), - .init(id: "grok", displayName: "Grok", assetName: nil, + .init(id: "grok", displayName: "Grok", assetName: "AgentIcons/Grok", launchKinds: ["grok"], directBasenames: ["grok", "grok-macos-aarch64", "grok-macos-aarch"], argumentNeedles: ["grok", "grok-build", "@xai/grok"]), .init(id: "opencode", displayName: "OpenCode", assetName: "AgentIcons/OpenCode", @@ -26,11 +26,11 @@ extension CmuxTaskManagerCodingAgentDefinition { argumentNeedles: ["@mariozechner/pi-coding-agent", "pi-coding-agent"]), .init(id: "amp", displayName: "Amp", assetName: "AgentIcons/Amp", launchKinds: ["amp"], directBasenames: ["amp"], argumentNeedles: ["@ampcode"]), - .init(id: "cursor", displayName: "Cursor", assetName: nil, + .init(id: "cursor", displayName: "Cursor", assetName: "AgentIcons/Cursor", launchKinds: ["cursor"], directBasenames: ["cursor-agent"], argumentNeedles: ["cursor-agent"]), - .init(id: "gemini", displayName: "Gemini", assetName: nil, + .init(id: "gemini", displayName: "Gemini", assetName: "AgentIcons/Gemini", launchKinds: ["gemini"], directBasenames: ["gemini"], argumentNeedles: ["gemini"]), - .init(id: "kiro", displayName: "Kiro", assetName: nil, + .init(id: "kiro", displayName: "Kiro", assetName: "AgentIcons/Kiro", launchKinds: ["kiro"], directBasenames: ["kiro", "kiro-cli"], argumentNeedles: ["kiro", "kiro-cli"]), .init(id: "antigravity", displayName: "Antigravity", assetName: "AgentIcons/Antigravity", launchKinds: ["antigravity", "agy"], directBasenames: ["agy", "antigravity"], @@ -39,18 +39,18 @@ extension CmuxTaskManagerCodingAgentDefinition { launchKinds: ["rovodev", "rovo"], directBasenames: ["rovodev"], argumentNeedles: ["rovodev"]), .init(id: "hermes-agent", displayName: "Hermes Agent", assetName: "AgentIcons/HermesAgent", launchKinds: ["hermes-agent"], directBasenames: ["hermes", "hermes-agent"], argumentNeedles: ["hermes-agent"]), - .init(id: "copilot", displayName: "Copilot", assetName: nil, - launchKinds: ["copilot"], directBasenames: ["copilot"], argumentNeedles: ["copilot"]), - .init(id: "codebuddy", displayName: "CodeBuddy", assetName: nil, + .init(id: "copilot", displayName: "Copilot", assetName: "AgentIcons/Copilot", + launchKinds: ["copilot"], directBasenames: ["copilot", "github-copilot"], argumentNeedles: ["copilot"]), + .init(id: "codebuddy", displayName: "CodeBuddy", assetName: "AgentIcons/CodeBuddy", launchKinds: ["codebuddy"], directBasenames: ["codebuddy"], argumentNeedles: ["codebuddy"]), - .init(id: "factory", displayName: "Factory", assetName: nil, + .init(id: "factory", displayName: "Factory", assetName: "AgentIcons/Factory", launchKinds: ["factory"], directBasenames: ["droid", "factory"], argumentNeedles: ["factory"]), - .init(id: "qoder", displayName: "Qoder", assetName: nil, + .init(id: "qoder", displayName: "Qoder", assetName: "AgentIcons/Qoder", launchKinds: ["qoder"], directBasenames: ["qoder", "qodercli"], argumentNeedles: ["qoder", "qodercli"]), .init( id: "kimi", displayName: String(localized: "agent.kimi.displayName", defaultValue: "Kimi Code"), - assetName: nil, + assetName: "AgentIcons/Kimi", launchKinds: ["kimi"], // Kimi's Python entrypoint deliberately overwrites its OS process title and argv with // "Kimi Code". This is process-status/foreground detection only; session persistence @@ -61,7 +61,7 @@ extension CmuxTaskManagerCodingAgentDefinition { .init( id: "ollama", displayName: String(localized: "agent.ollama.displayName", defaultValue: "Ollama"), - assetName: nil, + assetName: "AgentIcons/Ollama", launchKinds: ["ollama"], directBasenames: ["ollama"], // No argument needles: a bare "ollama" token plus the "run" diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 5950929cbe70..fb8e02df974b 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -3953,6 +3953,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { fileprivate private(set) var keyboardCopyModeActive = false private var wordPathHoverActive = false private var keyboardCopyModeConsumedKeyUps: Set = [] + private var textEditingGestureConsumedKeyUps: Set = [] private var imeConsumedKeyUps: Set = [] private var manualNamedKeyConsumedKeyUps: Set = [] /// Deferred native input actions retain their authored order until the @@ -5809,6 +5810,93 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { syncKeyboardCopyModeCursorOverlay(surface: surface) } + /// Whether opt-in terminal text-editing gestures are active. + /// + /// Reads the same defaults key as `terminal.textEditingGestures` in the + /// settings catalog, whose default is `false`, so an unset key leaves the + /// mode off. + private var textEditingGesturesEnabled: Bool { + UserDefaults.standard.bool(forKey: "terminal.textEditingGestures") + } + + /// Maps AppKit modifier flags onto the resolver's platform-neutral set. + private func textEditingModifiers( + from flags: NSEvent.ModifierFlags + ) -> TerminalTextEditingModifiers { + var modifiers: TerminalTextEditingModifiers = [] + if flags.contains(.command) { modifiers.insert(.command) } + if flags.contains(.shift) { modifiers.insert(.shift) } + if flags.contains(.control) { modifiers.insert(.control) } + if flags.contains(.option) { modifiers.insert(.option) } + if flags.contains(.numericPad) { modifiers.insert(.numericPad) } + if flags.contains(.function) { modifiers.insert(.function) } + if flags.contains(.capsLock) { modifiers.insert(.capsLock) } + return modifiers + } + + /// Carbon virtual key codes for the letters a resolved chord can name. + private static let textEditingChordKeyCodes: [Character: UInt16] = [ + "a": 0x00, "b": 0x0B, "d": 0x02, "e": 0x0E, + "f": 0x03, "k": 0x28, "u": 0x20, "w": 0x0D, + ] + + /// Replays a macOS text-editing gesture as the line-editor chord it means. + /// + /// The chord is sent as a synthesized key press rather than as raw bytes so + /// Ghostty performs the encoding, keeping the result correct under whichever + /// keyboard protocol the running application negotiated. + /// + /// - Parameters: + /// - event: The key-down event to consider. + /// - surface: The surface that receives the replayed chord. + /// - Returns: `true` when the gesture was consumed and must not reach the + /// terminal as the original keystroke. + private func handleTextEditingGestureIfNeeded( + _ event: NSEvent, + surface: ghostty_surface_t + ) -> Bool { + // Keyboard copy mode owns the keyboard while it is active. It lets + // Command-modified events through on purpose so menu shortcuts still + // fire, and every gesture that survives its filter is Command-modified, + // so without this guard reading scrollback with a half-typed command at + // the prompt would replay Ctrl+U/Ctrl+K and destroy that line. + guard !keyboardCopyModeActive, !hasMarkedText() else { return false } + guard let chord = terminalTextEditingResolve( + keyCode: event.keyCode, + modifiers: textEditingModifiers(from: event.modifierFlags) + ) else { return false } + // The defaults read is the costly half, so it runs only after the pure + // resolver has confirmed this keystroke is gesture-shaped at all. Every + // other keystroke leaves this path having done no I/O. + guard textEditingGesturesEnabled else { return false } + guard + let chordKeyCode = Self.textEditingChordKeyCodes[chord.letter], + let scalar = chord.letter.unicodeScalars.first + else { return false } + + var keyEvent = ghostty_input_key_s() + keyEvent.action = event.isARepeat ? GHOSTTY_ACTION_REPEAT : GHOSTTY_ACTION_PRESS + keyEvent.keycode = UInt32(chordKeyCode) + keyEvent.mods = chord.modifier == .control ? GHOSTTY_MODS_CTRL : GHOSTTY_MODS_ALT + keyEvent.consumed_mods = GHOSTTY_MODS_NONE + keyEvent.composing = false + keyEvent.unshifted_codepoint = scalar.value + keyEvent.text = nil + if sendGhosttyKey(surface, keyEvent) { return true } + // Only an Option chord can legitimately encode nothing. libghostty + // prefixes ESC for Alt only when `macos-option-as-alt` resolves true, + // and `detectOptionAsAlt` returns true solely for the US and + // US-International layouts, so a synthesized Alt+b writes nothing at + // all on AZERTY, German, Dvorak and friends -- and on any layout when + // the setting is `false` or a `right` that the synthesized left bit + // cannot match. Under the kitty protocol the key event already + // succeeded, so this runs only for the legacy encoding that `esc:` + // matches. A false return means nothing reached the pty, so re-sending + // here cannot double-write. + guard chord.modifier == .option else { return false } + return performBindingAction("esc:\(chord.letter)") + } + private func handleKeyboardCopyModeIfNeeded(_ event: NSEvent, surface: ghostty_surface_t) -> Bool { guard keyboardCopyModeActive else { return false } reconcileKeyboardCopyModeViewport(surface: surface) @@ -6081,6 +6169,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { if result { imeConsumedKeyUps.removeAll() manualNamedKeyConsumedKeyUps.removeAll() + textEditingGestureConsumedKeyUps.removeAll() if let terminalSurface, AppDelegate.shared?.allowsTerminalKeyboardFocus( workspaceId: terminalSurface.tabId, @@ -6136,6 +6225,15 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { ) } } + // Mirror the intent before requiring a live runtime, the way + // resignFirstResponder already does. createSurface re-applies + // desiredFocusState once the runtime exists, so a focus taken while the + // surface is still spawning survives; gating the mirror on the runtime + // left nothing for that reconciliation to converge to. + if result, shouldApplySurfaceFocus { + terminalSurface?.recordExternalFocusState(true) + terminalSurface?.hostedView.cancelSuppressedFirstResponderFocusReapply() + } if result, shouldApplySurfaceFocus, let surface = ensureSurfaceReadyForInput(reassertInputFocus: false) { let now = CACurrentMediaTime() let deltaMs = (now - lastScrollEventTime) * 1000 @@ -6163,8 +6261,6 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { userInfo: userInfo ) } - terminalSurface?.recordExternalFocusState(true) - terminalSurface?.hostedView.cancelSuppressedFirstResponderFocusReapply() ghostty_surface_set_focus(surface, true) // Ghostty only restarts its vsync display link on display-id changes while focused. @@ -6185,6 +6281,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { if result { imeConsumedKeyUps.removeAll() manualNamedKeyConsumedKeyUps.removeAll() + textEditingGestureConsumedKeyUps.removeAll() desiredFocus = false deferReleaseAllGhosttyMouseButtons( reason: "resignFirstResponder" @@ -6541,6 +6638,20 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { keyboardCopyModeConsumedKeyUps.insert(event.keyCode) return } + if handleTextEditingGestureIfNeeded(event, surface: surface) { + // sendGhosttyKey already reported the accepted input; only the + // originating gesture's key-up still needs suppressing, because the + // synthesized press has no matching release. + // + // AppKit never delivers a Command-modified key-up to the responder + // chain, so recording one would strand the code in this set and + // swallow the next *unmodified* release of the same physical key -- + // leaving a stuck arrow in any app that reads releases. + if !event.modifierFlags.contains(.command) { + textEditingGestureConsumedKeyUps.insert(event.keyCode) + } + return + } #if DEBUG keyboardCopyModeMs = (ProcessInfo.processInfo.systemUptime - keyboardCopyModeStart) * 1000.0 #endif @@ -7009,6 +7120,9 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { if keyboardCopyModeConsumedKeyUps.remove(event.keyCode) != nil { return } + if textEditingGestureConsumedKeyUps.remove(event.keyCode) != nil { + return + } if imeConsumedKeyUps.remove(event.keyCode) != nil { return } diff --git a/Sources/Panels/TerminalPanel.swift b/Sources/Panels/TerminalPanel.swift index 565028c85cce..553dd2ecbb67 100644 --- a/Sources/Panels/TerminalPanel.swift +++ b/Sources/Panels/TerminalPanel.swift @@ -716,8 +716,12 @@ final class TerminalPanel: Panel, ObservableObject { @discardableResult func sendText(_ text: String) -> Bool { + sendTextResult(text).accepted + } + + func sendTextResult(_ text: String) -> TerminalSurface.TextSendResult { resumeForExplicitInputIfNeeded() - return surface.sendText(text) + return surface.sendTextResult(text) } func sendInput(_ text: String) { diff --git a/Sources/SessionRemoteWorkspaceSnapshot+Restore.swift b/Sources/SessionRemoteWorkspaceSnapshot+Restore.swift index 3a7961c6dd7f..59889c30fc63 100644 --- a/Sources/SessionRemoteWorkspaceSnapshot+Restore.swift +++ b/Sources/SessionRemoteWorkspaceSnapshot+Restore.swift @@ -536,7 +536,7 @@ extension SessionRemoteWorkspaceSnapshot { "cmux_freestyle_cli=\"${CMUX_BUNDLED_CLI_PATH:-}\"", "if [ -z \"$cmux_freestyle_cli\" ] || [ ! -x \"$cmux_freestyle_cli\" ]; then cmux_freestyle_cli=\"$(command -v cmux 2>/dev/null || true)\"; fi", "if [ -z \"$cmux_freestyle_cli\" ]; then printf '%s\\n' '[cmux] bundled CLI not found for Cloud VM SSH attach.' >&2; exit 127; fi", - "CMUX_SSH_RECONNECT_LIMIT=\"${CMUX_SSH_RECONNECT_LIMIT:-86400}\"", + "CMUX_SSH_RECONNECT_LIMIT=\"${CMUX_SSH_RECONNECT_LIMIT:-\(SSHReconnectBudget().maximumLimit)}\"", "CMUX_SSH_RECONNECT_DELAY_SECONDS=\"${CMUX_SSH_RECONNECT_DELAY_SECONDS:-2}\"", "CMUX_DEFAULT_FREESTYLE_ATTACH_RETRY_LIMIT=\"${CMUX_DEFAULT_FREESTYLE_ATTACH_RETRY_LIMIT:-$CMUX_SSH_RECONNECT_LIMIT}\"", "CMUX_DEFAULT_FREESTYLE_ATTACH_RETRY_DELAY_SECONDS=\"${CMUX_DEFAULT_FREESTYLE_ATTACH_RETRY_DELAY_SECONDS:-$CMUX_SSH_RECONNECT_DELAY_SECONDS}\"", diff --git a/Sources/SettingsSearchAliases.swift b/Sources/SettingsSearchAliases.swift index cb858146fd15..e681ec6c9d5a 100644 --- a/Sources/SettingsSearchAliases.swift +++ b/Sources/SettingsSearchAliases.swift @@ -114,6 +114,7 @@ enum SettingsSearchAliasIndex { "terminal:session-content-width": localized("settings.search.alias.setting.terminal.session-content-width", defaultValue: "terminal.sessionContentMaxWidth terminal agent chat max width readable line length points pt narrow wide"), "terminal:session-content-alignment": localized("settings.search.alias.setting.terminal.session-content-alignment", defaultValue: "terminal.sessionContentAlignment terminal agent chat left center right alignment position"), "terminal:copy-on-select": localized("settings.search.alias.setting.terminal.copy-on-select", defaultValue: "terminal.copyOnSelect copy on selection select clipboard mouse double click triple click iterm"), + "terminal:text-editing-gestures": localized("settings.search.alias.setting.terminal.text-editing-gestures", defaultValue: "terminal.textEditingGestures text editing gestures option alt word line kill readline emacs keybindings command arrow delete"), "terminal:tab-bar-font-size": localized("settings.search.alias.setting.terminal.tab-bar-font-size", defaultValue: "surface-tab-bar-font-size tab bar font size text scale terminal browser pane tab title"), "terminal:resume-commands": localized("settings.search.alias.setting.terminal.resume-commands", defaultValue: "surface resume commands approvals command prefixes auto restore ask manual tmux hibernation sticky process"), "textBox:show-textbox-new-terminals": localized("settings.search.alias.setting.textBox.show-textbox-new-terminals", defaultValue: "terminal.showTextBoxOnNewTerminals show textbox text box rich input prompt default new terminal workspace split tab beta"), diff --git a/Sources/SettingsSearchIndex.swift b/Sources/SettingsSearchIndex.swift index 6b055121f00f..429d9f4b429b 100644 --- a/Sources/SettingsSearchIndex.swift +++ b/Sources/SettingsSearchIndex.swift @@ -94,6 +94,7 @@ enum SettingsSearchIndex { setting(.terminal, "session-content-width", String(localized: "settings.terminal.sessionContentWidth", defaultValue: "Session Content Width"), "terminal.sessionContentMaxWidth terminal agent chat max width readable line length narrow wide"), setting(.terminal, "session-content-alignment", String(localized: "settings.terminal.sessionContentAlignment", defaultValue: "Session Content Alignment"), "terminal.sessionContentAlignment left center right align terminal agent chat"), setting(.terminal, "copy-on-select", String(localized: "settings.terminal.copyOnSelect", defaultValue: "Copy on Selection"), "terminal.copyOnSelect clipboard selection mouse double click triple click"), + setting(.terminal, "text-editing-gestures", String(localized: "settings.terminal.textEditingGestures", defaultValue: "Text Editing Gestures"), "terminal.textEditingGestures text editing gestures option alt word line kill readline emacs keybindings command arrow delete"), setting(.terminal, "tab-bar-font-size", String(localized: "settings.terminal.tabBarFontSize", defaultValue: "Tab Bar Font Size"), "font size text scale terminal browser pane tab title surface-tab-bar-font-size"), setting(.terminal, "agent-auto-resume", String(localized: "settings.terminal.agentAutoResume", defaultValue: "Resume Agent Sessions on Reopen"), "terminal.autoResumeAgentSessions auto resume restore reopen relaunch quit sessions agents claude code codex opencode rovo dev rovodev toggle"), setting(.terminal, "agent-hibernation", String(localized: "settings.terminal.agentHibernation", defaultValue: "Agent Hibernation"), "terminal.agentHibernation idle hibernate suspend background agents claude code codex opencode live terminals"), @@ -284,6 +285,7 @@ enum SettingsSearchIndex { "terminal.textBoxDefaultSubmitAction": settingID(for: .textBox, idSuffix: "default-submit-action"), "terminal.textBoxMaxLines": settingID(for: .textBox, idSuffix: "textbox-max-lines"), "terminal.copyOnSelect": settingID(for: .terminal, idSuffix: "copy-on-select"), + "terminal.textEditingGestures": settingID(for: .terminal, idSuffix: "text-editing-gestures"), "terminal.sessionContentMaxWidth": settingID(for: .terminal, idSuffix: "session-content-width"), "terminal.sessionContentAlignment": settingID(for: .terminal, idSuffix: "session-content-alignment"), "terminal.autoResumeAgentSessions": settingID(for: .terminal, idSuffix: "agent-auto-resume"), diff --git a/Sources/SharedLiveAgentIndex.swift b/Sources/SharedLiveAgentIndex.swift index 64b77305156f..11ddee0acc54 100644 --- a/Sources/SharedLiveAgentIndex.swift +++ b/Sources/SharedLiveAgentIndex.swift @@ -663,6 +663,26 @@ final class SharedLiveAgentIndex { _ = await applyPendingForkValidations( pendingRequestIDsToRemoveOnCancellation: pendingRequestIDsOwnedByRequest ) + // The pass above may find this caller's request already claimed by + // another drainer: the unguarded tail restart in + // `applyPendingForkValidations` can spawn a detached refresh that + // wins the race against a contention waiter it just resumed, and + // that waiter then returns to an empty queue. Returning here would + // break this method's contract -- the queued validation must be + // applied before it returns -- so callers could read stale fork + // availability. + // + // This is a symptom fix, not the root cause. The root cause is that + // the tail restart in `applyPendingForkValidations` lacks the + // `!resumedWaiters` guard its in-loop sibling has, so it can resume + // a waiter and then immediately race it. Guarding it there is the + // real repair, but the obvious form can strand a pending request + // when the resumed waiter's task is cancelled right after resuming, + // so it needs its own change. The live-index branch below has the + // same hole when `didReload` is true -- `reload()` runs + // `applyPendingForkValidations` internally, so the same steal can + // happen and that path returns without waiting. + await waitForForkValidationRequestCompletions(pendingRequestIDsOwnedByRequest) return } let reloadResult = await reloadIfLiveAgentProcessFingerprintChanged( diff --git a/Sources/Surfaces/CmuxTuiSnapshotParser.swift b/Sources/Surfaces/CmuxTuiSnapshotParser.swift index 78c0b058d156..f43347cc8d6a 100644 --- a/Sources/Surfaces/CmuxTuiSnapshotParser.swift +++ b/Sources/Surfaces/CmuxTuiSnapshotParser.swift @@ -191,7 +191,16 @@ struct CmuxTuiSnapshotParser: Sendable { } let agents = ((snapshot["agents"] as? [[String: Any]]) ?? []).compactMap { raw -> CloudVMAgentState? in guard let terminalID = nonEmptyString(raw["terminal_id"]), let state = nonEmptyString(raw["state"]) else { return nil } - return CloudVMAgentState(id: nonEmptyString(raw["id"]), terminalID: terminalID, state: state, source: nonEmptyString(raw["source"])) + return CloudVMAgentState( + id: nonEmptyString(raw["id"]), + terminalID: terminalID, + state: state, + source: nonEmptyString(raw["source"]), + agent: nonEmptyString((raw["extra"] as? [String: Any])?["agent"]) + ?? nonEmptyString(raw["agent"]) + ?? nonEmptyString(raw["agent_type"]) + ?? nonEmptyString(raw["provider"]) + ) } return CloudVMState( @@ -536,7 +545,7 @@ struct CmuxTuiSnapshotParser: Sendable { lifecycle: SurfaceLifecycle(rawValue: terminal.lifecycle) ?? (terminal.running == true ? .running : .exited), agent: state.lookupIndex.agent(terminalID: terminal.id).map { - SurfaceAgentBadge(state: $0.state, source: $0.source) + SurfaceAgentBadge(state: $0.state, source: $0.source, agent: $0.agent) }, remoteWorkspace: nil, port: nil, @@ -1019,7 +1028,11 @@ struct CmuxTuiSnapshotParser: Sendable { id: nonEmptyString(value["id"]), terminalID: terminalID, state: state, - source: nonEmptyString(value["source"]) + source: nonEmptyString(value["source"]), + agent: nonEmptyString((value["extra"] as? [String: Any])?["agent"]) + ?? nonEmptyString(value["agent"]) + ?? nonEmptyString(value["agent_type"]) + ?? nonEmptyString(value["provider"]) ) } @@ -1421,7 +1434,14 @@ struct CmuxTuiSnapshotParser: Sendable { var agentByTerminal: [String: SurfaceAgentBadge] = [:] for agent in agentsRaw { guard let terminalID = agent["terminal_id"] as? String, let state = agent["state"] as? String else { continue } - agentByTerminal[terminalID] = SurfaceAgentBadge(state: state, source: agent["source"] as? String) + agentByTerminal[terminalID] = SurfaceAgentBadge( + state: state, + source: agent["source"] as? String, + agent: (agent["extra"] as? [String: Any])?["agent"] as? String + ?? (agent["agent"] as? String) + ?? (agent["agent_type"] as? String) + ?? (agent["provider"] as? String) + ) } let workspaces = Self.workspaces(fromSnapshot: snapshot) diff --git a/Sources/Surfaces/CmuxTuiSurfaceProvider+ManualMirror.swift b/Sources/Surfaces/CmuxTuiSurfaceProvider+ManualMirror.swift index ea4bf038a31f..08d4992ec9cf 100644 --- a/Sources/Surfaces/CmuxTuiSurfaceProvider+ManualMirror.swift +++ b/Sources/Surfaces/CmuxTuiSurfaceProvider+ManualMirror.swift @@ -95,6 +95,10 @@ extension CmuxTuiSurfaceProvider { ) else { throw CancellationError() } + workspace.updateCloudTerminalTabIcon( + panelID: adopted.panelID, + assetName: resource.terminalAgentIconAssetName + ) created = adopted reservation.inputRelay.attach(inputRouter) // The card's grace counts from the moment the pane appeared. @@ -103,6 +107,7 @@ extension CmuxTuiSurfaceProvider { created = try SurfacePaneFactory.makeCloudManualMirrorPane( at: destination, focus: focus, + iconAssetName: resource.terminalAgentIconAssetName, onInput: { input in inputRouter.send(input) }, keyNameResolver: { RemoteTmuxKeyName(inputEvent: $0)?.value }, onResize: { [weak session] sample in diff --git a/Sources/Surfaces/CmuxTuiSurfaceProviderRegistry.swift b/Sources/Surfaces/CmuxTuiSurfaceProviderRegistry.swift index d7b59e9bb721..4b1f9392e5fe 100644 --- a/Sources/Surfaces/CmuxTuiSurfaceProviderRegistry.swift +++ b/Sources/Surfaces/CmuxTuiSurfaceProviderRegistry.swift @@ -58,6 +58,10 @@ final class CmuxTuiSurfaceProviderRegistry { /// this registry until a fleet page positively observes them. A stale page /// must not prune a receipt that is still converging into discovery. private var pendingMachineCreationIDs: Set = []; private var hasCompletedInitialRefresh = false; private var refreshedMachineIDs: Set = [] + /// Create receipts that proved a trusted, directly dialable daemon + /// (snapshot-v2 contract plus a private address). Consumed by the first + /// `vm.cmux_remote_info` for that machine instead of an attach request. + private var createdTrustedCarrierIDs: Set = [] /// Whether account access has ended. Retired registries reject all new Cloud work /// until ``start(catalog:)`` reactivates them for the next account. private var isRetired = true @@ -108,14 +112,57 @@ final class CmuxTuiSurfaceProviderRegistry { /// Publishes the create response's friendly name before the first workspace bind. /// The response need not contain private addresses; provider discovery still /// owns transport initialization and registration. - func recordCreatedMachine(_ summary: VMSummary, scope: UUID?) { + /// + /// A receipt that carries the machine's private address registers its + /// provider directly, exactly as discovery would. New Machine then links + /// without first re-reading the whole fleet list (`GET /api/vm`, ~0.3 s). + /// Receipts from older backends without an address keep the old path. + func recordCreatedMachine(_ summary: VMSummary, scope: UUID?) async { guard let scope, scope == creationScope, let catalog else { return } // A replay cannot overwrite names or status already accepted by discovery. - guard catalog.machines[.cloud(summary.id)] == nil else { return } + guard catalog.machines[.cloud(summary.id)] == nil, providers[summary.id] == nil else { return } pendingMachineCreationIDs.insert(summary.id) catalog.admitMachineCreationReceipt(CmuxTuiSurfaceProvider.info( from: summary, linkState: .connecting, linkError: nil, stats: nil )) + let addresses = [summary.addressIPv4, summary.addressIPv6].compactMap { $0 } + guard !addresses.isEmpty, machineTeardowns[registeredMachineID(matching: summary.id)] == nil else { return } + let generation = refreshGeneration + await links.setPrivateAddresses(addresses, for: summary.id) + if summary.cmuxTuiContract == Self.trustedCarrierContract { + await links.markTrustedCarrier(machineID: summary.id) + } + // Same fences as discovery: a delete or account change during the + // await must not receive a provider. + guard !isRetired, generation == refreshGeneration, scope == creationScope, + providers[summary.id] == nil else { return } + let provider = CmuxTuiSurfaceProvider( + summary: summary, links: links, catalog: catalog, + portForwards: portForwards, portAccessStore: portAccess + ) + providers[summary.id] = provider + catalog.register(provider) + if summary.cmuxTuiContract == Self.trustedCarrierContract { + createdTrustedCarrierIDs.insert(summary.id) + } + // Start the first link and graph read now, while the caller is still + // creating its workspace. The open's `ensure_linked` catalog read joins + // this pass instead of starting its own after the fact. + Task { [weak provider] in + _ = await provider?.refreshCurrentGraph(force: false) + } + } + + /// The image contract whose daemon serves the trusted private-network + /// listener with no enrollment (web: FreestyleProvider `cmuxTuiContract`). + static let trustedCarrierContract = "snapshot-v2" + + /// The private route for a machine this registry just created from a + /// trusted-carrier receipt, consumed once. Nil means ask the control plane. + func takeCreatedTrustedCarrierRoute(machineID: String) async -> String? { + guard createdTrustedCarrierIDs.remove(machineID) != nil, + !isRetired, isCloudEnabled(), providers[machineID] != nil else { return nil } + return await links.privateRoute(for: machineID) } /// True while the periodic fleet read is scheduled. @@ -151,6 +198,7 @@ final class CmuxTuiSurfaceProviderRegistry { accessEpoch &+= 1 creationEpoch = UUID() pendingMachineCreationIDs.removeAll(); hasCompletedInitialRefresh = false; refreshedMachineIDs.removeAll() + createdTrustedCarrierIDs.removeAll() refreshGeneration &+= 1 let epoch = accessEpoch // Replacing block observers prevents stale callbacks after a restart. @@ -360,6 +408,7 @@ final class CmuxTuiSurfaceProviderRegistry { // Match the registered casing so every ownership table is removed. let id = registeredMachineID(matching: rawID) pendingMachineCreationIDs.remove(id); refreshedMachineIDs.remove(.cloud(id)) + createdTrustedCarrierIDs.remove(id) let provider = providers.removeValue(forKey: id) provider?.suspendForFeatureFlag() catalog?.removeCloudMachine(.cloud(id)) @@ -511,6 +560,7 @@ final class CmuxTuiSurfaceProviderRegistry { accessEpoch &+= 1 creationEpoch = UUID() pendingMachineCreationIDs.removeAll(); hasCompletedInitialRefresh = false; refreshedMachineIDs.removeAll() + createdTrustedCarrierIDs.removeAll() refreshGeneration &+= 1 pollTask?.cancel() pollTask = nil diff --git a/Sources/Surfaces/CmuxTuiSurfaceProviders.swift b/Sources/Surfaces/CmuxTuiSurfaceProviders.swift index 17c89407417d..caf9313ac105 100644 --- a/Sources/Surfaces/CmuxTuiSurfaceProviders.swift +++ b/Sources/Surfaces/CmuxTuiSurfaceProviders.swift @@ -243,7 +243,7 @@ final class CmuxTuiSurfaceProvider: SurfaceProvider { scannedPorts = portsCache?.ports } guard isCurrentRefresh(lifecycle: lifecycle, refresh: generation) else { return false } - var currentPorts = scannedPorts ?? portsCache?.ports ?? [] + let currentPorts = scannedPorts ?? portsCache?.ports ?? [] guard isAwake, let client = vmClient else { tabByTerminal = [:] let remoteWorkspaces = remoteWorkspaces(for: cloudState) @@ -278,7 +278,7 @@ final class CmuxTuiSurfaceProvider: SurfaceProvider { if hasDesktop, catalog.authoritativeSnapshot.resources(on: machine).isEmpty { catalog.replaceResources(displayResources, on: machine, info: info, from: self) } - async let stats = try? client.stats(id: machineID) + let statsRead = Task { try? await client.stats(id: machineID) } var linkState: SurfaceLinkState = .connected var linkError: String? // A decoded snapshot is not automatically an authorization boundary. It @@ -286,6 +286,17 @@ final class CmuxTuiSurfaceProvider: SurfaceProvider { // Callers must use only a graph established by this refresh as mutation // evidence, never the retained stale graph. var snapshotEstablishedCurrentGraph = false + var portScan: Task<[Int]?, Never>? + // Stats and the port scan never gate this pass: joined readers (a + // New Machine open's `ensure_linked`) wait for the pass, not for them. + // They are cancelled only when the pass ends before handing them off. + var handedOffFollowUps = false + defer { + if !handedOffFollowUps { + statsRead.cancel() + portScan?.cancel() + } + } do { guard isCurrentRefresh(lifecycle: lifecycle, refresh: generation) else { return false } let connected = try await links.connected(machineID: machineID) @@ -293,16 +304,15 @@ final class CmuxTuiSurfaceProvider: SurfaceProvider { guard let link = await links.link(machineID: machineID) else { throw ProviderError.machineAsleep(machineID) } guard isCurrentRefresh(lifecycle: lifecycle, refresh: generation) else { return false } // The port scan and graph snapshot use independent daemon requests. - // Start both after the link is ready, so refresh latency is the slower - // request rather than their sum. Each result remains guarded by the - // same generation fence before it publishes. - async let refreshedPorts = ports(link: link, socketPath: connected.socketPath, force: force, generation: generation, privateAddress: privateAddress, displayPortsOwned: hasDesktop) - async let snapshotData = link.run(arguments: CloudTuiRequests.snapshotArguments(socketPath: connected.socketPath)) - if let refreshedPorts = await refreshedPorts { - guard isCurrentRefresh(lifecycle: lifecycle, refresh: generation) else { return false } - scannedPorts = refreshedPorts - currentPorts = refreshedPorts + // Start both after the link is ready. The graph publishes as soon as + // the snapshot lands; ports publish when their scan finishes. The + // scan runs a guest command and took most of a second on a machine + // that had just resumed, which held New Machine's first terminal + // back for nothing (it only feeds port-preview rows). + portScan = Task { [weak self] in + await self?.ports(link: link, socketPath: connected.socketPath, force: force, generation: generation, privateAddress: privateAddress, displayPortsOwned: hasDesktop) } + async let snapshotData = link.run(arguments: CloudTuiRequests.snapshotArguments(socketPath: connected.socketPath)) watchChanges(link: link, generation: lifecycle) configureGuestURLOpen(link: link, socketPath: connected.socketPath) let data = try await snapshotData @@ -359,13 +369,18 @@ final class CmuxTuiSurfaceProvider: SurfaceProvider { linkError = eventsFeedWarning } let remoteWorkspaces = cloudState.map(Self.remoteWorkspaces) + // Publish the graph without waiting for stats. Stats is a control-plane + // HTTP read (provider status plus a guest exec, ~0.8 s) that only fills + // the CPU/memory/disk gauges; awaiting it here held a new machine's + // first terminal back by that long after the link was already up. + // Keep the last gauges until the new read lands below. info = Self.info( from: summary, linkState: linkState, linkError: linkError, - stats: await stats, + stats: nil, remoteWorkspaces: remoteWorkspaces - ) + ).carryingGauges(from: info) if let cloudState { // A successful read or an event install proves the retained graph is // current. A failed or stale read keeps the graph for diagnosis but @@ -404,6 +419,25 @@ final class CmuxTuiSurfaceProvider: SurfaceProvider { } guard isCurrentRefresh(lifecycle: lifecycle, refresh: generation) else { return false } reprojectRestoredPanes(generation: lifecycle) + handedOffFollowUps = true + let publishedPorts = currentPorts + let observation: CloudVMStateObservation = snapshotEstablishedCurrentGraph + ? .current + : .stale(reason: info.linkError ?? info.linkState.rawValue) + Task { [weak self, portScan] in + if let portScan, let refreshedPorts = await portScan.value, + let self, self.isCurrentRefresh(lifecycle: lifecycle, refresh: generation), + refreshedPorts != publishedPorts, let cloudState = self.cloudState { + self.publish(cloudState, ports: refreshedPorts, reconcileTitles: false, observation: observation) + } + } + Task { [weak self] in + if let stats = await statsRead.value, + let self, self.isCurrentRefresh(lifecycle: lifecycle, refresh: generation) { + self.info = self.info.applyingGauges(stats) + self.catalog.updateMachine(self.info, from: self) + } + } return snapshotEstablishedCurrentGraph } @discardableResult @@ -1546,3 +1580,27 @@ final class CmuxTuiSurfaceProvider: SurfaceProvider { } } } + +extension SurfaceMachineInfo { + /// The same machine row with `previous`'s resource gauges, so a refresh that + /// publishes before its stats read lands does not blank the sidebar gauges. + func carryingGauges(from previous: SurfaceMachineInfo) -> SurfaceMachineInfo { + var info = self + info.memoryMb = previous.memoryMb + info.diskMb = previous.diskMb + info.cpuPercent = previous.cpuPercent + info.memoryUsedMb = previous.memoryUsedMb + info.diskUsedMb = previous.diskUsedMb + return info + } + + func applyingGauges(_ stats: VMStats) -> SurfaceMachineInfo { + var info = self + info.memoryMb = stats.memoryTotalMb + info.diskMb = stats.diskTotalMb + info.cpuPercent = stats.cpuPercent + info.memoryUsedMb = stats.memoryUsedMb + info.diskUsedMb = stats.diskUsedMb + return info + } +} diff --git a/Sources/Surfaces/SurfaceCatalog+AgentIcons.swift b/Sources/Surfaces/SurfaceCatalog+AgentIcons.swift new file mode 100644 index 000000000000..06042e101069 --- /dev/null +++ b/Sources/Surfaces/SurfaceCatalog+AgentIcons.swift @@ -0,0 +1,24 @@ +import Foundation + +extension SurfaceCatalog { + /// Visits projections once per accepted catalog transaction, independent of + /// the number of resources in a full snapshot. Missing resources clear icons. + func syncCloudTerminalTabIcons(on machine: SurfaceMachineID, affected: Set? = nil) { + guard !machine.isLocal else { return } + for projection in projections where projection.resource.machine == machine { + if let affected, !affected.contains(projection.resource) { continue } + syncCloudTerminalTabIcon(projection) + } + } + + func syncCloudTerminalTabIcon(_ projection: SurfaceProjection) { + guard !projection.resource.machine.isLocal, projection.resource.kind == .terminal, + let workspace = cloudWorkspaceRenameService.environment.workspace(projection.workspaceID), + workspace.panels[projection.panelID] is TerminalPanel, + let tabID = workspace.surfaceIdFromPanelId(projection.panelID), + let tab = workspace.bonsplitController.tab(tabID) else { return } + let asset = resources[projection.resource]?.terminalAgentIconAssetName + guard tab.iconAsset != asset else { return } + workspace.bonsplitController.updateTab(tabID, iconAsset: .some(asset)) + } +} diff --git a/Sources/Surfaces/SurfaceCatalog.swift b/Sources/Surfaces/SurfaceCatalog.swift index 0fb7ae8799a4..a468e0ee73d2 100644 --- a/Sources/Surfaces/SurfaceCatalog.swift +++ b/Sources/Surfaces/SurfaceCatalog.swift @@ -193,6 +193,7 @@ final class SurfaceCatalog { machines[machine] = nil for id in resourceIDsByMachine[machine] ?? [] { resources[id] = nil } resourceIDsByMachine[machine] = nil + syncCloudTerminalTabIcons(on: machine) pendingRestoredProjections.remove(machine: machine) cloudWorkspaceProjectionCoordinator.cancel(machine: machine) cloudProjectionIndexDirty = true @@ -292,6 +293,7 @@ final class SurfaceCatalog { } if let info { machines[machine] = machineInfoPreservingCanonicalCloudState(info) } resolvePendingRestoredProjections(on: machine) + syncCloudTerminalTabIcons(on: machine) updateCloudDirectoryMetadata(on: machine) reconcileDeviceNames(on: machine) notifyChange() @@ -305,6 +307,7 @@ final class SurfaceCatalog { resources[resource.id] = resource resourceIDsByMachine[resource.machine, default: []].insert(resource.id) resolvePendingRestoredProjections(on: resource.machine) + syncCloudTerminalTabIcons(on: resource.machine, affected: [resource.id]) notifyChange() } @@ -316,6 +319,7 @@ final class SurfaceCatalog { if resourceIDsByMachine[id.machine]?.isEmpty == true { resourceIDsByMachine[id.machine] = nil } + syncCloudTerminalTabIcons(on: id.machine, affected: [id]) notifyChange() } @@ -468,6 +472,7 @@ final class SurfaceCatalog { machines[state.machine] = machineInfoPreservingCanonicalCloudState(info, state: state) cloudWorkspaceCreationCoordinator.reconcile(state) resolvePendingRestoredProjections(on: state.machine) + syncCloudTerminalTabIcons(on: state.machine, affected: changed) updateCloudDirectoryMetadata(on: state.machine, affectedResourceIDs: freshnessChanged ? nil : affectedResourceIDs) notifyChange() return changed @@ -510,6 +515,7 @@ final class SurfaceCatalog { machines[state.machine] = machineInfoPreservingCanonicalCloudState(info, state: state) cloudWorkspaceCreationCoordinator.reconcile(state) resolvePendingRestoredProjections(on: state.machine) + syncCloudTerminalTabIcons(on: state.machine, affected: changed) updateCloudDirectoryMetadata(on: state.machine) notifyChange() return changed @@ -556,6 +562,7 @@ final class SurfaceCatalog { rebuildResourceIndex(for: machine) machines[machine] = machineInfoPreservingCanonicalCloudState(info) resolvePendingRestoredProjections(on: machine) + syncCloudTerminalTabIcons(on: machine) updateCloudDirectoryMetadata(on: machine) notifyChange() } @@ -1104,6 +1111,7 @@ final class SurfaceCatalog { insertSupersedingLocalPlaceholder(cloudPlacementCoordinator.projectionInCurrentWorkspace(projection)) reconcileCloudWorkspaceBinding(localWorkspaceID: projection.workspaceID) reconcileCloudProjection(projection) + syncCloudTerminalTabIcon(projection) notifyChange() } diff --git a/Sources/Surfaces/SurfaceCatalogModel.swift b/Sources/Surfaces/SurfaceCatalogModel.swift index 0d784bb58027..1ce3269c3364 100644 --- a/Sources/Surfaces/SurfaceCatalogModel.swift +++ b/Sources/Surfaces/SurfaceCatalogModel.swift @@ -109,6 +109,8 @@ enum SurfaceLifecycle: String, Codable, Sendable { struct SurfaceAgentBadge: Hashable, Codable, Sendable { var state: String var source: String? + /// The adapter identity, separate from report provenance (`hook`, `socket`, or `plugin`). + var agent: String? = nil } /// The daemon's monotonic position for one complete remote session state. @@ -302,6 +304,7 @@ struct CloudVMAgentState: Hashable, Codable, Sendable { var terminalID: String var state: String var source: String? + var agent: String? = nil } /// How a remote session can be synchronized. diff --git a/Sources/Surfaces/SurfaceCatalogQueryService.swift b/Sources/Surfaces/SurfaceCatalogQueryService.swift index d2b5f139d28a..ef2da325f02b 100644 --- a/Sources/Surfaces/SurfaceCatalogQueryService.swift +++ b/Sources/Surfaces/SurfaceCatalogQueryService.swift @@ -1,5 +1,19 @@ import Foundation +/// How much work a catalog read may do before exporting. +enum SurfaceCatalogReadMode: Equatable, Sendable { + /// Export what the catalog already holds. Never discovers, connects, or wakes. + case cached + /// Ensure one machine has a connected, installed graph, then export. A + /// machine that is already connected is served from the live catalog (its + /// change watcher keeps the graph current), so this costs nothing on reopen. + /// A machine that is missing or not yet linked is discovered and joins the + /// provider's current refresh pass instead of forcing a new one. + case linked + /// Discover if missing, then force a new provider pass (port rescan included). + case forced +} + /// Reads the surface catalog and resolves providers for machines not yet discovered /// by the periodic Cloud fleet refresh. Socket entrypoints share this query owner. @MainActor @@ -28,10 +42,24 @@ struct SurfaceCatalogQueryService { } func read(machine: SurfaceMachineID?, refresh: Bool) async -> SurfaceCatalogExport { - if refresh { - if let machine { + await read(machine: machine, mode: refresh ? .forced : .cached) + } + + func read(machine: SurfaceMachineID?, mode: SurfaceCatalogReadMode) async -> SurfaceCatalogExport { + switch mode { + case .cached: + break + case .linked: + // Only a machine-scoped read can ask for a link. An unfiltered + // `.linked` read would connect every machine, which is `.forced`. + if let machine, !isLinked(machine) { // A create can finish before the fleet poll sees the machine. // Discover it before an empty catalog is treated as unavailable. + _ = await provider(for: machine) + await catalog.refresh(machine: machine, force: false) + } + case .forced: + if let machine { _ = await provider(for: machine) await catalog.refresh(machine: machine, force: true) } else { @@ -44,4 +72,8 @@ struct SurfaceCatalogQueryService { export.projectionIdentities = projectionIdentities(export.catalog.projections) return export } + + private func isLinked(_ machine: SurfaceMachineID) -> Bool { + catalog.provider(for: machine) != nil && catalog.machineInfo(for: machine)?.linkState == .connected + } } diff --git a/Sources/Surfaces/SurfacePaneFactory+CloudManualMirror.swift b/Sources/Surfaces/SurfacePaneFactory+CloudManualMirror.swift index 9f7ea1e51c9b..8ce294a9c4e9 100644 --- a/Sources/Surfaces/SurfacePaneFactory+CloudManualMirror.swift +++ b/Sources/Surfaces/SurfacePaneFactory+CloudManualMirror.swift @@ -13,6 +13,7 @@ extension SurfacePaneFactory { static func makeCloudManualMirrorPane( at destination: SurfaceDestination, focus: Bool, + iconAssetName: String? = nil, onInput: @escaping @Sendable (TerminalManualInput) -> Void, keyNameResolver: (@MainActor @Sendable (ghostty_input_key_s) -> String?)? = nil, onResize: @escaping @MainActor @Sendable (TerminalSurfaceRawSizingSample) -> Void, @@ -26,6 +27,7 @@ extension SurfacePaneFactory { return try workspace.addCloudManualMirrorPane( at: destination, focus: focus, + iconAssetName: iconAssetName, onInput: onInput, keyNameResolver: keyNameResolver, onResize: onResize, diff --git a/Sources/Surfaces/SurfaceResource+AgentIcon.swift b/Sources/Surfaces/SurfaceResource+AgentIcon.swift new file mode 100644 index 000000000000..cbeb35d08c98 --- /dev/null +++ b/Sources/Surfaces/SurfaceResource+AgentIcon.swift @@ -0,0 +1,26 @@ +import Foundation + +extension SurfaceResource { + /// One provider identity drives the tab strip and every Cloud tree placement. + /// Report provenance and user-controlled terminal titles are not provider IDs. + var terminalAgentIconAssetName: String? { + guard kind == .terminal, lifecycle != .exited, + let badge = agent, badge.state != "done" else { return nil } + + let definitions = CmuxTaskManagerCodingAgentDefinition.builtIns + let identities = [badge.agent, badge.source] + .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } + .filter { !$0.isEmpty && !["hook", "socket", "detected", "plugin", "unknown"].contains($0) } + if let asset = identities.lazy.compactMap({ identity in + definitions.first { definition in + definition.id == identity + || definition.launchKinds.contains(identity) + || definition.directBasenames.contains(identity) + }?.assetName + }).first { + return asset + } + + return nil + } +} diff --git a/Sources/Surfaces/SurfaceSocketCommands.swift b/Sources/Surfaces/SurfaceSocketCommands.swift index 58b62a6aa4a1..935e59d1c5c4 100644 --- a/Sources/Surfaces/SurfaceSocketCommands.swift +++ b/Sources/Surfaces/SurfaceSocketCommands.swift @@ -28,10 +28,20 @@ extension TerminalController { case "surface.catalog": let machine = Self.surfaceMachineFilter(params["machine"]) if let machine, machine.cloudMachineID != nil, let error = cloudDisabledSocketError(id: id) { return error } - let refresh = Self.surfaceBool(params["refresh"]) ?? false + // `refresh` forces a provider pass; `ensure_linked` only connects a + // machine that has no live graph yet (a just-created VM) and is free + // for one that is already linked. `refresh` wins when both are sent. + let mode: SurfaceCatalogReadMode + if Self.surfaceBool(params["refresh"]) == true { + mode = .forced + } else if Self.surfaceBool(params["ensure_linked"]) == true { + mode = .linked + } else { + mode = .cached + } return v2VmCall(id: id, timeoutSeconds: 120) { let query = await Self.surfaceCatalogQuery(catalog: .shared) - let export = await query.read(machine: machine, refresh: refresh) + let export = await query.read(machine: machine, mode: mode) return Self.surfaceCatalogPayload(export, machine: machine) } diff --git a/Sources/Surfaces/Workspace+CloudManualMirror.swift b/Sources/Surfaces/Workspace+CloudManualMirror.swift index d7e1436faedb..3f9ed7d431c7 100644 --- a/Sources/Surfaces/Workspace+CloudManualMirror.swift +++ b/Sources/Surfaces/Workspace+CloudManualMirror.swift @@ -16,14 +16,13 @@ import GhosttyKit @MainActor extension Workspace { /// A saved device terminal stays process-free until its provider reconnects: - /// the pane is built on the same manual-mirror path as a live attachment, - /// with no transport bound yet, and is never marked loading. + /// the pane is built on the same manual-mirror path as a live attachment. func restoreDeviceDisplayPanel(_ snapshot: SessionPanelSnapshot, in pane: PaneID) -> UUID? { guard let panel = makeRemoteTmuxPanePanel(onInput: { _ in }, keyNameResolver: nil) else { return nil } Self.bindCloudManualMirrorCallbacks( panel: panel, onResize: { _ in }, onRuntimeReady: {}, onFocus: {}, attachment: nil ) - guard let panelID = try? insertCloudManualMirrorTab(panel, in: pane, focus: false, isLoading: false) else { + guard let panelID = try? insertCloudManualMirrorTab(panel, in: pane, focus: false, isLoading: false, iconAssetName: nil) else { return nil } let status = DeviceTerminalAttachmentStatus() @@ -56,6 +55,7 @@ extension Workspace { func addCloudManualMirrorPane( at destination: SurfaceDestination, focus: Bool, + iconAssetName: String? = nil, onInput: @escaping @Sendable (TerminalManualInput) -> Void, keyNameResolver: (@MainActor @Sendable (ghostty_input_key_s) -> String?)? = nil, onResize: @escaping @MainActor @Sendable (TerminalSurfaceRawSizingSample) -> Void, @@ -86,7 +86,9 @@ extension Workspace { try workspace.adoptCloudMachineLoadingPanel(loading, terminal: panel, focus: focus) return (workspace.id, panel.id, panel.surface) } - let panelID = try workspace.insertCloudManualMirrorPanel(panel, at: destination, focus: focus, isLoading: false) + let panelID = try workspace.insertCloudManualMirrorPanel( + panel, at: destination, focus: focus, isLoading: false, iconAssetName: iconAssetName + ) return (workspace.id, panelID, panel.surface) } @@ -115,7 +117,8 @@ extension Workspace { _ panel: TerminalPanel, at destination: SurfaceDestination, focus: Bool, - isLoading: Bool + isLoading: Bool, + iconAssetName: String? = nil ) throws -> UUID { switch destination { case .workspace(_, let placement): @@ -123,20 +126,22 @@ extension Workspace { guard let pane else { throw SurfaceCatalogError.destinationNotFound("focused pane") } switch placement { case .tab: - return try insertCloudManualMirrorTab(panel, in: pane, focus: focus, isLoading: isLoading) + return try insertCloudManualMirrorTab(panel, in: pane, focus: focus, isLoading: isLoading, iconAssetName: iconAssetName) case .split: - return try splitCloudManualMirrorPane(panel, target: pane, direction: .right, focus: focus, isLoading: isLoading) + return try splitCloudManualMirrorPane(panel, target: pane, direction: .right, focus: focus, isLoading: isLoading, iconAssetName: iconAssetName) } case .tab(_, let paneID, let index): guard let pane = Self.pane(paneID, in: self) else { throw SurfaceCatalogError.destinationNotFound("pane (paneID)") } - return try insertCloudManualMirrorTab(panel, in: pane, focus: focus, isLoading: isLoading, index: index) + return try insertCloudManualMirrorTab( + panel, in: pane, focus: focus, isLoading: isLoading, iconAssetName: iconAssetName, index: index + ) case .split(_, let paneID, let direction): guard let pane = Self.pane(paneID, in: self) else { throw SurfaceCatalogError.destinationNotFound("pane (paneID)") } - return try splitCloudManualMirrorPane(panel, target: pane, direction: direction, focus: focus, isLoading: isLoading) + return try splitCloudManualMirrorPane(panel, target: pane, direction: direction, focus: focus, isLoading: isLoading, iconAssetName: iconAssetName) } } @@ -145,6 +150,7 @@ extension Workspace { in pane: PaneID, focus: Bool, isLoading: Bool, + iconAssetName: String?, index: Int? = nil ) throws -> UUID { let previousPane = bonsplitController.focusedPaneId @@ -154,6 +160,7 @@ extension Workspace { guard let tab = bonsplitController.createTab( title: Self.cloudManualMirrorTabTitle, icon: panel.displayIcon, + iconAsset: iconAssetName, kind: SurfaceKind.terminal.rawValue, isDirty: panel.isDirty, isLoading: false, @@ -193,7 +200,8 @@ extension Workspace { target: PaneID, direction: SurfaceSplitDirection, focus: Bool, - isLoading: Bool + isLoading: Bool, + iconAssetName: String? ) throws -> UUID { let previousPane = bonsplitController.focusedPaneId let previousTab = previousPane.flatMap { bonsplitController.selectedTab(inPane: $0)?.id } @@ -202,6 +210,7 @@ extension Workspace { let tab = Bonsplit.Tab( title: Self.cloudManualMirrorTabTitle, icon: panel.displayIcon, + iconAsset: iconAssetName, kind: SurfaceKind.terminal.rawValue, isDirty: panel.isDirty, isLoading: false, @@ -236,6 +245,19 @@ extension Workspace { return panel.id } + /// Flags or clears the tab-strip spinner of a pane whose terminal is still arriving. + func setCloudManualMirrorTabLoading(panelID: UUID, _ isLoading: Bool) { + guard let tabID = surfaceIdFromPanelId(panelID) else { return } + bonsplitController.updateTab(tabID, isLoading: isLoading) + } + + /// Updates a Cloud terminal tab after the daemon reports a provider identity change. + func updateCloudTerminalTabIcon(panelID: UUID, assetName: String?) { + guard let tabID = surfaceIdFromPanelId(panelID), + let tab = bonsplitController.tab(tabID), tab.iconAsset != assetName else { return } + bonsplitController.updateTab(tabID, iconAsset: .some(assetName)) + } + /// The live workspace with `id` in any window, or nil once it was retired. static func liveWorkspace(id: UUID) -> Workspace? { AppDelegate.shared?.tabManagerFor(tabId: id)?.tabs.first { $0.id == id } diff --git a/Sources/TerminalController+ControlTerminalBinding.swift b/Sources/TerminalController+ControlTerminalBinding.swift index 08f77e51c7b4..981a677fd82b 100644 --- a/Sources/TerminalController+ControlTerminalBinding.swift +++ b/Sources/TerminalController+ControlTerminalBinding.swift @@ -35,10 +35,14 @@ struct ControlTerminalSocketTarget { /// Sends a bracketed-paste payload through the canonical surface. func sendText(_ text: String) -> Bool { + sendTextResult(text).accepted + } + + func sendTextResult(_ text: String) -> TerminalSurface.TextSendResult { if surface === panel.surface { - return panel.sendText(text) + return panel.sendTextResult(text) } - return surface.sendText(text) + return surface.sendTextResult(text) } /// Sends a named key through the canonical surface, retaining the panel's diff --git a/Sources/TerminalController+ControlWorkspaceStrings.swift b/Sources/TerminalController+ControlWorkspaceStrings.swift index 4064429aaebb..81285ad83c2d 100644 --- a/Sources/TerminalController+ControlWorkspaceStrings.swift +++ b/Sources/TerminalController+ControlWorkspaceStrings.swift @@ -20,14 +20,26 @@ extension TerminalController { localized: "socket.workspace.reorderMany.duplicateWorkspace", defaultValue: "Duplicate workspace in order" ), - reorderManyWorkspaceNotFound: String( + workspaceNotFound: String( localized: "socket.workspace.reorderMany.workspaceNotFound", defaultValue: "Workspace not found" ), - reorderManyInvalidWorkspace: String( + invalidWorkspaceRef: String( localized: "socket.workspace.reorderMany.invalidWorkspace", defaultValue: "Invalid workspace id or ref" ), + reorderIndexNotAnInteger: String( + localized: "socket.workspace.reorder.indexNotAnInteger", + defaultValue: "index must be an integer" + ), + reorderMissingWorkspaceID: String( + localized: "socket.workspace.reorder.missingWorkspaceID", + defaultValue: "Missing or invalid workspace_id" + ), + reorderTargetRequired: String( + localized: "socket.workspace.reorder.targetRequired", + defaultValue: "Specify exactly one target: index, before_workspace_id, or after_workspace_id" + ), reorderManyTabManagerUnavailable: String( localized: "socket.workspace.reorderMany.tabManagerUnavailable", defaultValue: "TabManager not available" diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 2f4c064cbaa5..e2279423ad3d 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -15695,8 +15695,16 @@ class TerminalController { // surface): they run `resumeForExplicitInputIfNeeded()` first, waking a // hibernated agent terminal the same way local typing does, so a mobile // composer submit cannot write into a cold surface. - guard terminalTarget.sendText(text) else { + let textResult = terminalTarget.sendTextResult(text) + switch textResult { + case .sent, .queued: + break + case .inputQueueFull: + return .err(code: "input_queue_full", message: Self.terminalInputQueueFullMessage, data: ["surface_id": surfaceId.uuidString]) + case .surfaceUnavailable: return .err(code: "surface_unavailable", message: Self.terminalSurfaceUnavailableMessage, data: ["surface_id": surfaceId.uuidString]) + case .processExited: + return .err(code: "process_exited", message: Self.terminalProcessExitedMessage, data: ["surface_id": surfaceId.uuidString]) } // The paste text is already accepted by the surface above. From here on a @@ -15739,6 +15747,7 @@ class TerminalController { var payload: [String: Any] = [ "workspace_id": resolved.workspace.id.uuidString, "surface_id": terminalPanel.id.uuidString, + "delivery": textResult == .sent ? "delivered" : "queued", "submitted": submitted, ] if let submitError { diff --git a/Sources/TerminalTabAgentIcon.swift b/Sources/TerminalTabAgentIcon.swift new file mode 100644 index 000000000000..5d5a954be74b --- /dev/null +++ b/Sources/TerminalTabAgentIcon.swift @@ -0,0 +1,47 @@ +import Bonsplit +import Foundation + +/// Resolves the provider mark for a local terminal tab from the same agent +/// definitions used by process and hook detection. +struct TerminalTabAgentIconResolver { + func assetName(forStatusKey statusKey: String) -> String? { + let normalized = statusKey.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !normalized.isEmpty else { return nil } + return CmuxTaskManagerCodingAgentDefinition.builtIns.first { definition in + definition.id == normalized + || definition.launchKinds.contains(normalized) + || definition.directBasenames.contains(normalized) + }?.assetName + } + + func titleStatusKey(from title: String) -> String? { + let token = title.split(whereSeparator: { $0.isWhitespace }).first.map(String.init)?.lowercased() + guard let token else { return nil } + return assetName(forStatusKey: token) == nil ? nil : token + } +} + +extension Workspace { + /// Returns the current provider mark for one terminal panel, if known. + func terminalTabAgentIconAsset(forPanelId panelId: UUID) -> String? { + let resolver = TerminalTabAgentIconResolver() + let statusKeys = agentPIDKeysByPanelId[panelId, default: []] + .map(agentStatusKey(forAgentPIDKey:)) + .sorted() + if let asset = statusKeys.compactMap(resolver.assetName(forStatusKey:)).first { + return asset + } + guard let restored = restoredAgentSnapshotsByPanelId[panelId] else { return nil } + return restored.registration?.iconAssetName ?? resolver.assetName(forStatusKey: restored.kind.rawValue) + } + + /// Reconciles a terminal tab's provider mark after agent lifecycle state changes. + func syncTerminalTabAgentIconAsset(forPanelId panelId: UUID) { + guard panels[panelId] is TerminalPanel, + let tabID = surfaceIdFromPanelId(panelId), + let tab = bonsplitController.tab(tabID) else { return } + let asset = terminalTabAgentIconAsset(forPanelId: panelId) + guard tab.iconAsset != asset else { return } + bonsplitController.updateTab(tabID, iconAsset: .some(asset)) + } +} diff --git a/Sources/Workspace+PanelLifecycle.swift b/Sources/Workspace+PanelLifecycle.swift index e1b677f4cced..519369cb3f2b 100644 --- a/Sources/Workspace+PanelLifecycle.swift +++ b/Sources/Workspace+PanelLifecycle.swift @@ -197,6 +197,9 @@ extension Workspace { } } if refreshPorts { refreshTrackedAgentPorts() } + for changedPanelID in Set([previous.panelId, panelId].compactMap { $0 }) { + syncTerminalTabAgentIconAsset(forPanelId: changedPanelID) + } return didClearOtherStructuredAgentRuntime } @@ -343,12 +346,16 @@ extension Workspace { if didChange, refreshPorts { refreshTrackedAgentPorts() } + if didChange, let changedPanelId = ownedPanelId ?? panelId { + syncTerminalTabAgentIconAsset(forPanelId: changedPanelId) + } return didChange } /// Clears a panel's restored agent snapshot and resume metadata. func clearRestoredAgentSnapshot(panelId: UUID) { restoredAgentLifecycle.clearSessionRestore(panelId: panelId) + syncTerminalTabAgentIconAsset(forPanelId: panelId) } func refreshTrackedAgentPorts() { diff --git a/Sources/Workspace+TitleOwnership.swift b/Sources/Workspace+TitleOwnership.swift index c8da348a2790..95a51d66b360 100644 --- a/Sources/Workspace+TitleOwnership.swift +++ b/Sources/Workspace+TitleOwnership.swift @@ -167,6 +167,10 @@ extension Workspace { } } + if !isRemoteTmuxMirror { + syncTerminalTabAgentIconAsset(forPanelId: panelId) + } + let previousWorkspaceTitle = self.title if applyFocusedPanelTitle(panelId: panelId) { didMutate = true diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index f1a12d07c773..e8b27dfee314 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -7625,7 +7625,7 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos "cmux_freestyle_cli=\"${CMUX_BUNDLED_CLI_PATH:-}\"", "if [ -z \"$cmux_freestyle_cli\" ] || [ ! -x \"$cmux_freestyle_cli\" ]; then cmux_freestyle_cli=\"$(command -v cmux 2>/dev/null || true)\"; fi", "if [ -z \"$cmux_freestyle_cli\" ]; then printf '%s\\n' '[cmux] bundled CLI not found for Cloud VM SSH attach.' >&2; exit 127; fi", - "CMUX_SSH_RECONNECT_LIMIT=\"${CMUX_SSH_RECONNECT_LIMIT:-86400}\"", + "CMUX_SSH_RECONNECT_LIMIT=\"${CMUX_SSH_RECONNECT_LIMIT:-\(SSHReconnectBudget().maximumLimit)}\"", "CMUX_SSH_RECONNECT_DELAY_SECONDS=\"${CMUX_SSH_RECONNECT_DELAY_SECONDS:-2}\"", "CMUX_DEFAULT_FREESTYLE_ATTACH_RETRY_LIMIT=\"${CMUX_DEFAULT_FREESTYLE_ATTACH_RETRY_LIMIT:-$CMUX_SSH_RECONNECT_LIMIT}\"", "CMUX_DEFAULT_FREESTYLE_ATTACH_RETRY_DELAY_SECONDS=\"${CMUX_DEFAULT_FREESTYLE_ATTACH_RETRY_DELAY_SECONDS:-$CMUX_SSH_RECONNECT_DELAY_SECONDS}\"", diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md index 6c1aa614e04a..ced330a235ff 100644 --- a/THIRD_PARTY_LICENSES.md +++ b/THIRD_PARTY_LICENSES.md @@ -4,6 +4,18 @@ cmux includes the following third-party software: --- +## Lobe Icons (selected agent marks) + +- **License:** MIT License +- **Copyright:** Copyright (c) 2023 LobeHub +- **Source:** https://github.com/lobehub/lobe-icons/tree/a94750e3f5f8fc33757b839d85030e742284e43a/packages/static-svg/icons + +Selected Cursor, Gemini, Kiro, GitHub Copilot, CodeBuddy, Qoder, Kimi, and +Ollama SVG marks are bundled under `Assets.xcassets/AgentIcons`. The complete +license text is in `Assets.xcassets/AgentIcons/LOBE-LICENSE.txt`. + +--- + ## Ghostty - **License:** MIT License @@ -82,6 +94,39 @@ SOFTWARE. --- +## herdr agent-detection plugin + +cmux includes a userland agent-detection plugin derived from herdr. Its +manifests and adapted detector sources live under +`cmux-tui/bindings/examples/rust-agent-screen-detection/`. + +- **Package license:** MIT AND Apache-2.0 +- **Herdr-derived material:** Apache License 2.0 +- **Source:** https://github.com/herdrdev/herdr +- **Detector source reference:** commit `7b675f42af35508eab66ac42fe1598628597a893` +- **Pi bundled-launcher correction:** commit `b1ff4582e9688f52ffb943cfa8bee4871ae122e4` +- **Manifest snapshot:** commit `2290257acb2085ce6842ba5c7e3ca50c3ba64f02` +- **Included manifest fixes:** Claude MCP elicitation `f807b697353cfa00aa912c7cde4830e863001cf5`, Claude background-shell state `987b070fbfa187e85009b45cd7e208fc6175ff6a`, Codex weak-blocker scope `f457cff4f2648eee85d176f8a41861241d4e8428`, and Copilot background-agent activity `2290257acb2085ce6842ba5c7e3ca50c3ba64f02` +- **License text:** cmux-owned code is covered by + `cmux-tui/bindings/examples/rust-agent-screen-detection/LICENSE-MIT`; the + herdr-derived files use + `cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/LICENSE` +- **Latest agent-surface capability audit:** commit `987b070fbfa187e85009b45cd7e208fc6175ff6a`. The herdr repository tip checked on 2026-09-02 is `94f6d9c0d9bb9cf9ffae99d8bbfb09e9bf2fc9e0`; commits after the audit pin change client rendering, terminal reads, graphics ownership, Windows input and worktree handling, or sidebar focus, with no further `src/detect` or manifest changes. The audit includes the exact Pi bundled CLI path correction from `b1ff4582e9688f52ffb943cfa8bee4871ae122e4` and the Claude background-shell state correction from `987b070fbfa187e85009b45cd7e208fc6175ff6a`, both adapted and tested in the userland package. The first-acquisition OSC retention fix from `82e6a80eb3ae39fb3d3ebd4d1fed19389767e605` is adapted in the userland tracker. The foreground group-leader CWD fix from `3a3792622e59c7f2dc20f9c0236167161e4a5035` is already covered by cmux's generic `foreground_cwd` resource. The shell-render refactor in `207be3c771d281baae6e5fa0fb74be9a056e97a2` and independent multi-client tab views in `6c0bb273d5d5405a00985621b17e36f8b4d64609` are application/client architecture and are not copied. The delayed-agent-prompt fix in `8633a398e653eee47b375c963996c78a8a14aa48` changes PTY input sequencing, and `5616196942cbe752cc0659b9bd0fb616b2a6ed5c` hardens malformed Windows process environments in portable-pty. These changes are outside detector behavior and are not copied. SDK endpoint-generation compatibility remains a standalone-release requirement; review the Windows changes before publishing a Windows package. + +Nineteen manifests are unchanged from the manifest snapshot. `claude.toml` is +byte-identical to upstream commit `987b070fbfa187e85009b45cd7e208fc6175ff6a`. +`grok.toml` is based on the snapshot file and contains one documented cmux +precedence correction. `github-copilot.toml` is byte-identical to the snapshot +and uses upstream version `2026.08.29.1`. The manifest engine, process discovery, state detector, and update +logic are adapted for the cmux userland plugin contract. The source paths, +commits, license, and adaptations are recorded in +`cmux-tui/bindings/examples/rust-agent-screen-detection/ATTRIBUTIONS.md`. +The SHA256SUMS file is a checked-in byte-provenance record verified before the +bundled manifests are compiled. It detects accidental drift, but it is not a +cryptographic release signature for remote updates. + +--- + ## Sparkle - **License:** MIT License diff --git a/cmux-tui/AGENTS.md b/cmux-tui/AGENTS.md index 7918b07c83c9..5440ea5d68f3 100644 --- a/cmux-tui/AGENTS.md +++ b/cmux-tui/AGENTS.md @@ -9,7 +9,7 @@ Do not run `cargo`, `rustc`, or Zig on Lawrence's Mac. Do not use a local build 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. The reserved `chatmux_relay` (or `chatmux-relay`) selector runs the complete `chatmux-relay` package because Cargo test names do not include package names. Use `--full` for the merge gate. Full mode runs the complete Linux and macOS suites, package builds, and a Windows-hosted binary execution check. -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. +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 TUI and userland agent detector to `cmux-tui/target/hosted//cmux-tui` and `cmux-tui/target/hosted//cmux-agent-screen-detection`. Running those downloaded binaries on the Mac is allowed. `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. diff --git a/cmux-tui/ATTRIBUTIONS.md b/cmux-tui/ATTRIBUTIONS.md new file mode 100644 index 000000000000..11a28d1b5778 --- /dev/null +++ b/cmux-tui/ATTRIBUTIONS.md @@ -0,0 +1,120 @@ +# Third-party attributions + +## herdr + +- Project: https://github.com/herdrdev/herdr +- License: Apache-2.0 (upstream ships a LICENSE file and no NOTICE file; a + copy is included at + `bindings/examples/rust-agent-screen-detection/manifests/LICENSE`) +- Detector source reference commit: `7b675f42af35508eab66ac42fe1598628597a893` +- Pi bundled-launcher correction commit: `b1ff4582e9688f52ffb943cfa8bee4871ae122e4` +- Manifest snapshot commit: `2290257acb2085ce6842ba5c7e3ca50c3ba64f02` +- First-acquisition OSC retention commit: `82e6a80eb3ae39fb3d3ebd4d1fed19389767e605` +- Included manifest fixes: Claude MCP elicitation `f807b697353cfa00aa912c7cde4830e863001cf5`, + Claude background-shell state `987b070fbfa187e85009b45cd7e208fc6175ff6a`, + Codex weak-blocker scope `f457cff4f2648eee85d176f8a41861241d4e8428`, and + Copilot background-agent activity `2290257acb2085ce6842ba5c7e3ca50c3ba64f02`. + +Derived material and vendored material: + +- `bindings/examples/rust-agent-screen-detection/manifests/*.toml`: 19 + manifests are unchanged from the manifest snapshot's + `src/detect/manifests/`; `claude.toml` is byte-identical to upstream commit + `987b070fbfa187e85009b45cd7e208fc6175ff6a`. `grok.toml` carries the one + documented cmux correction; `github-copilot.toml` is byte-identical to the + upstream snapshot. Never refresh these files from herdr's update endpoint. + Re-vendor the 19 files from the exact snapshot, take Claude from its stated + commit, and reapply the Grok correction when changing the pin. +- `bindings/examples/rust-agent-screen-detection/src/manifest.rs`: the + manifest engine (rule grammar, region extraction, gate evaluation, + validation limits), ported from `src/detect/manifest.rs`. +- `bindings/examples/rust-agent-screen-detection/src/{detect.rs,scanner.rs}`: + detection semantics (state model, edge-triggered transitions, + foreground-process identification, quiescence sampling) derived from + `src/detect/mod.rs`, `src/pane/agent_detection.rs`, and `src/pane.rs`. + These files are a userland plugin. Herdr's first-acquisition OSC retention + fix (`82e6a80eb3ae39fb3d3ebd4d1fed19389767e605`) is adapted as a local + output-revision fence for replacement agents. Core only supervises the + process and folds its generic events. +- `bindings/examples/rust-agent-screen-detection/src/process.rs`: bounded + foreground process-group discovery and wrapper handling derived from + herdr's platform and detector modules, with platform fallbacks, stricter + candidate filtering, attached runtime-mode parsing, positional-argument + boundaries, direct shell-script parsing, shell-word unescaping, runtime-specific + shell invocation-mode checks, Python boolean/exit/value option boundaries, + attached-versus-separate option handling, and bounded `/proc` streaming added + by manaflow. The Python distinctions are + a local correctness improvement over the inherited option list: `-S` is + boolean, documented help aliases (`-?`, `-VV`) terminate, and + help/version/hash options cannot expose following tokens as agent + executables. Unsupported attached long options fail closed before they can + consume a later runtime mode flag. +- `crates/cmux-tui-core/src/terminal_metadata.rs`: OSC string framing adapted + from herdr's `src/pane/osc.rs`. Manaflow adds lead-specific UTF-8 + continuation validation and malformed-sequence recovery before C1 framing. + Core retains only generic bounded OSC 9 progress metadata; it has no agent + or roster policy. +- `bindings/examples/rust-agent-screen-detection/src/manifest_update.rs`: + explicit catalog and cache status concepts derived from herdr's update + surface. Network access, URL validation, atomic writes, and version policy + are a new manaflow implementation and never run during daemon startup. +- `crates/cmux-tui/src/sidebar_projection.rs` (`agent_attention`) and the + agents-view rendering in `crates/cmux-tui/src/ui/{sidebar.rs,rail.rs}`: + the two-line row and header layout follow `src/app/agent_view.rs` and + herdr's agents-panel design. cmux currently orders rows by blocked, + working, then idle, with newest transitions first inside each bucket. The + cache invalidation against cmux's terminal topology and the stable + tree-order tie break are manaflow additions. The herdr idle-unseen seen bit + is intentionally not copied because it is client-owned presentation state; + the deliberate exclusion is listed in `spec/plugins.md`. +- `bindings/examples/rust-agent-screen-detection/manifests/grok.toml`: the + local `2026.07.16.2.1` patch gives idle OSC progress precedence over a + generic custom title, keeps explicit braille-spinner activity stronger, and + excludes the blank braille code point from that activity rule. +- `bindings/examples/rust-agent-screen-detection/manifests/claude.toml`: the + upstream `2026.08.31.1` file removes background-shell activity as a working + signal, so an idle prompt or a permission blocker stays authoritative. +- The plugin's `manifests/SHA256SUMS` record is checked before bundled + compilation to catch accidental drift. It is not a cryptographic release + signature for remote updates. + +The capability audit was rerun against herdr's agent-surface revision +`987b070fbfa187e85009b45cd7e208fc6175ff6a`. Comparing `src/detect` with the +manifest snapshot found the exact Pi bundled CLI path correction from +`b1ff4582e9688f52ffb943cfa8bee4871ae122e4` and the Claude background-shell +manifest correction from `987b070fbfa187e85009b45cd7e208fc6175ff6a`. +The userland package ports and tests both. The `process.rs` adaptation covers +both direct and `dist/bundle/cli.js` entrypoints and rejects lookalike scripts. +The first-acquisition OSC retention fix in +`82e6a80eb3ae39fb3d3ebd4d1fed19389767e605` is adapted in the userland tracker +with a local revision fence. The foreground group-leader CWD fix in +`3a3792622e59c7f2dc20f9c0236167161e4a5035` is already covered by the generic +`foreground_cwd` resource, so no herdr-specific CWD policy is copied. + +The shell-render refactor in `207be3c771d281baae6e5fa0fb74be9a056e97a2` and +independent multi-client tab views in +`6c0bb273d5d5405a00985621b17e36f8b4d64609` are application/client architecture, +not detector behavior. The latest delayed-agent-prompt fix in +`8633a398e653eee47b375c963996c78a8a14aa48` changes PTY input sequencing, and +`5616196942cbe752cc0659b9bd0fb616b2a6ed5c` hardens malformed Windows process +environments in portable-pty. The later generic terminal-read fix +`45484aab84430ac2b18c7bbf44aba15f2b039677`, graphics ownership fix +`e22cba35ef7b405758097a5f9436aae8fb4caaf0`, Windows input fix +`2ae8b91ca5919c26df7ce779b0e9a5dd98b769ae`, and sidebar-focus fix +`94f6d9c0d9bb9cf9ffae99d8bbfb09e9bf2fc9e0` are also outside detector +behavior. These changes are not copied. If cmux needs atomic text-plus-Enter +submission, that belongs in a separate generic terminal-input contract, not in +a detector or an agent-specific core method. A standalone release must define +and test SDK endpoint-generation compatibility before it promises binary +upgrades across host versions. Review the Windows changes before publishing a +Windows package. + +The herdr repository tip checked on 2026-09-02 is +`94f6d9c0d9bb9cf9ffae99d8bbfb09e9bf2fc9e0`. The commits after the +agent-surface revision change client rendering, terminal reads, graphics, +Windows input and worktree handling, or sidebar focus. They do not change +`src/detect` or the manifests. The agent-surface revision is therefore the +reproducible capability-audit pin. + +Files that port herdr logic carry a header comment naming the upstream +file and the modifications. diff --git a/cmux-tui/bindings/cpp/.cmux-resource-api.json b/cmux-tui/bindings/cpp/.cmux-resource-api.json index 4c4481343150..0667d105f967 100644 --- a/cmux-tui/bindings/cpp/.cmux-resource-api.json +++ b/cmux-tui/bindings/cpp/.cmux-resource-api.json @@ -1,5 +1,5 @@ { - "catalog_sha256": "beef8293ded489648261ccddfd31b3f796f9d7d10506f13f6c5d1577a3f4fbac", + "catalog_sha256": "08a8190787e1b38d0592b85856d791fb0fe098d58eddce85ee98ecf88cc5a1a7", "operations": { "agent.list": { "class": "read" diff --git a/cmux-tui/bindings/cpp/.cmux-sdk-manifest.json b/cmux-tui/bindings/cpp/.cmux-sdk-manifest.json index 5b34ffaeeba0..2587367cbdb3 100644 --- a/cmux-tui/bindings/cpp/.cmux-sdk-manifest.json +++ b/cmux-tui/bindings/cpp/.cmux-sdk-manifest.json @@ -12,17 +12,17 @@ }, { "path": "include/cmux/raw/generated/models.hpp", - "sha256": "ceda21218b242a23ba9dd8486a1ee504f4cc2b2a9f4a01a98bbd8969043225f1", - "size": 137142 + "sha256": "77b2d50beb4d6a01eb725ae65f9d2fb47893f9fec4e1ab6a619a61192c025c79", + "size": 137234 }, { "path": "src/raw/generated/protocol.cpp", - "sha256": "56dff6004761f347f5f3f401dec65ede9f0107daef022aa63b1985364861ebea", - "size": 884368 + "sha256": "19f2e36e6174211519bc3abbf6171d6be1b6c033dfa85f442fbb45907d667b32", + "size": 885900 } ], "format": 1, - "ir_sha256": "7042c629f34d3606581d07b2d2c03b65116c2467810724163c54674865825cc0", + "ir_sha256": "133bac0154f8f94aa30e40c11ff7ed38b10dd4d82974aec87c02d404fcd12619", "language": "cpp", "mux_protocol": 12, "schema_version": 2 diff --git a/cmux-tui/bindings/cpp/include/cmux/raw/generated/models.hpp b/cmux-tui/bindings/cpp/include/cmux/raw/generated/models.hpp index 9cb6796e9058..892ac7cc41b7 100644 --- a/cmux-tui/bindings/cpp/include/cmux/raw/generated/models.hpp +++ b/cmux-tui/bindings/cpp/include/cmux/raw/generated/models.hpp @@ -14,7 +14,7 @@ namespace cmux::raw { inline constexpr std::uint32_t kMuxProtocolVersion = 12U; -inline constexpr std::string_view kProtocolIrSha256 = "7042c629f34d3606581d07b2d2c03b65116c2467810724163c54674865825cc0"; +inline constexpr std::string_view kProtocolIrSha256 = "133bac0154f8f94aa30e40c11ff7ed38b10dd4d82974aec87c02d404fcd12619"; struct AgentRecord; enum class AgentReportSource; @@ -331,6 +331,7 @@ enum class ClientAttachedEventTransport; enum class GraphicsStatusEventKind; enum class AgentSource { + plugin, detected, socket, hook, @@ -350,6 +351,7 @@ struct Id { }; struct AgentChangedEvent { + Field agent{}; std::optional session{}; AgentSource source{}; AgentState state{}; @@ -1851,6 +1853,7 @@ struct ProcessInfoResult { std::optional command{}; std::optional cwd{}; Field foreground_cwd{}; + Field foreground_executable{}; std::optional pid{}; friend bool operator==(const ProcessInfoResult&, const ProcessInfoResult&) = default; }; diff --git a/cmux-tui/bindings/cpp/include/cmux/resource.hpp b/cmux-tui/bindings/cpp/include/cmux/resource.hpp index 623d5995a985..19ed71bc6691 100644 --- a/cmux-tui/bindings/cpp/include/cmux/resource.hpp +++ b/cmux-tui/bindings/cpp/include/cmux/resource.hpp @@ -50,6 +50,9 @@ enum class Operation { session_creation_resolve, session_events, session_journal_subscribe, + session_journal_producer_list, + session_journal_producer_put, + session_journal_append, session_ping, session_shutdown, session_reload_config, @@ -533,6 +536,69 @@ struct SessionJournalRecord { std::optional previous_resource_revision; }; +// Generic journal producer contracts. Agent plugins use these records from +// userland; the daemon does not need a plugin-specific core type. +struct JournalEventSchema { + std::string kind; + std::uint32_t schema_version = 0; + JournalClass class_ = JournalClass::state; + JournalReplayPolicy replay = JournalReplayPolicy::required; + JournalSensitivity sensitivity = JournalSensitivity::sensitive; + Json payload_schema; +}; + +struct JournalProducerManifest { + std::string producer_id; + std::string namespace_; + std::uint32_t manifest_version = 0; + JournalSensitivity max_sensitivity = JournalSensitivity::sensitive; + std::vector permissions; + std::vector events; + + [[nodiscard]] Result to_json() const; +}; + +struct JournalIngress { + std::string producer_id; + std::uint32_t manifest_version = 0; + std::string kind; + std::uint32_t schema_version = 0; + std::optional occurred_at_ms; + std::vector subjects; + std::optional sensitivity; + Json payload; + std::optional causation_id; + std::optional correlation_id; + + [[nodiscard]] Result to_json() const; +}; + +struct JournalProducerPutResult { + std::string producer_id; + std::uint32_t manifest_version = 0; + std::string namespace_; + std::uint64_t sequence = 0; + std::string event_id; +}; + +struct JournalProducerListResult { + std::vector producers; +}; + +struct JournalAppendResult { + std::string producer_id; + std::uint64_t sequence = 0; + std::string event_id; +}; + +// Compatibility aliases from the first agent-plugin preview. +using AgentPluginEventSchema = JournalEventSchema; +using AgentPluginManifest = JournalProducerManifest; +using AgentPluginSubject = JournalSubject; +using AgentPluginIngress = JournalIngress; +using AgentPluginListResult = JournalProducerListResult; +using JournalEventSubject = JournalSubject; + struct ConfirmationRequiredDetails { std::string confirmation_token; std::uint64_t revision = 0; @@ -889,6 +955,7 @@ enum class AgentSource { hook, socket, detected, + plugin, }; enum class AgentReportSource { @@ -1165,6 +1232,49 @@ struct TerminalScreenResult { std::uint16_t cursor_col = 0; bool cursor_visible = false; Json::Object extra; + // Keep new metadata after the legacy aggregate fields. Existing callers + // can continue to initialize the original seven fields positionally via + // the compatibility constructor below. + std::optional revision; + std::optional osc_progress; + + TerminalScreenResult() = default; + + TerminalScreenResult( + std::string text_value, + std::uint16_t cols_value, + std::uint16_t rows_value, + std::uint16_t cursor_row_value, + std::uint16_t cursor_col_value, + bool cursor_visible_value, + Json::Object extra_value = {}) + : text(std::move(text_value)), + cols(cols_value), + rows(rows_value), + cursor_row(cursor_row_value), + cursor_col(cursor_col_value), + cursor_visible(cursor_visible_value), + extra(std::move(extra_value)) {} + + TerminalScreenResult( + std::string text_value, + std::uint16_t cols_value, + std::uint16_t rows_value, + std::uint16_t cursor_row_value, + std::uint16_t cursor_col_value, + bool cursor_visible_value, + Json::Object extra_value, + std::optional revision_value, + std::optional osc_progress_value) + : text(std::move(text_value)), + cols(cols_value), + rows(rows_value), + cursor_row(cursor_row_value), + cursor_col(cursor_col_value), + cursor_visible(cursor_visible_value), + extra(std::move(extra_value)), + revision(std::move(revision_value)), + osc_progress(std::move(osc_progress_value)) {} }; struct TerminalStateResult { @@ -1205,6 +1315,8 @@ struct ProcessInfoResult { // omits the field. std::optional foreground_cwd; std::vector children; + // Executable path or name of the PTY foreground process-group leader. + std::optional foreground_executable; }; struct CellPixelsResult { @@ -1333,6 +1445,10 @@ CMUX_DECLARE_TYPED_DECODER(TerminalDefaultsSnapshot); CMUX_DECLARE_TYPED_DECODER(PairingResolutionResult); CMUX_DECLARE_TYPED_DECODER(PaneNeighborResult); CMUX_DECLARE_TYPED_DECODER(TerminalScreenResult); +CMUX_DECLARE_TYPED_DECODER(JournalProducerManifest); +CMUX_DECLARE_TYPED_DECODER(JournalProducerListResult); +CMUX_DECLARE_TYPED_DECODER(JournalProducerPutResult); +CMUX_DECLARE_TYPED_DECODER(JournalAppendResult); CMUX_DECLARE_TYPED_DECODER(TerminalStateResult); CMUX_DECLARE_TYPED_DECODER(TerminalHistoryResult); CMUX_DECLARE_TYPED_DECODER(TerminalWaitResult); @@ -1933,6 +2049,24 @@ class Session final : public ResourceHandle { [[nodiscard]] Result journal( SessionJournalOptions options = {}, CallOptions call = {}) const; + [[nodiscard]] Result journal_producers() const; + [[nodiscard]] Result> + list_journal_producers() const; + [[nodiscard]] Result> + put_journal_producer( + JournalProducerManifest manifest, + MutationOptions mutation = MutationOptions::unique()) const; + [[nodiscard]] Result> + put_journal_producer_manifest( + JournalProducerManifest manifest, + MutationOptions mutation = MutationOptions::unique()) const; + [[nodiscard]] Result> append_journal( + JournalIngress event, + MutationOptions mutation = MutationOptions::unique()) const; + [[nodiscard]] Result> + append_journal_event( + JournalIngress event, + MutationOptions mutation = MutationOptions::unique()) const; [[nodiscard]] Result> shutdown( MutationOptions options = MutationOptions::unique()) const; [[nodiscard]] Result> reload_config( diff --git a/cmux-tui/bindings/cpp/src/journal_validation_internal.hpp b/cmux-tui/bindings/cpp/src/journal_validation_internal.hpp new file mode 100644 index 000000000000..b3a1dec835ba --- /dev/null +++ b/cmux-tui/bindings/cpp/src/journal_validation_internal.hpp @@ -0,0 +1,80 @@ +#pragma once + +#include +#include +#include + +#include "cmux/resource.hpp" + +namespace cmux::journal_detail { + +[[nodiscard]] inline bool valid_journal_class(JournalClass value) noexcept { + switch (value) { + case JournalClass::state: + case JournalClass::observation: + case JournalClass::effect: + case JournalClass::checkpoint: + return true; + } + return false; +} + +[[nodiscard]] inline bool valid_journal_replay( + JournalReplayPolicy value) noexcept { + switch (value) { + case JournalReplayPolicy::required: + case JournalReplayPolicy::advisory: + case JournalReplayPolicy::never: + return true; + } + return false; +} + +[[nodiscard]] inline bool valid_journal_sensitivity( + JournalSensitivity value) noexcept { + switch (value) { + case JournalSensitivity::public_: + case JournalSensitivity::metadata: + case JournalSensitivity::sensitive: + case JournalSensitivity::secret: + return true; + } + return false; +} + +[[nodiscard]] inline unsigned sensitivity_rank(JournalSensitivity value) noexcept { + switch (value) { + case JournalSensitivity::public_: return 0; + case JournalSensitivity::metadata: return 1; + case JournalSensitivity::sensitive: return 2; + case JournalSensitivity::secret: return 3; + } + return 3; +} + +[[nodiscard]] inline bool valid_component(std::string_view value) noexcept { + if (value.empty() || value.size() > 64 || + !((value.front() >= 'a' && value.front() <= 'z') || + (value.front() >= '0' && value.front() <= '9'))) { + return false; + } + return std::all_of(value.begin(), value.end(), [](char byte) { + return (byte >= 'a' && byte <= 'z') || + (byte >= '0' && byte <= '9') || byte == '_' || byte == '-'; + }); +} + +[[nodiscard]] inline bool valid_kind(std::string_view value) noexcept { + if (value.empty() || value.size() > 128) return false; + std::size_t start = 0; + while (start < value.size()) { + const auto dot = value.find('.', start); + const auto end = dot == std::string_view::npos ? value.size() : dot; + if (!valid_component(value.substr(start, end - start))) return false; + if (dot == std::string_view::npos) return true; + start = dot + 1; + } + return false; +} + +} // namespace cmux::journal_detail diff --git a/cmux-tui/bindings/cpp/src/raw/generated/protocol.cpp b/cmux-tui/bindings/cpp/src/raw/generated/protocol.cpp index c84af4974cf7..730cde40adcf 100644 --- a/cmux-tui/bindings/cpp/src/raw/generated/protocol.cpp +++ b/cmux-tui/bindings/cpp/src/raw/generated/protocol.cpp @@ -103,6 +103,7 @@ Result Codec::decode(const Json& value) { Result Codec::encode(const AgentSource& value) { switch (value) { + case AgentSource::plugin: return Json(std::string("plugin")); case AgentSource::detected: return Json(std::string("detected")); case AgentSource::socket: return Json(std::string("socket")); case AgentSource::hook: return Json(std::string("hook")); @@ -111,6 +112,7 @@ Result Codec::encode(const AgentSource& value) { } Result Codec::decode(const Json& value) { + if (value == Json(std::string("plugin"))) return AgentSource::plugin; if (value == Json(std::string("detected"))) return AgentSource::detected; if (value == Json(std::string("socket"))) return AgentSource::socket; if (value == Json(std::string("hook"))) return AgentSource::hook; @@ -3033,6 +3035,11 @@ Result Codec::encode(const ProcessInfoResult& value) { if (!encoded) return std::move(encoded).error(); object.emplace("foreground_cwd", std::move(encoded).value()); } + if (!value.foreground_executable.is_absent()) { + auto encoded = encode_value(value.foreground_executable); + if (!encoded) return std::move(encoded).error(); + object.emplace("foreground_executable", std::move(encoded).value()); + } if (value.pid) { auto encoded = encode_value(*value.pid); if (!encoded) return std::move(encoded).error(); @@ -3083,6 +3090,16 @@ Result Codec::decode(const Json& value) { result.foreground_cwd = Field(std::move(decoded).value()); } } + const Json* field_foreground_executable = value.find("foreground_executable"); + if (field_foreground_executable) { + if (field_foreground_executable->is_null()) { + result.foreground_executable = Field::null(); + } else { + auto decoded = decode_value(*field_foreground_executable); + if (!decoded) return std::move(decoded).error(); + result.foreground_executable = Field(std::move(decoded).value()); + } + } const Json* field_pid = value.find("pid"); if (!field_pid) { return make_error(ErrorCode::decode, "missing required field 'pid'"); @@ -13773,6 +13790,11 @@ Result Codec::encode(const AgentChangedEvent& value) { (void)value; Json::Object object; object.emplace("event", Json(std::string("agent-changed"))); + if (!value.agent.is_absent()) { + auto encoded = encode_value(value.agent); + if (!encoded) return std::move(encoded).error(); + object.emplace("agent", std::move(encoded).value()); + } if (value.session) { auto encoded = encode_value(*value.session); if (!encoded) return std::move(encoded).error(); @@ -13799,6 +13821,16 @@ Result Codec::decode(const Json& value) { auto source = value.as_object(); if (!source) return std::move(source).error(); AgentChangedEvent result{}; + const Json* field_agent = value.find("agent"); + if (field_agent) { + if (field_agent->is_null()) { + result.agent = Field::null(); + } else { + auto decoded = decode_value(*field_agent); + if (!decoded) return std::move(decoded).error(); + result.agent = Field(std::move(decoded).value()); + } + } const Json* field_session = value.find("session"); if (!field_session) { return make_error(ErrorCode::decode, "missing required field 'session'"); diff --git a/cmux-tui/bindings/cpp/src/resource.cpp b/cmux-tui/bindings/cpp/src/resource.cpp index 2fb3fede9f5c..851b32969310 100644 --- a/cmux-tui/bindings/cpp/src/resource.cpp +++ b/cmux-tui/bindings/cpp/src/resource.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -23,6 +24,7 @@ #endif #include "socket_path_internal.hpp" +#include "journal_validation_internal.hpp" #if defined(__APPLE__) #include @@ -88,6 +90,9 @@ struct OperationInfo { X(session_creation_resolve, "session.creation.resolve", read) \ X(session_events, "session.events", stream_open) \ X(session_journal_subscribe, "session.journal.subscribe", stream_open) \ + X(session_journal_producer_list, "session.journal.producer.list", read) \ + X(session_journal_producer_put, "session.journal.producer.put", mutation) \ + X(session_journal_append, "session.journal.append", mutation) \ X(session_ping, "session.ping", read) \ X(session_shutdown, "session.shutdown", mutation) \ X(session_reload_config, "session.reload_config", mutation) \ @@ -609,6 +614,160 @@ void inject_routing( }); } +[[nodiscard]] const char* journal_class_wire(JournalClass value) noexcept { + switch (value) { + case JournalClass::state: return "state"; + case JournalClass::observation: return "observation"; + case JournalClass::effect: return "effect"; + case JournalClass::checkpoint: return "checkpoint"; + } + return "state"; +} + +[[nodiscard]] const char* journal_replay_wire(JournalReplayPolicy value) noexcept { + switch (value) { + case JournalReplayPolicy::required: return "required"; + case JournalReplayPolicy::advisory: return "advisory"; + case JournalReplayPolicy::never: return "never"; + } + return "required"; +} + +[[nodiscard]] const char* journal_sensitivity_wire( + JournalSensitivity value) noexcept { + switch (value) { + case JournalSensitivity::public_: return "public"; + case JournalSensitivity::metadata: return "metadata"; + case JournalSensitivity::sensitive: return "sensitive"; + case JournalSensitivity::secret: return "secret"; + } + return "sensitive"; +} + +[[nodiscard]] Result validate_journal_manifest( + const JournalProducerManifest& manifest) { + if (!journal_detail::valid_component(manifest.producer_id)) { + return make_error( + ErrorCode::invalid_argument, + "producer_id must match [a-z0-9][a-z0-9_-]* and contain at most 64 bytes"); + } + if (manifest.namespace_ != "plugin." + manifest.producer_id) { + return make_error( + ErrorCode::invalid_argument, + "journal producer namespace must be plugin."); + } + if (manifest.manifest_version == 0 || manifest.events.empty() || + manifest.events.size() > 64 || manifest.permissions.empty() || + manifest.permissions.size() > 32) { + return make_error( + ErrorCode::invalid_argument, + "manifest_version must be positive, permissions must contain 1 to 32 entries, and events must contain 1 to 64 entries"); + } + if (!journal_detail::valid_journal_sensitivity(manifest.max_sensitivity)) { + return make_error( + ErrorCode::invalid_argument, + "journal producer manifest has an invalid max_sensitivity"); + } + if (manifest.max_sensitivity == JournalSensitivity::secret) { + return make_error( + ErrorCode::invalid_argument, + "secret journal payload storage is unavailable"); + } + const auto required_permission = "journal.append." + manifest.namespace_; + bool has_valid_permission = false; + for (const auto& permission : manifest.permissions) { + if (permission.empty() || permission.size() > 128) { + return make_error( + ErrorCode::invalid_argument, + "journal producer permissions must contain 1 to 128 bytes"); + } + if (permission == required_permission) has_valid_permission = true; + } + if (!has_valid_permission) { + return make_error( + ErrorCode::invalid_argument, + "journal producer manifest requires its journal append permission"); + } + std::set> identities; + const auto prefix = manifest.namespace_ + "."; + for (const auto& event : manifest.events) { + if (!journal_detail::valid_kind(event.kind) || + !event.kind.starts_with(prefix)) { + return make_error( + ErrorCode::invalid_argument, + "journal event kind must be a dotted lowercase name inside the producer namespace"); + } + if (event.schema_version == 0) { + return make_error( + ErrorCode::invalid_argument, + "journal event schema_version must be positive"); + } + if (!journal_detail::valid_journal_class(event.class_) || + !journal_detail::valid_journal_replay(event.replay) || + !journal_detail::valid_journal_sensitivity(event.sensitivity)) { + return make_error( + ErrorCode::invalid_argument, + "journal event schema contains an invalid enum value"); + } + if (event.sensitivity == JournalSensitivity::secret || + journal_detail::sensitivity_rank(event.sensitivity) > + journal_detail::sensitivity_rank(manifest.max_sensitivity)) { + return make_error( + ErrorCode::invalid_argument, + "journal event sensitivity exceeds producer authority"); + } + if (!identities.emplace(event.kind, event.schema_version).second) { + return make_error( + ErrorCode::invalid_argument, + "journal producer declares a duplicate event schema"); + } + } + return {}; +} + +[[nodiscard]] Result validate_journal_ingress(const JournalIngress& event) { + if (!journal_detail::valid_component(event.producer_id) || + event.manifest_version == 0 || event.schema_version == 0 || + !journal_detail::valid_kind(event.kind) || + !event.kind.starts_with("plugin." + event.producer_id + ".")) { + return make_error( + ErrorCode::invalid_argument, + "journal event envelope is invalid"); + } + if (event.subjects.size() > 64) { + return make_error( + ErrorCode::invalid_argument, + "journal event subjects must contain at most 64 entries"); + } + for (const auto& subject : event.subjects) { + if (!journal_detail::valid_component(subject.kind) || subject.id.empty() || + subject.id.size() > 512) { + return make_error( + ErrorCode::invalid_argument, + "journal event subject is invalid"); + } + } + for (const auto& identifier : {event.causation_id, event.correlation_id}) { + if (identifier && (identifier->empty() || identifier->size() > 128)) { + return make_error( + ErrorCode::invalid_argument, + "journal correlation identifiers must contain 1 to 128 bytes"); + } + } + if (event.sensitivity && + !journal_detail::valid_journal_sensitivity(*event.sensitivity)) { + return make_error( + ErrorCode::invalid_argument, + "journal event sensitivity is invalid"); + } + if (event.sensitivity == JournalSensitivity::secret) { + return make_error( + ErrorCode::invalid_argument, + "secret journal payload storage is unavailable"); + } + return {}; +} + [[nodiscard]] Result put_correlation_key( Json::Object& params, const std::optional& correlation_key) { @@ -1030,6 +1189,18 @@ Result SessionJournalOptions::to_params() const { ErrorCode::invalid_argument, "journal cursor and start are mutually exclusive"); } + if (start && *start != JournalStart::tail && + *start != JournalStart::beginning) { + return make_error( + ErrorCode::invalid_argument, + "journal start is invalid"); + } + if (filter.max_sensitivity && + !journal_detail::valid_journal_sensitivity(*filter.max_sensitivity)) { + return make_error( + ErrorCode::invalid_argument, + "journal max_sensitivity is invalid"); + } if (filter.max_sensitivity == JournalSensitivity::secret) { return make_error( ErrorCode::invalid_argument, @@ -1056,6 +1227,11 @@ Result SessionJournalOptions::to_params() const { if (!filter.classes.empty()) { Json::Array values; for (const auto value : filter.classes) { + if (!journal_detail::valid_journal_class(value)) { + return make_error( + ErrorCode::invalid_argument, + "journal class filter contains an invalid enum value"); + } switch (value) { case JournalClass::state: values.emplace_back("state"); break; case JournalClass::observation: values.emplace_back("observation"); break; @@ -1119,6 +1295,80 @@ Result SessionJournalOptions::to_params() const { return params; } +Result JournalProducerManifest::to_json() const { + auto valid = validate_journal_manifest(*this); + if (!valid) return std::move(valid).error(); + + Json::Array permissions; + permissions.reserve(this->permissions.size()); + for (const auto& permission : this->permissions) { + permissions.emplace_back(permission); + } + Json::Array events; + events.reserve(this->events.size()); + for (const auto& event : this->events) { + events.emplace_back(Json::Object{ + {"kind", Json(event.kind)}, + {"schema_version", Json(static_cast(event.schema_version))}, + {"class", Json(journal_class_wire(event.class_))}, + {"replay", Json(journal_replay_wire(event.replay))}, + {"sensitivity", Json(journal_sensitivity_wire(event.sensitivity))}, + {"payload_schema", event.payload_schema}, + }); + } + Json result(Json::Object{ + {"producer_id", Json(producer_id)}, + {"namespace", Json(namespace_)}, + {"manifest_version", Json(static_cast(manifest_version))}, + {"max_sensitivity", Json(journal_sensitivity_wire(max_sensitivity))}, + {"permissions", Json(std::move(permissions))}, + {"events", Json(std::move(events))}, + }); + auto encoded = result.encode(); + if (!encoded) return std::move(encoded).error(); + if (encoded.value().size() > 1024U * 1024U) { + return make_error( + ErrorCode::invalid_argument, + "journal producer manifest exceeds 1048576 bytes"); + } + return result; +} + +Result JournalIngress::to_json() const { + auto valid = validate_journal_ingress(*this); + if (!valid) return std::move(valid).error(); + + Json::Array subjects; + subjects.reserve(this->subjects.size()); + for (const auto& subject : this->subjects) { + subjects.emplace_back(Json::Object{ + {"kind", Json(subject.kind)}, + {"id", Json(subject.id)}, + }); + } + Json::Object result{ + {"producer_id", Json(producer_id)}, + {"manifest_version", Json(static_cast(manifest_version))}, + {"kind", Json(kind)}, + {"schema_version", Json(static_cast(schema_version))}, + {"payload", payload}, + }; + if (!this->subjects.empty()) { + result.emplace("subjects", Json(std::move(subjects))); + } + if (occurred_at_ms) { + result.emplace("occurred_at_ms", Json(std::to_string(*occurred_at_ms))); + } + if (sensitivity) { + result.emplace( + "sensitivity", + Json(journal_sensitivity_wire(*sensitivity))); + } + if (causation_id) result.emplace("causation_id", Json(*causation_id)); + if (correlation_id) result.emplace("correlation_id", Json(*correlation_id)); + return Json(std::move(result)); +} + Result TerminalAttachOptions::to_params() const { if (cols.has_value() != rows.has_value()) { return make_error( @@ -2173,6 +2423,51 @@ Result Session::journal( return SessionJournalStream(std::move(stream).value()); } +Result Session::journal_producers() const { + return read(Operation::session_journal_producer_list); +} + +Result> Session::list_journal_producers() const { + auto result = journal_producers(); + if (!result) return std::move(result).error(); + return std::move(result).value().producers; +} + +Result> Session::put_journal_producer( + JournalProducerManifest manifest, + MutationOptions mutation) const { + auto encoded = manifest.to_json(); + if (!encoded) return std::move(encoded).error(); + return mutate( + Operation::session_journal_producer_put, + Json::Object{{"manifest", std::move(encoded).value()}}, + std::move(mutation)); +} + +Result> +Session::put_journal_producer_manifest( + JournalProducerManifest manifest, + MutationOptions mutation) const { + return put_journal_producer(std::move(manifest), std::move(mutation)); +} + +Result> Session::append_journal( + JournalIngress event, + MutationOptions mutation) const { + auto encoded = event.to_json(); + if (!encoded) return std::move(encoded).error(); + return mutate( + Operation::session_journal_append, + Json::Object{{"event", std::move(encoded).value()}}, + std::move(mutation)); +} + +Result> Session::append_journal_event( + JournalIngress event, + MutationOptions mutation) const { + return append_journal(std::move(event), std::move(mutation)); +} + Result> Session::shutdown(MutationOptions options) const { return mutate(Operation::session_shutdown, {}, std::move(options)); } diff --git a/cmux-tui/bindings/cpp/src/resource_models.cpp b/cmux-tui/bindings/cpp/src/resource_models.cpp index 0d041943e4cc..5c329b0db68b 100644 --- a/cmux-tui/bindings/cpp/src/resource_models.cpp +++ b/cmux-tui/bindings/cpp/src/resource_models.cpp @@ -4,10 +4,12 @@ #include #include #include +#include #include #include #include "cmux/base64.hpp" +#include "journal_validation_internal.hpp" namespace cmux { namespace { @@ -16,6 +18,10 @@ struct DecodeFailure { Error error; }; +constexpr std::size_t MAX_JOURNAL_PRODUCER_PERMISSIONS = 32; +constexpr std::size_t MAX_JOURNAL_PRODUCER_EVENTS = 64; +constexpr std::size_t MAX_JOURNAL_PRODUCERS = 1'024; + [[noreturn]] void fail(std::string message) { throw DecodeFailure(make_error(ErrorCode::decode, std::move(message))); } @@ -172,6 +178,29 @@ std::vector array_value( return result; } +template +std::vector bounded_array_value( + const Json& value, + std::string_view context, + std::size_t maximum, + Parser&& parser) { + auto array = value.as_array(); + if (!array) { + fail(std::string(context) + " must be an array"); + } + if (array.value()->size() > maximum) { + fail( + std::string(context) + " contains more than " + + std::to_string(maximum) + " entries"); + } + std::vector result; + result.reserve(array.value()->size()); + for (const auto& item : *array.value()) { + result.push_back(parser(item)); + } + return result; +} + std::optional optional_string( const Json::Object& object, std::string_view name, @@ -208,6 +237,17 @@ std::optional optional_nullable_string( return string_value(found->second, context); } +std::optional optional_nullable_decimal( + const Json::Object& object, + std::string_view name, + std::string_view context) { + const auto found = object.find(name); + if (found == object.end() || found->second.is_null()) { + return std::nullopt; + } + return decimal_value(found->second, context); +} + template std::optional optional_id_value( const Json::Object& object, @@ -1320,6 +1360,7 @@ AgentSnapshot parse_agent(const Json& value) { {"hook", AgentSource::hook}, {"socket", AgentSource::socket}, {"detected", AgentSource::detected}, + {"plugin", AgentSource::plugin}, }, "agent source"), decimal_value( @@ -1330,6 +1371,235 @@ AgentSnapshot parse_agent(const Json& value) { }; } +JournalSubject parse_journal_subject(const Json& value) { + const auto& object = exact_object( + value, + {"kind", "id"}, + {"kind", "id"}, + "journal subject"); + auto kind = bounded_string( + field(object, "kind", "journal subject"), + "journal subject kind", + 1, + 64); + if (!journal_detail::valid_component(kind)) { + fail("journal subject kind must be a lowercase component"); + } + return { + std::move(kind), + bounded_string(field(object, "id", "journal subject"), "journal subject id", 1, 512), + }; +} + +JournalEventSchema parse_journal_event_schema(const Json& value) { + const auto& object = exact_object( + value, + {"kind", "schema_version", "class", "replay", "sensitivity", "payload_schema"}, + {"kind", "schema_version", "class", "replay", "sensitivity", "payload_schema"}, + "journal event schema"); + auto kind = bounded_string( + field(object, "kind", "journal event schema"), + "journal event kind", + 1, + 128); + if (!journal_detail::valid_kind(kind)) { + fail("journal event kind must be a dotted lowercase name"); + } + return { + std::move(kind), + static_cast(uint_value( + field(object, "schema_version", "journal event schema"), + std::numeric_limits::max(), + "journal event schema_version", + true)), + enum_value( + field(object, "class", "journal event schema"), + { + {"state", JournalClass::state}, + {"observation", JournalClass::observation}, + {"effect", JournalClass::effect}, + {"checkpoint", JournalClass::checkpoint}, + }, + "journal event class"), + enum_value( + field(object, "replay", "journal event schema"), + { + {"required", JournalReplayPolicy::required}, + {"advisory", JournalReplayPolicy::advisory}, + {"never", JournalReplayPolicy::never}, + }, + "journal event replay"), + enum_value( + field(object, "sensitivity", "journal event schema"), + { + {"public", JournalSensitivity::public_}, + {"metadata", JournalSensitivity::metadata}, + {"sensitive", JournalSensitivity::sensitive}, + {"secret", JournalSensitivity::secret}, + }, + "journal event sensitivity"), + field(object, "payload_schema", "journal event schema"), + }; +} + +JournalProducerManifest parse_journal_producer_manifest(const Json& value) { + const auto& object = exact_object( + value, + {"producer_id", "namespace", "manifest_version", "max_sensitivity", "permissions", "events"}, + {"producer_id", "namespace", "manifest_version", "max_sensitivity", "permissions", "events"}, + "journal producer manifest"); + auto permissions = bounded_array_value( + field(object, "permissions", "journal producer manifest"), + "journal producer permissions", + MAX_JOURNAL_PRODUCER_PERMISSIONS, + [](const Json& item) { + return bounded_string(item, "journal producer permission", 1, 128); + }); + auto events = bounded_array_value( + field(object, "events", "journal producer manifest"), + "journal producer events", + MAX_JOURNAL_PRODUCER_EVENTS, + parse_journal_event_schema); + auto producer_id = bounded_string( + field(object, "producer_id", "journal producer manifest"), + "journal producer id", + 1, + 64); + if (!journal_detail::valid_component(producer_id)) { + fail("journal producer id must match the lowercase component grammar"); + } + auto namespace_ = bounded_string( + field(object, "namespace", "journal producer manifest"), + "journal producer namespace", + 1, + 128); + JournalProducerManifest manifest{ + std::move(producer_id), + std::move(namespace_), + static_cast(uint_value( + field(object, "manifest_version", "journal producer manifest"), + std::numeric_limits::max(), + "journal producer manifest_version", + true)), + enum_value( + field(object, "max_sensitivity", "journal producer manifest"), + { + {"public", JournalSensitivity::public_}, + {"metadata", JournalSensitivity::metadata}, + {"sensitive", JournalSensitivity::sensitive}, + {"secret", JournalSensitivity::secret}, + }, + "journal producer max_sensitivity"), + std::move(permissions), + std::move(events), + }; + if (manifest.permissions.empty() || + manifest.events.empty()) { + fail("journal producer manifest has too many or too few entries"); + } + const auto required_permission = "journal.append." + manifest.namespace_; + if (std::find( + manifest.permissions.begin(), + manifest.permissions.end(), + required_permission) == manifest.permissions.end()) { + fail("journal producer manifest is missing its append permission"); + } + if (manifest.namespace_ != "plugin." + manifest.producer_id || + manifest.max_sensitivity == JournalSensitivity::secret) { + fail("journal producer manifest has an invalid namespace or sensitivity"); + } + const auto prefix = manifest.namespace_ + "."; + std::set> identities; + for (const auto& event : manifest.events) { + if (!journal_detail::valid_kind(event.kind) || + !event.kind.starts_with(prefix) || + !identities.emplace(event.kind, event.schema_version).second || + event.sensitivity == JournalSensitivity::secret || + journal_detail::sensitivity_rank(event.sensitivity) > + journal_detail::sensitivity_rank(manifest.max_sensitivity)) { + fail("journal producer manifest contains an invalid event schema"); + } + } + auto encoded = value.encode(); + if (!encoded) { + fail("journal producer manifest cannot be encoded"); + } + if (encoded.value().size() > 1024U * 1024U) { + fail("journal producer manifest exceeds 1048576 bytes"); + } + return manifest; +} + +JournalProducerListResult parse_journal_producer_list(const Json& value) { + const auto& object = exact_object( + value, + {"producers"}, + {"producers"}, + "journal producer list result"); + auto producers = bounded_array_value( + field(object, "producers", "journal producer list result"), + "journal producer list", + MAX_JOURNAL_PRODUCERS, + parse_journal_producer_manifest); + return {std::move(producers)}; +} + +JournalProducerPutResult parse_journal_producer_put(const Json& value) { + const auto& object = exact_object( + value, + {"producer_id", "manifest_version", "namespace", "sequence", "event_id"}, + {"producer_id", "manifest_version", "namespace", "sequence", "event_id"}, + "journal producer put result"); + auto producer_id = bounded_string( + field(object, "producer_id", "journal producer put"), + "journal producer id", + 1, + 64); + if (!journal_detail::valid_component(producer_id)) { + fail("journal producer id must match the lowercase component grammar"); + } + auto namespace_ = bounded_string( + field(object, "namespace", "journal producer put"), + "journal producer namespace", + 1, + 128); + if (namespace_ != "plugin." + producer_id) { + fail("journal producer namespace must equal plugin."); + } + return { + std::move(producer_id), + static_cast(uint_value( + field(object, "manifest_version", "journal producer put"), + std::numeric_limits::max(), + "journal producer manifest_version", + true)), + std::move(namespace_), + decimal_value(field(object, "sequence", "journal producer put"), "journal producer sequence"), + bounded_string(field(object, "event_id", "journal producer put"), "journal producer event_id", 1, 128), + }; +} + +JournalAppendResult parse_journal_append(const Json& value) { + const auto& object = exact_object( + value, + {"producer_id", "sequence", "event_id"}, + {"producer_id", "sequence", "event_id"}, + "journal append result"); + auto producer_id = bounded_string( + field(object, "producer_id", "journal append"), + "journal producer id", + 1, + 64); + if (!journal_detail::valid_component(producer_id)) { + fail("journal producer id must match the lowercase component grammar"); + } + return { + std::move(producer_id), + decimal_value(field(object, "sequence", "journal append"), "journal sequence"), + bounded_string(field(object, "event_id", "journal append"), "journal event_id", 1, 128), + }; +} + PairingRequestSnapshot parse_pairing(const Json& value) { const auto& object = exact_object( value, @@ -1848,6 +2118,8 @@ TerminalScreenResult parse_terminal_screen(const Json& value) { value, { "text", + "revision", + "osc_progress", "cols", "rows", "cursor_row", @@ -1864,7 +2136,11 @@ TerminalScreenResult parse_terminal_screen(const Json& value) { "cursor_visible", }, "terminal screen result"); - return { + const auto revision = optional_nullable_decimal( + object, + "revision", + "terminal screen revision"); + return TerminalScreenResult{ string_value(field(object, "text", "terminal screen"), "screen text"), static_cast(uint_value( field(object, "cols", "terminal screen"), @@ -1888,6 +2164,54 @@ TerminalScreenResult parse_terminal_screen(const Json& value) { field(object, "cursor_visible", "terminal screen"), "screen cursor_visible"), extra_value(object, "terminal screen"), + revision, + optional_nullable_string(object, "osc_progress", "terminal screen osc_progress"), + }; +} + +ProcessInfoResult parse_process_info(const Json& value) { + const auto& object = exact_object( + value, + { + "pid", + "executable", + "argv", + "cwd", + "foreground_cwd", + "foreground_executable", + "children", + }, + {"pid", "argv", "children"}, + "process info result"); + auto argv = array_value( + field(object, "argv", "process info result"), + "process argv", + [](const Json& item) { return string_value(item, "process argv item"); }); + auto children = array_value( + field(object, "children", "process info result"), + "process children", + [](const Json& item) { + return static_cast(uint_value( + item, + std::numeric_limits::max(), + "process child", + true)); + }); + return { + static_cast(uint_value( + field(object, "pid", "process info result"), + std::numeric_limits::max(), + "process pid", + true)), + optional_string(object, "executable", "process executable"), + std::move(argv), + optional_string(object, "cwd", "process cwd"), + optional_nullable_string(object, "foreground_cwd", "process foreground cwd"), + std::move(children), + optional_nullable_string( + object, + "foreground_executable", + "process foreground executable"), }; } @@ -1973,39 +2297,6 @@ TerminalCopyResult parse_terminal_copy(const Json& value) { }; } -ProcessInfoResult parse_process_info(const Json& value) { - const auto& object = exact_object( - value, - {"pid", "executable", "argv", "cwd", "foreground_cwd", "children"}, - {"pid", "argv", "children"}, - "process info result"); - return { - static_cast(uint_value( - field(object, "pid", "process info"), - std::numeric_limits::max(), - "process pid")), - optional_string(object, "executable", "process executable"), - array_value( - field(object, "argv", "process info"), - "process argv", - [](const Json& item) { - return string_value(item, "process argv item"); - }), - optional_string(object, "cwd", "process cwd"), - optional_nullable_string( - object, "foreground_cwd", "process foreground_cwd"), - array_value( - field(object, "children", "process info"), - "process children", - [](const Json& item) { - return static_cast(uint_value( - item, - std::numeric_limits::max(), - "process child pid")); - }), - }; -} - RendererGrant parse_renderer_grant(const Json& value) { const auto& object = exact_object( value, @@ -2456,6 +2747,10 @@ CMUX_DEFINE_DECODER(TerminalDefaultsSnapshot, parse_terminal_defaults) CMUX_DEFINE_DECODER(PairingResolutionResult, parse_pairing_resolution) CMUX_DEFINE_DECODER(PaneNeighborResult, parse_pane_neighbor) CMUX_DEFINE_DECODER(TerminalScreenResult, parse_terminal_screen) +CMUX_DEFINE_DECODER(JournalProducerManifest, parse_journal_producer_manifest) +CMUX_DEFINE_DECODER(JournalProducerListResult, parse_journal_producer_list) +CMUX_DEFINE_DECODER(JournalProducerPutResult, parse_journal_producer_put) +CMUX_DEFINE_DECODER(JournalAppendResult, parse_journal_append) CMUX_DEFINE_DECODER(TerminalStateResult, parse_terminal_state) CMUX_DEFINE_DECODER(TerminalHistoryResult, parse_terminal_history) CMUX_DEFINE_DECODER(TerminalWaitResult, parse_terminal_wait) @@ -2694,18 +2989,7 @@ Result decode_session_journal_record( array_value( field(object, "subjects", "session journal record"), "journal subjects", - [](const Json& item) { - const auto& subject = exact_object( - item, {"kind", "id"}, {"kind", "id"}, "journal subject"); - return JournalSubject{ - bounded_string( - field(subject, "kind", "journal subject"), - "journal subject kind", 1, 128), - bounded_string( - field(subject, "id", "journal subject"), - "journal subject id", 1, 512), - }; - }), + parse_journal_subject), enum_value( field(object, "sensitivity", "session journal record"), { diff --git a/cmux-tui/bindings/cpp/tests/test_resource.cpp b/cmux-tui/bindings/cpp/tests/test_resource.cpp index c210f60311e1..f5c0f805d43f 100644 --- a/cmux-tui/bindings/cpp/tests/test_resource.cpp +++ b/cmux-tui/bindings/cpp/tests/test_resource.cpp @@ -548,6 +548,292 @@ TEST("session auxiliary APIs emit typed notification and agent routes") { CHECK(!report_params->contains("agent")); } +TEST("generic journal producer contracts stay userland and wire-compatible") { + auto manifest_wire = cmux::Json::parse(R"({ + "producer_id":"screen-detector", + "namespace":"plugin.screen-detector", + "manifest_version":1, + "max_sensitivity":"sensitive", + "permissions":["journal.append.plugin.screen-detector"], + "events":[{ + "kind":"plugin.screen-detector.state.changed", + "schema_version":1, + "class":"state", + "replay":"required", + "sensitivity":"sensitive", + "payload_schema":{"type":"object"} + }] + })"); + CHECK(manifest_wire); + auto manifest = cmux::detail::decode_value( + manifest_wire.value()); + CHECK(manifest); + CHECK_EQ(manifest.value().namespace_, "plugin.screen-detector"); + CHECK_EQ(manifest.value().events.front().class_, cmux::JournalClass::state); + + auto encoded = manifest.value().to_json(); + CHECK(encoded); + CHECK(encoded.value().find("namespace") != nullptr); + CHECK(encoded.value().find("namespace_") == nullptr); + const auto* encoded_events = + encoded.value().find("events")->as_array().value(); + CHECK_EQ( + encoded_events->front().find("class")->as_string().value(), + std::string_view("state")); + + auto list_wire = cmux::Json::parse( + R"({"producers":[{"producer_id":"screen-detector","namespace":"plugin.screen-detector","manifest_version":1,"max_sensitivity":"sensitive","permissions":["journal.append.plugin.screen-detector"],"events":[{"kind":"plugin.screen-detector.state.changed","schema_version":1,"class":"state","replay":"required","sensitivity":"sensitive","payload_schema":{"type":"object"}}]}]})"); + CHECK(list_wire); + auto list = cmux::detail::decode_value( + list_wire.value()); + CHECK(list); + CHECK_EQ(list.value().producers.size(), 1U); + + auto put_wire = cmux::Json::parse( + R"({"producer_id":"screen-detector","manifest_version":1,"namespace":"plugin.screen-detector","sequence":"7","event_id":"evt-7"})"); + CHECK(put_wire); + auto put = cmux::detail::decode_value( + put_wire.value()); + CHECK(put); + CHECK_EQ(put.value().sequence, 7U); + + auto append_wire = cmux::Json::parse( + R"({"producer_id":"screen-detector","sequence":"8","event_id":"evt-8"})"); + CHECK(append_wire); + auto appended = cmux::detail::decode_value( + append_wire.value()); + CHECK(appended); + CHECK_EQ(appended.value().sequence, 8U); + + auto agent_wire = cmux::Json::parse( + R"({"id":"agent_11111111111111111111111111111111","session_id":"session_22222222222222222222222222222222","terminal_id":"term_33333333333333333333333333333333","state":"working","source":"plugin","updated_at_ms":"9","source_session":null})"); + CHECK(agent_wire); + auto agent = cmux::detail::decode_value( + agent_wire.value()); + CHECK(agent); + CHECK_EQ(agent.value().source, cmux::AgentSource::plugin); + + auto screen_wire = cmux::Json::parse( + R"({"text":"ready","revision":"12","osc_progress":"4;1;50","cols":80,"rows":24,"cursor_row":1,"cursor_col":2,"cursor_visible":true})"); + CHECK(screen_wire); + auto screen = cmux::detail::decode_value( + screen_wire.value()); + CHECK(screen); + CHECK_EQ(screen.value().revision, std::optional(12)); + CHECK_EQ(screen.value().osc_progress, std::optional("4;1;50")); + + auto unavailable_screen_wire = cmux::Json::parse( + R"({"text":"unavailable","revision":null,"osc_progress":null,"cols":80,"rows":24,"cursor_row":0,"cursor_col":0,"cursor_visible":true})"); + CHECK(unavailable_screen_wire); + auto unavailable_screen = cmux::detail::decode_value( + unavailable_screen_wire.value()); + CHECK(unavailable_screen); + CHECK(!unavailable_screen.value().revision); + CHECK(!unavailable_screen.value().osc_progress); + + cmux::JournalIngress invalid_ingress{ + "screen-detector", + 1, + "agent.state.changed", + 1, + std::nullopt, + {}, + std::nullopt, + cmux::Json(cmux::Json::Object{}), + std::nullopt, + std::nullopt}; + auto invalid_ingress_json = invalid_ingress.to_json(); + CHECK(!invalid_ingress_json); + CHECK_EQ( + invalid_ingress_json.error().code, + cmux::ErrorCode::invalid_argument); + + // The decoder applies the same grammar and size limits as the outgoing + // producer contract. A malformed server response must not enter the SDK. + auto malformed_manifest_wire = cmux::Json::parse( + R"({"producer_id":"screen!detector","namespace":"plugin.screen!detector","manifest_version":1,"max_sensitivity":"sensitive","permissions":["journal.append.plugin.screen!detector"],"events":[{"kind":"plugin.screen!detector.state.changed","schema_version":1,"class":"state","replay":"required","sensitivity":"sensitive","payload_schema":{}}]})"); + CHECK(malformed_manifest_wire); + auto malformed_manifest = + cmux::detail::decode_value( + malformed_manifest_wire.value()); + CHECK(!malformed_manifest); + CHECK_EQ(malformed_manifest.error().code, cmux::ErrorCode::decode); + + auto malformed_put_wire = cmux::Json::parse( + R"({"producer_id":"screen!detector","manifest_version":1,"namespace":"plugin.screen!detector","sequence":"1","event_id":"event-1"})"); + CHECK(malformed_put_wire); + auto malformed_put = cmux::detail::decode_value( + malformed_put_wire.value()); + CHECK(!malformed_put); + CHECK_EQ(malformed_put.error().code, cmux::ErrorCode::decode); + + auto malformed_append_wire = cmux::Json::parse( + R"({"producer_id":"screen!detector","sequence":"1","event_id":"event-1"})"); + CHECK(malformed_append_wire); + auto malformed_append = cmux::detail::decode_value( + malformed_append_wire.value()); + CHECK(!malformed_append); + CHECK_EQ(malformed_append.error().code, cmux::ErrorCode::decode); + + cmux::TerminalScreenResult legacy_screen{ + "legacy", 80, 24, 0, 0, true, {}}; + CHECK_EQ(legacy_screen.cols, 80); + CHECK(!legacy_screen.revision); +} + +TEST("journal subject decoder enforces lowercase component grammar") { + auto record_wire = cmux::Json::parse(R"({ + "sequence":"1", + "event_id":"event-1", + "schema_version":1, + "kind":"plugin.screen-detector.agent.state.changed", + "class":"state", + "replay":"required", + "occurred_at_ms":"1", + "committed_at_ms":"2", + "producer":{"kind":"plugin","id":"screen-detector"}, + "authority":null, + "causation_id":null, + "correlation_id":null, + "causation_depth":0, + "subjects":[{"kind":"Agent","id":"agent-1"}], + "sensitivity":"metadata", + "payload":{}, + "resource_revision":null, + "previous_resource_revision":null + })"); + CHECK(record_wire); + + auto decoded = cmux::detail::decode_session_journal_record( + record_wire.value(), cmux::Cursor{"g", 1}); + CHECK(!decoded); + CHECK_EQ(decoded.error().code, cmux::ErrorCode::decode); +} + +TEST("journal producer decoders reject oversized arrays before item parsing") { + const auto repeated = [](std::string_view item, std::size_t count) { + std::string result = "["; + for (std::size_t index = 0; index < count; ++index) { + if (index != 0) result += ','; + result += item; + } + result += ']'; + return result; + }; + const std::string event = + R"({"kind":"plugin.screen-detector.state.changed","schema_version":1,"class":"state","replay":"required","sensitivity":"sensitive","payload_schema":{}})"; + const std::string manifest = + R"({"producer_id":"screen-detector","namespace":"plugin.screen-detector","manifest_version":1,"max_sensitivity":"sensitive","permissions":["journal.append.plugin.screen-detector"],"events":)"; + + // Put an invalid item first. The size guard must win before the decoder + // attempts to parse that item. + auto oversized_permissions = cmux::Json::parse( + R"({"producer_id":"screen-detector","namespace":"plugin.screen-detector","manifest_version":1,"max_sensitivity":"sensitive","permissions":[1,"journal.append.plugin.screen-detector"] ,"events":[]})"); + CHECK(oversized_permissions); + auto permissions = oversized_permissions.value().find("permissions"); + CHECK(permissions != nullptr); + auto permission_array = permissions->as_array(); + CHECK(permission_array); + permission_array.value()->insert( + permission_array.value()->end(), 31, cmux::Json("journal.append.plugin.screen-detector")); + auto decoded_permissions = cmux::detail::decode_value( + oversized_permissions.value()); + CHECK(!decoded_permissions); + CHECK( + decoded_permissions.error().message.find("more than 32") != + std::string::npos); + + const auto event_array = repeated(event, 64); + auto oversized_events = cmux::Json::parse( + manifest + "[1," + event_array.substr(1) + "}"); + CHECK(oversized_events); + auto decoded_events = cmux::detail::decode_value( + oversized_events.value()); + CHECK(!decoded_events); + CHECK( + decoded_events.error().message.find("more than 64") != + std::string::npos); + + const auto producer_array = repeated(manifest + event_array + "}", 1024); + auto oversized_producers = cmux::Json::parse( + "{\"producers\":[1," + producer_array.substr(1) + "}"); + CHECK(oversized_producers); + auto decoded_producers = cmux::detail::decode_value( + oversized_producers.value()); + CHECK(!decoded_producers); + CHECK( + decoded_producers.error().message.find("more than 1024") != + std::string::npos); +} + +TEST("journal encoders reject out-of-range enum values") { + const auto manifest = [] { + cmux::JournalProducerManifest value; + value.producer_id = "screen-detector"; + value.namespace_ = "plugin.screen-detector"; + value.manifest_version = 1; + value.max_sensitivity = cmux::JournalSensitivity::sensitive; + value.permissions = {"journal.append.plugin.screen-detector"}; + value.events.push_back(cmux::JournalEventSchema{ + "plugin.screen-detector.state.changed", + 1, + cmux::JournalClass::state, + cmux::JournalReplayPolicy::required, + cmux::JournalSensitivity::sensitive, + cmux::Json(cmux::Json::Object{}), + }); + return value; + }(); + + auto invalid_class = manifest; + invalid_class.events.front().class_ = + static_cast(99); + CHECK(!invalid_class.to_json()); + + auto invalid_replay = manifest; + invalid_replay.events.front().replay = + static_cast(99); + CHECK(!invalid_replay.to_json()); + + auto invalid_event_sensitivity = manifest; + invalid_event_sensitivity.events.front().sensitivity = + static_cast(99); + CHECK(!invalid_event_sensitivity.to_json()); + + auto invalid_manifest_sensitivity = manifest; + invalid_manifest_sensitivity.max_sensitivity = + static_cast(99); + CHECK(!invalid_manifest_sensitivity.to_json()); + + cmux::JournalIngress ingress{ + "screen-detector", + 1, + "plugin.screen-detector.state.changed", + 1, + std::nullopt, + {}, + static_cast(99), + cmux::Json(cmux::Json::Object{}), + std::nullopt, + std::nullopt, + }; + CHECK(!ingress.to_json()); + + cmux::SessionJournalOptions invalid_filter; + invalid_filter.filter.classes.push_back( + static_cast(99)); + CHECK(!invalid_filter.to_params()); + + invalid_filter = {}; + invalid_filter.filter.max_sensitivity = + static_cast(99); + CHECK(!invalid_filter.to_params()); + + invalid_filter = {}; + invalid_filter.start = static_cast(99); + CHECK(!invalid_filter.to_params()); +} + TEST("session auxiliary options reject invalid values before I/O") { auto state = std::make_shared(); auto client = client_for(state); diff --git a/cmux-tui/bindings/examples/rust-agent-screen-detection/ATTRIBUTIONS.md b/cmux-tui/bindings/examples/rust-agent-screen-detection/ATTRIBUTIONS.md new file mode 100644 index 000000000000..2f657445f2d0 --- /dev/null +++ b/cmux-tui/bindings/examples/rust-agent-screen-detection/ATTRIBUTIONS.md @@ -0,0 +1,122 @@ +# Attributions + +The files under `manifests/` are derived from the herdr project: + +* Project: https://github.com/herdrdev/herdr +* Detector source reference revision: `7b675f42af35508eab66ac42fe1598628597a893` +* Pi bundled-launcher correction: `b1ff4582e9688f52ffb943cfa8bee4871ae122e4` +* Manifest snapshot revision: `2290257acb2085ce6842ba5c7e3ca50c3ba64f02` +* First-acquisition OSC retention: `82e6a80eb3ae39fb3d3ebd4d1fed19389767e605` +* Included manifest fixes: Claude MCP elicitation `f807b697353cfa00aa912c7cde4830e863001cf5`, + Claude background-shell state `987b070fbfa187e85009b45cd7e208fc6175ff6a`, + Codex weak-blocker scope `f457cff4f2648eee85d176f8a41861241d4e8428`, and + Copilot background-agent activity `2290257acb2085ce6842ba5c7e3ca50c3ba64f02`. +* License: Apache-2.0, reproduced in `manifests/LICENSE` +* The bundled manifests are refreshed from Herdr `master` at the checked + revision above. This includes the current Letta, Claude, Codex, Kiro, Cline, + Pi, and Grok rules. `github-copilot.toml` remains byte-identical to the + upstream snapshot at commit `2290257acb2085ce6842ba5c7e3ca50c3ba64f02`. +* The latest upstream Grok manifest includes the custom-title and spinner + precedence fix, so cmux no longer carries a divergent local Grok patch. +* Changes: cmux pins the files locally and validates them with its own + bounded manifest engine. It does not use herdr's network update path. + +The checked-in manifests/SHA256SUMS record is verified before bundled +compilation. It detects accidental drift, not a cryptographic release +signature for remote updates. + +The attribution and capability audit was rerun against herdr's agent-surface +revision `987b070fbfa187e85009b45cd7e208fc6175ff6a` after the pinned snapshot. +The package adapts and tests the exact Pi bundled CLI path correction in +`b1ff4582e9688f52ffb943cfa8bee4871ae122e4`. It also vendors and tests the +Claude background-shell state correction in +`987b070fbfa187e85009b45cd7e208fc6175ff6a`. The audit found the +first-acquisition OSC retention fix in +`82e6a80eb3ae39fb3d3ebd4d1fed19389767e605`; `src/detect.rs` ports that policy +with a local revision fence because the generic host API cannot clear OSC +state. It also found foreground group-leader CWD selection in +`3a3792622e59c7f2dc20f9c0236167161e4a5035`; cmux's generic +`foreground_cwd` resource already resolves the group leader, so no +herdr-specific CWD code is copied. Later upstream commits +`207be3c771d281baae6e5fa0fb74be9a056e97a2`, +`5158adab10b6dcfea9370782043392f80fa0643c`, +`5616196942cbe752cc0659b9bd0fb616b2a6ed5c`, +`da8c7b05f9ef7898cfb7494989df8a533b947bb9`, `99c23cd1ea7468bd3661f6483c7105396503b417`, +`0032c3b42751b6da9c5b1a91546b3c1a425d67f1`, +`18e69891dca486d669a584facd80644bb51f54a2`, +`45484aab84430ac2b18c7bbf44aba15f2b039677`, +`e22cba35ef7b405758097a5f9436aae8fb4caaf0`, +`2ae8b91ca5919c26df7ce779b0e9a5dd98b769ae`, and +`94f6d9c0d9bb9cf9ffae99d8bbfb09e9bf2fc9e0` change Windows launch, process +environment, process-job, input handling, recent terminal reads, graphics +ownership, or the application/client shell rendering architecture. The +post-audit multi-client tab-view change +`6c0bb273d5d5405a00985621b17e36f8b4d64609` and the reliable delayed-prompt +change `8633a398e653eee47b375c963996c78a8a14aa48` change host/client and PTY +input behavior, not this detector. These changes are not detector logic and +are not copied. This package has no Windows SDK transport, native process +backend, launch path, or input path, so those files are not copied. A +standalone release must define and test SDK endpoint-generation compatibility +before it promises upgrades across host versions. Recheck these upstream +areas before publishing a Windows package. + +The herdr repository tip checked on 2026-09-02 is +`94f6d9c0d9bb9cf9ffae99d8bbfb09e9bf2fc9e0`. The commits after the +agent-surface revision change client rendering, terminal reads, graphics, +Windows input and worktree handling, or sidebar focus. They do not change +`src/detect` or the manifests. The agent-surface revision is the reproducible +capability-audit pin. + +The original cmux portions of this package are licensed under MIT. The full +text is in `LICENSE-MIT`. The Apache-2.0 text for the derived herdr material is +in `manifests/LICENSE`. + +The detector engine in `src/manifest.rs` is adapted from herdr's +`src/detect/manifest.rs` semantics. It keeps the attribution above and adds +bounded recursion, case-normalized process aliases, and a public plugin +boundary. Its Claude background-shell regression fixtures are adapted from +herdr's `src/detect/manifest/tests.rs` at +`987b070fbfa187e85009b45cd7e208fc6175ff6a`. + +The package does not copy herdr's application, API server, sound assets, or +other multiplexer code. Only the listed detector files and manifests contain +derived herdr material. + +`src/process.rs` adapts herdr's `src/platform/{linux,macos}.rs` and +`src/detect/mod.rs` foreground process-group and wrapper discovery. It adds +bounded traversal and `/proc` streaming, safer path candidates, attached +runtime-mode parsing, positional-argument boundaries, direct shell-script and +shell-word parsing, runtime-specific shell invocation-mode checks, Python +boolean/exit/value option boundaries, attached-versus-separate option handling, +and an explicit Linux child-group fallback. The Python option distinctions are +a local correctness improvement: +`-S` does not consume the script, documented help aliases (`-?`, `-VV`) +terminate, and help/version/hash options cannot expose following tokens as +agent executables. Unsupported attached long options fail closed before they +can consume a later runtime mode flag. Its strict Pi package-entrypoint check +includes herdr's Windows fix +from commit `b1ff4582e9688f52ffb943cfa8bee4871ae122e4`; the check is adapted to +the replaceable manifest catalog. The reference package targets macOS and +Linux because its Rust SDK transport is Unix-only. A Windows publication needs +a Windows-capable SDK transport and process backend; it must not claim a +public-process fallback. + +Local hardening also validates the complete numeric Muse binary version, +rejects empty matchers before they can match every screen, and excludes the +Unicode BRAILLE PATTERN BLANK from Grok's spinner rule. These are cmux-owned +changes, not copied herdr material. + +`src/detect.rs` adapts herdr's `src/detect/mod.rs` and +`src/pane/agent_detection.rs` debounce, identity-edge, miss-confirmation, and +flowing-output signals. The one-second max-evaluation pacer, deterministic +activity-expiry debt, and same-name process-group replacement edge are +manaflow changes. Herdr's first-acquisition OSC retention fix from +`82e6a80eb3ae39fb3d3ebd4d1fed19389767e605` is adapted as a local +output-revision fence for replacement agents; it keeps that generic host +metadata from being attributed across an agent identity edge while preserving +evidence emitted before the first process probe. + +`src/manifest_update.rs` follows herdr's `src/detect/manifest_update.rs` +versioned update and status concepts. +Its explicit-only network policy, HTTPS checks, response bounds, independent +per-agent failures, and atomic cache writes are manaflow changes. diff --git a/cmux-tui/bindings/examples/rust-agent-screen-detection/Cargo.lock b/cmux-tui/bindings/examples/rust-agent-screen-detection/Cargo.lock new file mode 100644 index 000000000000..70522490839f --- /dev/null +++ b/cmux-tui/bindings/examples/rust-agent-screen-detection/Cargo.lock @@ -0,0 +1,365 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cmux-agent-screen-detection" +version = "0.1.0" +dependencies = [ + "cmux-sdk", + "libc", + "regex", + "serde", + "serde_json", + "sha2", + "toml", +] + +[[package]] +name = "cmux-sdk" +version = "1.0.0" +dependencies = [ + "base64", + "getrandom", + "libc", + "serde", + "serde_json", + "sha2", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "indexmap" +version = "2.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/cmux-tui/bindings/examples/rust-agent-screen-detection/Cargo.toml b/cmux-tui/bindings/examples/rust-agent-screen-detection/Cargo.toml new file mode 100644 index 000000000000..36b5dddc589d --- /dev/null +++ b/cmux-tui/bindings/examples/rust-agent-screen-detection/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "cmux-agent-screen-detection" +version = "0.1.0" +edition = "2024" +rust-version = "1.88" +# The package contains manaflow code under MIT and detector material adapted +# from herdr under Apache-2.0. Keep both obligations visible to packagers; +# license texts are LICENSE-MIT and manifests/LICENSE. +license = "MIT AND Apache-2.0" +publish = false + +[dependencies] +cmux-sdk = { version = "=1.0.0", path = "../../rust" } +libc = "0.2" +regex = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +toml = "0.8" + +# This reference package is intentionally independent from the cmux-tui +# workspace. The version is the public SDK contract. The path resolves the +# unreleased SDK in this source tree; a standalone checkout must keep a matching +# SDK checkout at this path or remove `path` after the SDK release. +[workspace] diff --git a/cmux-tui/bindings/examples/rust-agent-screen-detection/LICENSE-MIT b/cmux-tui/bindings/examples/rust-agent-screen-detection/LICENSE-MIT new file mode 100644 index 000000000000..607fd67729a7 --- /dev/null +++ b/cmux-tui/bindings/examples/rust-agent-screen-detection/LICENSE-MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024-present Manaflow, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/cmux-tui/bindings/examples/rust-agent-screen-detection/README.md b/cmux-tui/bindings/examples/rust-agent-screen-detection/README.md new file mode 100644 index 000000000000..7a9ac4414fc5 --- /dev/null +++ b/cmux-tui/bindings/examples/rust-agent-screen-detection/README.md @@ -0,0 +1,154 @@ +# cmux agent screen-detection plugin + +This is a userland reference plugin. cmux core starts and supervises the +process, but this package owns process identification, terminal sampling, +screen rules, pacing, and the herdr-derived manifests. + +Publish this directory as the root of its own Git repository, then install that +repository with: + +```text +cmux agent plugin install +cmux agent plugin use agent-screen-detection +``` + +This reference package is shipped beside the cmux-tui artifact by the build +workflow. An external package can publish the same manifest and run contract +without changing the daemon. + +The source tree is kept in the cmux repository as a reference package. The +plugin manager expects `cmux-plugin.toml` at the root of the repository that it +clones, so the parent cmux repository is not a valid install URL for this +example. + +The plugin uses the public Rust SDK. This source-tree reference pins the +matching `cmux-sdk` release as its contract and uses a path dependency while +that SDK is unreleased in this checkout. A standalone plugin repository must +either keep a matching SDK checkout at the same relative path or remove the +`path` field after the SDK release is available. Its build command uses Cargo's +`--locked` mode, so installation does not rewrite the checked-in dependency +graph. It registers a namespaced journal +producer, reads terminal process metadata and viewport text, and appends +`cmux.agent-plugin.v1` events. A different implementation can use Python, +another language, or a different ruleset without a cmux core change. + +The package also provides a read-only live diagnostic that follows the same +identity and manifest path as the scanner: + +```text +CMUX_TUI_SOCKET=/tmp/cmux-debug-demo.sock \ +CMUX_TUI_SESSION_ID=demo \ +./cmux-agent-screen-detection explain --live term_0123456789abcdef0123456789abcdef +``` + +The target is an exact terminal ID or an exact terminal title. Duplicate titles +are rejected and the error lists the IDs to use. The command returns the +matched rule, evaluated evidence, process identity source, screen revision, +and manifest provenance. It never writes to the journal or terminal. Its +one-shot OSC metadata freshness is reported as `one_shot_unknown`; the +continuous scanner applies the stronger revision fence between process edges. + +The supervisor must provide a `CMUX_PLUGIN_ID` that matches +`[a-z0-9][a-z0-9_-]*`, is at most 64 bytes, and is not `cmux_agent`. The +executable exits before connecting when that namespace is absent or invalid; it +never invents a shared producer ID. The manager generates and persists this +value for the installed package, while a hand-written configuration must set +`agents.plugin.id` explicitly. + +Process identity uses executable and wrapper arguments before reading +`CMUX_AGENT` or `HERDR_AGENT` from the host process environment. The hint is a +fallback for wrappers that hide their executable, which keeps normal scans +cheap and avoids treating a globally inherited hint as stronger than visible +process evidence. Runtime parsing handles attached eval and module flags and +stops at the first positional script. Shell parsing handles direct script +arguments and escaped command words. Command flags follow the grammar of the +specific shell, including fish's separate and inline `--command` forms, while +value-taking, no-exec, exit-only, and unknown shell modes fail closed. For +runtimes that document an attached form, the option value stays with its +option, so it cannot hide the following script. Unsupported spellings fail +closed. A package-shaped path inside eval text cannot claim an agent identity. + +When cmux supervises the process, the scanner copies +`CMUX_PLUGIN_GENERATION` into each event. This lets the core retire an old +process generation without removing observations from a replacement process. + +The manifests are derived from herdr at manifest snapshot commit +`2290257acb2085ce6842ba5c7e3ca50c3ba64f02` under Apache-2.0. The adapted +detector engine follows source reference commit +`7b675f42af35508eab66ac42fe1598628597a893`. The Claude manifest includes the +upstream background-shell correction from +`987b070fbfa187e85009b45cd7e208fc6175ff6a`. The Copilot manifest includes +the upstream background-agent rule at version `2026.08.29.1` from the pinned +snapshot. See +`manifests/LICENSE`, `manifests/README.md`, and `ATTRIBUTIONS.md`. The +Manaflow portions use MIT; the package includes that text in `LICENSE-MIT`. +The checked-in `manifests/SHA256SUMS` record is verified before the bundled +rules compile. It catches accidental edits to vendored bytes. It is not a +release signature, so an explicit remote update still needs signed catalog +verification before remote content is trusted. + +The host gives each plugin generation an owned process boundary. Keep any +helper processes in the inherited Unix process group, or they may outlive the +plugin if they call `setsid`. + +The generation fence protects journal state when a stopped process writes late. +It does not remove the normal Unix process-group identifier reuse race, so the +host treats process identity as authoritative only when the platform reports a +current foreground group. The scanner commits an edge only after journal +admission. A transport result with an uncertain outcome keeps the exact event +envelope and idempotency key, then retries it with bounded backoff; a definite +admission failure rolls the in-memory edge back so a later scan can try again. +A userland plugin must use its own generation and idempotency keys for every +event. + +The reference package currently targets macOS and Linux. Its Rust SDK +transport is Unix-only, and its native process backends cover macOS and Linux. +A Windows publication needs a Windows-capable SDK transport and process +backend. Do not list Windows in `cmux-plugin.toml` until those pieces exist. + +Manifest loading is bounded before parsing: a set can contain at most 256 +active manifests, a cache or override directory can contain at most 512 +entries, and each manifest is limited to 256 KiB. Rule and matcher limits are +also enforced by the manifest validator. + +The selected plugin configuration is limited to 4 MiB and registry metadata to +16 KiB before JSON parsing. On Linux, process files are streamed through a +128 KiB limit before parsing; an oversized file fails closed and +leaves name-based detection available when possible. + +The plugin manager stages the artifact and selected configuration with a local +rollback guard. They are separate filesystem transactions, so a power loss +between the two writes can leave a mismatched old/new pair. Startup validation +and a later explicit update repair that state. + +The manager bounds one installed-plugin root to 256 filesystem entries. This +includes hidden transaction files and registry metadata, so stale install debris +cannot turn a list or selector operation into an unbounded scan. Remove stale +entries before retrying an operation that reports this limit. + +Git install and update sources are passed to `git` as process arguments. The +manager rejects HTTP and HTTPS user information, query strings, and fragments +to keep passwords and tokens out of process listings. Use a Git credential +helper or an SSH key for private repositories. SSH user names, SCP-like sources, +and local paths remain supported. Git metadata output is capped at 16 KiB before +the manager parses it; overflow is treated as unavailable. + +The daemon keeps OSC title and progress as generic terminal metadata and may +retain them across a process change. The scanner records the output revision at +each identity edge and ignores those fields until a later revision proves that +the new process produced output. Older daemons that never expose revisions use +the startup-grace compatibility path. If a host has supplied a generation +anchor and later omits its revision, the scanner fails closed until a newer +revision is available. A local screen hash may schedule a read when the host +does not expose a revision, but it is never used as a generation fence. Exit +fencing uses only the host revision supplied for that exit; an exit without an +anchor keeps the old-host compatibility path rather than comparing unrelated +tokens. + +On Linux, hosts that do not expose a controlling-terminal foreground group can +opt in to herdr-compatible child-group inference with +`CMUX_AGENT_PROCESS_DETECTION=child-groups` (the legacy +`HERDR_PROCESS_DETECTION=child-groups` name is also accepted). The mode picks +the newest direct child process group and is disabled by default because the +kernel cannot prove which child is foreground in that situation. The scanner +fails closed after 64 direct-child probes. diff --git a/cmux-tui/bindings/examples/rust-agent-screen-detection/cmux-plugin.toml b/cmux-tui/bindings/examples/rust-agent-screen-detection/cmux-plugin.toml new file mode 100644 index 000000000000..5f9ceac7c5f8 --- /dev/null +++ b/cmux-tui/bindings/examples/rust-agent-screen-detection/cmux-plugin.toml @@ -0,0 +1,12 @@ +[plugin] +name = "agent-screen-detection" +kind = "agent" +version = "0.1.0" +description = "Screen-based lifecycle detection for terminal coding agents" +platforms = ["macos", "linux"] + +[run] +command = ["target/release/cmux-agent-screen-detection"] + +[build] +command = ["cargo", "build", "--release", "--locked"] diff --git a/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/LICENSE b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/LICENSE new file mode 100644 index 000000000000..261eeb9e9f8b --- /dev/null +++ b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/README.md b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/README.md new file mode 100644 index 000000000000..21a2ce8097d4 --- /dev/null +++ b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/README.md @@ -0,0 +1,35 @@ +# herdr agent-detection manifests + +Nineteen of these TOML files are unchanged from +https://github.com/herdrdev/herdr (Apache-2.0, see LICENSE), at manifest +snapshot commit `2290257acb2085ce6842ba5c7e3ca50c3ba64f02`, path +`src/detect/manifests/`. `claude.toml` is byte-identical to upstream commit +`987b070fbfa187e85009b45cd7e208fc6175ff6a`, which stops a background shell +from masking an idle prompt or a permission blocker. `grok.toml` is based on +the snapshot file and carries one cmux correction: idle OSC progress wins over +a generic custom title, while an explicit spinner still wins over retained idle metadata. +The local patch version is `2026.07.16.2.1`. `github-copilot.toml` is +byte-identical to the upstream snapshot, including its background-agent +waiting row, at version `2026.08.29.1`. The cmux package adapts their +semantics in the separately attributed Rust engine. Do not fetch herdr's +manifest update endpoint. Refresh the 19 unchanged files from the exact +snapshot commit, take Claude from its stated upstream correction commit, and +reapply the Grok local correction when changing this pin. + +SHA256SUMS records the bytes embedded by the plugin. The provenance test +checks this record before the bundled set is compiled, so an accidental edit +cannot silently change a vendored rule. The record is not a release signature: +remote updates still need authenticated, signed catalog data before they can be +treated as trusted. + +The capability audit was rerun against herdr's agent-surface revision +`987b070fbfa187e85009b45cd7e208fc6175ff6a`. It found the exact Pi bundled CLI +path correction from `b1ff4582e9688f52ffb943cfa8bee4871ae122e4` and the Claude +background-shell manifest correction from `987b070fbfa187e85009b45cd7e208fc6175ff6a`. +These two corrections are ported and tested. The multi-client tab-view, delayed-prompt, +recent-read, graphics, Windows input, and sidebar-focus commits are host/client +work outside this manifest package. The package does not claim parity with +that transport or input work. The repository tip checked on 2026-09-02 is +`94f6d9c0d9bb9cf9ffae99d8bbfb09e9bf2fc9e0`; commits after the agent-surface +revision do not change `src/detect` or the manifests. The agent-surface +revision is the reproducible capability-audit pin. diff --git a/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/SHA256SUMS b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/SHA256SUMS new file mode 100644 index 000000000000..539ff9856d6c --- /dev/null +++ b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/SHA256SUMS @@ -0,0 +1,22 @@ +b5806b0dd21e2f5e752d0eac7f084d5ce2e3f275638cfd1234c42cc88152dc8c amp.toml +11300b853130d037eb2c57d9c4b897893cc1f0a876e12eb54ff1b216177db9d7 antigravity.toml +038d0aa23fee3f9b39cb3c9ca117d0f95b0b3a5873cf0f38284ccbac279c9664 claude.toml +75fe33ec735c59638da8d62e16bddd257d9959e692edb83b1f11b7f28057866a cline.toml +bbac3e4a3d65d8d8440bd8436bed217bbbba077b22843786ada633661f4e0866 codex.toml +753b1f7f632d42fa21139c2767ecbb5e1078748aba2e59407ac4932d3ce36ad7 cursor.toml +250c9cea1d60bdb965dc6056f3066b785e941d60242756fbaca73b57ca6b0f85 devin.toml +d37e7c464177c0e8f3edce8d4fabc4bcc7a1874edf2c4c87a2f927888cf69ce9 droid.toml +d7013b5e772852ecc595febf964f00b4f9edcbc6047a2a5421613a154d92520d gemini.toml +b70c652584326a1a98475a5fcef16207dee9f23b65f8e78300f4fc2bb578cb11 github-copilot.toml +0f31b111144900b02f303577d27587f72d58d8c505185a682bd7887f822316ee grok.toml +533d21b65dea3a0c60c25d0475c9c900d28a5712b6392669a7788f75de2b6e85 hermes.toml +70f0ba4e58bc141fe69d7024013f973cd16e8616393079318914d70afeefef3b kilo.toml +ede08c0d2d5024f7606dc0a1b2f7a9c6a0ebb99f3b6c58ca6757049856d06e05 kimi.toml +c8990f3c9d4810995be97e8df29836411e37d90f6ac9d9303f505787b504806b kiro.toml +205b8c135584c86f9f529aa2d061109b5129085cc5c3124c6dbfe0e78fcdba43 letta.toml +3b392170ee3082051266509f575a4640bde8d693b2f37ecf52157a641bc75b28 maki.toml +b69c4d87fa9c19e3e6453b706fbe39c98a8b33ffbaa48e8cd5ae6751e9615074 muse.toml +faa82aed2d76ad856528caae04232894594b74cc903aed3e522b3e725c5f2c95 opencode.toml +57469b82e4239bf93559ed5d400c9d9f86a9e64d5def728b7d4bb398be7659ca pi.toml +2089f70fc78c6576fd7128a00f4e7eedb85be81bde9ed73301b3b88000048961 qodercli.toml +b27aa456af228e8a4ceac74f0dd431c33b21473b69314fc672a7fba474e2a7fe qwen.toml diff --git a/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/amp.toml b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/amp.toml new file mode 100644 index 000000000000..1fc5dfa6bbab --- /dev/null +++ b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/amp.toml @@ -0,0 +1,65 @@ +id = "amp" +version = "2026.07.09.1" +min_engine_version = 2 +updated_at = "2026-07-09T00:00:00Z" +aliases = ["amp-local"] + +[[rules]] +id = "osc_title_plugin_confirmation_blocked" +state = "blocked" +priority = 1100 +region = "osc_title" +visible_blocker = true +contains = ["Plugin confirmation needed"] + +[[rules]] +id = "osc_title_working" +state = "working" +priority = 1050 +region = "osc_title" +visible_working = true +regex = ['^[\x{2800}-\x{28FF}] '] + +[[rules]] +id = "approval_footer" +state = "blocked" +priority = 300 +region = "whole_recent" +visible_blocker = true +any = [ + { contains = ["waiting for approval"] }, + { contains = ["invoke tool"] }, + { contains = ["run this command?"] }, + { contains = ["allow editing file:"] }, + { contains = ["allow creating file:"] }, + { contains = ["confirm tool call"] }, + { contains = ["approve"], any = [{ contains = ["allow all for this session"] }, { contains = ["allow all for every session"] }, { contains = ["allow file for every session"] }, { contains = ["deny with feedback"] }] }, +] + +[[rules]] +id = "status_footer_working" +state = "working" +priority = 200 +region = "bottom_non_empty_lines(5)" +visible_working = true +line_regex = ['(?i)^\s*╰\s+\S+\s+(thinking|streaming|running tools|waiting)\s+─'] + +[[rules]] +id = "esc_cancel_working" +state = "working" +priority = 100 +region = "whole_recent" +visible_working = true +contains = ["esc to cancel"] + +[[rules]] +id = "osc_title_idle" +state = "idle" +priority = 50 +region = "osc_title" +visible_idle = true +contains = [" - amp - "] +not = [ + { regex = ['^[\x{2800}-\x{28FF}] '] }, + { contains = ["Plugin confirmation needed"] }, +] diff --git a/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/antigravity.toml b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/antigravity.toml new file mode 100644 index 000000000000..8b9bcfcf4aa5 --- /dev/null +++ b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/antigravity.toml @@ -0,0 +1,33 @@ +id = "agy" +version = "2026.06.24.1" +min_engine_version = 1 +updated_at = "2026-06-24T00:00:00Z" +aliases = ["antigravity", "antigravity-cli"] + +[[rules]] +id = "permission_prompt" +state = "blocked" +priority = 300 +region = "whole_recent" +visible_blocker = true +contains = ["requesting permission for:"] +any = [ + { contains = ["do you want to proceed?"] }, + { contains = ["tab amend", "edit command"] }, +] + +[[rules]] +id = "spinner_working" +state = "working" +priority = 100 +region = "whole_recent" +visible_working = true +line_regex = ['^\s*[\u2800-\u28FF]+\s+\p{Alphabetic}+\w*ing\b'] + +[[rules]] +id = "background_tasks_working" +state = "working" +priority = 90 +region = "bottom_non_empty_lines(5)" +visible_working = true +line_regex = ['(?i)·\s*[1-9][0-9]*\s+task'] diff --git a/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/claude.toml b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/claude.toml new file mode 100644 index 000000000000..0c7bdcf252c2 --- /dev/null +++ b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/claude.toml @@ -0,0 +1,230 @@ +id = "claude" +version = "2026.09.11.1" +min_engine_version = 2 +updated_at = "2026-09-11T00:00:00Z" +aliases = ["claude-code"] + +[[rules]] +id = "osc_title_working" +state = "working" +priority = 1100 +region = "osc_title" +visible_working = true +# Braille covers <= 2.1.227; half-circles are the 2.1.228 busy spinner. +regex = ['^[\x{2800}-\x{28FF}\x{25D0}-\x{25D3}] '] + +[[rules]] +id = "live_turn_working" +state = "working" +priority = 970 +region = "bottom_non_empty_lines(12)" +visible_working = true +any = [ + { line_regex = ['^\s*[⏸⏵].*esc to interrupt(?:\s|·|$)'] }, + { line_regex = ['^\s*[\x{002A}\x{00B7}\x{2722}\x{2733}\x{2736}\x{273B}\x{273D}]\s+\S.*…(?:\s+\(\d+[smh](?:\s|·)|\s*$)'] }, +] + +[[rules]] +id = "background_agents_working" +state = "working" +priority = 965 +region = "last_non_empty_above_prompt_box" +visible_working = true +line_regex = ['^\s*[\x{002A}\x{00B7}\x{2722}\x{2736}\x{273B}\x{273D}]\s+Waiting for [1-9]\d* background agents? to finish\s*$'] + +[[rules]] +id = "background_mcp_task_working" +state = "working" +priority = 965 +region = "bottom_non_empty_lines(12)" +visible_working = true +# Claude renders activity summaries at column zero; wrapped continuations are indented. +# Keeping that shape prevents user prompt text from impersonating this signal. +regex = ['(?m)^[\x{002A}\x{00B7}\x{2722}\x{2736}\x{273B}\x{273D}][ \t]+\S[^\n]*?(?:\n[ \t]+[^\n]*?){0,3}·(?:[ \t]+|\n[ \t]*)[1-9]\d*(?:[ \t]+|\n[ \t]*)MCP(?:[ \t]+|\n[ \t]*)tasks?(?:[ \t]+|\n[ \t]*)still(?:[ \t]+|\n[ \t]*)running[ \t]*$'] +not = [ + { contains = ["do you want to proceed?"] }, + { contains = ["esc to cancel"] }, + { contains = ["waiting for permission"] }, + { contains = ["do you want to allow this connection?"] }, + { contains = ["tab to amend"] }, + { contains = ["ctrl+e to explain"] }, +] + +[[rules]] +id = "btw_overlay_working" +state = "working" +priority = 975 +region = "bottom_non_empty_lines(5)" +visible_working = true +line_regex = [ + '^\s*/btw(?:\s|$)', + '(?i)esc to close\s*$', +] + +[[rules]] +id = "transcript_viewer" +state = "unknown" +priority = 1000 +region = "bottom_non_empty_lines(3)" +skip_state_update = true +contains = ["showing detailed transcript"] +any = [ + { contains = ["ctrl+o", "to toggle"] }, + { contains = ["ctrl+e", "show all"] }, + { contains = ["ctrl+e", "collapse"] }, + { contains = ["↑↓ scroll"] }, + { contains = ["? for shortcuts"] }, +] + +[[rules]] +id = "live_blocked_form" +state = "blocked" +priority = 980 +region = "after_last_horizontal_rule" +visible_blocker = true +contains = ["esc to cancel"] +any = [ + { contains = ["enter to confirm"] }, + { contains = ["enter to select"], any = [ + { contains = ["tab/arrow keys to navigate"] }, + { contains = ["arrow keys to navigate"] }, + { contains = ["arrows to navigate"] }, + { contains = ["↑/↓ to navigate"] }, + { contains = ["↑↓ to navigate"] }, + ] }, +] + +[[rules]] +id = "dynamic_workflow_prompt" +state = "blocked" +priority = 980 +region = "whole_recent" +visible_blocker = true +contains = ["run a dynamic workflow?", "esc to cancel"] + +[[rules]] +id = "mcp_elicitation_prompt" +state = "blocked" +priority = 980 +region = "whole_recent" +visible_blocker = true +# MCP elicitation dialogs (elicitation/create) show Accept/Decline controls +# with an "Esc to cancel" footer but no Enter hint, so live_blocked_form +# cannot see them (issue #3283). Gate on the invariant header line, the +# Accept/Decline control line, and the cancel footer. +contains = ["esc to cancel"] +line_regex = ['(?i)^\s*MCP server ["\x{201C}].+["\x{201D}] requests your input\s*$'] +all = [ + { any = [ + { line_regex = ['^\s*\x{276F}?\s*Accept\b'] }, + { line_regex = ['^\s*\x{276F}?\s*Decline\b'] }, + ] }, +] + +[[rules]] +id = "live_prompt_box" +state = "idle" +priority = 950 +region = "prompt_box_body" +visible_idle = true +line_regex = ['^\s*❯'] +not = [ + { contains = ["enter to select"] }, + { contains = ["esc to cancel"] }, + { contains = ["tab/arrow keys"] }, + { contains = ["arrow keys to navigate"] }, + { contains = ["↑/↓ to navigate"] }, +] + +[[rules]] +id = "model_picker_menu" +state = "unknown" +priority = 900 +region = "whole_recent" +skip_state_update = true +contains = ["select model", "enter to set as default", "esc to cancel"] +not = [ + { contains = ["do you want to proceed?"] }, + { contains = ["enter to select"] }, +] + +[[rules]] +id = "bash_permission_prompt" +state = "blocked" +priority = 850 +region = "whole_recent" +visible_blocker = true +contains = ["do you want to proceed?"] +any = [ + { contains = ["bash command"] }, + { contains = ["bash("] }, + { contains = ["contains expansion"] }, + { contains = ["tab to amend"] }, + { contains = ["ctrl+e to explain"] }, +] +# Claude marks the selected option with "❯", so every option branch has to allow +# that prefix. Numbered branches that omit it only match options the cursor has +# moved away from, which left the resting layout below unmatched here (#2650): +# ❯ 1. Yes / 2. Yes, and don't ask again for: / 3. No +# Cover both the two-option and "don't ask again" three-option shapes so this +# rule, not the narrower generic_permission_prompt, claims Bash approvals. +all = [ + { any = [ + { line_regex = ['(?i)^\s*❯?\s*yes\b'] }, + { line_regex = ['(?i)^\s*❯?\s*1\.\s*yes\b'] }, + { line_regex = ['(?i)^\s*❯?\s*2\.\s*yes\b'] }, + { line_regex = ['(?i)^\s*❯?\s*2\.\s*no\b'] }, + { line_regex = ['(?i)^\s*❯?\s*3\.\s*no\b'] }, + ] }, +] + +[[rules]] +id = "generic_permission_prompt" +state = "blocked" +priority = 840 +region = "after_last_horizontal_rule" +visible_blocker = true +contains = ["do you want to proceed?", "esc to cancel"] +all = [ + { any = [ + { line_regex = ['(?i)^\s*❯?\s*1\.\s*yes\b'] }, + { line_regex = ['(?i)^\s*2\.\s*yes\b'] }, + { line_regex = ['(?i)^\s*2\.\s*no\b'] }, + { line_regex = ['(?i)^\s*3\.\s*no\b'] }, + ] }, +] + +[[rules]] +id = "legacy_no_prompt_blocker" +state = "blocked" +priority = 300 +region = "whole_recent" +any = [ + { contains = ["do you want to"], any = [{ contains = ["yes"] }, { contains = ["❯"] }] }, + { contains = ["would you like to"], any = [{ contains = ["yes"] }, { contains = ["❯"] }] }, + { contains = ["waiting for permission"] }, + { contains = ["do you want to allow this connection?"] }, + { contains = ["tab to amend"] }, + { contains = ["ctrl+e to explain"] }, + { contains = ["do you want to proceed?", "esc to cancel"] }, + { contains = ["review your answers"] }, + { contains = ["skip interview and plan immediately"] }, +] +not = [ + { regex = ['(?m)^\s*❯\s*$'] }, +] + +[[rules]] +id = "osc_title_idle" +state = "idle" +priority = 250 +region = "osc_title" +visible_idle = true +regex = ['^\x{2733} '] + +[[rules]] +id = "osc_progress_idle" +state = "idle" +priority = 250 +region = "osc_progress" +regex = ['^4;0'] diff --git a/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/cline.toml b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/cline.toml new file mode 100644 index 000000000000..2d3140875e40 --- /dev/null +++ b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/cline.toml @@ -0,0 +1,63 @@ +id = "cline" +version = "2026.09.11.1" +min_engine_version = 1 +updated_at = "2026-09-11T00:00:00Z" + +[[rules]] +id = "tool_permission" +state = "blocked" +priority = 300 +region = "whole_recent" +visible_blocker = true +any = [ + { contains = ["let cline use this tool"] }, + { contains = ["[act mode]", "execute command?", "yes"] }, + { contains = ["[act mode]", "use this tool?", "yes"] }, + { contains = ["[plan mode]", "execute command?", "yes"] }, + { contains = ["[plan mode]", "use this tool?", "yes"] }, +] + +[[rules]] +id = "inline_tool_permission" +state = "blocked" +priority = 300 +region = "bottom_non_empty_lines(16)" +visible_blocker = true +contains = ["Cline needs permission", "Approve tool call?", "[y] Approve", "[n] Deny"] + +[[rules]] +id = "inline_question" +state = "blocked" +priority = 300 +region = "bottom_non_empty_lines(24)" +visible_blocker = true +line_regex = ['^\s*Cline is asking a question\s*$', '^\s*>\s+\S'] +contains = ["(Tab)", "Shift+Tab"] + +[[rules]] +id = "active_turn" +state = "working" +priority = 200 +region = "bottom_non_empty_lines(20)" +visible_working = true +any = [ + { line_regex = ['^\s*[\x{2801}-\x{28FF}]\s+\S'] }, + { contains = ["Thinking... (esc to cancel)"] }, +] + +[[rules]] +id = "composer_idle" +state = "idle" +priority = 100 +region = "bottom_non_empty_lines(12)" +visible_idle = true +line_regex = ['^\s*❯(?:\s.*)?$'] +contains = ["─", "(Tab)", "Shift+Tab"] + +[[rules]] +id = "default_cline_working" +state = "working" +priority = -10 +region = "whole_recent" +visible_working = true +regex = ['(?s).+'] diff --git a/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/codex.toml b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/codex.toml new file mode 100644 index 000000000000..8ceef3146de5 --- /dev/null +++ b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/codex.toml @@ -0,0 +1,105 @@ +id = "codex" +version = "2026.09.15.1" +min_engine_version = 3 +updated_at = "2026-09-15T00:00:00Z" + +[[rules]] +id = "osc_title_blocked" +state = "blocked" +priority = 1100 +region = "osc_title" +visible_blocker = true +contains = ["Action Required"] + +[[rules]] +id = "osc_title_working" +state = "working" +priority = 1050 +region = "osc_title" +visible_working = true +regex = ['(?:^| )[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏](?: |$)'] + +[[rules]] +id = "transcript_viewer" +state = "unknown" +priority = 1000 +region = "after_last_prompt_marker" +skip_state_update = true +contains = ["↑/↓ to scroll", "pgup/pgdn to", "home/end to jump", "q to quit"] +any = [ + { contains = ["esc to edit prev"] }, + { contains = ["esc/← to edit prev"] }, +] + +[[rules]] +id = "trust_directory" +state = "blocked" +priority = 950 +region = "top_non_empty_lines(20)" +visible_blocker = true +all = [ + { regex = ['\A> You are in [^\r\n]+(?:\r?\n|$)'] }, + { regex = ['(?s)Do\s+you\s+trust\s+the\s+contents\s+of\s+this\s+directory\?'] }, +] + +[[rules]] +id = "startup_update" +state = "blocked" +priority = 950 +region = "bottom_non_empty_lines(20)" +visible_blocker = true +contains = ["Update available!", "Update now"] +regex = ['Skip\s+until\s+next\s+version', 'Press enter to continue\s*\z'] + +[[rules]] +id = "live_strong_blocker" +state = "blocked" +priority = 900 +region = "after_last_prompt_marker" +visible_blocker = true +any = [ + { contains = ["press enter to confirm or esc to cancel"] }, + { contains = ["enter to submit answer"] }, + { contains = ["enter to submit all"] }, + { contains = ["allow command?"] }, +] + +[[rules]] +id = "weak_blocker" +state = "blocked" +priority = 600 +region = "whole_recent_without_current_prompt_marker" +# Sparkles can replace the space after ›. A later response marker makes that prompt stale. +not = [{ regex = ['(?m)^›[⠁⠂⠄⠈⠐⠠⡀⢀][^\n]*(?:\n(?:[^•■✗✓\n][^\n]*)?)*\z'] }] +any = [ + { contains = ["[y/n]"] }, + { contains = ["yes (y)"] }, + { contains = ["do you want to"], any = [{ contains = ["yes"] }, { contains = ["❯"] }] }, + { contains = ["would you like to"], any = [{ contains = ["yes"] }, { contains = ["❯"] }] }, +] + +[[rules]] +id = "screen_working_fallback" +state = "working" +priority = 500 +region = "before_current_prompt_marker" +visible_working = true +# Support animated and reduced-motion status, including dynamic activity labels. +# The interrupt hint can be remapped, unbound, or hidden, and queued inputs can +# sit below the status. Require the live timer suffix with no later response. +any = [{ contains = [" to interrupt)"] }, { contains = ["s)"] }] +regex = ['(?m)^(?:[•◦][ \t]+)?[^\s›•◦■✗✓─][^\r\n]* \((?:[0-9]+[hm] )*[0-9]+s(?: • [^\r\n]+? to interrupt)?\)(?: · [^\r\n]*)?(?:\r?\n(?:[^•◦›■✗✓─\r\n][^\r\n]*|•[ \t]+(?:Queued\s+follow-up\s+inputs|Messages\s+to\s+be\s+submitted\s+after\s+next\s+tool\s+call(?:\s+\(press\s+[^\r\n]+?\s+to\s+interrupt\s+and\s+send\s+immediately\))?|Messages\s+to\s+be\s+submitted\s+at\s+end\s+of\s+turn)|›[⠁⠂⠄⠈⠐⠠⡀⢀][^\r\n]*)?)*\s*\z'] +# A failed reconnect keeps its final elapsed timer but is no longer working. +not = [{ line_regex = ['^(?:[•◦][ \t]+)?Reconnect failed — check the endpoint, then relaunch \([0-9hms ]+\)$'] }] + +[[rules]] +id = "osc_title_idle" +state = "idle" +priority = 100 +region = "osc_title" +visible_idle = true +regex = ['\S'] +not = [ + { regex = ['(?:^| )[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏](?: |$)'] }, + { contains = ["Action Required"] }, +] diff --git a/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/cursor.toml b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/cursor.toml new file mode 100644 index 000000000000..ee03e6db9d75 --- /dev/null +++ b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/cursor.toml @@ -0,0 +1,57 @@ +id = "cursor" +version = "2026.08.03.1" +min_engine_version = 1 +updated_at = "2026-08-03T01:08:04Z" +aliases = ["cursor-agent"] + +[[rules]] +id = "write_file_approval" +state = "blocked" +priority = 320 +region = "bottom_non_empty_lines(8)" +visible_blocker = true +contains = ["write to this file?", "proceed (y)"] +any = [ + { contains = ["reject & propose changes"] }, + { contains = ["esc or n or p"] }, + { contains = ["add write("] }, +] + +[[rules]] +id = "approval_prompt" +state = "blocked" +priority = 300 +region = "whole_recent" +visible_blocker = true +any = [ + { contains = ["waiting for approval", "run this command?"], any = [{ contains = ["run (once) (y)"] }, { contains = ["skip (esc or n)"] }] }, + { contains = ["(y) (enter)"] }, + { line_regex = ['(?i)^\s*allow .*\(y\)'] }, + { contains = ["keep (n)"] }, + { contains = ["skip (esc or n)"] }, + { line_regex = ['(?i)^\s*(?:→\s*)?run .*\(y\)'] }, +] + +[[rules]] +id = "stop_hint_working" +state = "working" +priority = 100 +region = "bottom_non_empty_lines(6)" +visible_working = true +contains = ["ctrl+c to stop"] + +[[rules]] +id = "background_task_status_working" +state = "working" +priority = 95 +region = "bottom_non_empty_lines(5)" +visible_working = true +line_regex = ['(?i)\b[1-9][0-9]*\s+background\s+tasks?\b'] + +[[rules]] +id = "spinner_working" +state = "working" +priority = 90 +region = "bottom_non_empty_lines(8)" +visible_working = true +line_regex = ['^\s*(⬡|⬢|[\u2800-\u28FF]+)\s+\p{Alphabetic}+\w*ing\b'] diff --git a/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/devin.toml b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/devin.toml new file mode 100644 index 000000000000..c9564f7cc134 --- /dev/null +++ b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/devin.toml @@ -0,0 +1,86 @@ +id = "devin" +version = "2026.06.15.1" +min_engine_version = 1 +updated_at = "2026-06-15T00:00:00Z" +aliases = ["devin-cli", "devin cli"] + +[[rules]] +id = "workspace_trust_prompt" +state = "blocked" +priority = 300 +region = "bottom_non_empty_lines(8)" +visible_blocker = true +contains = [ + "do you trust the authors of this directory?", + "with untrusted content.", + "yes, trust ", +] + +[[rules]] +id = "permission_prompt" +state = "blocked" +priority = 290 +region = "bottom_non_empty_lines(8)" +visible_blocker = true +contains = ["approve once", "select", "confirm", "esc cancel"] + +[[rules]] +id = "running_tools_footer" +state = "working" +priority = 200 +region = "bottom_non_empty_lines(8)" +visible_working = true +contains = ["running tools", "esc to interrupt"] +not = [ + { contains = ["approve once", "esc cancel"] }, +] + +[[rules]] +id = "guide_while_working" +state = "working" +priority = 190 +region = "bottom_non_empty_lines(6)" +visible_working = true +contains = ["guide devin while it works"] +not = [ + { contains = ["approve once", "esc cancel"] }, +] + +[[rules]] +id = "tool_reading_timeout" +state = "working" +priority = 180 +region = "bottom_non_empty_lines(8)" +visible_working = true +contains = ["reading shell ", "timeout:"] +not = [ + { contains = ["approve once", "esc cancel"] }, +] + +[[rules]] +id = "welcome_prompt_footer" +state = "idle" +priority = 120 +region = "bottom_non_empty_lines(8)" +visible_idle = true +contains = ["ask devin to build", "features, fix bugs", "your code"] +line_regex = ['^\s*❭ Ask Devin to build'] +not = [ + { contains = ["approve once", "esc cancel"] }, + { contains = ["running tools", "esc to interrupt"] }, + { contains = ["guide devin while it works"] }, +] + +[[rules]] +id = "live_prompt_footer" +state = "idle" +priority = 100 +region = "bottom_non_empty_lines(6)" +visible_idle = true +contains = ["context:"] +line_regex = ['^\s*❭'] +not = [ + { contains = ["approve once", "esc cancel"] }, + { contains = ["running tools", "esc to interrupt"] }, + { contains = ["guide devin while it works"] }, +] diff --git a/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/droid.toml b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/droid.toml new file mode 100644 index 000000000000..c41d71b43bfd --- /dev/null +++ b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/droid.toml @@ -0,0 +1,48 @@ +id = "droid" +version = "2026.06.10.1" +min_engine_version = 1 +updated_at = "2026-06-10T00:00:00Z" + +[[rules]] +id = "execute_selection_blocker" +state = "blocked" +priority = 300 +region = "whole_recent" +visible_blocker = true +contains = ["enter to select", "esc to cancel"] +any = [ + { contains = ["↑↓ to navigate"] }, + { contains = ["use ↑↓ to navigate"] }, +] +all = [ + { any = [{ contains = ["> yes, allow"] }, { contains = ["> no, cancel"] }] }, +] + +[[rules]] +id = "selection_menu_blocker" +state = "blocked" +priority = 290 +region = "bottom_non_empty_lines(8)" +visible_blocker = true +contains = ["enter select", "esc cancel"] +any = [ + { contains = ["↑/↓ navigate"] }, + { contains = ["↑↓ navigate"] }, +] + +[[rules]] +id = "spinner_stop_working" +state = "working" +priority = 110 +region = "whole_recent" +visible_working = true +contains = ["esc to stop"] +line_regex = ['^\s*[\u2800-\u28FF]'] + +[[rules]] +id = "stop_hint_working" +state = "working" +priority = 100 +region = "whole_recent" +visible_working = true +contains = ["esc to stop"] diff --git a/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/gemini.toml b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/gemini.toml new file mode 100644 index 000000000000..9d7a28e112d6 --- /dev/null +++ b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/gemini.toml @@ -0,0 +1,25 @@ +id = "gemini" +version = "2026.06.10.1" +min_engine_version = 1 +updated_at = "2026-06-10T00:00:00Z" + +[[rules]] +id = "apply_or_allow_change" +state = "blocked" +priority = 300 +region = "whole_recent" +visible_blocker = true +any = [ + { contains = ["│ Apply this change"] }, + { contains = ["│ Allow execution"] }, + { all = [{ contains = ["yes"] }, { any = [{ contains = ["waiting for user confirmation"] }, { contains = ["│ Do you want to proceed"] }, { contains = ["do you want to proceed?"] }] }] }, + { line_regex = ['(?i)^\s*❯.*(yes|allow)'] }, +] + +[[rules]] +id = "esc_cancel_working" +state = "working" +priority = 100 +region = "whole_recent" +visible_working = true +contains = ["esc to cancel"] diff --git a/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/github-copilot.toml b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/github-copilot.toml new file mode 100644 index 000000000000..57fafdd874ed --- /dev/null +++ b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/github-copilot.toml @@ -0,0 +1,45 @@ +id = "copilot" +version = "2026.08.29.1" +min_engine_version = 1 +updated_at = "2026-08-29T00:00:00Z" +aliases = ["github-copilot", "ghcs"] + +[[rules]] +id = "selection_blocker" +state = "blocked" +priority = 300 +region = "whole_recent" +visible_blocker = true +all = [ + { any = [ + { contains = ["esc to cancel"] }, + { contains = ["esc cancel"] }, + ] }, + { any = [ + { contains = ["enter to select"] }, + { contains = ["enter to confirm"] }, + { contains = ["enter to submit"] }, + { contains = ["enter accept"] }, + ] }, +] + +[[rules]] +id = "background_agents_working" +state = "working" +priority = 110 +region = "bottom_non_empty_lines(6)" +visible_working = true +line_regex = ['^\s*◎\s+Waiting for background agents(?:\s|·|$)'] + +[[rules]] +id = "working_cancel_hint" +state = "working" +priority = 100 +region = "whole_recent" +visible_working = true +any = [ + { contains = ["esc to cancel"] }, + { contains = ["esc cancel"] }, + { contains = ["esc again to cancel"] }, + { contains = ["esc interrupt"] } +] diff --git a/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/grok.toml b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/grok.toml new file mode 100644 index 000000000000..4abcefd6fb64 --- /dev/null +++ b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/grok.toml @@ -0,0 +1,168 @@ +id = "grok" +version = "2026.09.18.1" +min_engine_version = 2 +updated_at = "2026-09-18T00:00:00Z" +aliases = ["grok-build"] + +# Evidence: Grok Build 0.2.101 source and 1.0.34 live pane reads. +# +# Grok emits OSC 0 titles by default. Idle is "grok" or +# " - grok". During a turn, the configured title gains a braille +# spinner and activity text. Permission prompts add "⚠ Action Required"; +# that prefix blinks while the terminal is unfocused, so visible blocker +# rules outrank the title spinner rule. +# +# Grok also emits OSC 9;4 progress on supported terminals. Herdr retains the +# payload after "9;": "4;1;-1" while busy and "4;0;0" when idle. +# +# Working turns render one live status line directly above the prompt box: +# "⠧ Waiting on subagent… 2.8s 13s ⇣29.7k [stop]" +# "⠴ Explore /tmp/… + 1 more… 5.6s 19s ⇣29.7k [stop]" +# with a braille spinner and a trailing [stop] chip, plus an Esc:cancel +# footer hint. Permission prompts and ask-user-question dialogs replace the +# spinner with "◆" and draw a "┃"-guttered option list: +# "┃ 2 (○) Yes, proceed" +# "┃ z (○) Type your answer here" +# with footer hints "1/3:select │ Ctrl+o:yolo │ Ctrl+c:cancel" (permission) +# or "Esc:unselect │ Tab:scrollback │ Shift+x:dismiss" (question dialog). +# Idle footers end with "Ctrl+.:shortcuts" and never contain "Esc:cancel". +# The startup splash draws its logo with braille characters, so working +# rules must anchor on the [stop] chip, not on a bare spinner glyph. + +[[rules]] +id = "osc_title_blocked" +state = "blocked" +priority = 1300 +region = "osc_title" +visible_blocker = true +contains = ["Action Required"] + +[[rules]] +id = "option_dialog_blocked" +state = "blocked" +priority = 1200 +region = "whole_recent" +visible_blocker = true +line_regex = ['^\s*┃\s+[0-9a-z]+\s+\([●○]\)\s'] + +[[rules]] +id = "permission_hints_blocked" +state = "blocked" +priority = 1190 +region = "bottom_non_empty_lines(2)" +visible_blocker = true +contains = [":select", "ctrl+o:yolo", "ctrl+c:cancel"] + +[[rules]] +id = "question_dialog_hints_blocked" +state = "blocked" +priority = 1185 +region = "bottom_non_empty_lines(2)" +visible_blocker = true +contains = ["tab:scrollback", "shift+x:dismiss"] + +# Pre-0.2.x permission UI kept for older Grok Build releases. +[[rules]] +id = "permission_scope_selector" +state = "blocked" +priority = 1180 +region = "whole_recent" +visible_blocker = true +contains = ["yes, proceed", "no, reject"] +any = [ + { contains = ["use ← → to choose permission whitelist scope"] }, + { contains = ["←/→:scope"] }, +] + +# Grok 1.0.34 moves background counts above the composer and clears OSC +# progress between turns even while these commands are still running. +[[rules]] +id = "background_status_working" +state = "working" +priority = 1165 +region = "bottom_non_empty_lines(12)" +visible_working = true +line_regex = ['^\s*[○◎◉]\s+[1-9][0-9]*\s+(?:commands?|monitors?|loops?|subagents?)(?:\s+·\s+[1-9][0-9]*\s+(?:commands?|monitors?|loops?|subagents?))*\s+still running(?:\s+·\s+send a message to interrupt)?\s*$'] + +[[rules]] +id = "osc_progress_working" +state = "working" +priority = 1150 +region = "osc_progress" +visible_working = true +regex = ['^4;1;-1$'] + +[[rules]] +id = "osc_title_idle" +state = "idle" +priority = 1100 +region = "osc_title" +visible_idle = true +regex = ['(?:^| - )grok$'] +not = [ + { regex = ['[\x{2800}-\x{28FF}]'] }, +] + +# Title items are configurable; omitting "grok" does not imply activity. +# Require a spinner, with OSC progress covering titles that omit it. +[[rules]] +id = "osc_title_working" +state = "working" +priority = 1000 +region = "osc_title" +visible_working = true +regex = ['(?:^|\s)[\x{2801}-\x{28FF}](?:\s|$)'] + +[[rules]] +id = "osc_progress_idle" +state = "idle" +priority = 950 +region = "osc_progress" +visible_idle = true +regex = ['^4;0;0$'] + +# Visible activity outranks idle OSC signals: disabling title updates can +# leave the startup "grok" title unchanged throughout a turn. +[[rules]] +id = "spinner_status_working" +state = "working" +priority = 1160 +region = "whole_recent" +visible_working = true +line_regex = ['^\s*[\x{2801}-\x{28FF}]\s.*\[stop\]\s*$'] + +[[rules]] +id = "esc_cancel_hints_working" +state = "working" +priority = 1155 +region = "bottom_non_empty_lines(2)" +visible_working = true +contains = ["ctrl+.:shortcuts"] +any = [ + { contains = ["esc:cancel"] }, + { contains = ["ctrl+c:cancel"] }, +] + +# Pre-0.2.x working chrome kept for older Grok Build releases. +[[rules]] +id = "waiting_tool_working" +state = "working" +priority = 1140 +region = "whole_recent" +visible_working = true +any = [ + { all = [{ contains = ["ctrl+c:cancel", "ctrl+enter:interject"] }, { contains = ["waiting"] }] }, + { line_regex = ['^\s*[\x{2801}-\x{28FF}]\s+(Run|Read|Search|List)\b'] }, +] + +[[rules]] +id = "prompt_hints_idle" +state = "idle" +priority = 100 +region = "bottom_non_empty_lines(2)" +visible_idle = true +contains = ["ctrl+.:shortcuts"] +not = [ + { contains = ["esc:cancel"] }, + { contains = ["ctrl+c:cancel"] }, +] diff --git a/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/hermes.toml b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/hermes.toml new file mode 100644 index 000000000000..17542184971e --- /dev/null +++ b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/hermes.toml @@ -0,0 +1,102 @@ +id = "hermes" +version = "2026.07.24.1" +min_engine_version = 2 +updated_at = "2026-07-24T19:12:54Z" +aliases = ["hermes-agent"] + +[[rules]] +id = "osc_title_blocked" +state = "blocked" +priority = 1100 +region = "osc_title" +visible_blocker = true +regex = ['^⚠[\u{fe0e}\u{fe0f}]?(?:\s|$)'] + +[[rules]] +id = "osc_title_working" +state = "working" +priority = 1050 +region = "osc_title" +visible_working = true +regex = ['^⏳[\u{fe0e}\u{fe0f}]?(?:\s|$)'] + +[[rules]] +id = "dangerous_command_approval" +state = "blocked" +priority = 900 +region = "bottom_non_empty_lines(14)" +visible_blocker = true +any = [ + { contains = ["dangerous"] }, + { contains = ["approval"] }, + { contains = ["allow once", "deny"] }, + { line_regex = ['(?i)^\s*[▸>]?\s*1\.\s*allow'] }, +] +all = [ + { any = [{ contains = ["enter confirm"] }, { contains = ["enter to confirm"] }, { contains = ["↑/↓ to select"] }, { contains = ["show full command"] }] }, +] + +[[rules]] +id = "clarification_prompt" +state = "blocked" +priority = 900 +region = "bottom_non_empty_lines(14)" +visible_blocker = true +any = [ + { contains = ["hermes needs your"] }, + { line_regex = ['^\s*ask\s+\S'] }, + { contains = ["type your answer"] }, +] +all = [ + { any = [{ contains = ["enter confirm"] }, { contains = ["enter to confirm"] }, { contains = ["enter send"] }, { contains = ["press enter"] }, { contains = ["↑/↓ select"] }, { contains = ["↑/↓ to select"] }, { contains = ["other (type"] }] }, +] + +[[rules]] +id = "credential_prompt" +state = "blocked" +priority = 900 +region = "bottom_non_empty_lines(14)" +visible_blocker = true +any = [ + { contains = ["sudo password"] }, + { contains = ["skill setup"] }, + { contains = ["🔑", "for "] }, +] + +[[rules]] +id = "confirmation_prompt" +state = "blocked" +priority = 900 +region = "bottom_non_empty_lines(14)" +visible_blocker = true +all = [ + { any = [{ contains = ["approve once", "cancel"] }, { contains = ["start a new session", "keep going"] }] }, + { any = [{ contains = ["enter to confirm"] }, { contains = ["enter confirm"] }, { contains = ["type 1/2/3"] }, { contains = ["y/n quick"] }] }, +] + +[[rules]] +id = "interrupt_status_working" +state = "working" +priority = 950 +region = "bottom_non_empty_lines(5)" +visible_working = true +any = [ + { contains = ["msg=interrupt"] }, + { contains = ["ctrl+c to interrupt"] }, +] + +[[rules]] +id = "classic_cancel_working" +state = "working" +priority = 500 +region = "bottom_non_empty_lines(5)" +visible_working = true +contains = ["ctrl+c cancel"] + +[[rules]] +id = "osc_title_idle" +state = "idle" +priority = 100 +region = "osc_title" +visible_idle = true +regex = ['^✓[\u{fe0e}\u{fe0f}]?(?:\s|$)'] diff --git a/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/kilo.toml b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/kilo.toml new file mode 100644 index 000000000000..4ef004e1de4a --- /dev/null +++ b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/kilo.toml @@ -0,0 +1,24 @@ +id = "kilo" +version = "2026.06.10.1" +min_engine_version = 1 +updated_at = "2026-06-10T00:00:00Z" +aliases = ["kilo-code", "kilo code", "herdr:kilo"] + +[[rules]] +id = "opencode_permission" +state = "blocked" +priority = 300 +region = "whole_recent" +visible_blocker = true +any = [ + { contains = ["△ Permission required"] }, + { contains = ["esc dismiss"], any = [{ contains = ["enter confirm"] }, { contains = ["enter submit"] }, { contains = ["enter toggle"] }], all = [{ any = [{ contains = ["↑↓ select"] }, { contains = ["⇆ tab"] }] }] }, +] + +[[rules]] +id = "esc_interrupt_working" +state = "working" +priority = 100 +region = "whole_recent" +visible_working = true +contains = ["esc interrupt"] diff --git a/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/kimi.toml b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/kimi.toml new file mode 100644 index 000000000000..b4d0100fbae7 --- /dev/null +++ b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/kimi.toml @@ -0,0 +1,77 @@ +id = "kimi" +version = "2026.06.10.1" +min_engine_version = 1 +updated_at = "2026-06-10T00:00:00Z" +aliases = ["kimi-code", "kimi code"] + +[[rules]] +id = "current_approval_panel" +state = "blocked" +priority = 400 +region = "whole_recent" +visible_blocker = true +contains = ["↵ confirm"] +any = [ + { contains = ["run this command?"] }, + { contains = ["write this file?"] }, + { contains = ["apply these edits?"] }, + { contains = ["stop this task?"] }, + { contains = ["ready to build with this plan?"] }, + { line_regex = ['(?i)^\s*▶?\s*approve .*\?$'] }, +] +all = [ + { contains = [" choose"] }, + { any = [{ contains = ["approve"] }, { contains = ["reject"] }, { contains = ["revise"] }] }, +] + +[[rules]] +id = "question_panel" +state = "blocked" +priority = 390 +region = "whole_recent" +visible_blocker = true +contains = ["↑↓ select", "esc cancel"] +line_regex = ['^\s*question\s*$', '^\s*\? '] +any = [ + { contains = ["↵ choose"] }, + { contains = ["↵ toggle"] }, + { contains = ["↵ save"] }, +] + +[[rules]] +id = "legacy_approval_panel" +state = "blocked" +priority = 300 +region = "whole_recent" +contains = ["requesting approval", "reject"] +any = [ + { contains = ["approve once"] }, + { contains = ["approve for this session"] }, +] +all = [ + { any = [{ contains = ["1/2/3/4 choose"] }, { contains = ["↵ confirm"] }] }, +] + +[[rules]] +id = "background_agent_status_working" +state = "working" +priority = 120 +region = "bottom_non_empty_lines(3)" +visible_working = true +line_regex = ['(?i)\bkimi[-\w.]*\s+thinking\b.*\[[1-9][0-9]*\s+agents?\s+running\]'] + +[[rules]] +id = "moon_spinner_working" +state = "working" +priority = 100 +region = "whole_recent" +visible_working = true +line_regex = ['^\s*(🌕|🌖|🌗|🌘|🌑|🌒|🌓|🌔)\s*$'] + +[[rules]] +id = "braille_spinner_working" +state = "working" +priority = 90 +region = "whole_recent" +visible_working = true +line_regex = ['(?i)^\s*[\u2800-\u28FF]+\s*(thinking\.\.\.|working\.\.\.|using )'] diff --git a/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/kiro.toml b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/kiro.toml new file mode 100644 index 000000000000..9d50f9320322 --- /dev/null +++ b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/kiro.toml @@ -0,0 +1,97 @@ +id = "kiro" +version = "2026.09.19.1" +min_engine_version = 2 +updated_at = "2026-09-19T00:00:00Z" +aliases = ["kiro-cli"] + +[[rules]] +id = "live_prompt_idle" +state = "idle" +priority = 1100 +region = "bottom_non_empty_lines(4)" +visible_idle = true +line_regex = ['(?i)^\s*[>›]\s*ask a question or describe a task(?:\s+(?:enter|↵))?\s*$'] + +[[rules]] +id = "tool_approval" +state = "blocked" +priority = 1050 +region = "bottom_non_empty_lines(8)" +visible_blocker = true +any = [ + { all = [ + { regex = ['(?is)(?:^|\n)\s*esc\s+to\s+close\b(?:[^\n]*\bto\s+navigate\b[^\n]*|.*\bto\s+navigate\b.*\bto\s+select\b.*\btab\s+to\s+edit|[^\n]*\benter\s+to\s+see\s+more\s+options)\s*\z'] }, + { any = [ + { line_regex = [ + '(?i)^\s*[>❯]\s*(?:allow|always allow|deny|always deny)\s*$', + '(?i)^\s*(?:[>❯]\s*)?allow\s*$', + '(?i)^\s*(?:[>❯]\s*)?always allow\s*$', + '(?i)^\s*(?:[>❯]\s*)?deny\s*$', + '(?i)^\s*(?:[>❯]\s*)?always deny\s*$', + ] }, + { line_regex = [ + '(?i)^\s*[>❯]\s*(?:yes, single permission|trust, always allow in this session|no \(tab to edit\))\s*$', + '(?i)^\s*(?:[>❯]\s*)?yes, single permission\s*$', + '(?i)^\s*(?:[>❯]\s*)?trust, always allow in this session\s*$', + '(?i)^\s*(?:[>❯]\s*)?no \(tab to edit\)\s*$', + ] }, + { line_regex = [ + '(?i)^\s*[>❯]\s*(?:trust\b.*|entire tool)\s*$', + '(?i)^\s*(?:[>❯]\s*)?(?:trust )?entire tool(?:\s+\([^\n]*\))?(?:\s+(?:session|workspace|always))?\s*$', + ] }, + ] }, + ] }, +] + +[[rules]] +id = "tool_approval_edit" +state = "blocked" +priority = 1045 +region = "whole_recent" +visible_blocker = true +regex = ['(?im)(?:^|\n)[ \t]*[-─]+[ \t]*\n[^\n]*requires\s+approval\s*[·.]\s*modify\s+request[ \t]*\n(?:[ \t]*\n|[^\n]*[^\s─-][^\n]*\n)*?[ \t]*[>›][ \t]*[^\n]*\n(?:[ \t]*\n|[^\n]*[^\s─-][^\n]*\n)*[ \t]*[-─]+[ \t]*\n[ \t]*esc[ \t]+to[ \t]+close[ \t]*\n?\z'] + +[[rules]] +id = "crew_approval" +state = "blocked" +priority = 1040 +region = "bottom_non_empty_lines(8)" +visible_blocker = true +contains = [ + "tool approval", + "approve all pending", + "configure individually (agent monitor)", + "exit (cancel subagents)", +] + +[[rules]] +id = "question_panel" +state = "blocked" +priority = 1030 +region = "bottom_non_empty_lines(8)" +visible_blocker = true +contains = ["to navigate", "to submit", "esc to cancel"] + +[[rules]] +id = "live_working_footer" +state = "working" +priority = 950 +region = "bottom_non_empty_lines(4)" +visible_working = true +contains = ["kiro is working", "type to steer", "ctrl+s to queue"] + +[[rules]] +id = "osc_title_working" +state = "working" +priority = 900 +region = "osc_title" +visible_working = true +regex = ['(?i)^[◐◓◑◒/|\\-]\s+kiro:'] + +[[rules]] +id = "osc_progress_working" +state = "working" +priority = 890 +region = "osc_progress" +visible_working = true +regex = ['^4;3;?$'] diff --git a/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/letta.toml b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/letta.toml new file mode 100644 index 000000000000..e27e2aa72258 --- /dev/null +++ b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/letta.toml @@ -0,0 +1,95 @@ +id = "letta" +version = "2026.08.24.1" +min_engine_version = 3 +updated_at = "2026-08-24T00:00:00Z" +aliases = ["letta-code", "letta code"] + +# Letta keeps completed tool rows and spinners in the transcript. A standalone +# screen spinner is not working evidence. Prefer its live OSC title/progress +# signals, with visible status chrome and running tool rows as fallbacks. +[[rules]] +id = "osc_progress_blocked" +state = "blocked" +priority = 1400 +region = "osc_progress" +visible_blocker = true +regex = ['^4;3(?:;|$)'] + +[[rules]] +id = "osc_title_blocked" +state = "blocked" +priority = 1300 +region = "osc_title" +visible_blocker = true +regex = ['^\[ [!.] \] Action Required(?: \| |$)'] + +[[rules]] +id = "command_approval" +state = "blocked" +priority = 1200 +region = "bottom_non_empty_lines(20)" +visible_blocker = true +contains = ["Run this command?", "Enter to select · Esc to cancel"] + +[[rules]] +id = "osc_title_working" +state = "working" +priority = 900 +region = "osc_title" +visible_working = true +regex = ['(?:^| )[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏](?: |$)'] + +[[rules]] +id = "active_status" +state = "working" +priority = 850 +region = "bottom_non_empty_lines(8)" +visible_working = true +line_regex = ['^\s*(?:\S+\s+)+is(?: \S+)*… \((?:esc to interrupt(?: · .*)?|interrupting)\)\s*$'] + +[[rules]] +id = "running_tool" +state = "working" +priority = 800 +region = "bottom_non_empty_lines(8)" +visible_working = true +line_regex = ['^\s*(?:└\s*)?Running\.\.\.\s*(?:\(.*\))?$'] + +[[rules]] +id = "profile_selector" +state = "unknown" +priority = 700 +region = "bottom_non_empty_lines(12)" +contains = ["Create a new agent (--new)", "Enter select · Esc exit"] + +[[rules]] +id = "composer_input" +state = "unknown" +priority = 150 +region = "bottom_non_empty_lines(8)" +line_regex = ['^\s*›\s+\S.*$'] +not = [ + { line_regex = ['^\s*›\s+Try\s+"'] }, +] + +[[rules]] +id = "composer_idle" +state = "idle" +priority = 100 +region = "bottom_non_empty_lines(8)" +visible_idle = true +any = [ + { line_regex = ['^\s*›\s*$'] }, + { line_regex = ['^\s*›\s+Try\s+"'] }, +] +not = [ + { line_regex = ['^\s*(?:\S+\s+)+is(?: \S+)*… \((?:esc to interrupt(?: · .*)?|interrupting)\)\s*$'] }, + { line_regex = ['^\s*(?:└\s*)?Running\.\.\.\s*(?:\(.*\))?$'] }, +] + +[[rules]] +id = "no_live_state_evidence" +state = "unknown" +priority = 0 +region = "whole_recent" +regex = ['(?s)^.*$'] diff --git a/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/maki.toml b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/maki.toml new file mode 100644 index 000000000000..5c58404a52a9 --- /dev/null +++ b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/maki.toml @@ -0,0 +1,68 @@ +id = "maki" +version = "2026.07.09.2" +min_engine_version = 1 +updated_at = "2026-07-09T00:00:00Z" + +# Maki renders a persistent one-line status bar on the bottom row. It starts +# with the mode label "[BUILD]", "[PLAN]", or "[BASH]" when idle and gets a +# leading braille spinner cell while the agent is streaming. Permission +# requests and the plan-complete form replace the input box above the status +# bar. Maki does not set OSC title or OSC 9;4 progress. + +[[rules]] +id = "permission_prompt" +state = "blocked" +priority = 980 +region = "whole_recent" +visible_blocker = true +contains = ["permission required"] +any = [ + { contains = ["y allow", "n deny"] }, + { contains = ["confirm allow"] }, + { contains = ["confirm deny"] }, + { contains = ["enter deny", "esc cancel"] }, +] + +[[rules]] +id = "plan_complete_form" +state = "blocked" +priority = 970 +region = "whole_recent" +visible_blocker = true +contains = ["plan complete", "enter confirm"] +any = [ + { contains = ["space toggle parallel"] }, + { contains = ["edit plan"] }, +] + +[[rules]] +id = "status_bar_spinner_working" +state = "working" +priority = 900 +region = "bottom_non_empty_lines(1)" +visible_working = true +line_regex = ['^( [\x{2800}-\x{28FF}]){1,2} \[(BUILD|PLAN|BASH)\]'] + +[[rules]] +id = "status_bar_idle" +state = "idle" +priority = 850 +region = "bottom_non_empty_lines(1)" +visible_idle = true +line_regex = ['^ \[(BUILD|PLAN|BASH)\]'] + +# On narrow panes the right side of the status bar overwrites the mode label, +# so fall back to the prompt chevron above the input box border. The not-gates +# keep this from matching the streaming placeholder or a status bar that still +# shows the spinner. +[[rules]] +id = "prompt_box_idle" +state = "idle" +priority = 840 +region = "bottom_non_empty_lines(3)" +visible_idle = true +line_regex = ['^❯ '] +not = [ + { contains = ["queue another prompt"] }, + { line_regex = ['^( [\x{2800}-\x{28FF}]){1,2} '] }, +] diff --git a/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/muse.toml b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/muse.toml new file mode 100644 index 000000000000..50818422c3cb --- /dev/null +++ b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/muse.toml @@ -0,0 +1,113 @@ +id = "muse" +version = "2026.08.26.1" +min_engine_version = 2 +updated_at = "2026-08-26T00:00:00Z" +aliases = ["muse-code", "muse-cli"] + +# Evidence: live bottom-buffer captures from Muse Code 0.2.1 in Herdr, using a local +# deterministic Responses provider so each UI state could be held and inspected. +# +# Idle has a `⟩` prompt and `model · effort · cwd` footer. Active turns show +# `◆ Working (... · esc to interrupt)` or another activity label with the same interrupt hint. +# +# Structured questions show two co-occurring footer controls: +# Enter to select · ↑/↓ to move · Tab for an optional note · Esc to interrupt +# Multi-select uses `Enter to toggle` instead. The paired controls distinguish a real picker +# from ordinary transcript text that happens to mention one action. +# +# First launch in an untrusted directory shows `Do you trust this workspace?` together with +# `Trust and continue`. This is a real blocker. User-opened `/theme` and `/skills` menus are +# not blockers; their paired footer controls identify overlays whose prior state must be kept. +# +# Muse 0.2.1 command approval shows `Allow this stage once` together with +# `Always allow in this workspace`. Muse 0.1 used `Allow once` with +# `Allow for this session`. Network approval shows `Yes, proceed` together with +# `Yes, don't ask again this session`. Each approval rule requires a pair because Muse can +# emit any one of these phrases as ordinary assistant text after a completed turn. + +[[rules]] +id = "workspace_trust_blocked" +state = "blocked" +priority = 970 +region = "bottom_non_empty_lines(12)" +visible_blocker = true +contains = ["Do you trust this workspace?"] +any = [ + { contains = ["Trust and continue"] }, + { contains = ["Use Up/Down"] }, +] + +[[rules]] +id = "pick_request_blocked" +state = "blocked" +priority = 950 +region = "bottom_non_empty_lines(8)" +visible_blocker = true +any = [ + { contains = ["Enter to select", "Tab for an optional note"] }, + { contains = ["Enter to toggle", "Esc to interrupt"] }, +] + +[[rules]] +id = "menu_overlay" +state = "unknown" +priority = 940 +region = "bottom_non_empty_lines(8)" +skip_state_update = true +any = [ + { contains = ["enter confirm", "esc go back"] }, + { contains = ["enter save", "esc go back"] }, + { contains = ["space toggle", "esc close", "type filter"] }, +] + +[[rules]] +id = "working_esc_interrupt" +state = "working" +priority = 900 +region = "bottom_non_empty_lines(8)" +visible_working = true +contains = ["esc to interrupt"] +not = [ + { contains = ["Enter to select", "Tab for an optional note"] }, + { contains = ["Enter to toggle", "Esc to interrupt"] }, +] + +[[rules]] +id = "blocked_approval" +state = "blocked" +priority = 850 +region = "bottom_non_empty_lines(8)" +visible_blocker = true +any = [ + { contains = ["Allow this stage once", "Always allow in this workspace"] }, + { contains = ["Allow once", "Allow for this session"] }, + { contains = ["Yes, proceed", "Yes, don't ask again this session"] }, +] + +[[rules]] +id = "idle_prompt" +state = "idle" +priority = 700 +region = "bottom_non_empty_lines(5)" +visible_idle = true +any = [ + { line_regex = ['^\s*⟩\s*$'] }, + { line_regex = ['^\s*⟩\s+\S'] }, +] +not = [ + { contains = ["esc to interrupt"] }, + { contains = ["Enter to select", "Tab for an optional note"] }, + { contains = ["Enter to toggle", "Esc to interrupt"] }, + { contains = ["enter confirm", "esc go back"] }, + { contains = ["enter save", "esc go back"] }, + { contains = ["space toggle", "esc close", "type filter"] }, +] + +[[rules]] +id = "idle_status_fallback" +state = "idle" +priority = 500 +region = "bottom_non_empty_lines(3)" +visible_idle = true +line_regex = ['^\s*\S+ · (none|minimal|low|medium|high|xhigh|ultra) · '] +not = [{ contains = ["esc to interrupt"] }] diff --git a/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/opencode.toml b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/opencode.toml new file mode 100644 index 000000000000..5245238371da --- /dev/null +++ b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/opencode.toml @@ -0,0 +1,37 @@ +id = "opencode" +version = "2026.06.10.1" +min_engine_version = 1 +updated_at = "2026-06-10T00:00:00Z" +aliases = ["open-code", "herdr:opencode"] + +[[rules]] +id = "permission_required" +state = "blocked" +priority = 300 +region = "whole_recent" +visible_blocker = true +any = [ + { contains = ["△ Permission required"] }, + { contains = ["esc dismiss"], any = [{ contains = ["enter confirm"] }, { contains = ["enter submit"] }, { contains = ["enter toggle"] }], all = [{ any = [{ contains = ["↑↓ select"] }, { contains = ["⇆ tab"] }] }] }, +] + +[[rules]] +id = "interrupt_hint_working" +state = "working" +priority = 110 +region = "whole_recent" +visible_working = true +any = [ + { contains = ["esc to interrupt"] }, + { contains = ["ctrl+c to interrupt"] }, + { contains = ["press esc to interrupt"] }, + { line_regex = ['(?i).*opencode.*esc (again to )?interrupt'] }, +] + +[[rules]] +id = "progress_bar_working" +state = "working" +priority = 100 +region = "whole_recent" +visible_working = true +regex = ['(■|⬝){4,}'] diff --git a/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/pi.toml b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/pi.toml new file mode 100644 index 000000000000..77b2d6324e54 --- /dev/null +++ b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/pi.toml @@ -0,0 +1,21 @@ +id = "pi" +version = "2026.09.14.1" +min_engine_version = 1 +updated_at = "2026-09-14T00:00:00Z" +aliases = ["herdr:pi"] + +[[rules]] +id = "working_literal" +state = "working" +priority = 100 +region = "whole_recent" +visible_working = true +contains = ["Working..."] + +[[rules]] +id = "working_border" +state = "working" +priority = 100 +region = "bottom_non_empty_lines(12)" +visible_working = true +line_regex = ['^── [⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏] Working ─+$'] diff --git a/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/qodercli.toml b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/qodercli.toml new file mode 100644 index 000000000000..51ca805ed77f --- /dev/null +++ b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/qodercli.toml @@ -0,0 +1,38 @@ +id = "qodercli" +version = "2026.06.10.1" +min_engine_version = 1 +updated_at = "2026-06-10T00:00:00Z" +aliases = ["qoderclicn", "qoder", "qodercn"] + +[[rules]] +id = "confirmation_or_input_blocker" +state = "blocked" +priority = 300 +region = "whole_recent" +visible_blocker = true +any = [ + { contains = ["waiting for user confirmation"], any = [{ contains = ["yes"] }, { contains = ["no"] }, { contains = ["allow"] }, { contains = ["reject"] }] }, + { contains = ["awaiting approval"], any = [{ contains = ["allow"] }, { contains = ["reject"] }] }, + { contains = ["permission required"] }, + { contains = ["allow once or always?"] }, + { contains = ["asking user"] }, + { contains = ["enter your response"] }, + { contains = ["review your answers:"] }, + { contains = ["shell awaiting input"] }, +] + +[[rules]] +id = "cancel_hint_working" +state = "working" +priority = 100 +region = "whole_recent" +visible_working = true +contains = ["(esc to cancel,"] + +[[rules]] +id = "spinner_working" +state = "working" +priority = 90 +region = "whole_recent" +visible_working = true +line_regex = ['^\s*[\u2800-\u28FF]\s+.*\p{Alphabetic}'] diff --git a/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/qwen.toml b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/qwen.toml new file mode 100644 index 000000000000..bbed23711b50 --- /dev/null +++ b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/qwen.toml @@ -0,0 +1,119 @@ +id = "qwen" +version = "2026.08.14.1" +min_engine_version = 2 +updated_at = "2026-08-14T00:00:00Z" +aliases = ["qwen-code", "qwen code"] + +# Qwen Code keeps its composer visible while responding, so the prompt box is +# not idle evidence by itself. Its status-prefixed terminal titles are the +# primary locale-independent signals when ui.showStatusInTitle is enabled. +# Exact screen fallbacks cover built-in locales and narrow terminals. OSC 9;4 +# is supplemental because Qwen emits it only while a tool is executing and +# only in terminals that advertise progress support. + +[[rules]] +id = "osc_title_blocked" +state = "blocked" +priority = 1200 +region = "osc_title" +visible_blocker = true +regex = ['^\x{2733}\x{FE0E}? '] + +[[rules]] +id = "osc_title_working" +state = "working" +priority = 1100 +region = "osc_title" +visible_working = true +regex = ['^\x{25D0}\x{FE0E}? '] + +[[rules]] +id = "waiting_for_confirmation" +state = "blocked" +priority = 1000 +region = "bottom_non_empty_lines(20)" +visible_blocker = true +line_regex = ['^\s*⠏\s+.*\.\.\.\s*$'] +any = [ + { contains = ["Waiting for user confirmation..."] }, + { contains = ["等待用户确认..."] }, + { contains = ["等待用戶確認..."] }, + { contains = ["Warten auf Benutzerbestätigung..."] }, + { contains = ["En attente de la confirmation de l'utilisateur..."] }, + { contains = ["ユーザーの確認を待っています..."] }, + { contains = ["Aguardando confirmação do usuário..."] }, + { contains = ["Ожидание подтверждения от пользователя..."] }, + { contains = ["Esperant la confirmació de l'usuari..."] }, +] + +[[rules]] +id = "tool_confirmation" +state = "blocked" +priority = 990 +region = "bottom_non_empty_lines(20)" +visible_blocker = true +contains = ["yes, allow once"] +any = [ + { contains = ["apply this change?"] }, + { contains = ["allow execution of:"] }, + { contains = ["allow execution of mcp tool"] }, + { contains = ["do you want to proceed?"] }, + { contains = ["shell command execution"] }, +] + +[[rules]] +id = "question_dialog" +state = "blocked" +priority = 980 +region = "bottom_non_empty_lines(20)" +visible_blocker = true +line_regex = [ + '^\s*[❯›]\s*(?:\[(?: |✓)\]\s*)?\d+\.\s+', + '^\s*↑/↓\s*:.*(?:Enter|Return)\s*:', +] + +[[rules]] +id = "folder_trust_dialog" +state = "blocked" +priority = 970 +region = "bottom_non_empty_lines(20)" +visible_blocker = true +contains = ["do you trust this folder?", "trust folder (", "don't trust (esc)"] + +[[rules]] +id = "cancel_hint_working" +state = "working" +priority = 900 +region = "bottom_non_empty_lines(8)" +visible_working = true +line_regex = ['^\s*(?:[⠁-⣿]|\.{1,2})\s+.*\(\d+(?:m(?:\s+\d+s)?|s).*\s·\sesc to cancel\)\s*$'] + +[[rules]] +id = "narrow_cancel_hint_working" +state = "working" +priority = 890 +region = "bottom_non_empty_lines(8)" +visible_working = true +line_regex = ['^\s*\(\d+(?:m(?:\s+\d+s)?|s)\s·\sesc to cancel\)\s*$'] + +[[rules]] +id = "osc_tool_progress_working" +state = "working" +priority = 850 +region = "osc_progress" +visible_working = true +regex = ['^4;3(?:;|$)'] + +[[rules]] +id = "composer_idle" +state = "idle" +priority = 100 +region = "bottom_non_empty_lines(30)" +visible_idle = true +line_regex = ['^\s*>\s*(?:type\s*)?.*$'] +any = [ + { contains = ["type your message"] }, + { contains = ["your message or @path/to/file"] }, + { contains = ["@path/to/file"] }, + { contains = ["type", "mes", "sage", "@pat", "h/to", "/fil"] }, +] diff --git a/cmux-tui/bindings/examples/rust-agent-screen-detection/src/detect.rs b/cmux-tui/bindings/examples/rust-agent-screen-detection/src/detect.rs new file mode 100644 index 000000000000..990723ec5c24 --- /dev/null +++ b/cmux-tui/bindings/examples/rust-agent-screen-detection/src/detect.rs @@ -0,0 +1,1547 @@ +//! Screen-derived agent lifecycle detection. +//! +//! Detection semantics derived from herdr (https://github.com/herdrdev/herdr), +//! Apache-2.0, commit `7b675f42af35508eab66ac42fe1598628597a893`, especially +//! `src/detect/mod.rs` and `src/pane/agent_detection.rs`, modified by +//! manaflow. First-acquisition OSC retention follows herdr commit +//! `82e6a80eb3ae39fb3d3ebd4d1fed19389767e605` (`src/pane.rs`), adapted here +//! as a local metadata fence because the generic host API does not let a +//! plugin clear terminal OSC state. +//! +//! The plugin watches every PTY's output stream; when a terminal goes quiet +//! (debounced), the foreground process name selects a herdr-derived manifest +//! and the terminal tail is evaluated against it. State transitions, never +//! per-scan states, append namespaced `agent.*` journal events, so the +//! journal-derived roster covers agents that expose no hooks. The engine port +//! lives in [`manifest`]; this module owns pure edge-trigger bookkeeping. + +use std::collections::HashMap; +use std::time::{Duration, Instant}; + +use crate::manifest::{Detection, ScreenState}; + +/// States emitted by the detector. They are serialized as the generic cmux +/// agent state strings at the journal boundary. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AgentState { + Working, + Blocked, + Idle, + Done, +} + +impl AgentState { + pub fn as_str(self) -> &'static str { + match self { + Self::Working => "working", + Self::Blocked => "blocked", + Self::Idle => "idle", + Self::Done => "done", + } + } +} + +/// Output must be quiet this long before the screen is evaluated, so +/// mid-redraw frames are rarely matched. +pub(crate) const QUIESCENCE_DEBOUNCE_MS: u64 = 300; + +/// A screen that never goes quiet (agent spinners animate every ~100ms, +/// so a working codex never quiesces) is still evaluated at this pace. +/// Without it, quiescence gating starves detection during the exact +/// phase it exists to report. +pub(crate) const MAX_EVAL_INTERVAL_MS: u64 = 1_000; + +/// Recent PTY output is a working signal for a screen source. It upgrades an +/// otherwise idle screen and expires through one deterministic re-evaluation. +pub(crate) const WORKING_ACTIVITY_WINDOW_MS: u64 = 1_500; + +/// Herdr confirms a plain idle screen several times before replacing a +/// working state. This avoids a single redraw frame making a live turn look +/// complete. +pub(crate) const PENDING_IDLE_RECHECK_MS: u64 = 100; +pub(crate) const PENDING_IDLE_CONFIRMATIONS: u8 = 3; +pub(crate) const PENDING_IDLE_CAP_MS: u64 = 700; + +/// A visible blocker is still live evidence even when its text does not +/// change. Refreshing it keeps roster recency useful for long prompts. +pub(crate) const STABLE_BLOCKER_REFRESH_MS: u64 = 800; + +/// Do not classify the first screen after a process identity edge. A shell +/// can leave its old prompt in the viewport while the agent is starting. The +/// process identity gives immediate presence; this grace window gives the +/// agent time to draw its own screen before screen rules can assert state. +pub(crate) const AGENT_STARTUP_GRACE_MS: u64 = 3_000; + +/// Process inspection can briefly return no foreground process while a PTY +/// changes groups or a platform permission check races the scan. Keep the +/// last agent through the same six consecutive misses used by herdr before +/// treating the identity as an exit. +pub(crate) const AGENT_MISS_CONFIRMATION_ATTEMPTS: u8 = 6; + +#[derive(Debug, Clone, Default)] +struct PendingIdle { + started_at: Option, + confirmations: u8, +} + +/// The part of a tracked terminal that an emission mutates. The scanner +/// records this snapshot before it appends to the journal. If admission fails, +/// the snapshot is restored so the next scan can publish the same edge. +#[derive(Debug, Clone)] +struct TrackerSnapshot { + emitted: Option<(String, AgentState)>, + identity_presence_needed: bool, + visible_idle: bool, + visible_blocker: bool, + visible_working: bool, + last_visible_blocker_refresh: Option, + pending_idle: PendingIdle, +} + +impl TrackerSnapshot { + fn capture(entry: &TrackedTerminal) -> Self { + Self { + emitted: entry.emitted.clone(), + identity_presence_needed: entry.identity_presence_needed, + visible_idle: entry.visible_idle, + visible_blocker: entry.visible_blocker, + visible_working: entry.visible_working, + last_visible_blocker_refresh: entry.last_visible_blocker_refresh, + pending_idle: entry.pending_idle.clone(), + } + } +} + +#[derive(Debug, Clone)] +struct PendingEmission { + emission: ScreenDetectEmission, + before: TrackerSnapshot, + after: TrackerSnapshot, +} + +impl PendingEmission { + fn arm(entry: &mut TrackedTerminal, before: TrackerSnapshot, emission: ScreenDetectEmission) { + let after = TrackerSnapshot::capture(entry); + entry.pending_emission = Some(Self { emission, before, after }); + } + + fn matches(&self, emission: &ScreenDetectEmission) -> bool { + self.emission == *emission + } +} + +impl TrackerSnapshot { + fn restore(self, entry: &mut TrackedTerminal) { + entry.emitted = self.emitted; + entry.identity_presence_needed = self.identity_presence_needed; + entry.visible_idle = self.visible_idle; + entry.visible_blocker = self.visible_blocker; + entry.visible_working = self.visible_working; + entry.last_visible_blocker_refresh = self.last_visible_blocker_refresh; + entry.pending_idle = self.pending_idle; + } +} + +impl PendingIdle { + fn clear(&mut self) { + self.started_at = None; + self.confirmations = 0; + } + + fn active(&self) -> bool { + self.started_at.is_some() + } + + fn should_hold(&mut self, now: Instant) -> bool { + let Some(started_at) = self.started_at else { + self.started_at = Some(now); + self.confirmations = 0; + return true; + }; + if now.duration_since(started_at).as_millis() as u64 >= PENDING_IDLE_CAP_MS { + self.clear(); + return false; + } + self.confirmations = self.confirmations.saturating_add(1); + if self.confirmations >= PENDING_IDLE_CONFIRMATIONS { + self.clear(); + false + } else { + true + } + } +} + +/// Whether retained OSC metadata may be used without a new PTY revision. +/// Herdr clears the host's OSC fields when leaving an identified agent. The +/// cmux host API is deliberately generic and cannot perform that reset for a +/// plugin, so the plugin models the same boundary locally. +#[derive(Debug, Clone, Copy, Default)] +enum OscMetadataState { + /// No agent has been identified on this terminal yet. + #[default] + NeverIdentified, + /// The first recognized agent may have emitted its title or progress + /// before process inspection caught up, so retained evidence stays usable. + FirstAgent, + /// A replacement or confirmed exit occurred. A revision is optional for + /// older hosts. A known fence fails closed when the current host omits its + /// revision, because the plugin cannot prove that retained OSC data is new. + Fenced { identity_revision: Option }, +} + +impl OscMetadataState { + fn is_fresh(self, stream_revision: Option) -> bool { + match self { + Self::NeverIdentified | Self::FirstAgent => true, + // Old hosts do not expose a revision. Preserve their historical + // compatibility behavior because there is no generation anchor + // to compare against. + Self::Fenced { identity_revision: None } => true, + // Once a host has supplied an anchor, missing metadata is not + // evidence that the retained OSC fields belong to a new process. + Self::Fenced { identity_revision: Some(identity_revision) } => { + stream_revision.is_some_and(|current| current > identity_revision) + } + } + } + + fn fence(&mut self, stream_revision: Option) { + *self = Self::Fenced { identity_revision: stream_revision }; + } +} + +/// One state transition the scanner must journal. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ScreenDetectEmission { + pub terminal_id: String, + /// Manifest id of the detected agent (`codex`, `claude`, ...). + pub agent: String, + pub state: AgentState, + pub matched_rule: Option, + pub visible_idle: bool, + pub visible_blocker: bool, + pub visible_working: bool, +} + +#[derive(Debug, Default)] +struct TrackedTerminal { + /// Last observed output-stream revision. + revision: u64, + /// When that revision was first observed (debounce anchor). + quiet_since: Option, + /// Revision already evaluated; skip re-evaluating identical screens. + evaluated_revision: Option, + /// When the screen was last evaluated (the max-interval pacer anchor). + last_evaluated_at: Option, + /// When output last advanced the revision. The first observation only + /// anchors the tracker and is not treated as fresh activity. + last_output_at: Option, + /// The last evaluation used output activity to upgrade idle to working. + /// This creates one expiry re-evaluation even when the screen is unchanged. + evaluated_with_activity: bool, + /// Agent the foreground process matched on the previous scan; identity + /// edges trigger immediate evaluation, before any quiescence. + foreground_agent: Option, + /// Foreground process group for the matched agent. A replacement process + /// can keep the same executable name, so a group change is also an + /// identity edge when both probes provide a group id. + foreground_process_group: Option, + /// Deadline for the stale-screen guard after an agent identity edge. + startup_grace_until: Option, + /// First acquisition accepts retained evidence. A replacement or confirmed + /// exit changes this to `Fenced`, so a later process cannot inherit the + /// prior process's metadata. + osc_metadata_state: OscMetadataState, + /// Consecutive process probes that did not identify an agent. A positive + /// probe resets this counter, so a transient inspection miss cannot close + /// a live row. + foreground_misses: u8, + /// Last (agent, state) journaled; emissions are edges over this. + emitted: Option<(String, AgentState)>, + /// Visibility evidence from the last emitted state. It drives stable + /// blocker refresh without treating every evaluation as a transition. + visible_idle: bool, + visible_blocker: bool, + visible_working: bool, + last_visible_blocker_refresh: Option, + pending_idle: PendingIdle, + /// The pre-emission state until the scanner confirms journal admission. + /// Only one emission is in flight because appends are synchronous. + pending_emission: Option, + /// A process identity edge remains unsatisfied until its presence event + /// is admitted. This is separate from the last screen state because an + /// agent can replace another agent while both report `idle`. + identity_presence_needed: bool, + /// A failed transport keeps the exact edge available for idempotent + /// replay. The scanner retries this before evaluating a newer screen. + retry_emission: Option, +} + +/// Pure edge-trigger state for the scanner. All timing is passed in, so +/// tests drive it deterministically. +#[derive(Debug, Default)] +pub struct ScreenDetectTracker { + terminals: HashMap, +} + +impl ScreenDetectTracker { + /// Record the terminal's current output revision. Returns `true` when + /// the screen changed since the last evaluation and either output has + /// been quiet for the debounce window or the max-interval pacer is due + /// (a never-quiet spinner screen still evaluates at 1Hz; quiescence + /// alone starves detection during the exact phase it must report). A + /// `true` return arms the pacer: the caller always evaluates then. + pub fn observe_revision(&mut self, terminal_id: &str, revision: u64, now: Instant) -> bool { + let entry = self.terminals.entry(terminal_id.to_string()).or_default(); + if entry.quiet_since.is_none() { + entry.revision = revision; + entry.quiet_since = Some(now); + } else if entry.revision != revision { + entry.revision = revision; + entry.quiet_since = Some(now); + entry.last_output_at = Some(now); + } + let output_active = entry.last_output_at.is_some_and(|at| { + now.duration_since(at).as_millis() as u64 <= WORKING_ACTIVITY_WINDOW_MS + }); + let activity_expired = entry.evaluated_with_activity && !output_active; + let pending_idle_due = entry.pending_idle.active() + && entry.last_evaluated_at.is_none_or(|at| { + now.duration_since(at).as_millis() as u64 >= PENDING_IDLE_RECHECK_MS + }); + let stable_blocker_due = entry.visible_blocker + && entry.last_visible_blocker_refresh.is_none_or(|at| { + now.duration_since(at).as_millis() as u64 >= STABLE_BLOCKER_REFRESH_MS + }); + if entry.evaluated_revision == Some(entry.revision) + && !activity_expired + && !pending_idle_due + && !stable_blocker_due + { + return false; + } + let quiet_since = entry.quiet_since.expect("anchored above"); + let quiesced = now.duration_since(quiet_since).as_millis() as u64 >= QUIESCENCE_DEBOUNCE_MS; + let overdue = entry.last_evaluated_at.is_none_or(|evaluated_at| { + now.duration_since(evaluated_at).as_millis() as u64 >= MAX_EVAL_INTERVAL_MS + }); + if quiesced || overdue || activity_expired || pending_idle_due || stable_blocker_due { + entry.last_evaluated_at = Some(now); + entry.evaluated_with_activity = false; + return true; + } + false + } + + /// One-shot work owed by a concrete observation. A stable terminal has + /// no deadline, including a visible blocker: journal recency is event + /// time, not a heartbeat. Process uncertainty is a bounded confirmation + /// sequence armed by output, never a periodic process scan. + pub(crate) fn next_deadline(&self, terminal_id: &str, now: Instant) -> Option { + let entry = self.terminals.get(terminal_id)?; + entry.foreground_agent.as_ref()?; + if let Some(deadline) = entry.startup_grace_until { + return Some(deadline.max(now)); + } + let mut deadlines = Vec::new(); + if entry.foreground_misses > 0 && entry.foreground_misses < AGENT_MISS_CONFIRMATION_ATTEMPTS + { + deadlines.push(now + Duration::from_millis(PENDING_IDLE_RECHECK_MS)); + } + if entry.evaluated_revision != Some(entry.revision) { + if let Some(quiet) = entry.quiet_since { + deadlines.push(quiet + Duration::from_millis(QUIESCENCE_DEBOUNCE_MS)); + } + } + if entry.evaluated_with_activity { + if let Some(output) = entry.last_output_at { + deadlines.push(output + Duration::from_millis(WORKING_ACTIVITY_WINDOW_MS + 1)); + } + } + if entry.pending_idle.active() { + deadlines.push(now + Duration::from_millis(PENDING_IDLE_RECHECK_MS)); + } + deadlines.into_iter().min().map(|deadline| deadline.max(now)) + } + + /// Mark that the last screen evaluation used flowing PTY output to + /// upgrade an idle state. The tracker then owes an expiry evaluation. + pub(crate) fn note_activity_upgrade(&mut self, terminal_id: &str) { + if let Some(entry) = self.terminals.get_mut(terminal_id) { + entry.evaluated_with_activity = true; + } + } + + /// True while PTY output has advanced within the activity window. + pub(crate) fn output_active(&self, terminal_id: &str, now: Instant) -> bool { + self.terminals.get(terminal_id).and_then(|entry| entry.last_output_at).is_some_and(|at| { + now.duration_since(at).as_millis() as u64 <= WORKING_ACTIVITY_WINDOW_MS + }) + } + + /// Return whether generic OSC metadata may be attributed to the current + /// foreground process. Hosts without a stream revision remain supported, + /// but the plugin cannot prove that their retained metadata is fresh. A + /// terminal with a known fence fails closed while its current revision is + /// missing. + pub(crate) fn metadata_is_fresh( + &self, + terminal_id: &str, + stream_revision: Option, + ) -> bool { + let Some(entry) = self.terminals.get(terminal_id) else { + return true; + }; + // Match herdr's first-acquisition rule. A newly recognized agent may + // have emitted its OSC title or progress before the process probe + // caught up, so do not discard that evidence on the first edge. + entry.osc_metadata_state.is_fresh(stream_revision) + } + + /// True when this terminal previously journaled a screen-derived state + /// that has not been closed out by an exit emission. + pub fn has_live_emission(&self, terminal_id: &str) -> bool { + self.terminals.get(terminal_id).is_some_and(|entry| entry.emitted.is_some()) + } + + /// Return whether the process identity still needs a durable presence + /// edge. This stays true after a failed append, including when the + /// foreground agent changed while an older agent row was live. + pub(crate) fn needs_identity_presence(&self, terminal_id: &str, agent: &str) -> bool { + self.terminals.get(terminal_id).is_none_or(|entry| { + entry.identity_presence_needed + || entry.emitted.as_ref().is_none_or(|(current_agent, _)| current_agent != agent) + }) + } + + /// Record which agent the foreground process currently matches. Returns + /// `true` on an identity edge (spawn, swap, or exit), which evaluates + /// the screen immediately: presence comes from the process, so the row + /// appears the moment `codex` starts, not after its first quiet screen. + pub fn note_foreground_agent(&mut self, terminal_id: &str, agent: Option<&str>) -> bool { + self.note_foreground_agent_at(terminal_id, agent, Instant::now()) + } + + /// Record a foreground identity edge with deterministic timing. A newly + /// identified process starts a grace window during which the scanner must + /// not interpret the previous shell or agent viewport as its state. + pub fn note_foreground_agent_at( + &mut self, + terminal_id: &str, + agent: Option<&str>, + now: Instant, + ) -> bool { + self.note_foreground_job_at(terminal_id, agent, None, now) + } + + /// Record a foreground identity and, when available, its process group. + /// A same-name process replacement is an edge only when both observations + /// carry a group id. Missing group data must not manufacture a restart. + pub fn note_foreground_job_at( + &mut self, + terminal_id: &str, + agent: Option<&str>, + process_group_id: Option, + now: Instant, + ) -> bool { + self.note_foreground_job_at_with_revision(terminal_id, agent, process_group_id, None, now) + } + + /// Record a foreground identity edge and the host stream revision seen at + /// that edge. On replacement edges, the revision lets the userland + /// detector reject OSC title or progress retained from the previous + /// process without adding agent semantics to the host metadata API. The + /// first acquisition keeps evidence that may have arrived before probing. + pub(crate) fn note_foreground_job_at_with_revision( + &mut self, + terminal_id: &str, + agent: Option<&str>, + process_group_id: Option, + stream_revision: Option, + now: Instant, + ) -> bool { + let entry = self.terminals.entry(terminal_id.to_string()).or_default(); + match agent { + Some(agent) => { + // A successful probe confirms the existing identity and + // cancels any transient-miss window. + entry.foreground_misses = 0; + let agent_changed = entry.foreground_agent.as_deref() != Some(agent); + let process_group_changed = matches!( + (entry.foreground_process_group, process_group_id), + (Some(previous), Some(current)) if previous != current + ); + if !agent_changed && !process_group_changed { + // A platform can expose the group only after the first + // probe. Enrich the identity without restarting grace. + if process_group_id.is_some() { + entry.foreground_process_group = process_group_id; + } + // Likewise, an older daemon can begin exposing the + // revision after the identity was established. Anchor it + // once, so retained metadata is fenced as soon as the + // host provides the evidence needed to fence it. + if let OscMetadataState::Fenced { identity_revision } = + &mut entry.osc_metadata_state + && identity_revision.is_none() + { + *identity_revision = stream_revision; + } + return false; + } + // A first acquisition keeps OSC evidence already emitted by + // the process. Once any agent was identified, a replacement + // must wait for a newer stream revision, including after a + // confirmed exit where the host could not clear its state. + let first_acquisition = entry.foreground_agent.is_none() + && entry.emitted.is_none() + && matches!(entry.osc_metadata_state, OscMetadataState::NeverIdentified); + if first_acquisition { + entry.osc_metadata_state = OscMetadataState::FirstAgent; + } else { + entry.osc_metadata_state.fence(stream_revision); + } + } + None => { + let Some(_) = entry.foreground_agent else { + entry.foreground_misses = 0; + return false; + }; + entry.foreground_misses = entry.foreground_misses.saturating_add(1); + if entry.foreground_misses < AGENT_MISS_CONFIRMATION_ATTEMPTS { + return false; + } + // The identity is actually gone. Clear the counter before + // publishing the edge so a later agent starts cleanly. + entry.foreground_misses = 0; + // The host cannot clear its retained OSC fields on this edge. + // Preserve a fence so the next acquisition cannot inherit the + // first agent's metadata. + entry.osc_metadata_state.fence(stream_revision); + } + } + entry.foreground_agent = agent.map(str::to_string); + entry.foreground_process_group = agent.and(process_group_id); + entry.identity_presence_needed = agent.is_some(); + entry.startup_grace_until = + agent.map(|_| now + Duration::from_millis(AGENT_STARTUP_GRACE_MS)); + // A process identity edge invalidates the prior screen evaluation. + // If the first read for the new process fails, the next scan must + // retry even when the PTY revision did not change. + entry.evaluated_revision = None; + // Do not carry shell or previous-agent output activity across the + // identity edge. New PTY output during the grace window will re-arm + // this signal through observe_revision. + entry.last_output_at = None; + entry.evaluated_with_activity = false; + entry.pending_idle.clear(); + true + } + + /// The last identity that survived the miss-confirmation window. The + /// scanner uses this to distinguish a transient process-query miss from + /// a confirmed agent exit without exposing process policy to core. + pub(crate) fn foreground_agent(&self, terminal_id: &str) -> Option<&str> { + self.terminals.get(terminal_id).and_then(|entry| entry.foreground_agent.as_deref()) + } + + /// Returns `true` while the stale-screen guard is active. + pub(crate) fn startup_grace_active(&self, terminal_id: &str, now: Instant) -> bool { + self.terminals + .get(terminal_id) + .and_then(|entry| entry.startup_grace_until) + .is_some_and(|until| now < until) + } + + /// End an expired startup grace window and force one screen evaluation. + /// The return value is edge-triggered, so a steady process does not cause + /// repeated forced reads after the deadline. + pub(crate) fn finish_startup_grace(&mut self, terminal_id: &str, now: Instant) -> bool { + let Some(entry) = self.terminals.get_mut(terminal_id) else { + return false; + }; + let Some(until) = entry.startup_grace_until else { + return false; + }; + if now < until { + return false; + } + entry.startup_grace_until = None; + entry.evaluated_revision = None; + entry.pending_idle.clear(); + true + } + + /// Emit presence from process identity without reading the viewport. + /// This keeps the roster responsive while the startup grace window blocks + /// stale screen classification. + pub(crate) fn record_identity_presence_at( + &mut self, + terminal_id: &str, + agent: &str, + _now: Instant, + ) -> Option { + let entry = self.terminals.entry(terminal_id.to_string()).or_default(); + entry.pending_emission = None; + // A direct tracker caller may advance state without the scanner. In + // that case an old retry is superseded; the scanner retries first. + entry.retry_emission = None; + let before = TrackerSnapshot::capture(entry); + entry.pending_idle.clear(); + entry.visible_idle = false; + entry.visible_blocker = false; + entry.visible_working = false; + entry.last_visible_blocker_refresh = None; + let next = (agent.to_string(), AgentState::Idle); + if entry.emitted.as_ref() == Some(&next) && !entry.identity_presence_needed { + entry.identity_presence_needed = false; + return None; + } + entry.emitted = Some(next); + let emission = ScreenDetectEmission { + terminal_id: terminal_id.to_string(), + agent: agent.to_string(), + state: AgentState::Idle, + matched_rule: None, + visible_idle: false, + visible_blocker: false, + visible_working: false, + }; + entry.identity_presence_needed = false; + PendingEmission::arm(entry, before, emission.clone()); + Some(emission) + } + + /// Fold one evaluated detection. `None` detection means the foreground + /// process is not a supported agent (or is gone): a live screen-derived + /// entry is closed with a session-ended-equivalent `Done` emission. + pub fn record_detection( + &mut self, + terminal_id: &str, + detection: Option<(&str, Detection)>, + ) -> Option { + self.record_detection_at(terminal_id, detection, Instant::now(), false, false) + } + + /// Record one evaluated screen with explicit timing and lifecycle edges. + /// The scanner uses this method; the timing-free wrapper above keeps the + /// pure state API convenient for callers that only need edge folding. + pub fn record_detection_at( + &mut self, + terminal_id: &str, + detection: Option<(&str, Detection)>, + now: Instant, + identity_edge: bool, + process_exited: bool, + ) -> Option { + self.record_detection_at_with_revision( + terminal_id, + detection, + now, + identity_edge, + process_exited, + None, + ) + } + + /// Record one evaluated screen and, when the host supplied one, the + /// daemon's output revision at the lifecycle edge. The local `revision` + /// field is only a scheduling key and must never be used as an OSC fence. + pub(crate) fn record_detection_at_with_revision( + &mut self, + terminal_id: &str, + detection: Option<(&str, Detection)>, + now: Instant, + identity_edge: bool, + process_exited: bool, + stream_revision: Option, + ) -> Option { + let entry = self.terminals.entry(terminal_id.to_string()).or_default(); + entry.pending_emission = None; + // See the identity-presence path above. A scanner retry is handled + // before this method is called, so a fresh direct fold can replace it. + entry.retry_emission = None; + let before = TrackerSnapshot::capture(entry); + if process_exited { + // A terminal exit is authoritative. Do not retain the identity + // or its startup grace when the PTY has gone away. + if entry.foreground_agent.is_some() { + // `entry.revision` may be a local screen hash on older hosts. + // Only a host-provided stream revision can establish the + // post-exit generation boundary. + entry.osc_metadata_state.fence(stream_revision); + } + entry.foreground_agent = None; + entry.foreground_process_group = None; + entry.foreground_misses = 0; + entry.startup_grace_until = None; + entry.identity_presence_needed = false; + } + entry.evaluated_revision = Some(entry.revision); + let Some((agent, detection)) = detection else { + entry.pending_idle.clear(); + entry.visible_idle = false; + entry.visible_blocker = false; + entry.visible_working = false; + entry.last_visible_blocker_refresh = None; + let (agent, _) = entry.emitted.take()?; + let emission = ScreenDetectEmission { + terminal_id: terminal_id.to_string(), + agent, + state: AgentState::Done, + matched_rule: None, + visible_idle: false, + visible_blocker: false, + visible_working: false, + }; + PendingEmission::arm(entry, before, emission.clone()); + return Some(emission); + }; + let asserted = if detection.skip_state_update { + // Agent-owned viewer (transcript scroll etc.): keep prior state. + None + } else { + match detection.state { + ScreenState::Working => Some(AgentState::Working), + ScreenState::Blocked => Some(AgentState::Blocked), + ScreenState::Idle => Some(AgentState::Idle), + // A matched unknown-state rule asserts nothing. + ScreenState::Unknown => None, + } + }; + let state = match (asserted, &entry.emitted) { + (Some(state), _) => state, + // The screen asserts nothing but the process IS the agent: + // presence must not wait for a stable screen, so the first + // emission for a terminal is idle until a later scan refines. + (None, None) => AgentState::Idle, + // A live emission keeps its prior state through viewer screens. + (None, Some(_)) => return None, + }; + let visible_idle = detection.visible_idle && state == AgentState::Idle; + let visible_blocker = detection.visible_blocker && state == AgentState::Blocked; + let visible_working = detection.visible_working && state == AgentState::Working; + let previous_state = entry.emitted.as_ref().map(|(_, state)| *state); + let plain_working_to_idle = previous_state == Some(AgentState::Working) + && state == AgentState::Idle + && !visible_idle + && !visible_blocker + && !identity_edge + && !process_exited; + if plain_working_to_idle { + if entry.pending_idle.should_hold(now) { + return None; + } + } else { + entry.pending_idle.clear(); + } + let next = (agent.to_string(), state); + let stable_blocker_refresh = next.1 == AgentState::Blocked + && visible_blocker + && entry.visible_blocker + && entry.last_visible_blocker_refresh.is_none_or(|at| { + now.duration_since(at).as_millis() as u64 >= STABLE_BLOCKER_REFRESH_MS + }); + if entry.emitted.as_ref() == Some(&next) && !stable_blocker_refresh { + return None; + } + entry.emitted = Some(next); + entry.visible_idle = visible_idle; + entry.visible_blocker = visible_blocker; + entry.visible_working = visible_working; + entry.last_visible_blocker_refresh = visible_blocker.then_some(now); + let emission = ScreenDetectEmission { + terminal_id: terminal_id.to_string(), + agent: agent.to_string(), + state, + matched_rule: detection.matched_rule, + visible_idle, + visible_blocker, + visible_working, + }; + PendingEmission::arm(entry, before, emission.clone()); + Some(emission) + } + + /// Mark an emission durable. The tracker keeps no pending transaction + /// after a successful journal append. + pub(crate) fn commit_emission(&mut self, emission: &ScreenDetectEmission) { + let Some(entry) = self.terminals.get_mut(&emission.terminal_id) else { return }; + if let Some(pending) = entry.pending_emission.take() { + if pending.matches(emission) { + // The initial append already left the tracker in this state. + // The restore also makes this method correct for a replayed + // retry. + pending.after.restore(entry); + return; + } + // A late callback for another edge must not consume the current + // transaction. + entry.pending_emission = Some(pending); + } + if let Some(retry) = entry.retry_emission.take() { + if retry.matches(emission) { + retry.after.restore(entry); + return; + } + // A late callback for another edge must not consume the retry. + entry.retry_emission = Some(retry); + } + } + + /// Undo an edge when journal admission fails. The next scan must be able + /// to emit the same transition again instead of treating it as delivered. + pub(crate) fn rollback_emission(&mut self, emission: &ScreenDetectEmission) { + let Some(entry) = self.terminals.get_mut(&emission.terminal_id) else { return }; + if let Some(pending) = entry.pending_emission.take() { + if pending.matches(emission) { + pending.before.clone().restore(entry); + entry.retry_emission = Some(pending); + // Force a fresh evaluation even when the PTY revision did not + // move. The retry remains pending until its exact envelope + // is accepted or explicitly discarded. + entry.evaluated_revision = None; + return; + } + entry.pending_emission = Some(pending); + } + if entry.retry_emission.as_ref().is_some_and(|pending| pending.matches(emission)) { + entry.evaluated_revision = None; + return; + } + // Keep the retry safe if a caller supplies an emission created by an + // older tracker that did not retain a snapshot. + entry.evaluated_revision = None; + } + + /// Drop an emission after a definite admission failure. Uncertain + /// transport failures use `rollback_emission` and retain the retry. + pub(crate) fn discard_emission(&mut self, emission: &ScreenDetectEmission) { + let Some(entry) = self.terminals.get_mut(&emission.terminal_id) else { return }; + if entry.pending_emission.as_ref().is_some_and(|pending| pending.matches(emission)) { + entry.pending_emission = None; + } + if entry.retry_emission.as_ref().is_some_and(|pending| pending.matches(emission)) { + entry.retry_emission = None; + } + } + + /// Drop terminals that left the session. Closed terminals are retired + /// from the roster by the terminal lifecycle, not by an exit emission. + pub fn retain_terminals(&mut self, live: impl Fn(&str) -> bool) { + self.terminals.retain(|terminal_id, _| live(terminal_id)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::{HashSet, hash_map::DefaultHasher}; + use std::hash::BuildHasher; + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::time::Duration; + + #[derive(Clone)] + struct CountingBuildHasher { + builds: Arc, + } + + impl BuildHasher for CountingBuildHasher { + type Hasher = DefaultHasher; + + fn build_hasher(&self) -> Self::Hasher { + self.builds.fetch_add(1, Ordering::Relaxed); + DefaultHasher::new() + } + } + + fn detection(state: ScreenState) -> Detection { + Detection { + state, + skip_state_update: false, + matched_rule: Some("rule".into()), + visible_idle: false, + visible_blocker: false, + visible_working: false, + } + } + + #[test] + fn screen_detect_tracker_debounces_quiescence_per_revision() { + let mut tracker = ScreenDetectTracker::default(); + let t0 = Instant::now(); + let at = |milliseconds: u64| t0 + Duration::from_millis(milliseconds); + + // A never-evaluated terminal evaluates on first sight. + assert!(tracker.observe_revision("term_a", 1, t0)); + tracker.record_detection("term_a", Some(("codex", detection(ScreenState::Idle)))); + // An unchanged screen never re-evaluates. + assert!(!tracker.observe_revision("term_a", 1, at(400))); + + // New output re-arms the debounce; inside the window with a recent + // evaluation nothing fires. + assert!(!tracker.observe_revision("term_a", 2, at(500))); + assert!(!tracker.observe_revision("term_a", 2, at(700))); + // Quiet long enough: evaluate exactly once per revision. + assert!(tracker.observe_revision("term_a", 2, at(900))); + tracker.record_detection("term_a", Some(("codex", detection(ScreenState::Idle)))); + assert!(!tracker.observe_revision("term_a", 2, at(1_100))); + } + + #[test] + fn screen_detect_activity_expires_with_one_recheck() { + let mut tracker = ScreenDetectTracker::default(); + let t0 = Instant::now(); + let at = |milliseconds: u64| t0 + Duration::from_millis(milliseconds); + + assert!(tracker.observe_revision("term_a", 1, t0)); + tracker.record_detection("term_a", Some(("codex", detection(ScreenState::Idle)))); + assert!(!tracker.observe_revision("term_a", 2, at(400))); + assert!(tracker.observe_revision("term_a", 2, at(800))); + assert!(tracker.output_active("term_a", at(800))); + tracker.record_detection("term_a", Some(("codex", detection(ScreenState::Working)))); + tracker.note_activity_upgrade("term_a"); + + assert!(!tracker.observe_revision("term_a", 2, at(1_200))); + assert!(tracker.observe_revision("term_a", 2, at(2_301))); + assert!(!tracker.output_active("term_a", at(2_301))); + tracker.record_detection("term_a", Some(("codex", detection(ScreenState::Idle)))); + assert!(!tracker.observe_revision("term_a", 2, at(2_400))); + } + + #[test] + fn screen_detect_tracker_paces_evaluation_of_never_quiet_spinner_screens() { + let mut tracker = ScreenDetectTracker::default(); + let t0 = Instant::now(); + let at = |milliseconds: u64| t0 + Duration::from_millis(milliseconds); + + assert!(tracker.observe_revision("term_a", 1, t0)); + tracker.record_detection("term_a", Some(("codex", detection(ScreenState::Idle)))); + + // A working spinner redraws every ~100ms, so the screen never goes + // quiet for the debounce window. The pacer still evaluates at 1Hz; + // without it a working codex would stay idle forever. + let mut evaluations = 0; + for tick in 1..=25u64 { + if tracker.observe_revision("term_a", 1 + tick, at(tick * 100)) { + evaluations += 1; + tracker + .record_detection("term_a", Some(("codex", detection(ScreenState::Working)))); + } + } + assert_eq!(evaluations, 2, "1Hz pacer under 2.5s of continuous output"); + assert!(tracker.has_live_emission("term_a")); + } + + #[test] + fn screen_detect_tracker_emits_only_state_edges() { + let mut tracker = ScreenDetectTracker::default(); + + let first = + tracker.record_detection("term_a", Some(("codex", detection(ScreenState::Working)))); + assert_eq!( + first, + Some(ScreenDetectEmission { + terminal_id: "term_a".into(), + agent: "codex".into(), + state: AgentState::Working, + matched_rule: Some("rule".into()), + visible_idle: false, + visible_blocker: false, + visible_working: false, + }) + ); + // Same state again: no event (edge-triggered, never per-scan). + let repeat = + tracker.record_detection("term_a", Some(("codex", detection(ScreenState::Working)))); + assert_eq!(repeat, None); + // Transition to blocked emits. + let blocked = + tracker.record_detection("term_a", Some(("codex", detection(ScreenState::Blocked)))); + assert_eq!(blocked.map(|emission| emission.state), Some(AgentState::Blocked)); + } + + #[test] + fn failed_journal_admission_rolls_back_an_edge_for_retry() { + let mut tracker = ScreenDetectTracker::default(); + + let first = tracker + .record_detection("term_a", Some(("codex", detection(ScreenState::Working)))) + .expect("first edge"); + tracker.commit_emission(&first); + + let blocked = tracker + .record_detection("term_a", Some(("codex", detection(ScreenState::Blocked)))) + .expect("blocked edge"); + tracker.rollback_emission(&blocked); + + let retry = tracker + .record_detection("term_a", Some(("codex", detection(ScreenState::Blocked)))) + .expect("a rejected edge must be retried"); + assert_eq!(retry.state, AgentState::Blocked); + assert_eq!(retry.agent, "codex"); + } + + #[test] + fn replayed_edge_commits_the_post_state_after_rollback() { + let mut tracker = ScreenDetectTracker::default(); + let working = tracker + .record_detection("term_a", Some(("codex", detection(ScreenState::Working)))) + .expect("working edge"); + tracker.commit_emission(&working); + + let blocked = tracker + .record_detection("term_a", Some(("codex", detection(ScreenState::Blocked)))) + .expect("blocked edge"); + tracker.rollback_emission(&blocked); + tracker.commit_emission(&blocked); + + assert_eq!( + tracker.record_detection("term_a", Some(("codex", detection(ScreenState::Blocked)))), + None, + "a successful replay must commit the edge exactly once", + ); + } + + #[test] + fn committed_edges_ignore_a_late_rollback() { + let mut tracker = ScreenDetectTracker::default(); + let working = tracker + .record_detection("term_a", Some(("codex", detection(ScreenState::Working)))) + .expect("working edge"); + tracker.commit_emission(&working); + tracker.rollback_emission(&working); + + assert_eq!( + tracker.record_detection("term_a", Some(("codex", detection(ScreenState::Working)))), + None, + "a committed edge must not be undone by a late failure callback", + ); + } + + #[test] + fn screen_detect_tracker_confirms_plain_idle_before_downgrading_working() { + let mut tracker = ScreenDetectTracker::default(); + let t0 = Instant::now(); + let at = |milliseconds: u64| t0 + Duration::from_millis(milliseconds); + tracker.record_detection_at( + "term_a", + Some(("codex", detection(ScreenState::Working))), + t0, + false, + false, + ); + + // Three 100 ms rechecks are held. The fourth confirms idle. + assert!( + tracker + .record_detection_at( + "term_a", + Some(("codex", detection(ScreenState::Idle))), + at(0), + false, + false, + ) + .is_none() + ); + assert!( + tracker + .record_detection_at( + "term_a", + Some(("codex", detection(ScreenState::Idle))), + at(PENDING_IDLE_RECHECK_MS), + false, + false, + ) + .is_none() + ); + assert!( + tracker + .record_detection_at( + "term_a", + Some(("codex", detection(ScreenState::Idle))), + at(PENDING_IDLE_RECHECK_MS * 2), + false, + false, + ) + .is_none() + ); + assert_eq!( + tracker + .record_detection_at( + "term_a", + Some(("codex", detection(ScreenState::Idle))), + at(PENDING_IDLE_RECHECK_MS * 3), + false, + false, + ) + .map(|emission| emission.state), + Some(AgentState::Idle) + ); + } + + #[test] + fn screen_detect_tracker_refreshes_a_stable_visible_blocker() { + let mut tracker = ScreenDetectTracker::default(); + let t0 = Instant::now(); + let blocked = Detection { + state: ScreenState::Blocked, + skip_state_update: false, + matched_rule: Some("confirm".into()), + visible_idle: false, + visible_blocker: true, + visible_working: false, + }; + assert!( + tracker + .record_detection_at("term_a", Some(("codex", blocked.clone())), t0, false, false) + .is_some() + ); + assert!( + tracker + .record_detection_at( + "term_a", + Some(("codex", blocked.clone())), + t0 + Duration::from_millis(STABLE_BLOCKER_REFRESH_MS - 1), + false, + false, + ) + .is_none() + ); + assert!( + tracker + .record_detection_at( + "term_a", + Some(("codex", blocked)), + t0 + Duration::from_millis(STABLE_BLOCKER_REFRESH_MS), + false, + false, + ) + .is_some() + ); + } + + #[test] + fn screen_detect_tracker_closes_departed_agents_with_done() { + let mut tracker = ScreenDetectTracker::default(); + assert!( + tracker.record_detection("term_a", None).is_none(), + "a terminal that never emitted stays silent" + ); + + tracker.record_detection("term_a", Some(("codex", detection(ScreenState::Working)))); + assert!(tracker.has_live_emission("term_a")); + let done = tracker.record_detection("term_a", None); + assert_eq!( + done, + Some(ScreenDetectEmission { + terminal_id: "term_a".into(), + agent: "codex".into(), + state: AgentState::Done, + matched_rule: None, + visible_idle: false, + visible_blocker: false, + visible_working: false, + }) + ); + assert!(!tracker.has_live_emission("term_a")); + assert_eq!(tracker.record_detection("term_a", None), None, "done is an edge too"); + } + + #[test] + fn screen_detect_tracker_keeps_prior_state_for_viewers_and_unknowns() { + let mut tracker = ScreenDetectTracker::default(); + tracker.record_detection("term_a", Some(("codex", detection(ScreenState::Working)))); + + let viewer = Detection { + state: ScreenState::Unknown, + skip_state_update: true, + matched_rule: Some("transcript_viewer".into()), + visible_idle: false, + visible_blocker: false, + visible_working: false, + }; + assert_eq!(tracker.record_detection("term_a", Some(("codex", viewer))), None); + + let unknown = detection(ScreenState::Unknown); + assert_eq!(tracker.record_detection("term_a", Some(("codex", unknown))), None); + assert!(tracker.has_live_emission("term_a"), "working emission still owns the terminal"); + } + + #[test] + fn screen_detect_tracker_flags_identity_edges_for_immediate_evaluation() { + let mut tracker = ScreenDetectTracker::default(); + // Shell pane: no agent means no edge, first scan included. + assert!(!tracker.note_foreground_agent("term_a", None)); + assert!(!tracker.note_foreground_agent("term_a", None)); + // codex launches: edge fires once, then the identity is steady. + assert!(tracker.note_foreground_agent("term_a", Some("codex"))); + assert!(!tracker.note_foreground_agent("term_a", Some("codex"))); + // Swapping agents in place is an edge, and so is exiting. + assert!(tracker.note_foreground_agent("term_a", Some("claude"))); + for attempt in 1..AGENT_MISS_CONFIRMATION_ATTEMPTS { + assert!( + !tracker.note_foreground_agent("term_a", None), + "miss {attempt} must stay inside the confirmation window" + ); + assert_eq!(tracker.foreground_agent("term_a"), Some("claude")); + } + assert!(tracker.note_foreground_agent("term_a", None)); + assert_eq!(tracker.foreground_agent("term_a"), None); + } + + #[test] + fn identity_edge_retries_a_failed_screen_read_without_new_output() { + let mut tracker = ScreenDetectTracker::default(); + let t0 = Instant::now(); + let at = |milliseconds: u64| t0 + Duration::from_millis(milliseconds); + + // Establish a previously evaluated screen for one foreground agent. + assert!(tracker.note_foreground_agent_at("term_a", Some("claude"), t0)); + assert!(tracker.observe_revision("term_a", 1, t0)); + tracker.record_detection_at( + "term_a", + Some(("claude", detection(ScreenState::Working))), + t0, + false, + false, + ); + + // The process changes, but the first read after the edge is assumed + // to fail. The retry must still be armed by the identity edge. + assert!(tracker.note_foreground_agent_at("term_a", Some("codex"), at(100))); + assert!( + tracker.observe_revision("term_a", 1, at(QUIESCENCE_DEBOUNCE_MS)), + "an identity edge must invalidate the old evaluated revision" + ); + } + + #[test] + fn screen_detect_tracker_resets_identity_misses_on_a_positive_probe() { + let mut tracker = ScreenDetectTracker::default(); + assert!(tracker.note_foreground_agent("term_a", Some("codex"))); + for _ in 0..AGENT_MISS_CONFIRMATION_ATTEMPTS - 1 { + assert!(!tracker.note_foreground_agent("term_a", None)); + } + // A successful process probe prevents the previous misses from + // carrying into the next disappearance window. + assert!(!tracker.note_foreground_agent("term_a", Some("codex"))); + for _ in 0..AGENT_MISS_CONFIRMATION_ATTEMPTS - 1 { + assert!(!tracker.note_foreground_agent("term_a", None)); + } + assert!(tracker.note_foreground_agent("term_a", None)); + } + + #[test] + fn screen_detect_tracker_process_exit_clears_identity_even_without_a_done_row() { + let mut tracker = ScreenDetectTracker::default(); + let t0 = Instant::now(); + assert!(tracker.note_foreground_agent_at("term_a", Some("codex"), t0)); + assert!(tracker.record_detection_at("term_a", None, t0, true, true).is_none()); + assert_eq!(tracker.foreground_agent("term_a"), None); + assert!(!tracker.startup_grace_active("term_a", t0 + Duration::from_secs(4))); + } + + #[test] + fn screen_detect_tracker_graces_new_identity_before_reading_the_screen() { + let mut tracker = ScreenDetectTracker::default(); + let t0 = Instant::now(); + let at = |milliseconds: u64| t0 + Duration::from_millis(milliseconds); + + assert!(tracker.observe_revision("term_a", 1, t0)); + tracker.record_detection("term_a", Some(("codex", detection(ScreenState::Working)))); + assert!(!tracker.observe_revision("term_a", 2, at(100))); + assert!(tracker.output_active("term_a", at(100))); + assert!(tracker.note_foreground_agent_at("term_a", Some("codex"), t0)); + assert!(!tracker.output_active("term_a", at(100))); + assert!(tracker.startup_grace_active("term_a", at(AGENT_STARTUP_GRACE_MS - 1))); + assert!(!tracker.startup_grace_active("term_a", at(AGENT_STARTUP_GRACE_MS))); + + // Presence is published without screen evidence. The scanner's grace + // check prevents a stale shell prompt from reaching record_detection. + assert_eq!( + tracker + .record_identity_presence_at("term_a", "codex", t0) + .map(|emission| emission.state), + Some(AgentState::Idle) + ); + assert!(!tracker.needs_identity_presence("term_a", "codex")); + tracker.rollback_emission(&ScreenDetectEmission { + terminal_id: "term_a".into(), + agent: "codex".into(), + state: AgentState::Idle, + matched_rule: None, + visible_idle: false, + visible_blocker: false, + visible_working: false, + }); + assert!(tracker.needs_identity_presence("term_a", "codex")); + + // The scanner calls finish once the deadline passes. It clears the + // evaluated revision so an unchanged viewport is read exactly once. + assert!(tracker.finish_startup_grace("term_a", at(AGENT_STARTUP_GRACE_MS))); + assert!(!tracker.finish_startup_grace("term_a", at(AGENT_STARTUP_GRACE_MS + 1))); + assert!(!tracker.startup_grace_active("term_a", at(AGENT_STARTUP_GRACE_MS + 1))); + } + + #[test] + fn screen_detect_tracker_restarts_grace_for_an_agent_swap() { + let mut tracker = ScreenDetectTracker::default(); + let t0 = Instant::now(); + let at = |milliseconds: u64| t0 + Duration::from_millis(milliseconds); + + assert!(tracker.note_foreground_agent_at("term_a", Some("codex"), t0)); + assert!(!tracker.note_foreground_agent_at("term_a", Some("codex"), at(500))); + assert!(tracker.note_foreground_agent_at("term_a", Some("claude"), at(700))); + assert!(tracker.startup_grace_active("term_a", at(700 + AGENT_STARTUP_GRACE_MS - 1))); + assert!(!tracker.startup_grace_active("term_a", at(700 + AGENT_STARTUP_GRACE_MS))); + } + + #[test] + fn screen_detect_tracker_restarts_grace_for_same_agent_process_replacement() { + let mut tracker = ScreenDetectTracker::default(); + let t0 = Instant::now(); + let at = |milliseconds: u64| t0 + Duration::from_millis(milliseconds); + + assert!(tracker.note_foreground_job_at("term_a", Some("codex"), Some(41), t0)); + assert!(!tracker.note_foreground_job_at("term_a", Some("codex"), Some(41), at(500))); + assert!(tracker.note_foreground_job_at("term_a", Some("codex"), Some(42), at(700))); + assert!(tracker.startup_grace_active("term_a", at(700 + AGENT_STARTUP_GRACE_MS - 1))); + assert!(!tracker.startup_grace_active("term_a", at(700 + AGENT_STARTUP_GRACE_MS))); + } + + #[test] + fn same_agent_process_replacement_republishes_idle_presence() { + let mut tracker = ScreenDetectTracker::default(); + let t0 = Instant::now(); + + assert!(tracker.note_foreground_job_at("term_a", Some("codex"), Some(41), t0)); + let first = tracker.record_identity_presence_at("term_a", "codex", t0).unwrap(); + tracker.commit_emission(&first); + assert!(!tracker.needs_identity_presence("term_a", "codex")); + + assert!(tracker.note_foreground_job_at( + "term_a", + Some("codex"), + Some(42), + t0 + Duration::from_millis(1), + )); + assert!(tracker.needs_identity_presence("term_a", "codex")); + let replacement = tracker + .record_identity_presence_at("term_a", "codex", t0 + Duration::from_millis(1)) + .expect("replacement must emit presence even when state stays idle"); + assert_eq!(replacement.agent, "codex"); + assert_eq!(replacement.state, AgentState::Idle); + } + + #[test] + fn screen_detect_tracker_keeps_first_acquisition_osc_evidence_and_fences_replacements() { + let mut tracker = ScreenDetectTracker::default(); + let t0 = Instant::now(); + + assert!(tracker.note_foreground_job_at_with_revision( + "term_a", + Some("codex"), + None, + Some(41), + t0, + )); + // Herdr keeps evidence emitted before the first process probe. The + // userland equivalent does not require a revision on this edge. + assert!(tracker.metadata_is_fresh("term_a", Some(41))); + assert!(tracker.metadata_is_fresh("term_a", Some(40))); + + // A replacement must not inherit the previous process's OSC fields. + assert!(tracker.note_foreground_job_at_with_revision( + "term_a", + Some("claude"), + None, + Some(41), + t0, + )); + assert!(!tracker.metadata_is_fresh("term_a", Some(41))); + assert!(!tracker.metadata_is_fresh("term_a", None)); + assert!(tracker.metadata_is_fresh("term_a", Some(42))); + + // A confirmed exit also leaves a fence. The host cannot clear its + // retained fields, so the next acquisition waits for new output. + for attempt in 0..AGENT_MISS_CONFIRMATION_ATTEMPTS { + let edge = + tracker.note_foreground_job_at_with_revision("term_a", None, None, Some(42), t0); + assert_eq!(edge, attempt + 1 == AGENT_MISS_CONFIRMATION_ATTEMPTS); + } + assert!(tracker.note_foreground_job_at_with_revision( + "term_a", + Some("codex"), + None, + Some(42), + t0, + )); + assert!(!tracker.metadata_is_fresh("term_a", Some(42))); + assert!(tracker.metadata_is_fresh("term_a", Some(43))); + + // Once this terminal has supplied a generation anchor, a missing + // revision cannot prove that retained metadata belongs to the new + // process. Fail closed until the host reports a newer revision. + assert!(!tracker.metadata_is_fresh("term_a", None)); + + // If the catalog omitted the revision on a first acquisition, a later + // revision does not change the first-acquisition policy. The evidence + // may have been emitted before the process probe caught up. + assert!(tracker.note_foreground_job_at_with_revision( + "term_b", + Some("codex"), + None, + None, + t0, + )); + assert!(!tracker.note_foreground_job_at_with_revision( + "term_b", + Some("codex"), + None, + Some(41), + t0, + )); + assert!(tracker.metadata_is_fresh("term_b", Some(41))); + assert!(tracker.metadata_is_fresh("term_b", Some(42))); + } + + #[test] + fn screen_detect_tracker_keeps_first_acquisition_without_revision_unfenced() { + let mut tracker = ScreenDetectTracker::default(); + let t0 = Instant::now(); + + assert!(tracker.note_foreground_job_at_with_revision( + "term_a", + Some("codex"), + None, + None, + t0, + )); + assert!(tracker.metadata_is_fresh("term_a", None)); + + // Once a replacement is observed, a later revision enriches the + // identity and starts the fence even if the edge had no revision. + assert!(tracker.note_foreground_job_at_with_revision( + "term_a", + Some("claude"), + None, + None, + t0, + )); + assert!(!tracker.note_foreground_job_at_with_revision( + "term_a", + Some("claude"), + None, + Some(9), + t0, + )); + assert!(!tracker.metadata_is_fresh("term_a", Some(9))); + assert!(tracker.metadata_is_fresh("term_a", Some(10))); + } + + #[test] + fn screen_detect_exit_does_not_use_local_scheduler_revision_as_osc_fence() { + let mut tracker = ScreenDetectTracker::default(); + let t0 = Instant::now(); + + // A daemon without stream revisions still uses a local screen key to + // schedule reads. That key is not evidence about PTY generations. + assert!(tracker.observe_revision("term_a", 7, t0)); + assert!(tracker.note_foreground_job_at_with_revision( + "term_a", + Some("codex"), + None, + None, + t0, + )); + let started = tracker + .record_detection_at( + "term_a", + Some(("codex", detection(ScreenState::Working))), + t0, + true, + false, + ) + .expect("agent state edge"); + tracker.commit_emission(&started); + + // The exit has no host revision. The compatibility path must remain + // open; the local scheduler key must not become a durable fence. + let ended = tracker + .record_detection_at_with_revision("term_a", None, t0, true, true, None) + .expect("exit edge"); + tracker.commit_emission(&ended); + assert!(tracker.metadata_is_fresh("term_a", Some(1))); + } + + #[test] + fn screen_detect_tracker_emits_idle_presence_when_the_first_screen_asserts_nothing() { + let mut tracker = ScreenDetectTracker::default(); + // First evaluation right after spawn hits a viewer/unknown screen: + // presence must not wait for a stable screen. + let viewer = Detection { + state: ScreenState::Unknown, + skip_state_update: true, + matched_rule: Some("transcript_viewer".into()), + visible_idle: false, + visible_blocker: false, + visible_working: false, + }; + let presence = tracker.record_detection("term_a", Some(("codex", viewer))); + assert_eq!( + presence, + Some(ScreenDetectEmission { + terminal_id: "term_a".into(), + agent: "codex".into(), + state: AgentState::Idle, + matched_rule: Some("transcript_viewer".into()), + visible_idle: false, + visible_blocker: false, + visible_working: false, + }) + ); + + let unknown = detection(ScreenState::Unknown); + assert_eq!( + tracker + .record_detection("term_b", Some(("codex", unknown))) + .map(|emission| emission.state), + Some(AgentState::Idle) + ); + } + + #[test] + fn screen_detect_tracker_prunes_closed_terminals_with_one_lookup_each() { + const LIVE_COUNT: usize = 1_024; + const CLOSED_COUNT: usize = 1_024; + + let mut tracker = ScreenDetectTracker::default(); + let live_ids: Vec = (0..LIVE_COUNT).map(|index| format!("live-{index}")).collect(); + let closed_ids: Vec = + (0..CLOSED_COUNT).map(|index| format!("closed-{index}")).collect(); + for terminal_id in live_ids.iter().chain(&closed_ids) { + tracker.record_detection(terminal_id, Some(("codex", detection(ScreenState::Working)))); + } + + let builds = Arc::new(AtomicUsize::new(0)); + let mut live = HashSet::with_capacity_and_hasher( + LIVE_COUNT, + CountingBuildHasher { builds: builds.clone() }, + ); + live.extend(live_ids.iter().map(String::as_str)); + builds.store(0, Ordering::Relaxed); + + tracker.retain_terminals(|terminal_id| live.contains(terminal_id)); + + assert_eq!( + builds.load(Ordering::Relaxed), + LIVE_COUNT + CLOSED_COUNT, + "retention must make one indexed membership lookup per tracked terminal", + ); + assert!(live_ids.iter().all(|terminal_id| tracker.has_live_emission(terminal_id))); + assert!(closed_ids.iter().all(|terminal_id| !tracker.has_live_emission(terminal_id))); + } +} diff --git a/cmux-tui/bindings/examples/rust-agent-screen-detection/src/diagnostics.rs b/cmux-tui/bindings/examples/rust-agent-screen-detection/src/diagnostics.rs new file mode 100644 index 000000000000..6a964cddecf7 --- /dev/null +++ b/cmux-tui/bindings/examples/rust-agent-screen-detection/src/diagnostics.rs @@ -0,0 +1,268 @@ +//! Read-only diagnostics for a live terminal. +//! +//! Herdr's live `agent explain` command is useful when a row is wrong. The +//! equivalent belongs in this package because process identity, manifests, +//! and state interpretation are plugin policy. The daemon is used only for +//! the generic terminal list, process, and screen reads. + +#[cfg(test)] +mod target_selection_tests { + use std::collections::BTreeMap; + + use cmux::{TerminalId, TerminalLifecycle, TerminalSnapshot}; + + use super::resolve_snapshot; + + fn snapshot(hex: &str, title: &str) -> TerminalSnapshot { + TerminalSnapshot { + id: TerminalId::parse(format!("term_{hex}")) + .expect("test terminal ID has the required shape"), + tab_ids: Vec::new(), + title: title.to_string(), + cwd: None, + cols: 80, + rows: 24, + running: true, + lifecycle: TerminalLifecycle::Running, + stream_revision: Some(1), + exit: None, + extra: BTreeMap::new(), + } + } + + #[test] + fn live_target_accepts_an_exact_terminal_id() { + let terminals = vec![snapshot("11111111111111111111111111111111", "build")]; + let selected = resolve_snapshot(&terminals, "term_11111111111111111111111111111111") + .expect("terminal ID should resolve"); + assert_eq!(selected.title, "build"); + } + + #[test] + fn live_target_rejects_an_ambiguous_title() { + let terminals = vec![ + snapshot("11111111111111111111111111111111", "agent"), + snapshot("22222222222222222222222222222222", "agent"), + ]; + let error = resolve_snapshot(&terminals, "agent").expect_err("duplicate title must fail"); + assert!(error.contains("more than one terminal"), "{error}"); + assert!(error.contains("term_1111"), "{error}"); + assert!(error.contains("term_2222"), "{error}"); + } +} +use cmux::{ + Client, Config, ProcessInfoResult, ReadScreenOptions, Selector, SessionId, TerminalId, + TerminalSnapshot, +}; +use serde_json::{Value, json}; + +use crate::manifest::{DetectionInput, ManifestSet}; +use crate::process; + +const MAX_TARGET_BYTES: usize = 256; + +/// Explain one live terminal selected by its opaque ID or exact title. +/// +/// The operation is read-only. It never registers a producer, appends a +/// journal event, changes terminal scroll position, or sends input. +pub fn explain_live(socket: &str, session_name: &str, target: &str) -> Result { + validate_target(target)?; + let session_selector = session_selector(session_name)?; + let client = Client::connect(Config::from_socket_path(socket)) + .map_err(|error| format!("connect to cmux: {error}"))?; + let session = client.session(session_selector); + let snapshots = session.terminal_snapshots().map_err(|error| error.to_string())?; + let snapshot = resolve_snapshot(&snapshots, target)?; + let terminal = session.terminal(snapshot.id.clone()); + let process_info = terminal.process().map_err(|error| error.to_string())?; + let screen = terminal.read_screen(ReadScreenOptions).map_err(|error| error.to_string())?; + + let (manifests, manifest_warning) = match ManifestSet::from_environment() { + Ok(set) => (set, None), + Err(error) => { + eprintln!("cmux-agent-screen-detection: optional manifest source ignored: {error}"); + (ManifestSet::bundled().clone(), Some(error)) + } + }; + + let native_job = process::foreground_job(process_info.pid); + let process_group_authoritative = native_job.is_some(); + let job = native_job.unwrap_or_else(|| process::fallback_job(&process_info)); + // Do not label the one-process SDK fallback as a native process-group + // result. The fallback job uses the terminal's reported PID as a synthetic + // group ID, so its identity is useful but not authoritative. + let group_identified = + process_group_authoritative.then(|| process::identify_job(&manifests, &job)).flatten(); + let group_identity_available = group_identified.is_some(); + let identified = group_identified + .or_else(|| process::identify_job_with_process_fallback(&manifests, &job, &process_info)); + let primary_process_name = process_name(&process_info); + let (identified_process_name, identity_source) = identified + .map(|(_, candidate)| { + ( + candidate, + if group_identity_available { + "foreground_process_group" + } else { + "sdk_process_fallback" + }, + ) + }) + .unwrap_or((primary_process_name, "sdk_process_fallback")); + + let explanation = manifests.explain( + &identified_process_name, + DetectionInput { + screen: &screen.text, + // The scanner uses the same generic title and OSC progress fields. + // A one-shot explain has no earlier revision to compare, so it + // reports the metadata and its freshness limitation explicitly. + osc_title: &snapshot.title, + osc_progress: screen.osc_progress.as_deref().unwrap_or_default(), + }, + ); + let mut output = serde_json::to_value(explanation) + .map_err(|error| format!("encode explanation: {error}"))?; + let object = output + .as_object_mut() + .ok_or_else(|| "explanation did not encode as an object".to_string())?; + object.insert("terminal_id".into(), json!(snapshot.id.as_str())); + object.insert("terminal_title".into(), json!(snapshot.title.clone())); + object.insert("terminal_lifecycle".into(), json!(lifecycle_name(snapshot))); + object.insert( + "process".into(), + json!({ + "pid": process_info.pid, + "executable": process_info.executable, + "foreground_executable": process_info.foreground_executable, + "foreground_cwd": process_info.foreground_cwd, + "identity_source": identity_source, + "process_group_authoritative": process_group_authoritative, + }), + ); + object.insert( + "screen".into(), + json!({ + "source": "terminal.screen.read", + "viewport": "live_bottom", + "revision": screen.revision.or(snapshot.stream_revision), + "cols": screen.cols, + "rows": screen.rows, + "cursor_row": screen.cursor_row, + "cursor_col": screen.cursor_col, + "cursor_visible": screen.cursor_visible, + "osc_progress_present": screen + .osc_progress + .as_deref() + .is_some_and(|progress| !progress.is_empty()), + "metadata_freshness": "one_shot_unknown", + }), + ); + if let Some(warning) = manifest_warning { + object.insert("manifest_load_warning".into(), json!(warning)); + } + Ok(output) +} + +fn validate_target(target: &str) -> Result<(), String> { + if target.is_empty() { + return Err("live explain target must not be empty".into()); + } + if target.len() > MAX_TARGET_BYTES { + return Err(format!("live explain target exceeds {MAX_TARGET_BYTES} bytes")); + } + Ok(()) +} + +fn session_selector(session_name: &str) -> Result, String> { + if session_name.trim().is_empty() { + return Err("CMUX_TUI_SESSION_ID must not be empty".into()); + } + match SessionId::parse(session_name.to_owned()) { + Ok(id) => Ok(Selector::id(id)), + Err(_) => Ok(Selector::name(session_name.to_owned())), + } +} + +/// Resolve an exact terminal ID or exact title. A title is not a stable +/// identity, so duplicate titles fail with actionable IDs instead of choosing +/// whichever catalog entry happened to arrive first. +pub(crate) fn resolve_snapshot<'a>( + snapshots: &'a [TerminalSnapshot], + target: &str, +) -> Result<&'a TerminalSnapshot, String> { + if let Ok(id) = TerminalId::parse(target.to_owned()) { + return snapshots + .iter() + .find(|snapshot| snapshot.id == id) + .ok_or_else(|| format!("terminal {target:?} was not found")); + } + + let matches = snapshots.iter().filter(|snapshot| snapshot.title == target).collect::>(); + match matches.as_slice() { + [] => Err(format!("no terminal has the exact title {target:?}")), + [snapshot] => Ok(snapshot), + many => { + let ids = + many.iter().map(|snapshot| snapshot.id.as_str()).collect::>().join(", "); + Err(format!("more than one terminal has the exact title {target:?}; use an ID: {ids}")) + } + } +} + +fn process_name(process: &ProcessInfoResult) -> String { + process + .foreground_executable + .clone() + .or_else(|| process.executable.clone()) + .or_else(|| process.argv.first().cloned()) + .unwrap_or_else(|| "unknown".into()) +} + +fn lifecycle_name(snapshot: &TerminalSnapshot) -> &'static str { + match snapshot.lifecycle { + cmux::TerminalLifecycle::Launching => "launching", + cmux::TerminalLifecycle::Running => "running", + cmux::TerminalLifecycle::Exited => "exited", + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeMap; + + fn snapshot(hex: &str, title: &str) -> TerminalSnapshot { + TerminalSnapshot { + id: TerminalId::parse(format!("term_{hex}")) + .expect("test terminal ID has the required shape"), + tab_ids: Vec::new(), + title: title.to_string(), + cwd: None, + cols: 80, + rows: 24, + running: true, + lifecycle: cmux::TerminalLifecycle::Running, + stream_revision: Some(1), + exit: None, + extra: BTreeMap::new(), + } + } + + #[test] + fn target_rejects_an_empty_or_oversized_value() { + assert!(validate_target("").is_err()); + assert!(validate_target(&"x".repeat(MAX_TARGET_BYTES + 1)).is_err()); + } + + #[test] + fn target_reports_missing_ids_and_titles() { + let terminals = vec![snapshot("11111111111111111111111111111111", "build")]; + assert!( + resolve_snapshot(&terminals, "term_22222222222222222222222222222222") + .unwrap_err() + .contains("was not found") + ); + assert!(resolve_snapshot(&terminals, "missing").unwrap_err().contains("no terminal has")); + } +} diff --git a/cmux-tui/bindings/examples/rust-agent-screen-detection/src/lib.rs b/cmux-tui/bindings/examples/rust-agent-screen-detection/src/lib.rs new file mode 100644 index 000000000000..5aea1955ef0e --- /dev/null +++ b/cmux-tui/bindings/examples/rust-agent-screen-detection/src/lib.rs @@ -0,0 +1,12 @@ +//! Reference userland agent plugin. +//! +//! The daemon supervises this process, but all agent-specific policy lives +//! here. The package can be replaced by another implementation that emits +//! the same generic journal envelope. + +pub mod detect; +pub mod diagnostics; +pub mod manifest; +pub mod manifest_update; +pub mod process; +pub mod scanner; diff --git a/cmux-tui/bindings/examples/rust-agent-screen-detection/src/main.rs b/cmux-tui/bindings/examples/rust-agent-screen-detection/src/main.rs new file mode 100644 index 000000000000..e3b4bc1e9a4c --- /dev/null +++ b/cmux-tui/bindings/examples/rust-agent-screen-detection/src/main.rs @@ -0,0 +1,368 @@ +use std::env; +use std::path::Path; +use std::process::ExitCode; + +const MAX_EXPLAIN_SCREEN_BYTES: usize = 8 * 1024 * 1024; + +fn main() -> ExitCode { + let mut args = env::args().skip(1); + let command = args.next(); + match command.as_deref() { + Some("--help") | Some("-h") => { + print_help(); + return ExitCode::SUCCESS; + } + Some("list") => return run_list(), + Some("status") => return run_status(), + Some("explain") => return run_explain(args.collect()), + Some("update") => return run_update(args.collect()), + Some(other) => { + eprintln!("cmux-agent-screen-detection: unknown command {other:?}"); + print_help(); + return ExitCode::from(2); + } + None => {} + } + + let socket = match env::var("CMUX_TUI_SOCKET") { + Ok(value) if !value.is_empty() => value, + _ => { + eprintln!("cmux-agent-screen-detection: CMUX_TUI_SOCKET is required"); + return ExitCode::FAILURE; + } + }; + let session = env::var("CMUX_TUI_SESSION_ID").unwrap_or_else(|_| "main".into()); + let plugin_id = match required_plugin_id(env::var("CMUX_PLUGIN_ID").ok()) { + Ok(value) => value, + Err(error) => { + eprintln!("cmux-agent-screen-detection: {error}"); + return ExitCode::FAILURE; + } + }; + match cmux_agent_screen_detection::scanner::run(&socket, &session, &plugin_id) { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("cmux-agent-screen-detection: {error}"); + ExitCode::FAILURE + } + } +} + +fn required_plugin_id(value: Option) -> Result { + let Some(value) = value else { + return Err("CMUX_PLUGIN_ID is required".into()); + }; + if value.trim().is_empty() { + return Err("CMUX_PLUGIN_ID must not be blank".into()); + } + cmux_agent_screen_detection::scanner::validate_plugin_id(&value) + .map_err(|error| format!("invalid CMUX_PLUGIN_ID: {error}"))?; + Ok(value) +} + +fn run_list() -> ExitCode { + match cmux_agent_screen_detection::manifest::ManifestSet::from_environment() { + Ok(set) => { + let manifests = set + .manifests() + .map(|manifest| { + serde_json::json!({ + "id": manifest.id(), + "version": manifest.version().map(ToString::to_string), + "source": manifest.source().label(), + }) + }) + .collect::>(); + print_json(&serde_json::json!({ + "engine_version": cmux_agent_screen_detection::manifest::SCREEN_DETECT_ENGINE_VERSION, + "manifests": manifests, + })) + } + Err(error) => print_error(error), + } +} + +fn run_status() -> ExitCode { + let cache_dir = cmux_agent_screen_detection::manifest_update::environment_cache_dir(); + print_json(&cmux_agent_screen_detection::manifest_update::status_json(&cache_dir)) +} + +fn run_explain(arguments: Vec) -> ExitCode { + let live = arguments.iter().any(|argument| argument == "--live"); + let mut process = None; + let mut screen_path = None; + let mut live_target = None; + let mut title = String::new(); + let mut progress = String::new(); + let mut json_output = true; + let mut index = 0; + while index < arguments.len() { + let value = &arguments[index]; + let next = |index: &mut usize, name: &str| -> Result { + *index += 1; + arguments.get(*index).cloned().ok_or_else(|| format!("{name} needs a value")) + }; + match value.as_str() { + "--live" => {} + "--json" => json_output = true, + "--format" => { + let format = match next(&mut index, "--format") { + Ok(value) => value, + Err(error) => return print_error(error), + }; + match format.as_str() { + "json" => json_output = true, + "text" => json_output = false, + _ => return print_error("--format must be json or text".into()), + } + } + "--terminal" => { + if live_target.is_some() { + return print_error("live explain target was supplied more than once".into()); + } + live_target = Some(match next(&mut index, "--terminal") { + Ok(value) => value, + Err(error) => return print_error(error), + }); + } + "--process" => { + process = Some(match next(&mut index, "--process") { + Ok(value) => value, + Err(error) => return print_error(error), + }) + } + "--screen" => { + screen_path = Some(match next(&mut index, "--screen") { + Ok(value) => value, + Err(error) => return print_error(error), + }) + } + "--title" => { + title = match next(&mut index, "--title") { + Ok(value) => value, + Err(error) => return print_error(error), + } + } + "--progress" => { + progress = match next(&mut index, "--progress") { + Ok(value) => value, + Err(error) => return print_error(error), + } + } + _ if live && live_target.is_none() => live_target = Some(value.clone()), + _ if live => return print_error(format!("unexpected live explain argument {value:?}")), + _ if process.is_none() => process = Some(value.clone()), + _ if screen_path.is_none() => screen_path = Some(value.clone()), + _ => return print_error(format!("unexpected explain argument {value:?}")), + } + index += 1; + } + + if !live && live_target.is_some() { + return print_error("--terminal requires --live".into()); + } + if live { + if process.is_some() || screen_path.is_some() || !title.is_empty() || !progress.is_empty() { + return print_error( + "--live cannot be combined with --process, --screen, --title, or --progress".into(), + ); + } + let Some(target) = live_target else { + return print_error( + "usage: cmux-agent-screen-detection explain --live ".into(), + ); + }; + let socket = match env::var("CMUX_TUI_SOCKET") { + Ok(value) if !value.is_empty() => value, + _ => return print_error("CMUX_TUI_SOCKET is required for live explain".into()), + }; + let session = env::var("CMUX_TUI_SESSION_ID").unwrap_or_else(|_| "main".into()); + return match cmux_agent_screen_detection::diagnostics::explain_live( + &socket, &session, &target, + ) { + Ok(value) if json_output => print_json(&value), + Ok(value) => print_explain_text(&value), + Err(error) => print_error(error), + }; + } + + let Some(process) = process else { + return print_error(explain_file_usage()); + }; + let Some(screen_path) = screen_path else { + return print_error(explain_file_usage()); + }; + let screen = match cmux_agent_screen_detection::manifest::read_bounded_utf8_file( + Path::new(&screen_path), + MAX_EXPLAIN_SCREEN_BYTES, + ) { + Ok(screen) => screen, + Err(error) => return print_error(format!("read screen {screen_path}: {error}")), + }; + match cmux_agent_screen_detection::manifest::ManifestSet::from_environment() { + Ok(set) => { + let value = serde_json::to_value(set.explain( + &process, + cmux_agent_screen_detection::manifest::DetectionInput { + screen: &screen, + osc_title: &title, + osc_progress: &progress, + }, + )) + .expect("detection explanation is serializable"); + if json_output { print_json(&value) } else { print_explain_text(&value) } + } + Err(error) => print_error(error), + } +} + +fn explain_file_usage() -> String { + "usage: cmux-agent-screen-detection explain [--title ] [--progress ] [--format json|text]" + .into() +} + +fn run_update(arguments: Vec) -> ExitCode { + let mut url = None; + let mut cache_dir = None; + let mut index = 0; + while index < arguments.len() { + match arguments[index].as_str() { + "--url" => { + index += 1; + let Some(value) = arguments.get(index) else { + return print_error("--url needs a value".into()); + }; + url = Some(value.clone()); + } + "--cache-dir" => { + index += 1; + let Some(value) = arguments.get(index) else { + return print_error("--cache-dir needs a value".into()); + }; + cache_dir = Some(value.clone()); + } + value => return print_error(format!("unexpected update argument {value:?}")), + } + index += 1; + } + let url = + url.unwrap_or_else(cmux_agent_screen_detection::manifest_update::environment_catalog_url); + let cache_dir = cache_dir + .map(std::path::PathBuf::from) + .unwrap_or_else(cmux_agent_screen_detection::manifest_update::environment_cache_dir); + match cmux_agent_screen_detection::manifest_update::update_catalog(&url, &cache_dir) { + Ok(summary) => { + print_json(&cmux_agent_screen_detection::manifest_update::summary_json(&summary)) + } + Err(error) => print_error(error), + } +} + +fn print_json(value: &serde_json::Value) -> ExitCode { + match serde_json::to_string_pretty(value) { + Ok(value) => { + println!("{value}"); + ExitCode::SUCCESS + } + Err(error) => print_error(format!("encode JSON: {error}")), + } +} + +fn print_error(error: String) -> ExitCode { + eprintln!("cmux-agent-screen-detection: {error}"); + ExitCode::FAILURE +} + +fn print_explain_text(value: &serde_json::Value) -> ExitCode { + println!( + "terminal: {} ({})", + value["terminal_id"].as_str().unwrap_or("file"), + value["terminal_title"].as_str().unwrap_or("-") + ); + println!("agent: {}", value["agent"].as_str().unwrap_or("unknown")); + println!("state: {}", value["state"].as_str().unwrap_or("unknown")); + println!( + "manifest: {} {}", + value["source"].as_str().unwrap_or("none"), + value["version"].as_str().unwrap_or("unknown") + ); + if let Some(rule) = value["matched_rule"].as_str() { + println!("rule: {rule}"); + } else { + println!("rule: none"); + } + if let Some(reason) = value["fallback_reason"].as_str() { + println!("fallback_reason: {reason}"); + } + if let Some(process) = value["process"].as_object() { + println!( + "process: {} pid={} source={}", + process["foreground_executable"] + .as_str() + .or_else(|| process["executable"].as_str()) + .unwrap_or("unknown"), + process["pid"].as_u64().unwrap_or(0), + process["identity_source"].as_str().unwrap_or("unknown") + ); + } + if let Some(screen) = value["screen"].as_object() { + println!( + "screen: {}x{} revision={}", + screen["cols"].as_u64().unwrap_or(0), + screen["rows"].as_u64().unwrap_or(0), + screen["revision"].as_u64().unwrap_or(0) + ); + } + ExitCode::SUCCESS +} + +fn print_help() { + eprintln!( + "usage:\n cmux-agent-screen-detection\n cmux-agent-screen-detection list\n cmux-agent-screen-detection status\n cmux-agent-screen-detection explain [--title ] [--progress ] [--format json|text]\n cmux-agent-screen-detection explain --live [--format json|text]\n cmux-agent-screen-detection update [--url ] [--cache-dir ]" + ); +} + +#[cfg(test)] +mod tests { + use super::{required_plugin_id, run_explain}; + + #[test] + fn plugin_id_requires_a_nonblank_supervisor_namespace() { + assert!(required_plugin_id(None).is_err()); + assert!(required_plugin_id(Some(String::new())).is_err()); + assert!(required_plugin_id(Some(" ".into())).is_err()); + assert_eq!(required_plugin_id(Some("agent-screen".into())).unwrap(), "agent-screen"); + } + + #[test] + fn plugin_id_rejects_values_that_cannot_name_a_journal_namespace() { + let too_long = "a".repeat(65); + for value in [ + "Screen-detector", + "screen.detector", + "screen detector", + "-screen-detector", + "cmux_agent", + ] { + assert!( + required_plugin_id(Some(value.to_string())).is_err(), + "invalid plugin id was accepted: {value:?}" + ); + } + assert!(required_plugin_id(Some(too_long)).is_err()); + assert!(required_plugin_id(Some("screen_detector-2".into())).is_ok()); + } + + #[test] + fn live_terminal_selector_is_not_silently_ignored_in_file_mode() { + assert_eq!( + run_explain(vec![ + "--terminal".into(), + "term_11111111111111111111111111111111".into(), + "codex".into(), + "/tmp/screen".into(), + ]), + std::process::ExitCode::FAILURE + ); + } +} diff --git a/cmux-tui/bindings/examples/rust-agent-screen-detection/src/manifest.rs b/cmux-tui/bindings/examples/rust-agent-screen-detection/src/manifest.rs new file mode 100644 index 000000000000..e1250085aa24 --- /dev/null +++ b/cmux-tui/bindings/examples/rust-agent-screen-detection/src/manifest.rs @@ -0,0 +1,2290 @@ +//! Screen-detection manifest engine. +//! +//! Ported from herdrdev/herdr `src/detect/manifest.rs` at commit +//! `7b675f42af35508eab66ac42fe1598628597a893` (Apache-2.0, see +//! `manifests/LICENSE`), modified by manaflow: agents are identified by +//! manifest id/alias strings instead of a closed enum, and the engine adds +//! bounded source loading and explain output for a userland plugin. Claude +//! background-shell regression fixtures are adapted from herdr's +//! `src/detect/manifest/tests.rs` at commit +//! `987b070fbfa187e85009b45cd7e208fc6175ff6a`. + +use std::cmp::Ordering; +use std::collections::{HashMap, hash_map::Entry}; +use std::fmt; +use std::fs::File; +use std::io::{self, Read}; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +use regex::Regex; +use serde::Deserialize; +use sha2::{Digest, Sha256}; + +/// Highest herdr manifest engine version whose semantics this port covers. +pub const SCREEN_DETECT_ENGINE_VERSION: u32 = 3; +/// Explain output used when a known agent has no matching visible rule. +pub const DEFAULT_KNOWN_AGENT_IDLE_FALLBACK: &str = "known_agent_idle_fallback"; + +pub const MAX_MANIFEST_BYTES: usize = 256 * 1024; + +/// Read a UTF-8 file with a hard byte bound. The CLI and library share this +/// helper so diagnostic commands cannot drift from manifest loading rules. +pub fn read_bounded_utf8_file(path: &Path, max_bytes: usize) -> io::Result { + read_bounded_utf8(File::open(path)?, max_bytes) +} + +fn read_bounded_utf8(reader: impl Read, max_bytes: usize) -> io::Result { + let mut bytes = Vec::with_capacity(max_bytes.min(8 * 1024)); + reader + .take(u64::try_from(max_bytes).unwrap_or(u64::MAX).saturating_add(1)) + .read_to_end(&mut bytes)?; + if bytes.len() > max_bytes { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("file exceeds {max_bytes} bytes"), + )); + } + String::from_utf8(bytes).map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)) +} + +/// Dotted numeric manifest version. Numeric comparison avoids lexical +/// surprises such as `2026.10` sorting before `2026.9`. +#[derive(Debug, Clone)] +pub struct ManifestVersion(String); + +impl ManifestVersion { + pub fn parse(value: &str) -> Result { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err("manifest version must not be empty".into()); + } + for segment in trimmed.split('.') { + if segment.is_empty() || !segment.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(format!("manifest version {trimmed:?} must be dotted numeric")); + } + segment.parse::().map_err(|_| { + format!("manifest version {trimmed:?} contains an oversized segment") + })?; + } + Ok(Self(trimmed.to_string())) + } +} + +impl fmt::Display for ManifestVersion { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0) + } +} + +impl Ord for ManifestVersion { + fn cmp(&self, other: &Self) -> Ordering { + let mut left = self.0.split('.'); + let mut right = other.0.split('.'); + loop { + match (left.next(), right.next()) { + (Some(left), Some(right)) => { + match left.parse::().unwrap_or(0).cmp(&right.parse::().unwrap_or(0)) { + Ordering::Equal => {} + ordering => return ordering, + } + } + (Some(left), None) => { + let value = left.parse::().unwrap_or(0); + if value != 0 { + return Ordering::Greater; + } + } + (None, Some(right)) => { + let value = right.parse::().unwrap_or(0); + if value != 0 { + return Ordering::Less; + } + } + (None, None) => return Ordering::Equal, + } + } + } +} + +impl PartialOrd for ManifestVersion { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl PartialEq for ManifestVersion { + fn eq(&self, other: &Self) -> bool { + self.cmp(other) == Ordering::Equal + } +} + +impl Eq for ManifestVersion {} + +impl<'de> Deserialize<'de> for ManifestVersion { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::parse(&value).map_err(serde::de::Error::custom) + } +} + +/// Where a manifest came from. Local overrides always take precedence over +/// a cached remote file, which takes precedence over the bundled copy. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ManifestSource { + Bundled, + Remote { path: PathBuf, version: ManifestVersion }, + Override(PathBuf), +} + +impl ManifestSource { + pub fn label(&self) -> String { + match self { + Self::Bundled => "bundled".into(), + Self::Remote { path, version } => format!("remote:{}@{version}", path.display()), + Self::Override(path) => format!("override:{}", path.display()), + } + } + + /// Stable source class for machine-readable diagnostics. Keep this + /// separate from `label`, which contains a local path and is not stable + /// across hosts. + pub fn kind(&self) -> &'static str { + match self { + Self::Bundled => "bundled", + Self::Remote { .. } => "remote", + Self::Override(_) => "local_override", + } + } +} + +/// Load and update diagnostics kept with one compiled manifest. These fields +/// mirror the useful herdr explanation surface without making the daemon +/// aware of cache files or network state. +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize)] +pub struct ManifestDiagnostics { + pub warning: Option, + pub cached_remote_version: Option, + pub local_override_shadowing_remote: bool, + pub remote_update_status: Option, + pub remote_update_error: Option, +} + +const MAX_RULES_PER_MANIFEST: usize = 128; +const MAX_GATE_DEPTH: usize = 8; +const MAX_TOTAL_GATES: usize = 512; +const MAX_MATCHERS_PER_GATE: usize = 32; +const MAX_TOTAL_MATCHERS: usize = 1024; +const MAX_MATCHER_CHARS: usize = 512; +// Keep user-provided catalogs and directories bounded before TOML parsing or +// regex compilation can allocate for every entry. +const MAX_MANIFESTS: usize = 256; +const MAX_MANIFEST_DIRECTORY_ENTRIES: usize = 512; +const TOP_NON_EMPTY_LINES_ENGINE_VERSION: u32 = 3; + +/// Detection states a manifest rule can assign to a screen snapshot. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ScreenState { + Idle, + Working, + Blocked, + Unknown, +} + +/// Screen snapshot plus OSC-derived strings. Empty `osc_title` / +/// `osc_progress` behave exactly like the pre-OSC herdr engine. +#[derive(Debug, Clone, Copy)] +pub struct DetectionInput<'a> { + pub screen: &'a str, + pub osc_title: &'a str, + pub osc_progress: &'a str, +} + +/// What one manifest evaluation concluded. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Detection { + pub state: ScreenState, + /// The screen shows an agent-owned viewer (transcript scroll etc.); + /// the previous state must be kept. + pub skip_state_update: bool, + /// Matched rule id, absent when the known-agent idle fallback applied. + pub matched_rule: Option, + /// Herdr's visibility hints are retained as evidence for plugin + /// diagnostics. A hint is true only when the matched rule declares the + /// same state, matching herdr's publication semantics. + pub visible_idle: bool, + pub visible_blocker: bool, + pub visible_working: bool, +} + +#[derive(Debug, Deserialize, Clone)] +#[serde(deny_unknown_fields)] +pub(crate) struct AgentManifest { + id: String, + version: Option, + min_engine_version: Option, + #[serde(rename = "updated_at")] + _updated_at: Option, + #[serde(default)] + aliases: Vec, + #[serde(default)] + rules: Vec, +} + +#[derive(Debug, Deserialize, Clone)] +#[serde(deny_unknown_fields)] +struct ManifestRule { + id: String, + state: Option, + #[serde(default)] + priority: i32, + #[serde(default = "default_region")] + region: String, + #[serde(default)] + visible_idle: bool, + #[serde(default)] + visible_blocker: bool, + #[serde(default)] + visible_working: bool, + #[serde(default)] + skip_state_update: bool, + #[serde(default)] + all: Vec, + #[serde(default)] + any: Vec, + #[serde(default, rename = "not")] + not_gate: Vec, + #[serde(default)] + contains: Vec, + #[serde(default)] + regex: Vec, + #[serde(default)] + line_regex: Vec, +} + +#[derive(Debug, Deserialize, Clone)] +#[serde(deny_unknown_fields)] +struct ManifestGate { + #[serde(default)] + all: Vec, + #[serde(default)] + any: Vec, + #[serde(default, rename = "not")] + not_gate: Vec, + #[serde(default)] + contains: Vec, + #[serde(default)] + regex: Vec, + #[serde(default)] + line_regex: Vec, +} + +#[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +enum ManifestState { + Idle, + Working, + Blocked, + Unknown, +} + +impl From for ScreenState { + fn from(value: ManifestState) -> Self { + match value { + ManifestState::Idle => ScreenState::Idle, + ManifestState::Working => ScreenState::Working, + ManifestState::Blocked => ScreenState::Blocked, + ManifestState::Unknown => ScreenState::Unknown, + } + } +} + +fn default_region() -> String { + "whole_recent".to_string() +} + +#[derive(Debug, Clone)] +struct CompiledGate { + all: Vec, + any: Vec, + not_gate: Vec, + contains: Vec, + regex: Vec, + line_regex: Vec, +} + +/// One agent manifest with its rule gates compiled to regex matchers. +#[derive(Debug, Clone)] +pub struct CompiledManifest { + manifest: AgentManifest, + compiled_rules: Vec, + source: ManifestSource, + diagnostics: ManifestDiagnostics, +} + +impl CompiledManifest { + pub fn id(&self) -> &str { + &self.manifest.id + } + + pub fn version(&self) -> Option<&ManifestVersion> { + self.manifest.version.as_ref() + } + + pub fn source(&self) -> &ManifestSource { + &self.source + } + + pub fn diagnostics(&self) -> &ManifestDiagnostics { + &self.diagnostics + } + + /// True when a process name equals the manifest id or one of its + /// aliases after path/basename and extension normalization. + pub fn matches_process_name(&self, process_name: &str) -> bool { + let name = normalized_agent_lookup_name(path_basename(process_name)); + name == self.manifest.id + || (self.manifest.id == "muse" && is_versioned_muse_binary(&name)) + || self.manifest.aliases.iter().any(|alias| normalized_agent_lookup_name(alias) == name) + } + + /// Evaluate every rule against the snapshot; the highest-priority match + /// wins (first rule wins a priority tie). No match falls back to `Idle`: + /// a known agent showing none of its working/blocked chrome is at rest + /// (herdr's `default_known_agent_idle_fallback`). + pub fn detect(&self, input: DetectionInput<'_>) -> Detection { + let mut matched: Option<&ManifestRule> = None; + let mut regions = HashMap::new(); + for (rule, compiled) in self.manifest.rules.iter().zip(&self.compiled_rules) { + let (region_text, lower_region_text) = cached_region(&mut regions, input, &rule.region); + if !compiled_gate_matches(compiled, region_text, lower_region_text) { + continue; + } + match matched { + Some(previous) if previous.priority >= rule.priority => {} + _ => matched = Some(rule), + } + } + let Some(rule) = matched else { + return Detection { + state: ScreenState::Idle, + skip_state_update: false, + matched_rule: None, + visible_idle: false, + visible_blocker: false, + visible_working: false, + }; + }; + let state = rule.state.map(ScreenState::from).unwrap_or(ScreenState::Unknown); + Detection { + state, + skip_state_update: rule.skip_state_update, + matched_rule: Some(rule.id.clone()), + visible_idle: rule.visible_idle && state == ScreenState::Idle, + visible_blocker: rule.visible_blocker && state == ScreenState::Blocked, + visible_working: rule.visible_working && state == ScreenState::Working, + } + } + + /// Explain every rule evaluation. This keeps diagnosis next to the + /// userland rule engine and avoids adding a privileged daemon endpoint. + pub fn explain(&self, input: DetectionInput<'_>) -> DetectionExplain { + let mut selected: Option<&ManifestRule> = None; + let mut evaluated_rules = Vec::with_capacity(self.manifest.rules.len()); + let mut regions = HashMap::new(); + for (rule, compiled) in self.manifest.rules.iter().zip(&self.compiled_rules) { + let (text, lower_text) = cached_region(&mut regions, input, &rule.region); + let matched = compiled_gate_matches(compiled, text, lower_text); + let evidence = + gate_evidence(&manifest_gate_from_rule(rule), compiled, text, lower_text); + evaluated_rules.push(RuleExplanation { + id: rule.id.clone(), + priority: rule.priority, + region: rule.region.clone(), + state: rule.state.map(ScreenState::from).unwrap_or(ScreenState::Unknown), + matched, + region_bytes: text.len(), + region_preview: preview(text), + visible_idle: rule.visible_idle, + visible_blocker: rule.visible_blocker, + visible_working: rule.visible_working, + contains: rule.contains.clone(), + regex: rule.regex.clone(), + line_regex: rule.line_regex.clone(), + contains_count: rule.contains.len(), + regex_count: rule.regex.len(), + line_regex_count: rule.line_regex.len(), + all_count: rule.all.len(), + any_count: rule.any.len(), + not_count: rule.not_gate.len(), + evidence, + }); + if matched && selected.is_none_or(|previous| previous.priority < rule.priority) { + selected = Some(rule); + } + } + let (state, matched_rule, skip_state_update, fallback_reason) = match selected { + Some(rule) => ( + rule.state.map(ScreenState::from).unwrap_or(ScreenState::Unknown), + Some(rule.id.clone()), + rule.skip_state_update, + None, + ), + None => { + (ScreenState::Idle, None, false, Some(DEFAULT_KNOWN_AGENT_IDLE_FALLBACK.into())) + } + }; + DetectionExplain { + process_name: self.manifest.id.clone(), + agent: Some(self.manifest.id.clone()), + state, + source: self.source.label(), + source_kind: self.source.kind(), + version: self.manifest.version.as_ref().map(ToString::to_string), + matched_rule, + skip_state_update, + fallback_reason, + visible_idle: selected.is_some_and(|rule| { + rule.visible_idle && rule.state.map(ScreenState::from) == Some(ScreenState::Idle) + }), + visible_blocker: selected.is_some_and(|rule| { + rule.visible_blocker + && rule.state.map(ScreenState::from) == Some(ScreenState::Blocked) + }), + visible_working: selected.is_some_and(|rule| { + rule.visible_working + && rule.state.map(ScreenState::from) == Some(ScreenState::Working) + }), + screen_detection_skipped: false, + skipped_update_reason: selected + .filter(|rule| rule.skip_state_update) + .map(|rule| format!("matched_rule:{}", rule.id)), + warning: self.diagnostics.warning.clone(), + cached_remote_version: self.diagnostics.cached_remote_version.clone(), + local_override_shadowing_remote: self.diagnostics.local_override_shadowing_remote, + remote_update_status: self.diagnostics.remote_update_status.clone(), + remote_update_error: self.diagnostics.remote_update_error.clone(), + evaluated_rules, + } + } +} + +/// One rule's diagnostic result. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +pub struct RuleExplanation { + pub id: String, + pub priority: i32, + pub region: String, + pub state: ScreenState, + pub matched: bool, + pub region_bytes: usize, + pub region_preview: String, + pub visible_idle: bool, + pub visible_blocker: bool, + pub visible_working: bool, + /// The literal matcher expressions from the manifest. Herdr exposes + /// these in its explain output; retaining them makes a userland rule + /// diagnosis actionable without exposing compiled regex internals. + pub contains: Vec, + pub regex: Vec, + pub line_regex: Vec, + pub contains_count: usize, + pub regex_count: usize, + pub line_regex_count: usize, + pub all_count: usize, + pub any_count: usize, + pub not_count: usize, + /// Matcher evidence contains only expressions that matched. Nested gate + /// results retain their own `matched` flag, so `explain` can show why an + /// `all`, `any`, or `not` gate passed or failed without exposing compiled + /// regex internals. + pub evidence: GateEvidence, +} + +/// Match evidence for one manifest gate. This is package-owned diagnostic +/// data, not a daemon policy type. The full expressions remain on +/// `RuleExplanation`; these lists contain only the expressions that matched +/// the supplied region. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +pub struct GateEvidence { + pub matched: bool, + pub contains: Vec, + pub regex: Vec, + pub line_regex: Vec, + pub all: Vec, + pub any: Vec, + pub not_gate: Vec, +} + +/// Userland diagnostic result for one process and terminal snapshot. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +pub struct DetectionExplain { + pub process_name: String, + pub agent: Option, + pub state: ScreenState, + pub source: String, + pub source_kind: &'static str, + pub version: Option, + pub matched_rule: Option, + pub skip_state_update: bool, + pub screen_detection_skipped: bool, + pub skipped_update_reason: Option, + pub fallback_reason: Option, + pub visible_idle: bool, + pub visible_blocker: bool, + pub visible_working: bool, + pub warning: Option, + pub cached_remote_version: Option, + pub local_override_shadowing_remote: bool, + pub remote_update_status: Option, + pub remote_update_error: Option, + pub evaluated_rules: Vec, +} + +impl DetectionExplain { + fn unknown(process_name: &str) -> Self { + Self { + process_name: process_name.to_string(), + agent: None, + state: ScreenState::Unknown, + source: "none".into(), + source_kind: "none", + version: None, + matched_rule: None, + skip_state_update: false, + screen_detection_skipped: false, + skipped_update_reason: None, + fallback_reason: Some("unknown_agent".into()), + visible_idle: false, + visible_blocker: false, + visible_working: false, + warning: None, + cached_remote_version: None, + local_override_shadowing_remote: false, + remote_update_status: None, + remote_update_error: None, + evaluated_rules: Vec::new(), + } + } +} + +fn preview(text: &str) -> String { + let mut preview: String = text.chars().take(160).collect(); + if text.chars().count() > 160 { + preview.push('…'); + } + preview +} + +/// Resolve and lowercase each distinct region once per screen evaluation. +/// Herdr evaluated the same region independently for every rule. A manifest +/// can contain many rules over `whole_recent` or a shared bottom slice, so +/// reusing both the slice and its case-folded text keeps the hot path linear in +/// the number of distinct regions rather than the number of rules. +fn cached_region<'cache, 'input, 'spec>( + cache: &'cache mut HashMap<&'spec str, (&'input str, String)>, + input: DetectionInput<'input>, + spec: &'spec str, +) -> (&'input str, &'cache str) { + if let Entry::Vacant(entry) = cache.entry(spec) { + let text = region(input, spec); + entry.insert((text, text.to_lowercase())); + } + let (text, lower_text) = cache.get(spec).expect("region was inserted above"); + (*text, lower_text.as_str()) +} + +fn compiled_gate_matches(gate: &CompiledGate, text: &str, lower_text: &str) -> bool { + if !gate.contains.iter().all(|needle| lower_text.contains(needle)) { + return false; + } + if !gate.regex.iter().all(|regex| regex.is_match(text)) { + return false; + } + if !gate.line_regex.iter().all(|regex| text.lines().any(|line| regex.is_match(line))) { + return false; + } + if !gate.all.iter().all(|nested| compiled_gate_matches(nested, text, lower_text)) { + return false; + } + if !gate.any.is_empty() + && !gate.any.iter().any(|nested| compiled_gate_matches(nested, text, lower_text)) + { + return false; + } + if gate.not_gate.iter().any(|nested| compiled_gate_matches(nested, text, lower_text)) { + return false; + } + true +} + +fn gate_evidence( + source: &ManifestGate, + compiled: &CompiledGate, + text: &str, + lower_text: &str, +) -> GateEvidence { + let contains = source + .contains + .iter() + .zip(&compiled.contains) + .filter(|(_, needle)| lower_text.contains(needle.as_str())) + .map(|(pattern, _)| pattern.clone()) + .collect(); + let regex = source + .regex + .iter() + .zip(&compiled.regex) + .filter(|(_, pattern)| pattern.is_match(text)) + .map(|(pattern, _)| pattern.clone()) + .collect(); + let line_regex = source + .line_regex + .iter() + .zip(&compiled.line_regex) + .filter(|(_, pattern)| text.lines().any(|line| pattern.is_match(line))) + .map(|(pattern, _)| pattern.clone()) + .collect(); + let all = source + .all + .iter() + .zip(&compiled.all) + .map(|(nested, compiled)| gate_evidence(nested, compiled, text, lower_text)) + .collect(); + let any = source + .any + .iter() + .zip(&compiled.any) + .map(|(nested, compiled)| gate_evidence(nested, compiled, text, lower_text)) + .collect(); + let not_gate = source + .not_gate + .iter() + .zip(&compiled.not_gate) + .map(|(nested, compiled)| gate_evidence(nested, compiled, text, lower_text)) + .collect(); + GateEvidence { + matched: compiled_gate_matches(compiled, text, lower_text), + contains, + regex, + line_regex, + all, + any, + not_gate, + } +} + +fn normalized_agent_lookup_name(name: &str) -> String { + let mut name = name.trim().to_lowercase(); + for suffix in [".exe", ".cmd", ".bat", ".ps1", ".js"] { + if name.ends_with(suffix) { + name.truncate(name.len() - suffix.len()); + break; + } + } + name +} + +fn path_basename(path: &str) -> &str { + path.rsplit(['/', '\\']).find(|component| !component.is_empty()).unwrap_or(path) +} + +fn is_versioned_muse_binary(name: &str) -> bool { + let Some(version) = name.strip_prefix("muse-bin-") else { + return false; + }; + let (numeric, suffix) = version.split_once('-').unwrap_or((version, "")); + let numeric_parts = numeric.split('.').collect::>(); + if numeric_parts.len() < 2 + || numeric_parts + .iter() + .any(|part| part.is_empty() || !part.bytes().all(|byte| byte.is_ascii_digit())) + { + return false; + } + suffix.is_empty() + || suffix + .split(['.', '-']) + .all(|part| !part.is_empty() && part.bytes().all(|byte| byte.is_ascii_alphanumeric())) +} + +/// Every bundled manifest, keyed for foreground-process identification. +#[derive(Debug, Clone)] +pub struct ManifestSet { + manifests: Vec, +} + +/// The vendored herdr manifests (see `manifests/README.md` +/// for the upstream pin). Compile-time embedded; never fetched. +const BUNDLED_MANIFESTS: &[(&str, &str)] = &[ + ("amp", include_str!("../manifests/amp.toml")), + ("agy", include_str!("../manifests/antigravity.toml")), + ("claude", include_str!("../manifests/claude.toml")), + ("cline", include_str!("../manifests/cline.toml")), + ("codex", include_str!("../manifests/codex.toml")), + ("cursor", include_str!("../manifests/cursor.toml")), + ("devin", include_str!("../manifests/devin.toml")), + ("droid", include_str!("../manifests/droid.toml")), + ("gemini", include_str!("../manifests/gemini.toml")), + ("grok", include_str!("../manifests/grok.toml")), + ("hermes", include_str!("../manifests/hermes.toml")), + ("kilo", include_str!("../manifests/kilo.toml")), + ("kimi", include_str!("../manifests/kimi.toml")), + ("kiro", include_str!("../manifests/kiro.toml")), + ("letta", include_str!("../manifests/letta.toml")), + ("maki", include_str!("../manifests/maki.toml")), + ("muse", include_str!("../manifests/muse.toml")), + ("opencode", include_str!("../manifests/opencode.toml")), + ("pi", include_str!("../manifests/pi.toml")), + ("qodercli", include_str!("../manifests/qodercli.toml")), + ("qwen", include_str!("../manifests/qwen.toml")), + ("copilot", include_str!("../manifests/github-copilot.toml")), +]; + +/// The source filename for each embedded manifest. Labels above are canonical +/// adapter ids; two upstream filenames use compatibility names. +const BUNDLED_MANIFEST_FILES: &[(&str, &str)] = &[ + ("amp", "amp.toml"), + ("agy", "antigravity.toml"), + ("claude", "claude.toml"), + ("cline", "cline.toml"), + ("codex", "codex.toml"), + ("cursor", "cursor.toml"), + ("devin", "devin.toml"), + ("droid", "droid.toml"), + ("gemini", "gemini.toml"), + ("grok", "grok.toml"), + ("hermes", "hermes.toml"), + ("kilo", "kilo.toml"), + ("kimi", "kimi.toml"), + ("kiro", "kiro.toml"), + ("letta", "letta.toml"), + ("maki", "maki.toml"), + ("muse", "muse.toml"), + ("opencode", "opencode.toml"), + ("pi", "pi.toml"), + ("qodercli", "qodercli.toml"), + ("qwen", "qwen.toml"), + ("copilot", "github-copilot.toml"), +]; + +const BUNDLED_MANIFEST_CHECKSUMS: &str = include_str!("../manifests/SHA256SUMS"); + +static BUNDLED_SET: OnceLock = OnceLock::new(); + +impl ManifestSet { + /// The embedded manifest set. Bundled files are pinned by unit tests, + /// so a compile failure here is a vendoring bug, not a runtime input. + pub fn bundled() -> &'static ManifestSet { + BUNDLED_SET.get_or_init(|| { + verify_bundled_manifest_checksums() + .expect("bundled screen-detection manifest provenance is invalid"); + Self::from_sources(BUNDLED_MANIFESTS) + .expect("bundled screen-detection manifests are pinned valid by tests") + }) + } + + pub fn from_sources(sources: &[(&str, &str)]) -> Result { + if sources.len() > MAX_MANIFESTS { + return Err(format!( + "manifest set contains {} sources, max is {MAX_MANIFESTS}", + sources.len() + )); + } + let mut set = Self { manifests: Vec::with_capacity(sources.len()) }; + for (label, content) in sources { + let compiled = compile_manifest_source_with_source(content, ManifestSource::Bundled) + .map_err(|err| format!("bundled manifest {label} is invalid: {err}"))?; + set.insert_compiled(compiled)?; + } + Ok(set) + } + + /// Load bundled manifests and apply optional userland sources. The daemon + /// never reads these directories. This keeps updates and experiments out + /// of core while preserving deterministic source precedence. + pub fn from_environment() -> Result { + let mut set = Self::from_sources(BUNDLED_MANIFESTS)?; + let cache_dir = + environment_path("CMUX_AGENT_MANIFEST_CACHE_DIR").or_else(default_cache_directory); + if let Some(cache_dir) = cache_dir.as_ref() { + set.apply_directory(cache_dir, |path, manifest| { + let version = manifest + .version + .clone() + .ok_or_else(|| "remote manifest must include version".to_string())?; + Ok(ManifestSource::Remote { path, version }) + })?; + } + if let Some(override_dir) = + environment_path("CMUX_AGENT_MANIFEST_DIR").or_else(default_override_directory) + { + set.apply_directory(&override_dir, |path, _| Ok(ManifestSource::Override(path)))?; + } + // Status is read only after source precedence is resolved. This keeps + // update diagnostics visible even when a local override is the active + // manifest, without allowing the status file to select a manifest. + if let Some(cache_dir) = cache_dir { + let status = crate::manifest_update::load_status(&cache_dir); + set.apply_update_status(&status); + } + Ok(set) + } + + fn apply_update_status(&mut self, status: &crate::manifest_update::ManifestUpdateStatus) { + for manifest in &mut self.manifests { + let Some(agent) = status.agents.get(manifest.id()) else { continue }; + manifest.diagnostics.remote_update_status = Some(agent.last_result.clone()); + manifest.diagnostics.remote_update_error = agent.last_error.clone(); + } + } + + fn apply_directory( + &mut self, + directory: &Path, + source: impl Fn(PathBuf, &AgentManifest) -> Result, + ) -> Result<(), String> { + let entries = match std::fs::read_dir(directory) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => { + return Err(format!("read manifest directory {}: {error}", directory.display())); + } + }; + let mut paths = Vec::new(); + for entry in entries { + if paths.len() >= MAX_MANIFEST_DIRECTORY_ENTRIES { + return Err(format!( + "manifest directory contains more than {MAX_MANIFEST_DIRECTORY_ENTRIES} entries" + )); + } + paths.push(entry.map(|entry| entry.path()).map_err(|error| error.to_string())?); + } + paths.sort(); + for path in paths { + if path.extension().and_then(|extension| extension.to_str()) != Some("toml") { + continue; + } + if crate::manifest_update::is_status_file(&path) { + continue; + } + let result = (|| -> Result { + let content = read_bounded_utf8_file(&path, MAX_MANIFEST_BYTES) + .map_err(|error| format!("read manifest {}: {error}", path.display()))?; + let parsed = parse_manifest(&content) + .map_err(|error| format!("manifest {} is invalid: {error}", path.display()))?; + let manifest_source = source(path.clone(), &parsed)?; + compile_manifest(parsed, manifest_source) + })(); + let compiled = match result { + Ok(compiled) => compiled, + Err(error) => { + // Optional userland sources are independent. One broken + // override must not hide valid manifests for other agents. + eprintln!("cmux-agent-screen-detection: ignoring {error}"); + continue; + } + }; + if let Err(error) = self.insert_compiled(compiled) { + // Optional userland sources are independent. A conflicting + // adapter must not prevent valid cached or override files + // from loading for the other agents. + eprintln!( + "cmux-agent-screen-detection: ignoring manifest {}: {error}", + path.display() + ); + } + } + Ok(()) + } + + fn insert_compiled(&mut self, mut compiled: CompiledManifest) -> Result<(), String> { + let existing_index = self.manifests.iter().position(|item| item.id() == compiled.id()); + if let Some(index) = existing_index { + let existing = &self.manifests[index]; + if matches!(compiled.source, ManifestSource::Remote { .. }) + && let (Some(incoming), Some(current)) = + (compiled.version().cloned(), existing.version().cloned()) + && incoming < current + { + compiled.diagnostics.cached_remote_version = Some(incoming.to_string()); + compiled.diagnostics.warning = Some(format!( + "ignored remote manifest {} because incoming version {} is older than active version {}", + compiled.id(), + incoming, + current + )); + self.manifests[index].diagnostics = compiled.diagnostics; + return Ok(()); + } + if matches!(compiled.source, ManifestSource::Override(_)) { + compiled.diagnostics.cached_remote_version = + existing.diagnostics.cached_remote_version.clone().or_else(|| match &existing + .source + { + ManifestSource::Remote { version, .. } => Some(version.to_string()), + _ => None, + }); + compiled.diagnostics.local_override_shadowing_remote = + compiled.diagnostics.cached_remote_version.is_some(); + } + + // Replacing an existing id can change its aliases. Check the new + // identity set against every other manifest before mutating the + // collection. Otherwise an override could silently make an alias + // resolve to two adapters and leave the result dependent on file + // ordering. + for (candidate_index, candidate) in self.manifests.iter().enumerate() { + if candidate_index != index + && let Some(identity) = conflicting_identity(candidate, &compiled) + { + return Err(format!( + "manifest {} conflicts with {} on process identity {identity:?}", + compiled.id(), + candidate.id() + )); + } + } + self.manifests[index] = compiled; + return Ok(()); + } + + // A userland source may add a new adapter. Reject ambiguous process + // identities instead of silently choosing whichever directory entry + // happened to sort first. + for candidate in self.manifests.iter() { + if let Some(identity) = conflicting_identity(candidate, &compiled) { + return Err(format!( + "manifest {} conflicts with {} on process identity {identity:?}", + compiled.id(), + candidate.id() + )); + } + } + if self.manifests.len() >= MAX_MANIFESTS { + return Err(format!( + "manifest set contains {} manifests, max is {MAX_MANIFESTS}", + self.manifests.len() + 1 + )); + } + self.manifests.push(compiled); + Ok(()) + } + + pub fn manifests(&self) -> impl Iterator { + self.manifests.iter() + } + + /// The manifest whose id or aliases match the foreground process name, + /// or `None` when the process is not a supported agent. + pub fn identify(&self, process_name: &str) -> Option<&CompiledManifest> { + self.manifests.iter().find(|manifest| manifest.matches_process_name(process_name)) + } + + /// Return a diagnostic explanation for a process name and terminal input. + /// This is intentionally an SDK/plugin concern, not a daemon endpoint. + pub fn explain(&self, process_name: &str, input: DetectionInput<'_>) -> DetectionExplain { + let Some(manifest) = self.identify(process_name) else { + return DetectionExplain::unknown(process_name); + }; + let mut explanation = manifest.explain(input); + explanation.process_name = process_name.to_string(); + explanation + } +} + +/// Verify embedded bytes against the checked-in provenance record. This catches +/// accidental edits to vendored files. It is source integrity, not a release +/// signature, because the checksum file is in the same artifact. +pub fn verify_bundled_manifest_checksums() -> Result<(), String> { + if BUNDLED_MANIFESTS.len() != BUNDLED_MANIFEST_FILES.len() { + return Err(format!( + "bundled manifest mapping has {} ids for {} sources", + BUNDLED_MANIFEST_FILES.len(), + BUNDLED_MANIFESTS.len() + )); + } + + let mut expected = HashMap::new(); + for (line_number, line) in BUNDLED_MANIFEST_CHECKSUMS.lines().enumerate() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let Some((digest, filename)) = line.split_once(" ") else { + return Err(format!( + "manifest checksum line {} must use ' '", + line_number + 1 + )); + }; + if digest.len() != 64 || !digest.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err(format!( + "manifest checksum for {filename:?} is not a 64-character hexadecimal digest" + )); + } + if filename.is_empty() + || !filename.ends_with(".toml") + || expected.insert(filename, digest).is_some() + { + return Err(format!( + "manifest checksum filename {filename:?} is duplicated or invalid" + )); + } + } + + if expected.len() != BUNDLED_MANIFEST_FILES.len() { + return Err(format!( + "manifest checksum record has {} files for {} bundled manifests", + expected.len(), + BUNDLED_MANIFEST_FILES.len() + )); + } + + for ((id, content), (mapped_id, filename)) in + BUNDLED_MANIFESTS.iter().zip(BUNDLED_MANIFEST_FILES.iter()) + { + if id != mapped_id { + return Err(format!("manifest checksum mapping disagrees for adapter {id:?}")); + } + let Some(expected_digest) = expected.get(filename) else { + return Err(format!("manifest checksum record has no entry for {filename}")); + }; + let actual_digest = format!("{:x}", Sha256::digest(content.as_bytes())); + if !actual_digest.eq_ignore_ascii_case(expected_digest) { + return Err(format!( + "bundled manifest {filename} checksum {actual_digest} does not match {expected_digest}" + )); + } + } + + Ok(()) +} + +pub fn compile_manifest_source(content: &str) -> Result { + compile_manifest_source_with_source(content, ManifestSource::Bundled) +} + +fn compile_manifest_source_with_source( + content: &str, + source: ManifestSource, +) -> Result { + if content.len() > MAX_MANIFEST_BYTES { + return Err(format!("manifest exceeds {MAX_MANIFEST_BYTES} bytes")); + } + let manifest = parse_manifest(content)?; + compile_manifest(manifest, source) +} + +fn parse_manifest(content: &str) -> Result { + let manifest = toml::from_str::(content).map_err(|err| err.to_string())?; + validate_manifest(&manifest)?; + Ok(manifest) +} + +fn environment_path(name: &str) -> Option { + std::env::var_os(name).map(PathBuf::from).filter(|path| !path.as_os_str().is_empty()) +} + +fn default_cache_directory() -> Option { + if let Some(path) = std::env::var_os("XDG_CACHE_HOME").map(PathBuf::from) { + return Some(path.join("cmux").join("agent-detection")); + } + std::env::var_os("HOME").map(PathBuf::from).map(|home| { + let cache_root = if cfg!(target_os = "macos") { + home.join("Library").join("Caches") + } else if cfg!(windows) { + std::env::var_os("LOCALAPPDATA") + .map(PathBuf::from) + .unwrap_or_else(|| home.join("AppData").join("Local")) + } else { + home.join(".cache") + }; + cache_root.join("cmux").join("agent-detection") + }) +} + +fn default_override_directory() -> Option { + let path = std::env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")))? + .join("cmux") + .join("agent-detection"); + path.exists().then_some(path) +} + +fn validate_manifest(manifest: &AgentManifest) -> Result<(), String> { + validate_manifest_id(&manifest.id, "manifest id")?; + let mut identities = std::collections::HashSet::new(); + identities.insert(normalized_agent_lookup_name(&manifest.id)); + for alias in &manifest.aliases { + validate_manifest_alias(alias)?; + let normalized = normalized_agent_lookup_name(alias); + if !identities.insert(normalized) { + return Err(format!("manifest {} contains a duplicate id or alias", manifest.id)); + } + } + if let Some(version) = manifest.min_engine_version + && version > SCREEN_DETECT_ENGINE_VERSION + { + return Err(format!( + "manifest requires engine {version}, this engine is {SCREEN_DETECT_ENGINE_VERSION}" + )); + } + if manifest.rules.is_empty() { + return Err("manifest must contain at least one rule".to_string()); + } + if manifest.rules.len() > MAX_RULES_PER_MANIFEST { + return Err(format!( + "manifest contains {} rules, max is {MAX_RULES_PER_MANIFEST}", + manifest.rules.len() + )); + } + + let mut complexity = ManifestComplexity::default(); + let mut rule_ids = std::collections::HashSet::new(); + for rule in &manifest.rules { + if !rule_ids.insert(rule.id.as_str()) { + return Err(format!( + "manifest {} contains duplicate rule id {:?}", + manifest.id, rule.id + )); + } + validate_rule_id(&rule.id)?; + if rule.skip_state_update { + if rule.state != Some(ManifestState::Unknown) { + return Err(format!( + "rule {} uses skip_state_update without state = \"unknown\"", + rule.id + )); + } + if rule.visible_idle || rule.visible_blocker || rule.visible_working { + return Err(format!( + "rule {} uses skip_state_update with visible state evidence", + rule.id + )); + } + } + validate_region_name(&rule.region) + .map_err(|err| format!("rule {} uses invalid region: {err}", rule.id))?; + if rule.region.trim().starts_with("top_non_empty_lines(") + && manifest + .min_engine_version + .is_some_and(|version| version < TOP_NON_EMPTY_LINES_ENGINE_VERSION) + { + return Err(format!( + "rule {} uses top_non_empty_lines but min_engine_version is below {}", + rule.id, TOP_NON_EMPTY_LINES_ENGINE_VERSION + )); + } + validate_gate(&manifest_gate_from_rule(rule), "rule", 0, &mut complexity) + .map_err(|err| format!("rule {} has invalid matcher gates: {err}", rule.id))?; + } + Ok(()) +} + +fn validate_manifest_id(value: &str, label: &str) -> Result<(), String> { + if value.is_empty() + || value.len() > 64 + || !value.as_bytes().first().is_some_and(|byte| byte.is_ascii_alphanumeric()) + || !value.bytes().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_' || byte == b'-' + }) + { + return Err(format!("{label} must match [a-z0-9][a-z0-9_-]* and be at most 64 bytes")); + } + Ok(()) +} + +fn validate_rule_id(value: &str) -> Result<(), String> { + if value.is_empty() + || value.len() > 128 + || !value.bytes().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_' || byte == b'-' + }) + { + return Err(format!("manifest rule id {value:?} must match [a-z0-9_-]+")); + } + Ok(()) +} + +fn validate_manifest_alias(value: &str) -> Result<(), String> { + let trimmed = value.trim(); + if trimmed.is_empty() + || trimmed.len() > 128 + || trimmed + .bytes() + .any(|byte| byte == 0 || byte.is_ascii_control() || byte == b'/' || byte == b'\\') + { + return Err(format!( + "manifest alias {value:?} is empty, too long, or contains a path/control character" + )); + } + Ok(()) +} + +#[derive(Default)] +struct ManifestComplexity { + total_gates: usize, + total_matchers: usize, +} + +fn validate_gate( + gate: &ManifestGate, + context: &str, + depth: usize, + complexity: &mut ManifestComplexity, +) -> Result<(), String> { + if depth > MAX_GATE_DEPTH { + return Err(format!("{context} exceeds max gate depth {MAX_GATE_DEPTH}")); + } + complexity.total_gates += 1; + if complexity.total_gates > MAX_TOTAL_GATES { + return Err(format!("manifest exceeds max gate count {MAX_TOTAL_GATES}")); + } + validate_matcher_limits(gate, context, complexity)?; + if !gate_has_positive_matcher(gate) { + return Err(format!("{context} must contain a positive matcher")); + } + validate_regex_patterns(&gate.regex, context, "regex")?; + validate_regex_patterns(&gate.line_regex, context, "line_regex")?; + for nested in &gate.all { + validate_gate(nested, "all gate", depth + 1, complexity)?; + } + for nested in &gate.any { + validate_gate(nested, "any gate", depth + 1, complexity)?; + } + for nested in &gate.not_gate { + if !gate_has_any_matcher(nested) { + return Err(format!("{context} contains an empty not gate")); + } + validate_not_gate(nested, depth + 1, complexity)?; + } + Ok(()) +} + +fn validate_not_gate( + gate: &ManifestGate, + depth: usize, + complexity: &mut ManifestComplexity, +) -> Result<(), String> { + if depth > MAX_GATE_DEPTH { + return Err(format!("not gate exceeds max gate depth {MAX_GATE_DEPTH}")); + } + complexity.total_gates += 1; + if complexity.total_gates > MAX_TOTAL_GATES { + return Err(format!("manifest exceeds max gate count {MAX_TOTAL_GATES}")); + } + validate_matcher_limits(gate, "not gate", complexity)?; + if !gate_has_any_matcher(gate) { + return Err("not gate must contain a matcher".to_string()); + } + validate_regex_patterns(&gate.regex, "not gate", "regex")?; + validate_regex_patterns(&gate.line_regex, "not gate", "line_regex")?; + for nested in &gate.all { + validate_gate(nested, "not all gate", depth + 1, complexity)?; + } + for nested in &gate.any { + validate_gate(nested, "not any gate", depth + 1, complexity)?; + } + for nested in &gate.not_gate { + validate_not_gate(nested, depth + 1, complexity)?; + } + Ok(()) +} + +fn validate_matcher_limits( + gate: &ManifestGate, + context: &str, + complexity: &mut ManifestComplexity, +) -> Result<(), String> { + let matcher_count = gate.contains.len() + gate.regex.len() + gate.line_regex.len(); + if matcher_count > MAX_MATCHERS_PER_GATE { + return Err(format!( + "{context} has {matcher_count} direct matchers, max is {MAX_MATCHERS_PER_GATE}" + )); + } + complexity.total_matchers += matcher_count; + if complexity.total_matchers > MAX_TOTAL_MATCHERS { + return Err(format!("manifest exceeds max matcher count {MAX_TOTAL_MATCHERS}")); + } + for (field, values) in [ + ("contains", gate.contains.as_slice()), + ("regex", gate.regex.as_slice()), + ("line_regex", gate.line_regex.as_slice()), + ] { + for value in values { + if value.is_empty() { + return Err(format!("{context} {field} matcher must not be empty")); + } + if value.chars().count() > MAX_MATCHER_CHARS { + return Err(format!("{context} matcher exceeds max length {MAX_MATCHER_CHARS}")); + } + } + } + Ok(()) +} + +fn validate_regex_patterns(patterns: &[String], context: &str, field: &str) -> Result<(), String> { + for pattern in patterns { + Regex::new(pattern).map_err(|err| { + format!("{context} contains invalid {field} pattern {pattern:?}: {err}") + })?; + } + Ok(()) +} + +fn gate_has_positive_matcher(gate: &ManifestGate) -> bool { + !gate.contains.is_empty() + || !gate.regex.is_empty() + || !gate.line_regex.is_empty() + || !gate.all.is_empty() + || !gate.any.is_empty() +} + +fn gate_has_any_matcher(gate: &ManifestGate) -> bool { + gate_has_positive_matcher(gate) || !gate.not_gate.is_empty() +} + +fn manifest_gate_from_rule(rule: &ManifestRule) -> ManifestGate { + ManifestGate { + all: rule.all.clone(), + any: rule.any.clone(), + not_gate: rule.not_gate.clone(), + contains: rule.contains.clone(), + regex: rule.regex.clone(), + line_regex: rule.line_regex.clone(), + } +} + +fn compile_manifest( + manifest: AgentManifest, + source: ManifestSource, +) -> Result { + let compiled_rules = compile_rules(&manifest)?; + Ok(CompiledManifest { + manifest, + compiled_rules, + source, + diagnostics: ManifestDiagnostics::default(), + }) +} + +fn conflicting_identity(left: &CompiledManifest, right: &CompiledManifest) -> Option { + let mut left_names = Vec::with_capacity(left.manifest.aliases.len() + 1); + left_names.push(normalized_agent_lookup_name(left.id())); + left_names + .extend(left.manifest.aliases.iter().map(|alias| normalized_agent_lookup_name(alias))); + std::iter::once(right.id()) + .chain(right.manifest.aliases.iter().map(String::as_str)) + .map(normalized_agent_lookup_name) + .find(|name| left_names.iter().any(|left_name| left_name == name)) +} + +fn compile_rules(manifest: &AgentManifest) -> Result, String> { + manifest + .rules + .iter() + .map(|rule| { + compile_gate(&manifest_gate_from_rule(rule)) + .map_err(|err| format!("rule {} could not be compiled: {err}", rule.id)) + }) + .collect() +} + +fn validate_region_name(spec: &str) -> Result<(), String> { + let trimmed = spec.trim(); + match trimmed { + "whole_recent" + | "after_last_prompt_marker" + | "before_current_prompt_marker" + | "whole_recent_without_current_prompt_marker" + | "current_prompt_block_marker" + | "after_current_prompt_block_marker" + | "prompt_box_body" + | "above_prompt_box" + | "last_non_empty_above_prompt_box" + | "after_last_horizontal_rule" + | "osc_title" + | "osc_progress" => Ok(()), + _ if region_count(trimmed, "bottom_lines").is_some() + || region_count(trimmed, "bottom_non_empty_lines").is_some() + || top_region_count(trimmed).is_some() => + { + Ok(()) + } + _ => Err(trimmed.to_string()), + } +} + +fn region<'a>(input: DetectionInput<'a>, spec: &str) -> &'a str { + let trimmed = spec.trim(); + // OSC regions source from their dedicated fields, not the screen. + match trimmed { + "osc_title" => return input.osc_title, + "osc_progress" => return input.osc_progress, + _ => {} + } + let content = input.screen; + match trimmed { + "whole_recent" => content, + "after_last_prompt_marker" => after_last_prompt_marker(content), + "before_current_prompt_marker" => before_current_prompt_marker(content), + "whole_recent_without_current_prompt_marker" => { + whole_recent_without_current_prompt_marker(content) + } + "current_prompt_block_marker" => current_prompt_block_marker(content).unwrap_or(""), + "after_current_prompt_block_marker" => { + after_current_prompt_block_marker(content).unwrap_or("") + } + "prompt_box_body" => prompt_box_body(content).unwrap_or(""), + "above_prompt_box" => above_prompt_box(content), + "last_non_empty_above_prompt_box" => last_non_empty_line(above_prompt_box(content)), + "after_last_horizontal_rule" => after_last_horizontal_rule(content), + _ => { + if let Some(count) = region_count(trimmed, "bottom_lines") { + return bottom_lines(content, count); + } + if let Some(count) = region_count(trimmed, "bottom_non_empty_lines") { + return bottom_non_empty_lines(content, count); + } + if let Some(count) = top_region_count(trimmed) { + return top_non_empty_lines(content, count); + } + "" + } + } +} + +fn region_count(spec: &str, name: &str) -> Option { + spec.strip_prefix(name) + .and_then(|rest| rest.strip_prefix('(')) + .and_then(|rest| rest.strip_suffix(')')) + .and_then(|count| count.parse::().ok()) +} + +const MAX_TOP_REGION_LINE_COUNT: usize = u16::MAX as usize; + +fn top_region_count(spec: &str) -> Option { + let count = spec.strip_prefix("top_non_empty_lines")?.strip_prefix('(')?.strip_suffix(')')?; + if count.starts_with('0') || !count.bytes().all(|byte| byte.is_ascii_digit()) { + return None; + } + count.parse::().ok().filter(|count| *count <= MAX_TOP_REGION_LINE_COUNT) +} + +fn bottom_lines(content: &str, count: usize) -> &str { + let lines: Vec<&str> = content.lines().collect(); + let start = lines.len().saturating_sub(count); + slice_from_line_index(content, &lines, start) +} + +fn bottom_non_empty_lines(content: &str, count: usize) -> &str { + let lines: Vec<&str> = content.lines().collect(); + let Some(start_index) = lines + .iter() + .enumerate() + .rev() + .filter(|(_, line)| !line.trim().is_empty()) + .take(count) + .last() + .map(|(index, _)| index) + else { + return ""; + }; + slice_from_line_index(content, &lines, start_index) +} + +fn top_non_empty_lines(content: &str, count: usize) -> &str { + let lines: Vec<&str> = content.lines().collect(); + let Some(end_index) = lines + .iter() + .enumerate() + .filter(|(_, line)| !line.trim().is_empty()) + .take(count) + .last() + .map(|(index, _)| index) + else { + return ""; + }; + let byte_offset = line_start_offset(content, &lines, end_index + 1); + &content[..byte_offset] +} + +fn after_last_prompt_marker(content: &str) -> &str { + let lines: Vec<&str> = content.lines().collect(); + let Some(index) = lines.iter().rposition(|line| codex_prompt_line(line)) else { + return content; + }; + slice_from_line_index(content, &lines, index + 1) +} + +fn before_current_prompt_marker(content: &str) -> &str { + let lines: Vec<&str> = content.lines().collect(); + let Some(index) = current_codex_prompt_index(&lines) else { + return content; + }; + let byte_offset = line_start_offset(content, &lines, index); + &content[..byte_offset.min(content.len())] +} + +fn whole_recent_without_current_prompt_marker(content: &str) -> &str { + let lines: Vec<&str> = content.lines().collect(); + if current_codex_prompt_index(&lines).is_some() { "" } else { content } +} + +fn current_prompt_block_marker(content: &str) -> Option<&str> { + let lines: Vec<&str> = content.lines().collect(); + let prompt_index = current_codex_prompt_index(&lines)?; + lines[..prompt_index].iter().rev().find(|line| codex_block_marker_line(line)).copied() +} + +fn after_current_prompt_block_marker(content: &str) -> Option<&str> { + let lines: Vec<&str> = content.lines().collect(); + let prompt_index = current_codex_prompt_index(&lines)?; + let block_index = + lines[..prompt_index].iter().rposition(|line| codex_block_marker_line(line))?; + Some(slice_from_line_index(content, &lines, block_index)) +} + +fn current_codex_prompt_index(lines: &[&str]) -> Option { + let prompt_index = lines.iter().rposition(|line| codex_prompt_line(line))?; + if lines[prompt_index + 1..].iter().any(|line| codex_block_marker_line(line)) { + return None; + } + Some(prompt_index) +} + +fn codex_prompt_line(line: &str) -> bool { + line == "›" || line.starts_with("› ") +} + +fn codex_block_marker_line(line: &str) -> bool { + line.starts_with('•') || line.starts_with('■') || line.starts_with('✗') || line.starts_with('✓') +} + +fn prompt_box_body(content: &str) -> Option<&str> { + let lines: Vec<&str> = content.lines().collect(); + let top = prompt_box_top_border_index(&lines)?; + let start = line_start_offset(content, &lines, top + 1); + let end_index = lines[top + 1..] + .iter() + .position(|line| is_horizontal_rule(line)) + .map(|relative| top + 1 + relative) + .unwrap_or(lines.len()); + let end = line_start_offset(content, &lines, end_index); + Some(&content[start.min(content.len())..end.min(content.len())]) +} + +fn above_prompt_box(content: &str) -> &str { + let lines: Vec<&str> = content.lines().collect(); + let Some(top) = prompt_box_top_border_index(&lines) else { + return content; + }; + let end = line_start_offset(content, &lines, top); + &content[..end.min(content.len())] +} + +fn after_last_horizontal_rule(content: &str) -> &str { + let lines: Vec<&str> = content.lines().collect(); + let mut last_rule_end = 0usize; + for (index, line) in lines.iter().enumerate() { + if is_horizontal_rule(line) { + last_rule_end = line_start_offset(content, &lines, index + 1); + } + } + &content[last_rule_end..] +} + +fn last_non_empty_line(content: &str) -> &str { + content.lines().rev().find(|line| !line.trim().is_empty()).unwrap_or("") +} + +fn prompt_box_top_border_index(lines: &[&str]) -> Option { + let mut border_count = 0; + for index in (0..lines.len()).rev() { + if is_horizontal_rule(lines[index]) { + border_count += 1; + if border_count == 2 { + return Some(index); + } + } + } + None +} + +fn is_horizontal_rule(line: &str) -> bool { + let trimmed = line.trim(); + if trimmed.is_empty() { + return false; + } + let rule_chars = trimmed.chars().take_while(|&ch| ch == '─').count(); + if rule_chars == 0 { + return false; + } + let rule_bytes = + trimmed.char_indices().nth(rule_chars).map(|(index, _)| index).unwrap_or(trimmed.len()); + let suffix = trimmed[rule_bytes..].trim_start(); + suffix.is_empty() || rule_chars >= 3 +} + +fn slice_from_line_index<'a>(content: &'a str, lines: &[&str], index: usize) -> &'a str { + let byte_offset = line_start_offset(content, lines, index); + &content[byte_offset.min(content.len())..] +} + +fn line_start_offset(content: &str, lines: &[&str], index: usize) -> usize { + let target = index.min(lines.len()); + if target == 0 { + return 0; + } + // `str::lines` hides the carriage return in CRLF input. Counting the + // original newline-delimited chunks preserves byte offsets for both LF + // and CRLF terminals. + content.split_inclusive('\n').take(target).map(str::len).sum::().min(content.len()) +} + +fn compile_gate(gate: &ManifestGate) -> Result { + Ok(CompiledGate { + all: gate.all.iter().map(compile_gate).collect::>()?, + any: gate.any.iter().map(compile_gate).collect::>()?, + not_gate: gate.not_gate.iter().map(compile_gate).collect::>()?, + contains: gate.contains.iter().map(|needle| needle.to_lowercase()).collect(), + regex: gate + .regex + .iter() + .map(|pattern| Regex::new(pattern).map_err(|err| err.to_string())) + .collect::>()?, + line_regex: gate + .line_regex + .iter() + .map(|pattern| Regex::new(pattern).map_err(|err| err.to_string())) + .collect::>()?, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn input(screen: &str) -> DetectionInput<'_> { + DetectionInput { screen, osc_title: "", osc_progress: "" } + } + + #[test] + fn bounded_utf8_reader_rejects_oversized_and_invalid_input() { + assert_eq!(read_bounded_utf8(&b"hello"[..], 5).unwrap(), "hello"); + assert!(read_bounded_utf8(&b"hello!"[..], 5).is_err()); + assert!(read_bounded_utf8(&[0xff][..], 5).is_err()); + } + + #[test] + fn screen_detect_manifest_set_rejects_too_many_sources() { + let contents: Vec = (0..=MAX_MANIFESTS) + .map(|index| { + format!( + "id = \"agent-{index}\"\n[[rules]]\nid = \"idle\"\nstate = \"idle\"\ncontains = [\"ready\"]\n" + ) + }) + .collect(); + let sources: Vec<(&str, &str)> = + contents.iter().map(|content| ("generated", content.as_str())).collect(); + + let error = ManifestSet::from_sources(&sources).unwrap_err(); + assert!(error.contains("manifest set contains"), "{error}"); + } + + #[test] + fn screen_detect_manifest_directory_rejects_too_many_entries() { + let suffix = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock is after the Unix epoch") + .as_nanos(); + let directory = std::env::temp_dir().join(format!( + "cmux-agent-screen-detection-manifest-limit-{}-{suffix}", + std::process::id() + )); + std::fs::create_dir(&directory).expect("create temporary manifest directory"); + for index in 0..=MAX_MANIFEST_DIRECTORY_ENTRIES { + std::fs::write(directory.join(format!("entry-{index}.txt")), b"") + .expect("write temporary directory entry"); + } + + let mut set = ManifestSet::from_sources(&[]).expect("empty manifest set"); + let result = set.apply_directory(&directory, |path, _| Ok(ManifestSource::Override(path))); + let _ = std::fs::remove_dir_all(&directory); + + let error = result.unwrap_err(); + assert!(error.contains("manifest directory contains"), "{error}"); + } + + #[test] + fn screen_detect_manifest_directory_accepts_agent_named_status() { + let suffix = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock is after the Unix epoch") + .as_nanos(); + let directory = std::env::temp_dir().join(format!( + "cmux-agent-screen-detection-status-agent-{}-{suffix}", + std::process::id() + )); + std::fs::create_dir(&directory).expect("create temporary manifest directory"); + std::fs::write( + directory.join("status.toml"), + "id = \"status\"\n[[rules]]\nid = \"idle\"\nstate = \"idle\"\ncontains = [\"ready\"]\n", + ) + .expect("write status agent manifest"); + + let mut set = ManifestSet::from_sources(&[]).expect("empty manifest set"); + set.apply_directory(&directory, |path, _| Ok(ManifestSource::Override(path))) + .expect("status agent manifest should load"); + let _ = std::fs::remove_dir_all(&directory); + + assert_eq!(set.identify("status").map(CompiledManifest::id), Some("status")); + } + + #[test] + fn screen_detect_bundled_manifests_all_parse_and_identify() { + let set = ManifestSet::bundled(); + let ids: Vec<&str> = set.manifests().map(CompiledManifest::id).collect(); + assert_eq!(ids.len(), 22, "all vendored manifests load: {ids:?}"); + for expected in [ + "amp", "agy", "claude", "cline", "codex", "cursor", "devin", "droid", "gemini", "grok", + "hermes", "kilo", "kimi", "kiro", "letta", "maki", "muse", "opencode", "pi", + "qodercli", "qwen", "copilot", + ] { + assert_eq!( + set.identify(expected).map(CompiledManifest::id), + Some(expected), + "{expected} identifies itself" + ); + } + } + + #[test] + fn screen_detect_bundled_manifest_provenance_matches_checked_hashes() { + verify_bundled_manifest_checksums().expect("bundled manifest hashes should match"); + } + + #[test] + fn screen_detect_identify_normalizes_paths_aliases_and_case() { + let set = ManifestSet::bundled(); + for (name, id) in [ + ("/opt/homebrew/bin/codex", "codex"), + ("claude-code", "claude"), + ("CLAUDE", "claude"), + ("cursor-agent", "cursor"), + ("opencode.exe", "opencode"), + ("github-copilot", "copilot"), + (r"C:\Users\dev\kiro-cli.exe", "kiro"), + ] { + assert_eq!(set.identify(name).map(CompiledManifest::id), Some(id), "{name}"); + } + for shell in ["bash", "zsh", "vim", "node", "muse-helper", "codex-helper", ""] { + assert!(set.identify(shell).is_none(), "{shell} is not an agent"); + } + } + + #[test] + fn screen_detect_rule_priority_and_gates_pick_the_strongest_match() { + let manifest = compile_manifest_source( + r#" +id = "codex" + +[[rules]] +id = "low" +state = "idle" +priority = 1 +contains = ["match"] + +[[rules]] +id = "high" +state = "working" +priority = 10 +contains = ["match"] +all = [{ any = [{ regex = ["w[io]n"] }, { contains = ["fallback"] }] }] +not = [{ contains = ["suppressed"] }] + +[[rules]] +id = "line" +state = "blocked" +priority = 5 +line_regex = ["^prompt: .*\\?$"] +"#, + ) + .unwrap(); + + let matched = manifest.detect(input("a match that won")); + assert_eq!(matched.state, ScreenState::Working); + assert_eq!(matched.matched_rule.as_deref(), Some("high")); + + // The not gate suppresses the strong rule; the weak one remains. + let suppressed = manifest.detect(input("a match that won but suppressed")); + assert_eq!(suppressed.state, ScreenState::Idle); + assert_eq!(suppressed.matched_rule.as_deref(), Some("low")); + + // line_regex must match one whole line, not the flattened text. + let lined = manifest.detect(input("noise\nprompt: continue?\ntail")); + assert_eq!(lined.state, ScreenState::Blocked); + let unlined = manifest.detect(input("prompt: continue? trailing")); + assert_eq!(unlined.state, ScreenState::Idle); + assert_eq!(unlined.matched_rule, None, "known-agent idle fallback"); + } + + #[test] + fn screen_detect_regions_scope_matching_to_screen_slices() { + let manifest = compile_manifest_source( + r#" +id = "codex" + +[[rules]] +id = "tail" +state = "working" +priority = 10 +region = "bottom_non_empty_lines(2)" +contains = ["spinner"] + +[[rules]] +id = "title" +state = "blocked" +priority = 20 +region = "osc_title" +contains = ["action required"] +"#, + ) + .unwrap(); + + let tail = manifest.detect(input("spinner far above\nline\nlast\nend")); + assert_eq!(tail.state, ScreenState::Idle, "match above the bottom slice is out of scope"); + let hit = manifest.detect(input("above\nline\nspinner here\nend")); + assert_eq!(hit.state, ScreenState::Working); + + let titled = manifest.detect(DetectionInput { + screen: "plain", + osc_title: "⚠ Action Required", + osc_progress: "", + }); + assert_eq!(titled.state, ScreenState::Blocked); + } + + #[test] + fn screen_detect_regions_preserve_crlf_boundaries() { + let manifest = compile_manifest_source( + r#" +id = "codex" + +[[rules]] +id = "before-prompt" +state = "working" +region = "before_current_prompt_marker" +contains = ["work"] + +[[rules]] +id = "after-rule" +state = "blocked" +priority = 10 +region = "after_last_horizontal_rule" +contains = ["blocked"] +"#, + ) + .unwrap(); + + let before_prompt = manifest.detect(input("work\r\nnoise\r\n› \r\n")); + assert_eq!(before_prompt.state, ScreenState::Working); + + let after_rule = manifest.detect(input("old\r\n────\r\nblocked\r\n")); + assert_eq!(after_rule.state, ScreenState::Blocked); + } + + #[test] + fn screen_detect_explain_preserves_matcher_evidence() { + let manifest = compile_manifest_source( + r#" +id = "codex" + +[[rules]] +id = "working" +state = "working" +contains = ["working", "missing literal"] +regex = ["work\\s+now", "missing regex"] +line_regex = ["^working$", "^missing line$"] +"#, + ) + .unwrap(); + let explanation = manifest.explain(DetectionInput { + screen: "working\nwork now", + osc_title: "", + osc_progress: "", + }); + let rule = &explanation.evaluated_rules[0]; + assert_eq!(rule.contains, vec!["working", "missing literal"]); + assert_eq!(rule.regex, vec![r"work\s+now", "missing regex"]); + assert_eq!(rule.line_regex, vec!["^working$", "^missing line$"]); + assert!(!rule.matched); + assert_eq!(rule.evidence.contains, vec!["working"]); + assert_eq!(rule.evidence.regex, vec![r"work\s+now"]); + assert_eq!(rule.evidence.line_regex, vec!["^working$"]); + assert!(!rule.evidence.matched); + } + + #[test] + fn screen_detect_explain_includes_explicit_update_status() { + let mut set = ManifestSet::bundled().clone(); + let mut status = crate::manifest_update::ManifestUpdateStatus::default(); + status.agents.insert( + "codex".into(), + crate::manifest_update::ManifestAgentStatus { + cached_version: Some("2026.08.1".into()), + attempted_version: Some("2026.08.2".into()), + last_checked_unix: Some(42), + last_result: "failed".into(), + last_error: Some("network unavailable".into()), + }, + ); + set.apply_update_status(&status); + + let explanation = set.explain("codex", input("idle")); + assert_eq!(explanation.remote_update_status.as_deref(), Some("failed")); + assert_eq!(explanation.remote_update_error.as_deref(), Some("network unavailable")); + } + + #[test] + fn screen_detect_codex_manifest_classifies_live_screens() { + let set = ManifestSet::bundled(); + let codex = set.identify("codex").unwrap(); + + let working = codex.detect(input("context\n\n• Working (1s • esc to interrupt)\n› \n")); + assert_eq!(working.state, ScreenState::Working); + + let blocked = codex.detect(input("$ rm -rf build\nAllow command?\n")); + assert_eq!(blocked.state, ScreenState::Blocked); + + let idle = codex.detect(input("ordinary prompt text")); + assert_eq!(idle.state, ScreenState::Idle); + assert!(idle.matched_rule.is_none()); + + let viewer = codex.detect(input( + "› old prompt\ntranscript\n↑/↓ to scroll pgup/pgdn to page\nhome/end to jump q to quit esc to edit prev\n", + )); + assert!(viewer.skip_state_update, "transcript viewer keeps the prior state"); + } + + #[test] + fn screen_detect_imported_claude_mcp_elicitation_is_blocked() { + let claude = ManifestSet::bundled().identify("claude").unwrap(); + assert_eq!(claude.version().map(ToString::to_string).as_deref(), Some("2026.09.11.1")); + + let blocked = claude.detect(input( + "MCP server \u{201C}calendar\u{201D} requests your input\n\ + \u{276F} Accept\n\ + Decline\n\ + Esc to cancel\n", + )); + assert_eq!(blocked.state, ScreenState::Blocked); + assert_eq!(blocked.matched_rule.as_deref(), Some("mcp_elicitation_prompt")); + assert!(blocked.visible_blocker); + + let incomplete = claude.detect(input( + "MCP server \u{201C}calendar\u{201D} requests your input\n\ + Esc to cancel\n", + )); + assert_ne!(incomplete.matched_rule.as_deref(), Some("mcp_elicitation_prompt")); + } + + #[test] + fn screen_detect_claude_idle_prompt_ignores_background_shells() { + let claude = ManifestSet::bundled().identify("claude").unwrap(); + let idle = claude.detect(input(concat!( + "✻ Sautéed for 10s · 1 shell still running\n\n", + "──────────────────────────────────────────────────────── WINDOWS ─\n", + "❯\n", + "────────────────────────────────────────────────────────────────\n", + " ⏵⏵ auto mode on · 1 shell · ← for agents /rc\n", + ))); + + assert_eq!(idle.state, ScreenState::Idle); + assert_eq!(idle.matched_rule.as_deref(), Some("live_prompt_box")); + assert!(idle.visible_idle); + assert!(!idle.visible_working); + } + + #[test] + fn screen_detect_claude_background_shell_alone_is_idle_fallback() { + let idle = ManifestSet::bundled() + .explain("claude", input(" ⏵⏵ auto mode on · 1 shell · ← for agents\n")); + + assert_eq!(idle.state, ScreenState::Idle); + assert!(idle.matched_rule.is_none()); + assert_eq!(idle.fallback_reason.as_deref(), Some(DEFAULT_KNOWN_AGENT_IDLE_FALLBACK)); + assert!(!idle.visible_working); + } + + #[test] + fn screen_detect_claude_live_turn_with_background_shell_stays_working() { + let claude = ManifestSet::bundled().identify("claude").unwrap(); + let working = claude.detect(input(concat!( + "────────────────────────────────────────────────────────────────\n", + "❯\n", + "────────────────────────────────────────────────────────────────\n", + " ⏵⏵ auto mode on · 1 shell · esc to interrupt\n", + ))); + + assert_eq!(working.state, ScreenState::Working); + assert_eq!(working.matched_rule.as_deref(), Some("live_turn_working")); + assert!(working.visible_working); + } + + #[test] + fn screen_detect_claude_blocker_with_background_shell_stays_blocked() { + let claude = ManifestSet::bundled().identify("claude").unwrap(); + let blocked = claude.detect(input(concat!( + "do you want to proceed?\n", + "bash command: rm -rf /tmp/test\n", + "❯ 1. Yes\n", + " 2. No\n\n", + "Esc to cancel · Tab to amend · ctrl+e to explain\n", + " ⏵⏵ auto mode on · 1 shell · ← for agents\n", + ))); + + assert_eq!(blocked.state, ScreenState::Blocked); + assert_eq!(blocked.matched_rule.as_deref(), Some("bash_permission_prompt")); + assert!(blocked.visible_blocker); + assert!(!blocked.visible_working); + } + + #[test] + fn screen_detect_imported_codex_weak_blocker_ignores_previous_prompt() { + let codex = ManifestSet::bundled().identify("codex").unwrap(); + assert_eq!(codex.version().map(ToString::to_string).as_deref(), Some("2026.09.15.1")); + + let screen = "previous question [y/n]\n\ + \u{203A} "; + let result = codex.detect(input(screen)); + assert_eq!(result.state, ScreenState::Idle); + assert_ne!(result.matched_rule.as_deref(), Some("weak_blocker")); + } + + #[test] + fn screen_detect_imported_copilot_background_agents_are_working() { + let copilot = ManifestSet::bundled().identify("copilot").unwrap(); + assert_eq!(copilot.version().map(ToString::to_string).as_deref(), Some("2026.08.29.1")); + + let working = + copilot.detect(input("task output\n◎ Waiting for background agents · 2 running\n")); + assert_eq!(working.state, ScreenState::Working); + assert_eq!(working.matched_rule.as_deref(), Some("background_agents_working")); + assert!(working.visible_working); + + let no_icon = copilot.detect(input("Waiting for background agents · 2 running\n")); + assert_ne!(no_icon.matched_rule.as_deref(), Some("background_agents_working")); + } + + #[test] + fn screen_detect_bundled_osc_rules_remain_active() { + let set = ManifestSet::bundled(); + + let claude = set.identify("claude").unwrap(); + let claude_working = + claude + .detect(DetectionInput { screen: "", osc_title: "⠋ project", osc_progress: "" }); + assert_eq!(claude_working.state, ScreenState::Working); + + let claude_idle = claude.detect(DetectionInput { + screen: "", + osc_title: "✳ project", + osc_progress: "4;0;0", + }); + assert_eq!(claude_idle.state, ScreenState::Idle); + + let codex = set.identify("codex").unwrap(); + let codex_blocked = codex.detect(DetectionInput { + screen: "", + osc_title: "⚠ Action Required", + osc_progress: "", + }); + assert_eq!(codex_blocked.state, ScreenState::Blocked); + + let grok = set.identify("grok").unwrap(); + let grok_working = + grok.detect(DetectionInput { screen: "", osc_title: "", osc_progress: "4;1;-1" }); + assert_eq!(grok_working.state, ScreenState::Working); + + let grok_idle = + grok.detect(DetectionInput { screen: "", osc_title: "grok", osc_progress: "4;0;0" }); + assert_eq!(grok_idle.state, ScreenState::Idle); + } + + #[test] + fn screen_detect_grok_idle_progress_overrides_custom_title() { + let grok = ManifestSet::bundled().identify("grok").unwrap(); + + let idle = grok.detect(DetectionInput { + screen: "", + osc_title: "custom session title", + osc_progress: "4;0;0", + }); + + assert_eq!(idle.state, ScreenState::Idle); + assert_eq!(idle.matched_rule.as_deref(), Some("osc_progress_idle")); + } + + #[test] + fn screen_detect_grok_spinner_title_overrides_idle_progress() { + let grok = ManifestSet::bundled().identify("grok").unwrap(); + + let working = grok.detect(DetectionInput { + screen: "", + osc_title: "⠋ custom session title", + osc_progress: "4;0;0", + }); + + assert_eq!(working.state, ScreenState::Working); + assert_eq!(working.matched_rule.as_deref(), Some("osc_title_working")); + } + + #[test] + fn screen_detect_grok_blank_braille_does_not_override_idle_progress() { + let grok = ManifestSet::bundled().identify("grok").unwrap(); + + let idle = grok.detect(DetectionInput { + screen: "", + osc_title: "\u{2800}", + osc_progress: "4;0;0", + }); + + assert_eq!(idle.state, ScreenState::Idle); + assert_eq!(idle.matched_rule.as_deref(), Some("osc_progress_idle")); + } + + #[test] + fn screen_detect_grok_local_patch_rejects_older_remote_manifest() { + let bundled = include_str!("../manifests/grok.toml"); + let upstream = bundled.replacen("version = \"2026.09.18.1\"", "version = \"2026.09.18\"", 1); + let mut set = ManifestSet::from_sources(&[("grok", bundled)]).unwrap(); + let remote = compile_manifest_source_with_source( + &upstream, + ManifestSource::Remote { + path: PathBuf::from("/tmp/grok.toml"), + version: ManifestVersion::parse("2026.09.18").unwrap(), + }, + ) + .unwrap(); + + set.insert_compiled(remote).unwrap(); + + let active = set.identify("grok").unwrap(); + assert_eq!(active.version().map(ToString::to_string).as_deref(), Some("2026.09.18.1")); + assert!( + active + .diagnostics() + .warning + .as_deref() + .is_some_and(|warning| { warning.contains("older than active version") }) + ); + } + + #[test] + fn screen_detect_manifest_validation_rejects_malformed_sources() { + for (source, why) in [ + ("id = \"x\"\n", "no rules"), + ( + "id = \"x\"\n[[rules]]\nid = \"r\"\nstate = \"idle\"\nregion = \"nope\"\ncontains = [\"a\"]\n", + "unknown region", + ), + ( + "id = \"x\"\n[[rules]]\nid = \"r\"\nstate = \"working\"\nskip_state_update = true\ncontains = [\"a\"]\n", + "skip_state_update requires unknown state", + ), + ( + "id = \"x\"\n[[rules]]\nid = \"r\"\nstate = \"idle\"\nregex = [\"(\"]\n", + "invalid regex", + ), + ( + "id = \"x\"\n[[rules]]\nid = \"r\"\nstate = \"idle\"\nsurprise = true\ncontains = [\"a\"]\n", + "unknown field", + ), + ( + "id = \"x\"\nmin_engine_version = 99\n[[rules]]\nid = \"r\"\nstate = \"idle\"\ncontains = [\"a\"]\n", + "future engine version", + ), + ( + "id = \"x\"\n[[rules]]\nid = \"r\"\nstate = \"idle\"\nnot = [{ contains = [\"a\"] }]\n", + "not-only rule has no positive matcher", + ), + ( + "id = \"Codex\"\n[[rules]]\nid = \"r\"\nstate = \"idle\"\ncontains = [\"a\"]\n", + "manifest ids are stable lowercase names", + ), + ( + "id = \"x\"\naliases = [\"x\"]\n[[rules]]\nid = \"r\"\nstate = \"idle\"\ncontains = [\"a\"]\n", + "duplicate id alias", + ), + ( + "id = \"x\"\naliases = [\"../x\"]\n[[rules]]\nid = \"r\"\nstate = \"idle\"\ncontains = [\"a\"]\n", + "path aliases are unsafe", + ), + ( + "id = \"x\"\n[[rules]]\nid = \"same\"\nstate = \"idle\"\ncontains = [\"a\"]\n[[rules]]\nid = \"same\"\nstate = \"working\"\ncontains = [\"b\"]\n", + "duplicate rule ids", + ), + ( + "id = \"x\"\n[[rules]]\nid = \"empty\"\nstate = \"idle\"\ncontains = [\"\"]\n", + "empty contains matcher", + ), + ( + "id = \"x\"\n[[rules]]\nid = \"empty\"\nstate = \"idle\"\nregex = [\"\"]\n", + "empty regex matcher", + ), + ( + "id = \"x\"\n[[rules]]\nid = \"empty\"\nstate = \"idle\"\nline_regex = [\"\"]\n", + "empty line regex matcher", + ), + ] { + assert!(compile_manifest_source(source).is_err(), "{why}"); + } + } + + #[test] + fn screen_detect_rejects_alias_conflicts_when_replacing_a_manifest() { + let first = r#" +id = "first" +aliases = ["shared"] + +[[rules]] +id = "idle" +state = "idle" +contains = ["ready"] +"#; + let second = r#" +id = "second" + +[[rules]] +id = "idle" +state = "idle" +contains = ["ready"] +"#; + let replacement = r#" +id = "first" +aliases = ["second"] + +[[rules]] +id = "idle" +state = "idle" +contains = ["ready"] +"#; + + let mut set = ManifestSet::from_sources(&[("first", first), ("second", second)]).unwrap(); + let compiled = compile_manifest_source_with_source( + replacement, + ManifestSource::Override(PathBuf::from("/tmp/first.toml")), + ) + .unwrap(); + let error = set.insert_compiled(compiled).unwrap_err(); + assert!(error.contains("conflicts with second")); + assert_eq!(set.identify("shared").map(CompiledManifest::id), Some("first")); + assert_eq!(set.identify("second").map(CompiledManifest::id), Some("second")); + } +} diff --git a/cmux-tui/bindings/examples/rust-agent-screen-detection/src/manifest_update.rs b/cmux-tui/bindings/examples/rust-agent-screen-detection/src/manifest_update.rs new file mode 100644 index 000000000000..1b89fbf87372 --- /dev/null +++ b/cmux-tui/bindings/examples/rust-agent-screen-detection/src/manifest_update.rs @@ -0,0 +1,851 @@ +//! Opt-in manifest catalog updates for the userland detector. +//! +//! Network access is never part of daemon startup. The `update` command must +//! be invoked explicitly, and every response is bounded, validated, and +//! committed with an atomic rename before the scanner can read it. +//! +//! The catalog and manifest format are derived from herdrdev/herdr's +//! `src/detect/manifest_update.rs` at commit +//! `7b675f42af35508eab66ac42fe1598628597a893` (Apache-2.0). The updater is a +//! manaflow implementation with stricter URL, size, version, and cache rules. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::io::{self, Read, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::time::{SystemTime, UNIX_EPOCH}; + +#[cfg(unix)] +use std::os::fd::AsRawFd; + +use serde::{Deserialize, Serialize}; + +use crate::manifest::{MAX_MANIFEST_BYTES, compile_manifest_source, read_bounded_utf8_file}; + +pub const DEFAULT_CATALOG_URL: &str = "https://herdr.dev/agent-detection/index.toml"; +pub const CATALOG_URL_ENV: &str = "CMUX_AGENT_MANIFEST_CATALOG_URL"; +pub const CACHE_DIR_ENV: &str = "CMUX_AGENT_MANIFEST_CACHE_DIR"; +const MAX_FETCH_BYTES: usize = 256 * 1024; +const MAX_CATALOG_AGENTS: usize = 256; +const MAX_CATALOG_PATH_BYTES: usize = 512; +const MAX_CATALOG_URL_BYTES: usize = 2 * 1024; +const STATUS_FILE_NAME: &str = ".cmux-agent-detection-status.toml"; +const LEGACY_STATUS_FILE_NAME: &str = "status.toml"; +const UPDATE_LOCK_FILE_NAME: &str = ".cmux-agent-detection-update.lock"; + +/// An OS-owned advisory lock for the whole explicit catalog transaction. +/// Atomic renames protect individual files, but they cannot stop an older +/// concurrent response from replacing a newer manifest after its version +/// check. Unix flock releases this lock when the process exits, including a +/// crash, so no stale PID cleanup protocol is needed. +struct UpdateLock { + file: fs::File, +} + +impl UpdateLock { + fn acquire(cache_dir: &Path) -> Result { + fs::create_dir_all(cache_dir) + .map_err(|error| format!("create {}: {error}", cache_dir.display()))?; + let path = cache_dir.join(UPDATE_LOCK_FILE_NAME); + let file = fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + // The lock file is a persistent inode used only for flock. Keep + // any existing contents and make the non-truncating intent + // explicit for clippy and future readers. + .truncate(false) + .open(&path) + .map_err(|error| format!("open update lock {}: {error}", path.display()))?; + #[cfg(unix)] + { + // SAFETY: file owns a valid open descriptor for the lock path. + let result = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }; + if result != 0 { + let error = io::Error::last_os_error(); + return Err(format!( + "another manifest catalog update is already using {}: {error}", + cache_dir.display() + )); + } + } + #[cfg(not(unix))] + { + return Err("manifest catalog updates require a Unix advisory-lock backend".into()); + } + Ok(Self { file }) + } +} + +impl Drop for UpdateLock { + fn drop(&mut self) { + #[cfg(unix)] + { + // SAFETY: this is the descriptor locked by acquire; dropping the + // file would also release it, but an explicit unlock makes the + // lifetime contract clear and testable. + unsafe { + libc::flock(self.file.as_raw_fd(), libc::LOCK_UN); + } + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ManifestUpdateSummary { + pub catalog_url: String, + pub cache_dir: PathBuf, + pub checked: Vec, + pub updated: Vec, + pub current: Vec, + pub failed: Vec, + pub status: ManifestUpdateStatus, +} + +/// Durable diagnostics for the last explicit catalog check. This mirrors +/// herdr's useful status surface while keeping the state in the plugin cache, +/// never in the daemon registry. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ManifestUpdateStatus { + pub last_check_unix: Option, + pub last_result: Option, + #[serde(default)] + pub agents: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ManifestAgentStatus { + pub cached_version: Option, + pub attempted_version: Option, + pub last_checked_unix: Option, + pub last_result: String, + pub last_error: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ManifestUpdateFailure { + pub id: String, + pub error: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ManifestCatalog { + schema_version: u32, + #[serde(default)] + agents: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ManifestCatalogAgent { + id: String, + path: String, +} + +struct ValidatedCatalogAgent { + entry: ManifestCatalogAgent, + manifest_url: String, +} + +/// Fetch and validate a catalog and its manifests. This function does not +/// mutate the cache until each individual manifest has passed validation. +pub fn update_catalog(url: &str, cache_dir: &Path) -> Result { + let _lock = UpdateLock::acquire(cache_dir)?; + let check_time = now_unix(); + let mut status = load_status(cache_dir); + status.last_check_unix = Some(check_time); + let catalog = match fetch_text(url).and_then(|content| parse_catalog(&content)) { + Ok(catalog) => catalog, + Err(error) => { + status.last_result = Some(format!("failed: {error}")); + let _ = save_status(cache_dir, &status); + return Err(error); + } + }; + let base = match base_url(url) { + Ok(base) => base, + Err(error) => { + status.last_result = Some(format!("failed: {error}")); + let _ = save_status(cache_dir, &status); + return Err(error); + } + }; + // Validate the complete catalog shape before fetching or writing any + // manifest. A malformed entry must not leave a half-applied update. + let entries = match validate_catalog_entries(catalog, &base) { + Ok(entries) => entries, + Err(error) => { + status.last_result = Some(format!("failed: {error}")); + let _ = save_status(cache_dir, &status); + return Err(error); + } + }; + let checked = entries.iter().map(|entry| entry.entry.id.clone()).collect::>(); + let mut updated = Vec::new(); + let mut current = Vec::new(); + let mut failed = Vec::new(); + status.last_result = Some("checked".into()); + for validated in entries { + let entry = validated.entry; + let manifest_url = validated.manifest_url; + let result = (|| -> Result<(String, crate::manifest::CompiledManifest), String> { + let content = fetch_text(&manifest_url) + .map_err(|error| format!("fetch {manifest_url}: {error}"))?; + let compiled = compile_manifest_source(&content) + .map_err(|error| format!("manifest {} is invalid: {error}", entry.id))?; + if compiled.id() != entry.id { + return Err(format!( + "catalog id {:?} does not match manifest id {:?}", + entry.id, + compiled.id() + )); + } + if compiled.version().is_none() { + return Err(format!("manifest {} has no version", entry.id)); + } + Ok((content, compiled)) + })(); + let (content, compiled) = match result { + Ok(value) => value, + Err(error) => { + failed.push(ManifestUpdateFailure { id: entry.id.clone(), error: error.clone() }); + status.agents.insert( + entry.id.clone(), + ManifestAgentStatus { + cached_version: cached_version(cache_dir, &entry.id), + attempted_version: None, + last_checked_unix: Some(check_time), + last_result: "failed".into(), + last_error: Some(error), + }, + ); + continue; + } + }; + let version = compiled.version().cloned().expect("validated manifest version"); + let version_text = version.to_string(); + let path = cache_dir.join(format!("{}.toml", entry.id)); + match read_bounded_utf8_file(&path, MAX_MANIFEST_BYTES) { + Ok(existing) => { + let existing_manifest = match compile_manifest_source(&existing) { + Ok(manifest) => manifest, + Err(error) => { + let error = format!("cached manifest {} is invalid: {error}", entry.id); + failed.push(ManifestUpdateFailure { + id: entry.id.clone(), + error: error.clone(), + }); + status.agents.insert( + entry.id.clone(), + ManifestAgentStatus { + cached_version: None, + attempted_version: Some(version_text.clone()), + last_checked_unix: Some(check_time), + last_result: "failed".into(), + last_error: Some(error), + }, + ); + continue; + } + }; + if let Some(existing_version) = existing_manifest.version() + && version < existing_version.clone() + { + let error = format!( + "manifest {} regressed from {} to {}", + entry.id, existing_version, version + ); + failed + .push(ManifestUpdateFailure { id: entry.id.clone(), error: error.clone() }); + status.agents.insert( + entry.id.clone(), + ManifestAgentStatus { + cached_version: Some(existing_version.to_string()), + attempted_version: Some(version_text.clone()), + last_checked_unix: Some(check_time), + last_result: "failed".into(), + last_error: Some(error), + }, + ); + continue; + } + if existing_manifest.version().is_some_and(|current| current == &version) { + if existing != content { + let error = + format!("manifest {} changed content without a version bump", entry.id); + failed.push(ManifestUpdateFailure { + id: entry.id.clone(), + error: error.clone(), + }); + status.agents.insert( + entry.id.clone(), + ManifestAgentStatus { + cached_version: Some(version_text.clone()), + attempted_version: Some(version_text.clone()), + last_checked_unix: Some(check_time), + last_result: "failed".into(), + last_error: Some(error), + }, + ); + continue; + } + current.push(entry.id.clone()); + status.agents.insert( + entry.id.clone(), + ManifestAgentStatus { + cached_version: Some(version_text.clone()), + attempted_version: Some(version_text.clone()), + last_checked_unix: Some(check_time), + last_result: "current".into(), + last_error: None, + }, + ); + continue; + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + let error = format!("read cached manifest {}: {error}", entry.id); + failed.push(ManifestUpdateFailure { id: entry.id.clone(), error: error.clone() }); + status.agents.insert( + entry.id.clone(), + ManifestAgentStatus { + cached_version: cached_version(cache_dir, &entry.id), + attempted_version: Some(version_text.clone()), + last_checked_unix: Some(check_time), + last_result: "failed".into(), + last_error: Some(error), + }, + ); + continue; + } + } + if let Err(error) = atomic_write(&path, content.as_bytes()) { + failed.push(ManifestUpdateFailure { id: entry.id.clone(), error: error.clone() }); + status.agents.insert( + entry.id.clone(), + ManifestAgentStatus { + cached_version: cached_version(cache_dir, &entry.id), + attempted_version: Some(version_text.clone()), + last_checked_unix: Some(check_time), + last_result: "failed".into(), + last_error: Some(error), + }, + ); + continue; + } + updated.push(entry.id.clone()); + status.agents.insert( + entry.id.clone(), + ManifestAgentStatus { + cached_version: Some(version_text.clone()), + attempted_version: Some(version_text), + last_checked_unix: Some(check_time), + last_result: "updated".into(), + last_error: None, + }, + ); + } + status.last_result = Some(if failed.is_empty() { + "ok".into() + } else { + format!("partial_failure:{}", failed.len()) + }); + save_status(cache_dir, &status)?; + Ok(ManifestUpdateSummary { + catalog_url: url.to_string(), + cache_dir: cache_dir.to_path_buf(), + checked, + updated, + current, + failed, + status, + }) +} + +pub fn environment_catalog_url() -> String { + std::env::var(CATALOG_URL_ENV) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| DEFAULT_CATALOG_URL.to_string()) +} + +pub fn environment_cache_dir() -> PathBuf { + if let Some(path) = std::env::var_os(CACHE_DIR_ENV).map(PathBuf::from) + && !path.as_os_str().is_empty() + { + return path; + } + if let Some(path) = std::env::var_os("XDG_CACHE_HOME").map(PathBuf::from) { + return path.join("cmux").join("agent-detection"); + } + if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) { + let cache_root = if cfg!(target_os = "macos") { + home.join("Library").join("Caches") + } else if cfg!(windows) { + std::env::var_os("LOCALAPPDATA") + .map(PathBuf::from) + .unwrap_or_else(|| home.join("AppData").join("Local")) + } else { + home.join(".cache") + }; + return cache_root.join("cmux").join("agent-detection"); + } + PathBuf::from(".cmux-agent-detection-cache") +} + +pub fn status_path(cache_dir: &Path) -> PathBuf { + cache_dir.join(STATUS_FILE_NAME) +} + +pub fn load_status(cache_dir: &Path) -> ManifestUpdateStatus { + let path = status_path(cache_dir); + match read_status_file(&path, true) { + Ok(Some(status)) => status, + Ok(None) => { + // Read the old location only when the new namespaced file does + // not exist. This preserves existing diagnostics while allowing + // `status.toml` to become a normal agent manifest. + let legacy = cache_dir.join(LEGACY_STATUS_FILE_NAME); + read_status_file(&legacy, false).ok().flatten().unwrap_or_default() + } + Err(()) => ManifestUpdateStatus::default(), + } +} + +fn read_status_file(path: &Path, report_invalid: bool) -> Result, ()> { + let content = match read_bounded_utf8_file(path, MAX_FETCH_BYTES) { + Ok(content) => content, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => { + eprintln!( + "cmux-agent-screen-detection: ignoring unreadable status {}: {error}", + path.display() + ); + return Err(()); + } + }; + match toml::from_str(&content) { + Ok(status) => Ok(Some(status)), + Err(error) => { + if report_invalid { + eprintln!( + "cmux-agent-screen-detection: ignoring invalid status {}: {error}", + path.display() + ); + } + Err(()) + } + } +} + +/// Return whether a path is owned by the updater rather than a manifest. +/// `status.toml` is treated as metadata only when it contains a valid legacy +/// status document, so an agent named `status` remains loadable. +pub(crate) fn is_status_file(path: &Path) -> bool { + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + return false; + }; + if name == STATUS_FILE_NAME { + return true; + } + if name != LEGACY_STATUS_FILE_NAME { + return false; + } + read_bounded_utf8_file(path, MAX_FETCH_BYTES) + .ok() + .and_then(|content| toml::from_str::(&content).ok()) + .is_some() +} + +fn save_status(cache_dir: &Path, status: &ManifestUpdateStatus) -> Result<(), String> { + let content = toml::to_string_pretty(status) + .map_err(|error| format!("encode manifest update status: {error}"))?; + atomic_write(&status_path(cache_dir), content.as_bytes()) +} + +fn cached_version(cache_dir: &Path, id: &str) -> Option { + let content = + read_bounded_utf8_file(&cache_dir.join(format!("{id}.toml")), MAX_MANIFEST_BYTES).ok()?; + compile_manifest_source(&content) + .ok() + .and_then(|manifest| manifest.version().map(ToString::to_string)) +} + +fn now_unix() -> u64 { + SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs() +} + +pub fn default_override_dir() -> Option { + if let Some(path) = std::env::var_os("CMUX_AGENT_MANIFEST_DIR").map(PathBuf::from) + && !path.as_os_str().is_empty() + { + return Some(path); + } + let path = std::env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")))? + .join("cmux") + .join("agent-detection"); + path.exists().then_some(path) +} + +fn parse_catalog(content: &str) -> Result, String> { + let catalog: ManifestCatalog = + toml::from_str(content).map_err(|error| format!("invalid catalog TOML: {error}"))?; + if catalog.schema_version != 1 { + return Err(format!("unsupported catalog schema_version {}", catalog.schema_version)); + } + Ok(catalog.agents) +} + +fn validate_catalog_entries( + catalog: Vec, + base: &str, +) -> Result, String> { + if catalog.len() > MAX_CATALOG_AGENTS { + return Err(format!( + "catalog contains {} agents, max is {MAX_CATALOG_AGENTS}", + catalog.len() + )); + } + let mut seen = BTreeSet::new(); + let mut entries = Vec::with_capacity(catalog.len()); + for entry in catalog { + validate_agent_id(&entry.id)?; + if entry.path.len() > MAX_CATALOG_PATH_BYTES { + return Err(format!( + "manifest path for {} exceeds the {MAX_CATALOG_PATH_BYTES}-byte limit", + entry.id + )); + } + if !seen.insert(entry.id.clone()) { + return Err(format!("catalog contains duplicate agent {:?}", entry.id)); + } + let manifest_url = join_url(base, &entry.path)?; + entries.push(ValidatedCatalogAgent { entry, manifest_url }); + } + Ok(entries) +} + +fn validate_agent_id(id: &str) -> Result<(), String> { + if id.is_empty() + || id.len() > 64 + || !id.as_bytes().first().is_some_and(|byte| byte.is_ascii_alphanumeric()) + || !id.bytes().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_' || byte == b'-' + }) + { + return Err(format!("invalid catalog agent id {id:?}")); + } + Ok(()) +} + +fn base_url(url: &str) -> Result { + validate_catalog_url(url)?; + let prefix_len = "https://".len(); + let rest = &url[prefix_len..]; + let Some(_) = rest.find('/') else { + return Ok(url.to_string()); + }; + let Some(last_slash) = url.rfind('/') else { + return Ok(url.to_string()); + }; + Ok(url[..last_slash].to_string()) +} + +fn join_url(base: &str, path: &str) -> Result { + if path.trim().is_empty() + || path.len() > MAX_CATALOG_PATH_BYTES + || path.contains("://") + || path.starts_with('/') + || path.contains('\\') + || path.contains('?') + || path.contains('#') + || path.bytes().any(|byte| byte == 0 || byte.is_ascii_control()) + || path.split('/').any(|part| part == "..") + { + return Err(format!("unsafe manifest path {path:?}")); + } + Ok(format!("{}/{}", base.trim_end_matches('/'), path)) +} + +fn fetch_text(url: &str) -> Result { + validate_catalog_url(url)?; + let max_bytes = MAX_FETCH_BYTES.to_string(); + let mut child = Command::new("curl") + .args([ + "-sfL", + "--proto", + "=https", + "--proto-redir", + "=https", + "--retry", + "2", + "--connect-timeout", + "5", + "--max-time", + "15", + "--max-filesize", + &max_bytes, + url, + ]) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .map_err(|error| format!("curl failed: {error}"))?; + let mut bytes = Vec::new(); + let read_result = { + let Some(stdout) = child.stdout.as_mut() else { + let _ = child.kill(); + let _ = child.wait(); + return Err("curl stdout was not captured".into()); + }; + stdout.take((MAX_FETCH_BYTES + 1) as u64).read_to_end(&mut bytes) + }; + if let Err(error) = read_result { + // Reap curl on every read failure. Returning while it still owns the + // pipe can leak a child and leave a network process behind the plugin. + let _ = child.kill(); + let _ = child.wait(); + return Err(format!("read curl response: {error}")); + } + if bytes.len() > MAX_FETCH_BYTES { + // Stop curl before waiting. Without this, a server that omits + // Content-Length can keep writing into a full pipe while the parent + // waits forever for the child to exit. + let _ = child.kill(); + } + let status = child.wait().map_err(|error| format!("wait for curl: {error}"))?; + if bytes.len() > MAX_FETCH_BYTES { + return Err(format!("response exceeded {MAX_FETCH_BYTES} bytes")); + } + if !status.success() { + return Err(format!("curl exited with {status}")); + } + String::from_utf8(bytes).map_err(|error| format!("response was not UTF-8: {error}")) +} + +fn validate_catalog_url(url: &str) -> Result<(), String> { + let trimmed = url.trim(); + if url != trimmed + || url.len() > MAX_CATALOG_URL_BYTES + || !trimmed.starts_with("https://") + || trimmed.contains('?') + || trimmed.contains('#') + || trimmed + .bytes() + .any(|byte| byte == 0 || byte.is_ascii_control() || byte.is_ascii_whitespace()) + { + return Err("manifest catalog URL must be an HTTPS URL without credentials".into()); + } + let rest = &trimmed["https://".len()..]; + let authority_end = rest.find('/').unwrap_or(rest.len()); + let authority = &rest[..authority_end]; + validate_https_authority(authority)?; + Ok(()) +} + +/// Validate the small HTTPS URL surface accepted by the updater. A strict +/// authority parser avoids handing credentials, malformed ports, or shell +/// metacharacters to curl. IPv6 literals are intentionally not accepted until +/// the updater has a URL parser with equivalent bounds and tests. +fn validate_https_authority(authority: &str) -> Result<(), String> { + if authority.is_empty() || authority.len() > 255 || authority.contains('@') { + return Err("manifest catalog URL must include a valid host".into()); + } + if authority.bytes().filter(|byte| *byte == b':').count() > 1 { + return Err("manifest catalog URL must include a valid host".into()); + } + let (host, _port) = if let Some((host, port)) = authority.rsplit_once(':') { + if port.is_empty() || !port.bytes().all(|byte| byte.is_ascii_digit()) { + return Err("manifest catalog URL must include a valid host".into()); + } + let port = port + .parse::() + .ok() + .filter(|port| *port != 0) + .ok_or_else(|| "manifest catalog URL must include a valid host".to_string())?; + (host, Some(port)) + } else { + (authority, None) + }; + if host.is_empty() { + return Err("manifest catalog URL must include a valid host".into()); + } + for label in host.split('.') { + if label.is_empty() + || label.starts_with('-') + || label.ends_with('-') + || !label.bytes().all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') + { + return Err("manifest catalog URL must include a valid host".into()); + } + } + Ok(()) +} + +fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), String> { + let parent = path.parent().ok_or_else(|| format!("path {} has no parent", path.display()))?; + fs::create_dir_all(parent).map_err(|error| format!("create {}: {error}", parent.display()))?; + let tmp = parent.join(format!( + ".{}.{}-{}.tmp", + path.file_name().and_then(|name| name.to_str()).unwrap_or("manifest"), + std::process::id(), + now_nanos() + )); + let result = (|| { + // Do not follow or overwrite a pre-existing temporary symlink. The + // cache may live in a shared user directory, so the write itself must + // be race-safe even though the final rename is atomic. + let mut file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&tmp) + .map_err(|error| error.to_string())?; + file.write_all(bytes).map_err(|error| error.to_string())?; + file.sync_all().map_err(|error| error.to_string())?; + fs::rename(&tmp, path).map_err(|error| error.to_string())?; + // A durable file rename also needs its parent directory flushed. The + // rename already committed the new content, so a directory-sync error + // is a durability warning, not an update failure that callers could + // safely roll back. + if let Err(error) = fs::File::open(parent).and_then(|directory| directory.sync_all()) { + eprintln!( + "cmux-agent-screen-detection: committed {}, but could not flush its parent directory: {error}", + path.display() + ); + } + Ok::<(), String>(()) + })(); + if result.is_err() { + let _ = fs::remove_file(&tmp); + } + result +} + +fn now_nanos() -> u128 { + SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_nanos() +} + +pub fn summary_json(summary: &ManifestUpdateSummary) -> serde_json::Value { + serde_json::json!({ + "catalog_url": summary.catalog_url, + "cache_dir": summary.cache_dir, + "checked": summary.checked, + "updated": summary.updated, + "current": summary.current, + "failed": summary.failed.iter().map(|failure| serde_json::json!({ + "id": failure.id, + "error": failure.error, + })).collect::>(), + "status": summary.status, + }) +} + +pub fn status_json(cache_dir: &Path) -> serde_json::Value { + serde_json::to_value(load_status(cache_dir)).unwrap_or_else(|_| serde_json::json!({})) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn catalog_paths_reject_traversal_and_absolute_urls() { + assert!(join_url("https://example.test/catalog", "codex.toml").is_ok()); + assert!(join_url("https://example.test/catalog", "../codex.toml").is_err()); + assert!(join_url("https://example.test/catalog", "/codex.toml").is_err()); + assert!(join_url("https://example.test/catalog", "https://evil.test/x").is_err()); + } + + #[test] + fn catalog_urls_require_a_bounded_https_authority() { + assert!(validate_catalog_url("https://example.test/catalog/index.toml").is_ok()); + assert!(validate_catalog_url("https://example.test:443/catalog/index.toml").is_ok()); + assert!(validate_catalog_url("https://127.0.0.1/catalog/index.toml").is_ok()); + for invalid in [ + "http://example.test/catalog/index.toml", + "https://", + "https:///catalog/index.toml", + "https://user@example.test/catalog/index.toml", + "https://example..test/catalog/index.toml", + "https://-example.test/catalog/index.toml", + "https://example-.test/catalog/index.toml", + "https://example.test:0/catalog/index.toml", + "https://example.test:65536/catalog/index.toml", + "https://[::1]/catalog/index.toml", + " https://example.test/catalog/index.toml", + "https://example.test/catalog/index.toml?token=1", + "https://example.test/catalog/index.toml#fragment", + "https://example.test\\catalog\\index.toml", + ] { + assert!(validate_catalog_url(invalid).is_err(), "{invalid:?}"); + } + } + + #[test] + fn catalog_ids_are_bounded_and_normalized() { + assert!(validate_agent_id("codex").is_ok()); + assert!(validate_agent_id("screen_detector").is_ok()); + assert!(validate_agent_id("screen-detector").is_ok()); + assert!(validate_agent_id("status").is_ok()); + assert!(validate_agent_id("bad id").is_err()); + assert!(validate_agent_id("A").is_err()); + assert!(validate_agent_id("-codex").is_err()); + } + + #[test] + fn catalog_shape_is_validated_before_network_fetches() { + let duplicate = vec![ + ManifestCatalogAgent { id: "codex".into(), path: "codex.toml".into() }, + ManifestCatalogAgent { id: "codex".into(), path: "other.toml".into() }, + ]; + assert!(validate_catalog_entries(duplicate, "https://example.test/catalog").is_err()); + + let unsafe_path = + vec![ManifestCatalogAgent { id: "codex".into(), path: "../codex.toml".into() }]; + assert!(validate_catalog_entries(unsafe_path, "https://example.test/catalog").is_err()); + + let valid = vec![ManifestCatalogAgent { id: "codex".into(), path: "codex.toml".into() }]; + let entries = validate_catalog_entries(valid, "https://example.test/catalog").unwrap(); + assert_eq!(entries[0].manifest_url, "https://example.test/catalog/codex.toml"); + + let too_long_path = vec![ManifestCatalogAgent { + id: "codex".into(), + path: "x".repeat(MAX_CATALOG_PATH_BYTES + 1), + }]; + assert!(validate_catalog_entries(too_long_path, "https://example.test/catalog").is_err()); + + let too_many = (0..=MAX_CATALOG_AGENTS) + .map(|index| ManifestCatalogAgent { + id: format!("agent-{index}"), + path: format!("agent-{index}.toml"), + }) + .collect(); + assert!(validate_catalog_entries(too_many, "https://example.test/catalog").is_err()); + } + + #[cfg(unix)] + #[test] + fn catalog_updates_hold_an_exclusive_cache_lock() { + let directory = std::env::temp_dir().join(format!( + "cmux-agent-update-lock-{}-{}", + std::process::id(), + now_nanos() + )); + let first = UpdateLock::acquire(&directory).expect("first updater acquires the lock"); + assert!( + UpdateLock::acquire(&directory).is_err(), + "a second updater must not race the first cache transaction" + ); + drop(first); + assert!(UpdateLock::acquire(&directory).is_ok(), "the lock must release on drop"); + let _ = std::fs::remove_dir_all(directory); + } +} diff --git a/cmux-tui/bindings/examples/rust-agent-screen-detection/src/process.rs b/cmux-tui/bindings/examples/rust-agent-screen-detection/src/process.rs new file mode 100644 index 000000000000..9ee4cd22cd42 --- /dev/null +++ b/cmux-tui/bindings/examples/rust-agent-screen-detection/src/process.rs @@ -0,0 +1,2169 @@ +//! Foreground process-group discovery and userland agent identification. +//! +//! The process model is adapted from herdr's `src/platform/{linux,macos}.rs` +//! and `src/detect/mod.rs` at commit +//! `7b675f42af35508eab66ac42fe1598628597a893` (Apache-2.0). The strict Pi +//! bundled-launcher suffixes also incorporate herdr commit +//! `b1ff4582e9688f52ffb943cfa8bee4871ae122e4` (Apache-2.0). The plugin keeps +//! this platform code outside cmux core, adds bounded traversal and precise +//! attached-versus-separate runtime option boundaries, and resolves names +//! through the replaceable manifest set instead of a closed agent enum. + +use cmux::ProcessInfoResult; + +use crate::manifest::{CompiledManifest, ManifestSet}; + +/// One process in the terminal's foreground process group. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ForegroundProcess { + pub pid: u32, + /// Kernel process name, or the platform equivalent. + pub name: String, + /// Effective argv[0], when the platform exposes it. + pub argv0: Option, + pub argv: Vec, + pub cmdline: Option, +} + +/// The complete process group currently attached to a terminal. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ForegroundJob { + pub process_group_id: u32, + pub processes: Vec, +} + +/// Collect the foreground process group for a terminal PTY child. +/// +/// This is best effort. The scanner falls back to the generic process fields +/// returned by the daemon when a host denies process inspection. +pub fn foreground_job(child_pid: u32) -> Option { + if child_pid == 0 { + return None; + } + platform::foreground_job(child_pid) +} + +/// Build a one-process job from the public SDK response. This path keeps the +/// plugin usable on hosts without native process-group APIs. +pub fn fallback_job(process: &ProcessInfoResult) -> ForegroundJob { + let name = process + .foreground_executable + .clone() + .or_else(|| process.executable.clone()) + .or_else(|| process.argv.first().cloned()) + .unwrap_or_default(); + ForegroundJob { + process_group_id: process.pid, + processes: vec![ForegroundProcess { + pid: process.pid, + name, + argv0: process.argv.first().cloned(), + argv: process.argv.clone(), + cmdline: (!process.argv.is_empty()).then(|| process.argv.join(" ")), + }], + } +} + +/// Identify an agent in a foreground process group. +/// +/// The process-group leader gets first refusal. If it is a shell or runtime, +/// all group members are scored next. This preserves herdr's useful behavior +/// for `node`, Python, shell, cmd, and PowerShell wrappers while keeping the +/// actual supported-agent catalog in user-editable manifests. +pub fn identify_job<'a>( + manifests: &'a ManifestSet, + job: &ForegroundJob, +) -> Option<(&'a CompiledManifest, String)> { + if let Some(leader) = job.processes.iter().find(|process| process.pid == job.process_group_id) + && let Some(found) = identify_process(manifests, leader) + { + return Some(found); + } + + let mut best: Option<(u8, &'a CompiledManifest, String)> = None; + for process in &job.processes { + let Some((manifest, candidate)) = identify_process(manifests, process) else { + continue; + }; + let priority = process_priority(process); + match best { + Some((best_priority, _, _)) if best_priority >= priority => {} + _ => best = Some((priority, manifest, candidate)), + } + } + best.map(|(_, manifest, candidate)| (manifest, candidate)) +} + +/// Identify a process using the same foreground-group and public-process +/// fallback used by the scanner. Keeping this order shared with diagnostics +/// prevents an explain result from disagreeing with the state publisher. +pub fn identify_job_with_process_fallback<'a>( + manifests: &'a ManifestSet, + job: &ForegroundJob, + process: &ProcessInfoResult, +) -> Option<(&'a CompiledManifest, String)> { + identify_job(manifests, job).or_else(|| { + process + .foreground_executable + .as_deref() + .or(process.executable.as_deref()) + .or_else(|| process.argv.first().map(String::as_str)) + .and_then(|name| manifests.identify(name).map(|manifest| (manifest, name.to_string()))) + }) +} + +fn identify_process<'a>( + manifests: &'a ManifestSet, + process: &ForegroundProcess, +) -> Option<(&'a CompiledManifest, String)> { + identify_process_with_hint(manifests, process, platform::agent_hint) +} + +fn identify_process_with_hint<'a, Hint>( + manifests: &'a ManifestSet, + process: &ForegroundProcess, + mut hint: Hint, +) -> Option<(&'a CompiledManifest, String)> +where + Hint: FnMut(u32) -> Option, +{ + // Executable and wrapper evidence is already present in the process + // record. Try it first so ordinary agent scans do not read /proc or the + // macOS process environment. The explicit hint remains a fallback for a + // VM, sandbox, or other wrapper that hides the real executable. + if let Some(found) = process_candidates(process) + .into_iter() + .find_map(|candidate| manifests.identify(&candidate).map(|manifest| (manifest, candidate))) + { + return Some(found); + } + + // An explicit process hint is optional and stays inside the plugin. The + // replaceable manifest set validates the value before it becomes an + // adapter identity. + hint(process.pid).and_then(|hint| manifests.identify(&hint).map(|manifest| (manifest, hint))) +} + +fn process_candidates(process: &ForegroundProcess) -> Vec { + let mut candidates = Vec::new(); + let mut push = |candidate: String| { + if !candidate.is_empty() && !candidates.iter().any(|existing| existing == &candidate) { + candidates.push(candidate); + } + }; + + if let Some(argv0) = process.argv0.as_deref() { + push(argv0.to_string()); + } + push(process.name.clone()); + if let Some(argv0) = process.argv.first() { + push(argv0.clone()); + } + + let effective = process + .argv0 + .as_deref() + .or_else(|| process.argv.first().map(String::as_str)) + .unwrap_or(&process.name); + let runtime = normalized_name(effective); + + // Some package launchers keep a generic `node` process name and use a + // non-agent script basename. Match only the known executable path shape, + // so a package's build or postinstall script cannot look like a live + // agent. This is the same false-positive guard herdr uses for these + // launchers, expressed in terms of replaceable manifest ids. + if let Some(candidate) = known_package_agent(effective, &process.argv) { + push(candidate); + } + if let Some(candidate) = cursor_bundled_agent(&process.argv) { + push(candidate); + } + + if is_runtime_or_shell(&runtime) + && let Some(candidate) = wrapped_agent_from_argv(&runtime, &process.argv) + { + push(candidate); + } + + // A runtime can expose a generic argv[0] while its script path names the + // agent. Inspect path components, but never inspect arbitrary eval text. + if !is_eval_invocation(&runtime, &process.argv) { + let arguments = runtime_path_arguments(&runtime, &process.argv); + for argument in arguments { + for candidate in path_candidates(argument) { + push(candidate); + } + } + } + // Herdr's final fallback inspects only argv[0] from a raw command line. + // Scanning every token lets ordinary option values or model text claim an + // agent identity. Use this path only when the structured argv is absent. + if process.argv.is_empty() + && let Some(cmdline) = process.cmdline.as_deref() + && !is_eval_invocation(&runtime, &process.argv) + && !is_runtime_or_shell(&runtime) + && let Some(token) = shell_words(cmdline).into_iter().next() + { + for candidate in path_candidates(&token) { + push(candidate); + } + } + + candidates +} + +fn process_priority(process: &ForegroundProcess) -> u8 { + let effective = process + .argv0 + .as_deref() + .or_else(|| process.argv.first().map(String::as_str)) + .unwrap_or(&process.name); + let effective = normalized_name(effective); + let kernel_name = normalized_name(&process.name); + if effective != kernel_name { + 3 + } else if !is_runtime_or_shell(&effective) { + 2 + } else { + 1 + } +} + +fn wrapped_agent_from_argv(runtime: &str, argv: &[String]) -> Option { + match runtime { + "node" | "bun" => { + if is_eval_invocation(runtime, argv) { + None + } else { + runtime_path_arguments(runtime, argv) + .into_iter() + .find_map(|argument| path_candidates(argument).into_iter().next()) + } + } + name if is_python_runtime(name) => { + if is_eval_invocation(runtime, argv) { + None + } else { + runtime_path_arguments(runtime, argv) + .into_iter() + .find_map(|argument| path_candidates(argument).into_iter().next()) + } + } + "sh" | "bash" | "zsh" | "fish" => shell_wrapped_agent(runtime, argv), + "cmd" => windows_cmd_agent(argv), + "powershell" | "pwsh" => powershell_agent(argv), + // tmux is a process-group transport, not an agent wrapper. Its + // children are inspected separately when the platform exposes them. + "tmux" => None, + _ => None, + } +} + +fn shell_wrapped_agent(runtime: &str, argv: &[String]) -> Option { + let mut index = 1; + let mut reads_stdin = false; + while let Some(argument) = argv.get(index) { + // Shell option letters are case-sensitive. Keep the raw spelling for + // this parser; `normalized_flag` is reserved for the case-insensitive + // Windows and PowerShell wrappers below. + let flag = shell_flag(argument); + if flag == "--" { + if reads_stdin { + return None; + } + return argv + .get(index + 1) + .and_then(|script| path_candidates(script).into_iter().next()); + } + if let Some(command) = + shell_command_words(runtime, flag, argv.get(index + 1).map(String::as_str)) + { + return command_first_path_candidate(&command); + } + if flag.starts_with('-') || (runtime == "zsh" && flag.starts_with('+')) { + if shell_option_exits(runtime, flag) { + return None; + } + if shell_option_takes_value(runtime, flag) { + index = index.saturating_add(2); + continue; + } + if shell_option_has_attached_value(runtime, flag) { + // The value is already part of this option. Do not consume + // the following token, which may be the script. + index += 1; + continue; + } + if shell_option_reads_stdin(runtime, flag) { + reads_stdin = true; + index += 1; + continue; + } + if shell_option_without_value(runtime, flag) { + index += 1; + continue; + } + // Unknown options may consume the next value. Failing closed is + // safer than treating that value as an agent executable. + return None; + } + if reads_stdin { + return None; + } + // For `sh /path/to/agent`, the first positional value is the script. + // Later values are script arguments and must not affect identity. + return path_candidates(argument).into_iter().next(); + } + None +} + +fn windows_cmd_agent(argv: &[String]) -> Option { + let mut index = 1; + while let Some(argument) = argv.get(index) { + match normalized_flag(argument).as_str() { + "/c" | "/k" => { + return argv + .get(index + 1) + .and_then(|command| command_first_path_candidate(&shell_words(command))); + } + "/d" | "/s" | "/q" | "/a" | "/u" | "/e:on" | "/e:off" | "/f:on" | "/f:off" + | "/v:on" | "/v:off" => {} + _ => {} + } + index += 1; + } + None +} + +fn powershell_agent(argv: &[String]) -> Option { + let mut index = 1; + while let Some(argument) = argv.get(index) { + match normalized_flag(argument).as_str() { + "-file" | "-f" | "/file" => { + return argv + .get(index + 1) + .and_then(|path| path_candidates(path).into_iter().next()); + } + "-command" | "-c" | "/command" | "/c" => { + return argv + .get(index + 1) + .and_then(|command| command_first_path_candidate(&shell_words(command))); + } + "-encodedcommand" | "-enc" | "/encodedcommand" | "/enc" => return None, + // These options consume the next token. Without advancing over + // that value, a path or word equal to an agent id can be treated + // as the executable even though PowerShell is only configuring + // itself. + "-configurationname" | "-executionpolicy" | "-outputformat" | "-psconsolefile" + | "-version" | "-windowstyle" | "-workingdirectory" => { + index = index.saturating_add(1); + } + _ if argument.starts_with('-') || argument.starts_with('/') => {} + _ => return path_candidates(argument).into_iter().next(), + } + index += 1; + } + None +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum ShellOptionKind { + Safe, + NoScript, + TakesValue, + NoExecute, + Exits, +} + +/// Return shell words for a command-mode flag. The command syntax is kept +/// runtime-specific because a generic "any cluster ending in c" rule treats +/// value-taking options such as bash `-o` as command mode. Fish accepts both +/// the separate `--command` argument and the inline `--command=...` form. +fn shell_command_words(runtime: &str, flag: &str, next: Option<&str>) -> Option> { + if runtime == "fish" { + if flag == "--command" { + return Some(next.map_or_else(Vec::new, shell_words)); + } + if let Some(command) = flag.strip_prefix("--command=") { + return Some(shell_words(command)); + } + } + if is_shell_command_flag(runtime, flag) { + return Some(next.map_or_else(Vec::new, shell_words)); + } + None +} + +fn is_shell_command_flag(runtime: &str, flag: &str) -> bool { + if flag == "-c" { + return matches!(runtime, "bash" | "sh" | "zsh" | "fish"); + } + + let Some(characters) = flag.strip_prefix('-').map(str::chars) else { + return false; + }; + // Bash, sh, and zsh accept `c` anywhere in a short-option cluster. Fish + // requires it to be the final short option. Value-taking, no-execute, + // exit-only, and unknown options remain invalid on either side of `c`. + let mut characters = characters.peekable(); + let mut command_count = 0; + while let Some(character) = characters.next() { + if character == 'c' { + command_count += 1; + if runtime == "fish" && characters.peek().is_some() { + return false; + } + continue; + } + if !matches!( + shell_short_option_kind(runtime, character), + Some(ShellOptionKind::Safe | ShellOptionKind::NoScript) + ) { + return false; + } + } + command_count == 1 +} + +fn shell_short_option_kind(runtime: &str, option: char) -> Option { + match runtime { + // Bash documents these as invocation flags. `-n` and `-D` prevent a + // command from running, while `-o` and `-O` consume an option name. + "bash" => match option { + 'a' | 'b' | 'e' | 'f' | 'h' | 'i' | 'k' | 'l' | 'm' | 'p' | 'r' | 'u' | 'v' | 'x' + | 'B' | 'C' | 'E' | 'H' | 'P' | 'T' => Some(ShellOptionKind::Safe), + 's' | 't' => Some(ShellOptionKind::NoScript), + 'n' => Some(ShellOptionKind::NoExecute), + 'D' => Some(ShellOptionKind::Exits), + 'o' | 'O' => Some(ShellOptionKind::TakesValue), + _ => None, + }, + // Keep the POSIX/common shell switches here. Different `sh` + // implementations add flags, so unknown switches fail closed. + "sh" => match option { + 'a' | 'b' | 'e' | 'f' | 'h' | 'i' | 'k' | 'l' | 'm' | 'p' | 'r' | 'u' | 'v' | 'x' => { + Some(ShellOptionKind::Safe) + } + 's' | 't' => Some(ShellOptionKind::NoScript), + 'n' => Some(ShellOptionKind::NoExecute), + 'o' => Some(ShellOptionKind::TakesValue), + _ => None, + }, + // zsh exposes a larger set of single-letter option aliases. These + // are all non-consuming options; `-n` is noexec, `-o` takes a name, + // and `-b` is the documented end-options switch. + "zsh" => match option { + 'J' | 'N' | 'T' | 'w' | 'E' | 'D' | '9' | 'X' | 'Y' | 'S' | '4' | 'I' | '8' | 'G' + | 'P' | 'h' | 'g' | 'a' | '0' | 'O' | '7' | 'k' | 'U' | 'Q' | '1' | 'H' | 'L' | 'W' + | '6' | 'R' | 'm' | '5' | 'e' | 'v' | 'x' | 'y' | 'i' | 'l' | 'p' | 'r' | 'M' | 'Z' + | 'b' | 'd' | 'f' => Some(ShellOptionKind::Safe), + 's' | 't' => Some(ShellOptionKind::NoScript), + 'n' => Some(ShellOptionKind::NoExecute), + 'o' => Some(ShellOptionKind::TakesValue), + _ => None, + }, + // Fish's short options follow its command-line synopsis. `-h` and + // `-v` exit, while `-C`, `-d`, `-f`, `-o`, and `-p` consume values. + "fish" => match option { + 'i' | 'l' | 'N' | 'P' => Some(ShellOptionKind::Safe), + 'n' => Some(ShellOptionKind::NoExecute), + 'h' | 'v' => Some(ShellOptionKind::Exits), + 'C' | 'd' | 'f' | 'o' | 'p' => Some(ShellOptionKind::TakesValue), + _ => None, + }, + _ => None, + } +} + +fn shell_option_exits(runtime: &str, flag: &str) -> bool { + match runtime { + "bash" => matches!( + flag, + "-D" | "--dump-po-strings" + | "--dump-strings" + | "--help" + | "--pretty-print" + | "--version" + ), + "sh" => matches!( + flag, + "--dump-po-strings" | "--dump-strings" | "--help" | "--pretty-print" | "--version" + ), + "zsh" => matches!(flag, "--help" | "--version"), + "fish" => matches!(flag, "-h" | "-v" | "--help" | "--print-debug-categories" | "--version"), + _ => false, + } +} + +fn shell_option_takes_value(runtime: &str, flag: &str) -> bool { + match runtime { + "bash" => { + // This predicate means that the option consumes the *next* + // argv element. Attached spellings are accepted only below when + // the runtime documents them. + matches!(flag, "-o" | "-O" | "--rcfile" | "--init-file") + } + "zsh" => matches!(flag, "-o" | "+o"), + "fish" => { + matches!(flag, "-C" | "-d" | "-f" | "-o" | "-p") + || matches!( + flag, + "--debug" + | "--debug-output" + | "--features" + | "--init-command" + | "--profile" + | "--profile-startup" + ) + } + "sh" => flag == "-o", + _ => false, + } +} + +fn shell_option_has_attached_value(runtime: &str, flag: &str) -> bool { + match runtime { + "fish" => [ + "--debug", + "--debug-output", + "--features", + "--init-command", + "--profile", + "--profile-startup", + ] + .iter() + .any(|option| long_option_with_attached_value(flag, option)), + _ => false, + } +} + +/// Return true for a short-option cluster that selects stdin as the shell's +/// command source. These options do not consume a following argument, but a +/// following positional token becomes `$0` or a shell argument rather than a +/// script. A later `-c` remains valid, so the caller tracks this state instead +/// of treating `-s` or `-t` as an unknown option. +fn shell_option_reads_stdin(runtime: &str, flag: &str) -> bool { + let Some(characters) = flag.strip_prefix('-').filter(|value| !value.is_empty()) else { + return false; + }; + let mut saw_stdin_mode = false; + for character in characters.chars() { + match shell_short_option_kind(runtime, character) { + Some(ShellOptionKind::Safe) => {} + Some(ShellOptionKind::NoScript) => saw_stdin_mode = true, + _ => return false, + } + } + saw_stdin_mode +} + +fn long_option_with_attached_value(flag: &str, option: &str) -> bool { + flag.strip_prefix(option).is_some_and(|value| value.starts_with('=')) +} + +fn shell_option_without_value(runtime: &str, flag: &str) -> bool { + if flag.starts_with("--") { + return match runtime { + "bash" => matches!( + flag, + "--login" + | "--noprofile" + | "--norc" + | "--posix" + | "--restricted" + | "--verbose" + | "--xtrace" + | "--noediting" + | "--help" + | "--version" + ), + "zsh" => matches!(flag, "--login" | "--no-rcs" | "--sh" | "--emacs" | "--vi"), + "fish" => matches!( + flag, + "--interactive" + | "--login" + | "--no-config" + | "--no-editing" + | "--private" + | "--print-rusage-self" + | "--help" + | "--version" + ), + "sh" => matches!(flag, "--login" | "--posix" | "--restricted" | "--verbose"), + _ => false, + }; + } + + // These are the runtime-specific short shell switches that do not + // consume the next argument. Value-taking, no-exec, exit-only, and + // unknown switches deliberately fail closed. + let Some(characters) = flag.strip_prefix('-').filter(|value| !value.is_empty()) else { + return false; + }; + characters.chars().all(|character| { + matches!(shell_short_option_kind(runtime, character), Some(ShellOptionKind::Safe)) + }) +} + +/// Return only the executable token from a shell command. Scanning every +/// token makes `echo codex` look like a codex process even though codex is +/// merely text. Wrapper keywords used by common shells are skipped. +fn command_first_path_candidate(tokens: &[String]) -> Option { + for token in tokens { + let token = token.trim(); + if token.is_empty() { + continue; + } + if matches!(token, "exec" | "command" | "env" | "sudo" | "call" | "." | "&") { + continue; + } + if token == "&&" || token == "||" || token == ";" { + break; + } + if token.contains('=') + && !token.bytes().next().is_some_and(|byte| byte == b'/' || byte == b'\\') + { + continue; + } + return path_candidates(token).into_iter().next(); + } + None +} + +/// Return executable/script arguments for a runtime without treating option +/// values or arbitrary model text as a process name. +fn runtime_path_arguments<'a>(runtime: &str, argv: &'a [String]) -> Vec<&'a str> { + // Command interpreters have their own grammar. Their positional + // arguments can be commands, configuration values, or arbitrary text, + // so only the dedicated wrapper parsers above may identify an agent. + if matches!(runtime, "sh" | "bash" | "zsh" | "fish" | "cmd" | "powershell" | "pwsh" | "tmux") { + return Vec::new(); + } + let mut result = Vec::new(); + let mut index = 1; + while let Some(argument) = argv.get(index) { + if argument == "--" { + if let Some(next) = argv.get(index + 1) { + result.push(next.as_str()); + } + break; + } + if is_eval_flag(runtime, argument) { + break; + } + if runtime_option_exits(runtime, argument) { + // Python exits after printing help or its version. Any later + // token is command-line data, never an executable script. + break; + } + if runtime_option_has_unsupported_attached_value(runtime, argument) { + // An unknown or unsupported `--name=value` spelling may be + // rejected by the runtime. Do not consume the next token as its + // value, because that could turn a later mode flag and command + // text into a false agent script. + return Vec::new(); + } + if is_python_runtime(runtime) && runtime_flag_matches(argument, "-m") { + // Python module mode consumes the following token as a module + // name. Remaining tokens are module arguments, not executables. + break; + } + if argument.starts_with('-') { + if runtime_option_takes_value(runtime, argument) { + index += 1; + } + index += 1; + continue; + } + result.push(argument.as_str()); + break; + } + result +} + +fn is_eval_flag(runtime: &str, argument: &str) -> bool { + match runtime { + "node" | "bun" => ["-e", "--eval", "-p", "--print"] + .iter() + .any(|flag| runtime_flag_matches(argument, flag)), + name if is_python_runtime(name) => runtime_flag_matches(argument, "-c"), + _ => false, + } +} + +/// Match a runtime mode flag in either its separate-argument form or its +/// attached short/long value form. This follows herdr's conservative parser: +/// an attached short value is treated as script text, never as a later path. +fn runtime_flag_matches(argument: &str, flag: &str) -> bool { + argument == flag + || (flag.starts_with('-') + && !flag.starts_with("--") + && argument.starts_with(flag) + && argument.len() > flag.len()) + || (flag.starts_with("--") + && argument.strip_prefix(flag).is_some_and(|rest| rest.starts_with('='))) +} + +fn runtime_option_takes_value(runtime: &str, argument: &str) -> bool { + match runtime { + "node" | "bun" => matches!( + argument, + "-r" | "--require" + | "--loader" + | "--import" + | "--experimental-loader" + | "--inspect-port" + ), + name if is_python_runtime(name) => { + // `-S` is a boolean site-import switch. `-L` and `-o` are kept + // for alternate Python runtimes that document those options. + // This predicate only describes options that consume the next + // argv element. Unsupported attached long options are handled + // separately so they cannot skip a later mode flag. + matches!(argument, "-m" | "-W" | "-X" | "-L" | "-o" | "--check-hash-based-pycs") + } + _ => false, + } +} + +/// Return true when a runtime option uses an attached long value that this +/// parser cannot prove is valid. Python's documented long options use a +/// separate value token, so fail closed for every `--name=value` spelling. +fn runtime_option_has_unsupported_attached_value(runtime: &str, argument: &str) -> bool { + is_python_runtime(runtime) && argument.starts_with("--") && argument.contains('=') +} + +fn runtime_option_exits(runtime: &str, argument: &str) -> bool { + match runtime { + name if is_python_runtime(name) => matches!( + argument, + // CPython documents `-?` as an alias for `-h` and `-VV` as the + // verbose form of `-V`; both terminate before a script path. + "-h" | "-?" + | "-V" + | "-VV" + | "--help" + | "--help-env" + | "--help-xoptions" + | "--help-all" + | "--version" + ), + _ => false, + } +} + +fn is_eval_invocation(runtime: &str, argv: &[String]) -> bool { + let mut index = 1; + while let Some(argument) = argv.get(index) { + if argument == "--" { + return false; + } + if is_eval_flag(runtime, argument) { + return true; + } + if is_python_runtime(runtime) && runtime_flag_matches(argument, "-m") { + return false; + } + if argument.starts_with('-') { + if runtime_option_takes_value(runtime, argument) { + index += 1; + } + index += 1; + continue; + } + // The first positional argument is the script/module entrypoint. Any + // later flags belong to that program and cannot change the runtime + // invocation mode. + break; + } + false +} + +fn path_candidates(token: &str) -> Vec { + let token = token.trim_matches(|character| matches!(character, '\'' | '"' | '`')); + if token.is_empty() || token.starts_with('-') { + return Vec::new(); + } + let mut candidates = Vec::new(); + if let Some(candidate) = known_package_path_agent(token) { + candidates.push(candidate); + } + let basename = token.rsplit(['/', '\\']).find(|part| !part.is_empty()).unwrap_or(token); + push_path_candidate(&mut candidates, basename); + + let components = + token.split(['/', '\\']).filter(|component| !component.is_empty()).collect::>(); + if let Some(node_modules) = + components.iter().rposition(|component| *component == "node_modules") + { + // Only package names immediately below node_modules are inspected. + // This avoids treating an arbitrary directory named `codex` as an + // agent while retaining npm and pnpm launcher paths. + if let Some(package) = components.get(node_modules + 1) { + let package = package.trim_start_matches('@'); + let package_is_scoped = components + .get(node_modules + 1) + .is_some_and(|component| component.starts_with('@')); + let package_is_known_launcher = known_package_path_agent(token).is_some(); + if !package_is_known_launcher + && !package_is_scoped + && !package.is_empty() + && !package.contains('.') + { + push_path_candidate(&mut candidates, package); + } + } + } + if let Some(resolved) = canonical_path_basename(token) { + push_path_candidate(&mut candidates, &resolved); + } + // `push_path_candidate` and the local `push` closure preserve the + // evidence order. Sorting here would let a low-confidence basename beat + // a package-specific identity, which is a false-positive risk in wrapper + // processes. + candidates +} + +fn known_package_agent(effective: &str, argv: &[String]) -> Option { + let runtime = normalized_name(effective); + if runtime != "node" && runtime != "bun" { + return None; + } + // A package-shaped path can be script text in an attached eval flag. + // Check the runtime grammar before applying the path-specific launcher + // exception, or eval text could claim an agent identity. + if is_eval_invocation(&runtime, argv) { + return None; + } + argv.get(1).and_then(|script| known_package_path_agent(script)) +} + +fn known_package_path_agent(path: &str) -> Option { + let raw_components = + path.split(['/', '\\']).filter(|component| !component.is_empty()).collect::>(); + let ends_with = |suffix: &[&str]| { + raw_components.len() >= suffix.len() + && raw_components[raw_components.len() - suffix.len()..] + .iter() + .zip(suffix) + .all(|(actual, expected)| actual.eq_ignore_ascii_case(expected)) + }; + // Pi's current Windows package emits either the direct CLI or the + // bundled CLI entrypoint. Compare raw components here. Normalizing file + // extensions first would turn `cli.exe` into `cli` and accept an invalid + // executable as a live agent. + if ends_with(&["node_modules", "@earendil-works", "pi-coding-agent", "dist", "cli.js"]) + || ends_with(&[ + "node_modules", + "@earendil-works", + "pi-coding-agent", + "dist", + "bundle", + "cli.js", + ]) + { + return Some("pi".into()); + } + + let components = raw_components.into_iter().map(normalized_name).collect::>(); + for window in components.windows(5) { + if window == ["node_modules", "@qwen-code", "qwen-code", "dist", "index"] { + return Some("qwen".into()); + } + } + for window in components.windows(4) { + if window == ["node_modules", "mastracode", "dist", "cli"] { + return Some("mastracode".into()); + } + } + // pnpm's package exposes opencode through `opencode-ai/bin/opencode`. + if components.windows(3).any(|window| window == ["opencode-ai", "bin", "opencode"]) { + return Some("opencode".into()); + } + None +} + +fn cursor_bundled_agent(argv: &[String]) -> Option { + let runtime = argv.first().map(|value| normalized_name(value))?; + if runtime != "node" { + return None; + } + let runtime_path = argv.first()?; + let script_path = argv.get(1)?; + let (runtime_parent, runtime_name) = path_parent_and_basename(runtime_path)?; + let (script_parent, script_name) = path_parent_and_basename(script_path)?; + if !runtime_name.eq_ignore_ascii_case("node.exe") + || !script_name.eq_ignore_ascii_case("index.js") + || !runtime_parent.eq_ignore_ascii_case(script_parent) + { + return None; + } + let mut tail = runtime_parent.rsplit(['/', '\\']).filter(|component| !component.is_empty()); + let (Some(version), Some(versions), Some(package)) = (tail.next(), tail.next(), tail.next()) + else { + return None; + }; + (package.eq_ignore_ascii_case("cursor-agent") + && versions.eq_ignore_ascii_case("versions") + && !version.is_empty()) + .then(|| "cursor".into()) +} + +fn path_parent_and_basename(path: &str) -> Option<(&str, &str)> { + let split = path.rfind(['/', '\\'])?; + let parent = path[..split].trim_end_matches(['/', '\\']); + let basename = &path[split + 1..]; + (!parent.is_empty() && !basename.is_empty()).then_some((parent, basename)) +} + +fn canonical_path_basename(path: &str) -> Option { + if !path.bytes().any(|byte| byte == b'/' || byte == b'\\') || path.len() > 4096 { + return None; + } + std::fs::canonicalize(path) + .ok() + .and_then(|resolved| resolved.file_name().and_then(|name| name.to_str()).map(str::to_owned)) +} + +fn push_path_candidate(candidates: &mut Vec, value: &str) { + let mut value = value.to_string(); + for suffix in [".exe", ".cmd", ".bat", ".ps1", ".js", ".py"] { + if value.to_ascii_lowercase().ends_with(suffix) { + value.truncate(value.len() - suffix.len()); + break; + } + } + // Script launchers often use a stable `-code` or `-cli` + // filename. Accept the first component only for these explicit suffixes. + let mut aliases = Vec::new(); + for suffix in ["-code", "-cli", "-coding-agent"] { + if let Some(prefix) = value.strip_suffix(suffix) + && !prefix.is_empty() + { + aliases.push(prefix.to_string()); + } + } + if !value.is_empty() { + candidates.push(value); + } + candidates.extend(aliases); +} + +fn shell_words(input: &str) -> Vec { + let mut words = Vec::new(); + let mut current = String::new(); + let mut quote = None; + let mut escaped = false; + for character in input.chars() { + if escaped { + current.push(character); + escaped = false; + continue; + } + if character == '\\' && quote != Some('\'') { + escaped = true; + continue; + } + if let Some(active) = quote { + if character == active { + quote = None; + } else { + current.push(character); + } + } else if matches!(character, '\'' | '"') { + quote = Some(character); + } else if character.is_whitespace() { + if !current.is_empty() { + words.push(std::mem::take(&mut current)); + } + } else { + current.push(character); + } + } + if escaped { + current.push('\\'); + } + if !current.is_empty() { + words.push(current); + } + words +} + +fn normalized_flag(argument: &str) -> String { + argument.trim_matches('"').to_ascii_lowercase() +} + +fn shell_flag(argument: &str) -> &str { + argument.trim_matches(|character| matches!(character, '\'' | '"')) +} + +fn normalized_name(name: &str) -> String { + let basename = name.rsplit(['/', '\\']).find(|part| !part.is_empty()).unwrap_or(name); + let mut normalized = basename.trim_start_matches('-').to_ascii_lowercase(); + for suffix in [".exe", ".cmd", ".bat", ".ps1", ".js", ".py"] { + if normalized.ends_with(suffix) { + normalized.truncate(normalized.len() - suffix.len()); + break; + } + } + normalized +} + +fn is_runtime_or_shell(name: &str) -> bool { + is_python_runtime(name) + || matches!( + name, + "sh" | "bash" + | "zsh" + | "fish" + | "tmux" + | "node" + | "bun" + | "cmd" + | "powershell" + | "pwsh" + ) +} + +fn is_python_runtime(name: &str) -> bool { + name == "python" + || name.strip_prefix("python").is_some_and(|version| { + !version.is_empty() + && version + .split('.') + .all(|part| !part.is_empty() && part.bytes().all(|byte| byte.is_ascii_digit())) + }) +} + +/// Parse the optional process identity hints used by herdr-compatible agent +/// launchers. `CMUX_AGENT` is the native name; `HERDR_AGENT` keeps existing +/// integrations working. The value is still checked against the active +/// manifest set by `identify_process`. +fn parse_agent_env_hint(environ: &[u8]) -> Option { + let mut herdr_hint = None; + for record in environ.split(|byte| *byte == 0) { + let Some(separator) = record.iter().position(|byte| *byte == b'=') else { + continue; + }; + let (key, value) = record.split_at(separator); + let value = &value[1..]; + let is_cmux = key == b"CMUX_AGENT"; + let is_herdr = key == b"HERDR_AGENT"; + if !is_cmux && !is_herdr { + continue; + } + let Ok(value) = std::str::from_utf8(value) else { + continue; + }; + let value = value.trim(); + if value.is_empty() || value.len() > 64 || value.bytes().any(|byte| byte.is_ascii_control()) + { + continue; + } + if is_cmux { + return Some(value.to_string()); + } + herdr_hint = Some(value.to_string()); + } + herdr_hint +} + +#[cfg(target_os = "linux")] +mod platform { + use super::{ForegroundJob, ForegroundProcess}; + use std::collections::{HashSet, VecDeque}; + use std::fs::File; + use std::io::Read; + use std::path::Path; + use std::sync::OnceLock; + + const PROCESS_DETECTION_ENV: &str = "CMUX_AGENT_PROCESS_DETECTION"; + const HERDR_PROCESS_DETECTION_ENV: &str = "HERDR_PROCESS_DETECTION"; + const CHILD_GROUPS_SCAN_LIMIT: usize = 64; + const MAX_PROCESS_COUNT: usize = 256; + const MAX_PROC_FILE_BYTES: usize = 128 * 1024; + const MAX_THREADS_PER_PROCESS: usize = 256; + + pub(super) fn foreground_job(child_pid: u32) -> Option { + let process_group_id = match foreground_process_group_id(child_pid) { + Some(process_group_id) => process_group_id, + None if process_detection_mode() == ProcessDetectionMode::ChildGroups => { + child_groups_foreground_process_group(child_pid)? + } + None => return None, + }; + let mut pids = process_tree_pids([child_pid, process_group_id]); + pids.sort_unstable(); + pids.dedup(); + let processes = pids + .into_iter() + .filter_map(|pid| process_for_group(pid, process_group_id)) + .collect::>(); + (!processes.is_empty()).then_some(ForegroundJob { process_group_id, processes }) + } + + pub(super) fn agent_hint(pid: u32) -> Option { + if pid == 0 { + return None; + } + let bytes = read_proc_file(format!("/proc/{pid}/environ"), MAX_PROC_FILE_BYTES)?; + super::parse_agent_env_hint(&bytes) + } + + fn process_for_group(pid: u32, process_group_id: u32) -> Option { + let stat = read_proc_text(format!("/proc/{pid}/stat"))?; + let (pgrp, name) = parse_process_stat(&stat)?; + if pgrp != process_group_id { + return None; + } + let argv = process_argv(pid); + Some(ForegroundProcess { + pid, + name, + argv0: argv.first().cloned(), + cmdline: (!argv.is_empty()).then(|| argv.join(" ")), + argv, + }) + } + + fn foreground_process_group_id(pid: u32) -> Option { + let stat = read_proc_text(format!("/proc/{pid}/stat"))?; + let close = stat.rfind(')')?; + let fields = stat.get(close + 1..)?.split_whitespace().collect::>(); + let tpgid = fields.get(5)?.parse::().ok()?; + (tpgid > 0).then_some(tpgid as u32) + } + + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + enum ProcessDetectionMode { + Native, + ChildGroups, + } + + fn parse_process_detection_mode(value: Option<&str>) -> Result { + match value { + None | Some("") | Some("native") => Ok(ProcessDetectionMode::Native), + Some("child-groups") => Ok(ProcessDetectionMode::ChildGroups), + Some(value) => Err(value), + } + } + + fn process_detection_mode() -> ProcessDetectionMode { + static MODE: OnceLock = OnceLock::new(); + *MODE.get_or_init(|| { + let value = std::env::var(PROCESS_DETECTION_ENV) + .ok() + .or_else(|| std::env::var(HERDR_PROCESS_DETECTION_ENV).ok()); + parse_process_detection_mode(value.as_deref()).unwrap_or_else(|value| { + eprintln!( + "cmux-agent-screen-detection: unknown process detection mode {value:?}; using native" + ); + ProcessDetectionMode::Native + }) + }) + } + + /// Infer a foreground process group when a Linux host does not expose a + /// controlling terminal foreground group. This mode is opt-in because a + /// child process can be running in the background and Linux provides no + /// kernel signal that distinguishes it from the foreground job. + fn child_groups_foreground_process_group(child_pid: u32) -> Option { + let shell_group_id = + process_pgrp_and_comm(child_pid).map(|(pgrp, _)| pgrp).filter(|pgrp| *pgrp > 0)? as u32; + child_groups_foreground_process_group_with( + child_pid, + shell_group_id, + task_ids, + task_children, + |pid| process_pgrp_and_comm(pid).map(|(pgrp, _)| pgrp), + ) + } + + fn child_groups_foreground_process_group_with( + child_pid: u32, + shell_group_id: u32, + mut task_ids: impl FnMut(u32) -> Vec, + mut task_children: impl FnMut(u32, u32) -> Vec, + mut process_group_id: impl FnMut(u32) -> Option, + ) -> Option { + let mut newest = None; + let mut scanned = 0usize; + for tid in task_ids(child_pid) { + for child in task_children(child_pid, tid) { + if scanned >= CHILD_GROUPS_SCAN_LIMIT { + return None; + } + scanned += 1; + let Some(pgrp) = process_group_id(child) else { continue }; + if pgrp <= 0 || pgrp as u32 == shell_group_id { + continue; + } + let pgrp = pgrp as u32; + newest = Some(newest.map_or(pgrp, |current: u32| current.max(pgrp))); + } + } + newest.or(Some(shell_group_id)) + } + + fn process_pgrp_and_comm(pid: u32) -> Option<(i32, String)> { + let stat = read_proc_text(format!("/proc/{pid}/stat"))?; + let open = stat.find('(')?; + let close = stat.rfind(')')?; + let comm = stat.get(open + 1..close)?.to_string(); + let fields = stat.get(close + 1..)?.split_whitespace().collect::>(); + let pgrp = fields.get(2)?.parse::().ok()?; + Some((pgrp, comm)) + } + + fn parse_process_stat(stat: &str) -> Option<(u32, String)> { + let open = stat.find('(')?; + let close = stat.rfind(')')?; + let name = stat.get(open + 1..close)?.to_string(); + let fields = stat.get(close + 1..)?.split_whitespace().collect::>(); + let pgrp = fields.get(2)?.parse::().ok()?; + (pgrp > 0).then_some((pgrp as u32, name)) + } + + fn process_argv(pid: u32) -> Vec { + let Some(bytes) = read_proc_file(format!("/proc/{pid}/cmdline"), MAX_PROC_FILE_BYTES) + else { + return Vec::new(); + }; + bytes + .split(|byte| *byte == 0) + .filter(|part| !part.is_empty()) + .map(|part| String::from_utf8_lossy(part).into_owned()) + .collect() + } + + fn process_tree_pids(roots: impl IntoIterator) -> Vec { + let mut pending = VecDeque::new(); + let mut visited = HashSet::new(); + for pid in roots { + if pid > 0 && visited.insert(pid) { + pending.push_back(pid); + } + } + let mut result = Vec::new(); + while let Some(pid) = pending.pop_front() { + result.push(pid); + if result.len() >= MAX_PROCESS_COUNT { + break; + } + for tid in task_ids(pid) { + for child in task_children(pid, tid) { + if child > 0 && visited.insert(child) { + pending.push_back(child); + } + } + } + } + result + } + + fn task_ids(pid: u32) -> Vec { + std::fs::read_dir(format!("/proc/{pid}/task")) + .into_iter() + .flatten() + .flatten() + .take(MAX_THREADS_PER_PROCESS) + .filter_map(|entry| entry.file_name().to_str()?.parse().ok()) + .collect() + } + + fn task_children(pid: u32, tid: u32) -> Vec { + let Some(text) = read_proc_text(format!("/proc/{pid}/task/{tid}/children")) else { + return Vec::new(); + }; + text.split_whitespace().filter_map(|child| child.parse().ok()).collect() + } + + /// Read a proc file with a hard allocation bound. Reading one extra byte + /// distinguishes an exact-limit file from an oversized file without + /// allocating the unbounded file first. + fn read_proc_file(path: impl AsRef, max_bytes: usize) -> Option> { + let file = File::open(path).ok()?; + let read_limit = u64::try_from(max_bytes).ok()?.checked_add(1)?; + let mut bytes = Vec::with_capacity(max_bytes.min(8 * 1024)); + file.take(read_limit).read_to_end(&mut bytes).ok()?; + (bytes.len() <= max_bytes).then_some(bytes) + } + + fn read_proc_text(path: impl AsRef) -> Option { + String::from_utf8(read_proc_file(path, MAX_PROC_FILE_BYTES)?).ok() + } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn proc_file_reader_enforces_the_limit_before_parsing() { + let path = std::env::temp_dir().join(format!( + "cmux-agent-screen-detection-proc-read-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should be after the Unix epoch") + .as_nanos() + )); + std::fs::write(&path, b"four").expect("write temporary proc fixture"); + + assert_eq!(read_proc_file(&path, 4), Some(b"four".to_vec())); + assert_eq!(read_proc_file(&path, 3), None); + + std::fs::remove_file(path).expect("remove temporary proc fixture"); + } + + #[test] + fn process_detection_mode_requires_explicit_child_groups_value() { + assert_eq!(parse_process_detection_mode(None), Ok(ProcessDetectionMode::Native)); + assert_eq!( + parse_process_detection_mode(Some("native")), + Ok(ProcessDetectionMode::Native) + ); + assert_eq!( + parse_process_detection_mode(Some("child-groups")), + Ok(ProcessDetectionMode::ChildGroups) + ); + assert_eq!(parse_process_detection_mode(Some("guess")), Err("guess")); + } + + #[test] + fn child_groups_selects_the_newest_non_shell_group() { + let group = child_groups_foreground_process_group_with( + 10, + 10, + |_| vec![10, 11], + |_, tid| match tid { + 10 => vec![20, 21], + 11 => vec![22], + _ => Vec::new(), + }, + |pid| match pid { + 20 => Some(12), + 21 => Some(17), + 22 => Some(15), + _ => None, + }, + ); + assert_eq!(group, Some(17)); + } + + #[test] + fn child_groups_fall_back_to_shell_when_no_child_has_a_new_group() { + let group = child_groups_foreground_process_group_with( + 10, + 10, + |_| vec![10], + |_, _| vec![20], + |_| Some(10), + ); + assert_eq!(group, Some(10)); + } + + #[test] + fn child_groups_fail_closed_at_the_scan_limit() { + let group = child_groups_foreground_process_group_with( + 10, + 10, + |_| vec![10], + |_, _| (0..=CHILD_GROUPS_SCAN_LIMIT as u32).collect(), + |pid| Some(pid as i32), + ); + assert_eq!(group, None); + } + } +} + +#[cfg(target_os = "macos")] +mod platform { + use super::{ForegroundJob, ForegroundProcess}; + use std::mem::size_of; + + const PROC_PGRP_ONLY: u32 = 2; + const MAX_PROCESS_COUNT: usize = 256; + const MAX_PROCARGS_BYTES: usize = 128 * 1024; + + pub(super) fn foreground_job(child_pid: u32) -> Option { + let process_group_id = foreground_process_group_id(child_pid)?; + let mut processes = Vec::new(); + for pid in process_group_pids(process_group_id).into_iter().take(MAX_PROCESS_COUNT) { + let Some(info) = process_bsdinfo(pid) else { continue }; + if info.pbi_pgid != process_group_id { + continue; + } + let Some(name) = comm_from_bsdinfo(&info) else { continue }; + let argv = process_argv(pid); + processes.push(ForegroundProcess { + pid, + name, + argv0: argv.first().cloned(), + cmdline: (!argv.is_empty()).then(|| argv.join(" ")), + argv, + }); + } + (!processes.is_empty()).then_some(ForegroundJob { process_group_id, processes }) + } + + pub(super) fn agent_hint(pid: u32) -> Option { + let buffer = kern_procargs2(pid)?; + let environment = procargs2_env(&buffer)?; + super::parse_agent_env_hint(environment) + } + + fn foreground_process_group_id(pid: u32) -> Option { + let mut info = unsafe { std::mem::zeroed::() }; + let size = libc::c_int::try_from(size_of::()).ok()?; + let written = unsafe { + libc::proc_pidinfo( + pid as libc::c_int, + libc::PROC_PIDTBSDINFO, + 0, + (&mut info as *mut libc::proc_bsdinfo).cast(), + size, + ) + }; + (written == size && info.e_tpgid > 0).then_some(info.e_tpgid) + } + + fn process_group_pids(process_group_id: u32) -> Vec { + let mut capacity = 32usize; + for _ in 0..8 { + let mut pids = vec![0 as libc::pid_t; capacity]; + let Some(bytes) = capacity.checked_mul(size_of::()) else { + return Vec::new(); + }; + let Ok(bytes) = libc::c_int::try_from(bytes) else { + return Vec::new(); + }; + let written = unsafe { + libc::proc_listpids( + PROC_PGRP_ONLY, + process_group_id, + pids.as_mut_ptr().cast(), + bytes, + ) + }; + if written <= 0 { + return Vec::new(); + } + let written = written as usize; + let count = written / size_of::(); + if written < bytes as usize { + return pids + .into_iter() + .take(count) + .filter_map(|pid| u32::try_from(pid).ok()) + .filter(|pid| *pid > 0) + .collect(); + } + capacity = capacity.saturating_mul(2); + } + Vec::new() + } + + fn process_bsdinfo(pid: u32) -> Option { + let mut info = unsafe { std::mem::zeroed::() }; + let size = libc::c_int::try_from(size_of::()).ok()?; + let written = unsafe { + libc::proc_pidinfo( + pid as libc::c_int, + libc::PROC_PIDTBSDINFO, + 0, + (&mut info as *mut libc::proc_bsdinfo).cast(), + size, + ) + }; + (written == size).then_some(info) + } + + fn comm_from_bsdinfo(info: &libc::proc_bsdinfo) -> Option { + let end = info.pbi_comm.iter().position(|byte| *byte == 0).unwrap_or(info.pbi_comm.len()); + (end > 0).then(|| { + String::from_utf8_lossy( + &info.pbi_comm[..end].iter().map(|byte| *byte as u8).collect::>(), + ) + .into_owned() + }) + } + + fn process_argv(pid: u32) -> Vec { + let Some(buffer) = kern_procargs2(pid) else { return Vec::new() }; + procargs2_argv(&buffer) + } + + fn kern_procargs2(pid: u32) -> Option> { + unsafe { + let mut mib = [libc::CTL_KERN, libc::KERN_PROCARGS2, pid as libc::c_int]; + let mut size = 0usize; + if libc::sysctl( + mib.as_mut_ptr(), + 3, + std::ptr::null_mut(), + &mut size, + std::ptr::null_mut(), + 0, + ) != 0 + || size == 0 + { + return None; + } + // A hostile or corrupted process can report an unbounded argv + // size. Keep the plugin's inspection memory bounded; the argv + // parser already handles a truncated final argument. + let mut buffer = vec![0u8; size.min(MAX_PROCARGS_BYTES)]; + let mut capacity = buffer.len(); + if libc::sysctl( + mib.as_mut_ptr(), + 3, + buffer.as_mut_ptr().cast(), + &mut capacity, + std::ptr::null_mut(), + 0, + ) != 0 + { + return None; + } + buffer.truncate(capacity.min(MAX_PROCARGS_BYTES)); + Some(buffer) + } + } + + fn procargs2_argv(buffer: &[u8]) -> Vec { + if buffer.len() < 4 { + return Vec::new(); + } + let argc = i32::from_ne_bytes([buffer[0], buffer[1], buffer[2], buffer[3]]); + if argc <= 0 { + return Vec::new(); + } + let rest = &buffer[4..]; + let Some(exec_end) = rest.iter().position(|byte| *byte == 0) else { return Vec::new() }; + let mut position = exec_end; + while position < rest.len() && rest[position] == 0 { + position += 1; + } + let mut argv = Vec::new(); + for _ in 0..argc { + if position >= rest.len() { + break; + } + let end = rest[position..] + .iter() + .position(|byte| *byte == 0) + .map_or(rest.len(), |offset| position + offset); + if end == position { + break; + } + argv.push(String::from_utf8_lossy(&rest[position..end]).into_owned()); + position = end.saturating_add(1); + } + argv + } + + fn procargs2_env(buffer: &[u8]) -> Option<&[u8]> { + if buffer.len() < 4 { + return None; + } + let argc = i32::from_ne_bytes([buffer[0], buffer[1], buffer[2], buffer[3]]); + if argc <= 0 { + return None; + } + let rest = &buffer[4..]; + let exec_end = rest.iter().position(|byte| *byte == 0)?; + let mut position = exec_end; + while position < rest.len() && rest[position] == 0 { + position += 1; + } + for _ in 0..argc { + let end = rest.get(position..)?.iter().position(|byte| *byte == 0)?; + position = position.checked_add(end)?.checked_add(1)?; + } + (position <= rest.len()).then_some(&rest[position..]) + } +} + +#[cfg(not(any(target_os = "linux", target_os = "macos")))] +mod platform { + use super::ForegroundJob; + + pub(super) fn foreground_job(_child_pid: u32) -> Option { + None + } + + pub(super) fn agent_hint(_pid: u32) -> Option { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn process(pid: u32, name: &str, argv: &[&str]) -> ForegroundProcess { + ForegroundProcess { + pid, + name: name.into(), + argv0: argv.first().map(|value| (*value).into()), + argv: argv.iter().map(|value| (*value).into()).collect(), + cmdline: Some(argv.join(" ")), + } + } + + #[test] + fn direct_manifest_name_is_identified() { + let job = + ForegroundJob { process_group_id: 7, processes: vec![process(7, "codex", &["codex"])] }; + let (manifest, _) = identify_job(ManifestSet::bundled(), &job).expect("codex should match"); + assert_eq!(manifest.id(), "codex"); + } + + #[test] + fn direct_process_identity_does_not_probe_environment_hint() { + let process = process(7, "codex", &["codex"]); + let mut probes = 0; + let (manifest, _) = identify_process_with_hint(ManifestSet::bundled(), &process, |pid| { + probes += 1; + assert_eq!(pid, 7); + Some("claude".into()) + }) + .expect("codex should match"); + + assert_eq!(manifest.id(), "codex"); + assert_eq!(probes, 0, "a direct identity must not read process environment"); + } + + #[test] + fn environment_hint_is_used_when_process_candidates_are_unknown() { + let process = process(8, "sandbox-wrapper", &["sandbox-wrapper"]); + let mut probes = 0; + let (manifest, candidate) = + identify_process_with_hint(ManifestSet::bundled(), &process, |pid| { + probes += 1; + assert_eq!(pid, 8); + Some("claude".into()) + }) + .expect("the explicit wrapper hint should identify claude"); + + assert_eq!(manifest.id(), "claude"); + assert_eq!(candidate, "claude"); + assert_eq!(probes, 1); + } + + #[test] + fn node_package_launcher_is_identified_without_matching_eval_text() { + let job = ForegroundJob { + process_group_id: 7, + processes: vec![process( + 7, + "node", + &["node", "/tmp/node_modules/@qwen-code/qwen-code/dist/index.js"], + )], + }; + let (manifest, _) = identify_job(ManifestSet::bundled(), &job).expect("qwen should match"); + assert_eq!(manifest.id(), "qwen"); + + let eval = ForegroundJob { + process_group_id: 8, + processes: vec![process(8, "node", &["node", "-e", "console.log('codex')"])], + }; + assert!(identify_job(ManifestSet::bundled(), &eval).is_none()); + } + + #[test] + fn known_package_launchers_require_the_real_cli_entrypoint() { + for (pid, script) in [ + (7, r"C:\Users\user\node_modules\@earendil-works\pi-coding-agent\dist\cli.js"), + (8, r"C:\Users\user\node_modules\@earendil-works\pi-coding-agent\dist\bundle\cli.js"), + ] { + let pi = ForegroundJob { + process_group_id: pid, + processes: vec![process(pid, "node.exe", &["node.exe", script])], + }; + assert_eq!(identify_job(ManifestSet::bundled(), &pi).unwrap().0.id(), "pi"); + } + + for (pid, script) in [ + (9, r"C:\Users\user\node_modules\@earendil-works\pi-coding-agent\scripts\build.js"), + ( + 10, + r"C:\Users\user\node_modules\@earendil-works\pi-coding-agent\dist\bundle\update.js", + ), + (11, r"C:\workspace\dist\bundle\cli.js"), + (12, r"C:\workspace\node_modules\other-package\dist\bundle\cli.js"), + (13, r"C:\workspace\node_modules\@earendil-works\pi-coding-agent\dist\cli.exe"), + (14, r"C:\workspace\node_modules\@earendil-works\pi-coding-agent\dist\cli.js\other.js"), + (15, r"C:\workspace\node_modules\@earendil-works\pi-coding-agent\dist\bundle\cli.exe"), + ( + 16, + r"C:\workspace\node_modules\@earendil-works\pi-coding-agent\dist\bundle\cli.js\other.js", + ), + ] { + let build_script = ForegroundJob { + process_group_id: pid, + processes: vec![process(pid, "node.exe", &["node.exe", script])], + }; + assert!( + identify_job(ManifestSet::bundled(), &build_script).is_none(), + "script: {script}" + ); + } + } + + #[test] + fn package_identity_keeps_priority_over_path_basename() { + let candidates = path_candidates("/tmp/node_modules/opencode-ai/bin/opencode"); + assert_eq!(candidates.first().map(String::as_str), Some("opencode")); + } + + #[test] + fn cursor_bundled_node_requires_versioned_index_pair() { + let valid = ForegroundJob { + process_group_id: 7, + processes: vec![process( + 7, + "node.exe", + &[ + r"C:\Users\user\AppData\Local\cursor-agent\versions\2026.08.11\node.exe", + r"C:\Users\user\AppData\Local\cursor-agent\versions\2026.08.11\index.js", + ], + )], + }; + assert_eq!(identify_job(ManifestSet::bundled(), &valid).unwrap().0.id(), "cursor"); + + let postinstall = ForegroundJob { + process_group_id: 8, + processes: vec![process( + 8, + "node.exe", + &[ + r"C:\Users\user\AppData\Local\cursor-agent\versions\2026.08.11\node.exe", + r"C:\Users\user\AppData\Local\cursor-agent\versions\2026.08.11\scripts\postinstall.js", + ], + )], + }; + assert!(identify_job(ManifestSet::bundled(), &postinstall).is_none()); + } + + #[test] + fn shell_command_scanning_does_not_match_arguments_as_executables() { + let plain = ForegroundJob { + process_group_id: 7, + processes: vec![process(7, "bash", &["bash", "-lc", "echo codex"])], + }; + assert!(identify_job(ManifestSet::bundled(), &plain).is_none()); + + let exec = ForegroundJob { + process_group_id: 8, + processes: vec![process(8, "bash", &["bash", "-lc", "exec codex"])], + }; + assert_eq!(identify_job(ManifestSet::bundled(), &exec).unwrap().0.id(), "codex"); + } + + #[test] + fn shell_and_python_wrappers_are_supported() { + let shell = ForegroundJob { + process_group_id: 7, + processes: vec![process(7, "zsh", &["zsh", "-c", "exec claude"])], + }; + assert_eq!(identify_job(ManifestSet::bundled(), &shell).unwrap().0.id(), "claude"); + + let python = ForegroundJob { + process_group_id: 8, + processes: vec![process(8, "python3.12", &["python3.12", "/opt/hermes-agent.py"])], + }; + assert_eq!(identify_job(ManifestSet::bundled(), &python).unwrap().0.id(), "hermes"); + } + + #[test] + fn shell_escaped_agent_token_is_identified() { + let shell = ForegroundJob { + process_group_id: 7, + processes: vec![process(7, "bash", &["bash", "-lc", r"exec c\odex"])], + }; + assert_eq!(identify_job(ManifestSet::bundled(), &shell).unwrap().0.id(), "codex"); + } + + #[test] + fn shell_script_argument_is_identified() { + let shell = ForegroundJob { + process_group_id: 7, + processes: vec![process(7, "sh", &["/bin/sh", "/tmp/test-bin/pi"])], + }; + assert_eq!(identify_job(ManifestSet::bundled(), &shell).unwrap().0.id(), "pi"); + } + + #[test] + fn shell_option_values_are_not_treated_as_script_agents() { + let shell = ForegroundJob { + process_group_id: 7, + processes: vec![process(7, "bash", &["bash", "--rcfile", "/tmp/codex"])], + }; + assert!(identify_job(ManifestSet::bundled(), &shell).is_none()); + } + + #[test] + fn shell_non_script_modes_do_not_identify_following_arguments() { + for (name, argv) in [ + ("bash", vec!["bash", "-s", "codex"]), + ("bash", vec!["bash", "-n", "/tmp/codex"]), + ("sh", vec!["sh", "-t", "claude"]), + ("bash", vec!["bash", "--help", "codex"]), + // `-C` is a case-sensitive shell option. It is not `-c`. + ("bash", vec!["bash", "-C", "exec codex"]), + ("fish", vec!["fish", "-C", "exec claude"]), + ] { + let job = + ForegroundJob { process_group_id: 7, processes: vec![process(7, name, &argv)] }; + assert!( + identify_job(ManifestSet::bundled(), &job).is_none(), + "non-script shell mode must not identify an argument: {argv:?}", + ); + } + } + + #[test] + fn shell_command_flags_follow_each_runtime_grammar() { + for (name, argv) in [ + // `-o` consumes an option name in POSIX shells; it cannot be + // combined with `-c` to form a command flag. + ("bash", vec!["bash", "-oc", "codex"]), + // A value-taking option after `c` is also not a command flag: + // bash consumes the remainder as the command text for `-c`, not + // as another option and a following script. + ("bash", vec!["bash", "-co", "codex"]), + ("sh", vec!["sh", "-oc", "codex"]), + ("sh", vec!["sh", "-co", "codex"]), + ("zsh", vec!["zsh", "-oc", "codex"]), + ("zsh", vec!["zsh", "-co", "codex"]), + // Fish does not accept a repeated `c` short option. + ("fish", vec!["fish", "-cc", "codex"]), + // Fish options that take a value or exit cannot expose the next + // token as a script path. + ("fish", vec!["fish", "-p", "codex"]), + ("fish", vec!["fish", "-v", "codex"]), + ("fish", vec!["fish", "-q", "codex"]), + ("fish", vec!["fish", "-o", "codex"]), + // These long forms are not command options for bash or zsh. + ("bash", vec!["bash", "--command", "codex", "/tmp/claude"]), + ("zsh", vec!["zsh", "--command", "codex", "/tmp/claude"]), + ] { + let job = + ForegroundJob { process_group_id: 7, processes: vec![process(7, name, &argv)] }; + assert!( + identify_job(ManifestSet::bundled(), &job).is_none(), + "invalid shell command mode must not identify an argument: {argv:?}", + ); + } + + let fish = ForegroundJob { + process_group_id: 8, + processes: vec![process(8, "fish", &["fish", "--command=exec codex"])], + }; + assert_eq!(identify_job(ManifestSet::bundled(), &fish).unwrap().0.id(), "codex"); + + let fish_separate = ForegroundJob { + process_group_id: 9, + processes: vec![process(9, "fish", &["fish", "--command", "exec codex"])], + }; + assert_eq!(identify_job(ManifestSet::bundled(), &fish_separate).unwrap().0.id(), "codex"); + + for (name, argv, expected) in [ + ("bash", vec!["bash", "-s", "-c", "exec codex"], "codex"), + ("bash", vec!["bash", "-t", "-c", "exec codex"], "codex"), + ("sh", vec!["sh", "-s", "-c", "exec claude"], "claude"), + ("zsh", vec!["zsh", "-t", "-c", "exec pi"], "pi"), + // Bash, sh, and zsh accept `c` anywhere in a short-option + // cluster. Fish requires `c` to be the final short option. + ("bash", vec!["bash", "-cs", "exec codex"], "codex"), + ("bash", vec!["bash", "-ci", "exec codex"], "codex"), + ("sh", vec!["sh", "-cs", "exec claude"], "claude"), + ("zsh", vec!["zsh", "-ci", "exec pi"], "pi"), + ] { + let job = + ForegroundJob { process_group_id: 10, processes: vec![process(10, name, &argv)] }; + assert_eq!( + identify_job(ManifestSet::bundled(), &job).map(|(manifest, _)| manifest.id()), + Some(expected), + "stdin-mode flags may precede a valid command flag: {argv:?}", + ); + } + + for (name, argv) in [ + ("bash", vec!["bash", "-s", "--", "codex"]), + ("sh", vec!["sh", "-t", "claude"]), + // Unlike POSIX shells, fish treats `-ci` as an unknown + // option because its command flag must be last in the + // cluster. + ("fish", vec!["fish", "-ci", "codex"]), + ] { + let job = + ForegroundJob { process_group_id: 11, processes: vec![process(11, name, &argv)] }; + assert!( + identify_job(ManifestSet::bundled(), &job).is_none(), + "stdin-mode flags must not expose a positional token: {argv:?}", + ); + } + } + + #[test] + fn fish_documented_long_options_preserve_script_identity() { + for argv in [ + vec!["fish", "--interactive", "/tmp/codex"], + vec!["fish", "--login", "/tmp/codex"], + vec!["fish", "--no-config", "/tmp/codex"], + vec!["fish", "--private", "/tmp/codex"], + vec!["fish", "--print-rusage-self", "/tmp/codex"], + ] { + let job = + ForegroundJob { process_group_id: 7, processes: vec![process(7, "fish", &argv)] }; + assert_eq!( + identify_job(ManifestSet::bundled(), &job).unwrap().0.id(), + "codex", + "documented non-exit option must preserve the script: {argv:?}", + ); + } + + let no_execute = ForegroundJob { + process_group_id: 8, + processes: vec![process(8, "fish", &["fish", "--no-execute", "/tmp/codex"])], + }; + assert!(identify_job(ManifestSet::bundled(), &no_execute).is_none()); + } + + #[test] + fn runtime_option_values_are_not_treated_as_agent_commands() { + for (name, argv) in [ + ("node", vec!["node", "--experimental-loader", "codex"]), + ("node", vec!["node", "--inspect-port", "claude"]), + ("python3.12", vec!["python3.12", "-o", "claude"]), + ("python3.12", vec!["python3.12", "-m", "some_module", "codex"]), + ("python3.12", vec!["python3.12", "--check-hash-based-pycs", "codex"]), + ] { + let job = + ForegroundJob { process_group_id: 7, processes: vec![process(7, name, &argv)] }; + assert!( + identify_job(ManifestSet::bundled(), &job).is_none(), + "option value must not identify an agent: {argv:?}", + ); + } + } + + #[test] + fn attached_option_values_preserve_the_following_script() { + for (name, argv, expected) in [ + ("python3.12", vec!["python3.12", "-Xutf8", "/tmp/claude"], "claude"), + ("python3.12", vec!["python3.12", "-Wignore", "/tmp/pi"], "pi"), + ("fish", vec!["fish", "--debug=error", "/tmp/codex"], "codex"), + ] { + let job = + ForegroundJob { process_group_id: 10, processes: vec![process(10, name, &argv)] }; + assert_eq!( + identify_job(ManifestSet::bundled(), &job).map(|(manifest, _)| manifest.id()), + Some(expected), + "attached option value must not hide the script: {argv:?}", + ); + } + } + + #[test] + fn unsupported_python_attached_option_does_not_expose_script() { + for argv in [ + vec!["python3.12", "--check-hash-based-pycs=always", "/tmp/codex"], + // An unsupported attached option must not consume the following + // mode flag as its value and expose the mode's command text. + vec!["python3.12", "--check-hash-based-pycs=always", "-c", "codex"], + ] { + let job = ForegroundJob { + process_group_id: 11, + processes: vec![process(11, "python3.12", &argv)], + }; + assert!( + identify_job(ManifestSet::bundled(), &job).is_none(), + "unsupported attached option must fail closed: {argv:?}", + ); + } + } + + #[test] + fn python_boolean_site_flag_preserves_script_and_eval_boundaries() { + let script = ForegroundJob { + process_group_id: 7, + processes: vec![process(7, "python3.12", &["python3.12", "-S", "/tmp/codex"])], + }; + assert_eq!(identify_job(ManifestSet::bundled(), &script).unwrap().0.id(), "codex"); + + let eval = ForegroundJob { + process_group_id: 8, + processes: vec![process(8, "python3.12", &["python3.12", "-S", "-c", "codex"])], + }; + assert!(identify_job(ManifestSet::bundled(), &eval).is_none()); + } + + #[test] + fn python_exit_options_do_not_expose_following_tokens() { + for argv in [ + vec!["python3.12", "-h", "codex"], + vec!["python3.12", "-?", "codex"], + vec!["python3.12", "-V", "claude"], + vec!["python3.12", "-VV", "claude"], + vec!["python3.12", "--help", "/tmp/pi"], + vec!["python3.12", "--version", "/tmp/codex"], + ] { + let job = ForegroundJob { + process_group_id: 9, + processes: vec![process(9, "python3.12", &argv)], + }; + assert!( + identify_job(ManifestSet::bundled(), &job).is_none(), + "exit option must not identify a following token: {argv:?}", + ); + } + } + + #[test] + fn attached_runtime_modes_are_not_treated_as_agent_commands() { + for (name, argv) in [ + ("node", vec!["node", "-econsole.log('codex')", "claude"]), + ("bun", vec!["bun", "-pcodex", "claude"]), + ("python3.12", vec!["python3.12", "-msome_module", "codex"]), + ] { + let job = + ForegroundJob { process_group_id: 7, processes: vec![process(7, name, &argv)] }; + assert!( + identify_job(ManifestSet::bundled(), &job).is_none(), + "attached runtime mode must not identify an agent: {argv:?}", + ); + } + } + + #[test] + fn package_path_in_attached_node_eval_is_not_an_agent_identity() { + let job = ForegroundJob { + process_group_id: 7, + processes: vec![process( + 7, + "node", + &["node", "-e/tmp/node_modules/@qwen-code/qwen-code/dist/index.js"], + )], + }; + assert!(identify_job(ManifestSet::bundled(), &job).is_none()); + } + + #[test] + fn runtime_flags_after_the_script_do_not_hide_the_script_identity() { + for (name, argv, expected) in [ + ("node", vec!["node", "/tmp/codex", "--eval"], "codex"), + ("python3.12", vec!["python3.12", "/tmp/claude", "-c"], "claude"), + ("node", vec!["node", "--", "/tmp/codex", "--eval"], "codex"), + ("node", vec!["node", "--require", "preload.js", "/tmp/codex"], "codex"), + ] { + let job = + ForegroundJob { process_group_id: 7, processes: vec![process(7, name, &argv)] }; + assert_eq!( + identify_job(ManifestSet::bundled(), &job).map(|(manifest, _)| manifest.id()), + Some(expected), + "script identity must survive trailing runtime-looking arguments: {argv:?}", + ); + } + } + + #[test] + fn command_line_fallback_only_uses_the_executable_token() { + let arbitrary_argument = ForegroundJob { + process_group_id: 31, + processes: vec![ForegroundProcess { + pid: 31, + name: "wrapper".into(), + argv0: None, + argv: Vec::new(), + cmdline: Some("wrapper --message codex".into()), + }], + }; + assert!(identify_job(ManifestSet::bundled(), &arbitrary_argument).is_none()); + + let missing_argv = ForegroundJob { + process_group_id: 32, + processes: vec![ForegroundProcess { + pid: 32, + name: "wrapper".into(), + argv0: None, + argv: Vec::new(), + cmdline: Some("/opt/bin/codex --message hello".into()), + }], + }; + assert_eq!(identify_job(ManifestSet::bundled(), &missing_argv).unwrap().0.id(), "codex"); + } + + #[test] + fn tmux_is_transport_and_does_not_scan_its_arguments() { + let job = ForegroundJob { + process_group_id: 7, + processes: vec![process(7, "tmux", &["tmux", "new-session", "codex"])], + }; + assert!(identify_job(ManifestSet::bundled(), &job).is_none()); + assert!(is_runtime_or_shell("tmux")); + } + + #[test] + fn powershell_option_values_are_not_treated_as_agent_commands() { + let job = ForegroundJob { + process_group_id: 7, + processes: vec![process(7, "pwsh", &["pwsh", "-WorkingDirectory", "codex"])], + }; + assert!(identify_job(ManifestSet::bundled(), &job).is_none()); + } + + #[test] + fn versioned_muse_binary_is_identified() { + let job = ForegroundJob { + process_group_id: 7, + processes: vec![process(7, "muse-bin-0.2.1", &["muse-bin-0.2.1"])], + }; + assert_eq!(identify_job(ManifestSet::bundled(), &job).unwrap().0.id(), "muse"); + } + + #[test] + fn malformed_versioned_muse_names_are_not_identified() { + for name in ["muse-bin-1garbage", "muse-bin-1", "muse-bin-1.2_unsafe"] { + let job = + ForegroundJob { process_group_id: 7, processes: vec![process(7, name, &[name])] }; + assert!(identify_job(ManifestSet::bundled(), &job).is_none(), "{name}"); + } + } + + #[test] + fn process_identity_hints_prefer_cmux_and_keep_herdr_compatibility() { + assert_eq!( + parse_agent_env_hint(b"PATH=/bin\0HERDR_AGENT=claude\0TERM=xterm\0"), + Some("claude".into()) + ); + assert_eq!( + parse_agent_env_hint(b"HERDR_AGENT=claude\0CMUX_AGENT=codex\0"), + Some("codex".into()) + ); + assert_eq!(parse_agent_env_hint(b"HERDR_AGENT=not valid\0"), Some("not valid".into())); + assert_eq!(parse_agent_env_hint(b"CMUX_AGENT=\0HERDR_AGENT=codex\0"), Some("codex".into())); + assert_eq!( + parse_agent_env_hint(b"CMUX_AGENT=codex\0HERDR_AGENT=claude\0"), + Some("codex".into()) + ); + } + + #[test] + fn custom_manifest_aliases_remain_userland_extensible() { + let job = ForegroundJob { + process_group_id: 7, + processes: vec![process(7, "node", &["node", "wrapper.js"])], + }; + let custom = ManifestSet::from_sources(&[ ( + "custom", + "id = \"custom-agent\"\nversion = \"1\"\naliases = [\"wrapper\"]\n\n[[rules]]\nid = \"idle\"\nstate = \"idle\"\ncontains = [\"ready\"]\n", + )]) + .unwrap(); + let (manifest, candidate) = identify_job(&custom, &job).expect("custom alias should match"); + assert_eq!(manifest.id(), "custom-agent"); + assert_eq!(candidate, "wrapper"); + } +} diff --git a/cmux-tui/bindings/examples/rust-agent-screen-detection/src/scanner.rs b/cmux-tui/bindings/examples/rust-agent-screen-detection/src/scanner.rs new file mode 100644 index 000000000000..cb4bb9a216b5 --- /dev/null +++ b/cmux-tui/bindings/examples/rust-agent-screen-detection/src/scanner.rs @@ -0,0 +1,1303 @@ +//! Userland scanner for terminal-backed agent sessions. +//! +//! The daemon exposes generic terminal reads through the SDK. This module +//! owns the policy that turns those reads into agent events. It uses the +//! foreground process-group executable, not the shell leader, and keeps only +//! state transitions in the journal. +//! +//! The process-group and edge-trigger ideas are derived from herdr's +//! `src/pane.rs` and `src/pane/agent_detection.rs` at commit +//! `7b675f42af35508eab66ac42fe1598628597a893` (Apache-2.0), then adapted to +//! the cmux journal contract. + +use std::collections::hash_map::DefaultHasher; +use std::collections::{HashMap, HashSet}; +use std::hash::{Hash, Hasher}; +use std::thread; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use cmux::{ + Client, Config, JournalAppendResult, JournalClass, JournalEventSchema, JournalIngress, + JournalProducerManifest, JournalReplayPolicy, JournalSensitivity, JournalStart, JournalSubject, + MutationOptions, ReadScreenOptions, Selector, Session, SessionId, SessionJournalOptions, + StreamPoll, Terminal, TerminalId, +}; +use serde_json::json; + +use crate::detect::{AgentState, ScreenDetectTracker}; +use crate::manifest::{DetectionInput, ManifestSet}; +use crate::process as process_discovery; + +const RECONNECT_INTERVAL: Duration = Duration::from_secs(1); +const PROCESS_GROUP_RECHECK_IDENTIFIED: Duration = Duration::from_secs(5); +const PROCESS_GROUP_RECHECK_UNKNOWN: Duration = Duration::from_millis(500); +const PROCESS_GROUP_RECHECK_ON_OUTPUT: Duration = Duration::from_secs(1); +const PROCESS_INFO_RECHECK_IDENTIFIED: Duration = Duration::from_secs(5); +const PROCESS_INFO_RECHECK_UNKNOWN: Duration = Duration::from_millis(500); +const PROCESS_INFO_RECHECK_ON_OUTPUT: Duration = Duration::from_secs(1); +const PROCESS_ACQUISITION_FAST_WINDOW: Duration = Duration::from_millis(1_500); +const PROCESS_ACQUISITION_WINDOW: Duration = Duration::from_secs(8); +const PROCESS_ACQUISITION_FAST_RECHECK: Duration = Duration::from_millis(500); +const PROCESS_ACQUISITION_SLOW_RECHECK: Duration = Duration::from_secs(2); +const RETRY_BACKOFF_MIN: Duration = Duration::from_millis(250); +const RETRY_BACKOFF_MAX: Duration = Duration::from_secs(5); +const PLUGIN_VERSION: u32 = 1; +const RESERVED_AGENT_HOOK_PRODUCER_ID: &str = "cmux_agent"; + +/// An exact journal request retained after an uncertain transport outcome. +/// Keeping the envelope and key together is required by the journal's +/// idempotency contract: rebuilding it later would change its fingerprint. +#[derive(Clone, Debug)] +struct PendingAppend { + emission: crate::detect::ScreenDetectEmission, + ingress: JournalIngress, + idempotency_key: String, + attempts: u32, + retry_not_before: Instant, +} + +#[derive(Debug)] +struct ScannerState { + tracker: ScreenDetectTracker, + process_cache: ProcessGroupCache, + process_info_cache: ProcessInfoCache, + pending_appends: HashMap, + emission_nonce: String, + emission_sequence: u64, +} + +impl ScannerState { + fn new() -> Self { + Self { + tracker: ScreenDetectTracker::default(), + process_cache: ProcessGroupCache::default(), + process_info_cache: ProcessInfoCache::default(), + pending_appends: HashMap::new(), + emission_nonce: format!("{}-{}", std::process::id(), now_nanos()), + emission_sequence: 0, + } + } + + fn retain_terminals(&mut self, live: &HashSet) { + // Keep the in-memory tracker and probe caches for an uncertain + // envelope until replay settles it. Otherwise a later successful + // replay would be followed by a duplicate identity edge. + let mut retained = live.clone(); + retained.extend(self.pending_appends.keys().cloned()); + self.tracker.retain_terminals(|terminal_id| retained.contains(terminal_id)); + self.process_cache.retain_terminals(|terminal_id| retained.contains(terminal_id)); + self.process_info_cache.retain_terminals(|terminal_id| retained.contains(terminal_id)); + // Pending appends are exact journal envelopes. They can have been + // committed even when this catalog snapshot briefly omits a terminal, + // so the replay path owns their lifetime instead of catalog pruning. + } +} + +#[derive(Debug)] +enum AppendError { + Definite(String), + Uncertain(String), +} + +impl AppendError { + fn message(&self) -> &str { + match self { + Self::Definite(message) | Self::Uncertain(message) => message, + } + } + + fn is_uncertain(&self) -> bool { + matches!(self, Self::Uncertain(_)) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum PublishResult { + Committed, + Deferred, +} + +/// Runs until the daemon closes the socket or the process is terminated. +pub fn run(socket: &str, session_name: &str, plugin_id: &str) -> Result<(), String> { + validate_plugin_id(plugin_id)?; + let plugin_generation = + std::env::var("CMUX_PLUGIN_GENERATION").ok().filter(|value| !value.is_empty()); + let session_selector = configured_session_selector(session_name)?; + let mut state = ScannerState::new(); + loop { + let config = Config::from_socket_path(socket); + match Client::connect(config) { + Ok(client) => { + let session = client.session(session_selector.clone()); + if let Err(error) = register_manifest(&session, plugin_id) { + eprintln!("cmux-agent-screen-detection: manifest registration failed: {error}"); + thread::sleep(RECONNECT_INTERVAL); + continue; + } + match scan_connection(&session, plugin_id, plugin_generation.as_deref(), &mut state) + { + Ok(()) => return Ok(()), + Err(error) => eprintln!( + "cmux-agent-screen-detection: {session_name} connection ended: {error}" + ), + } + } + Err(error) => eprintln!("cmux-agent-screen-detection: connect failed: {error}"), + } + thread::sleep(RECONNECT_INTERVAL); + } +} + +/// Validate the producer namespace before entering the reconnect loop. +/// +/// The SDK owns the structural grammar used by the daemon. The reserved hook +/// producer is a daemon-owned namespace, so a standalone plugin rejects it +/// before it can create a permanent registration retry loop. +pub fn validate_plugin_id(plugin_id: &str) -> Result<(), String> { + if plugin_id == RESERVED_AGENT_HOOK_PRODUCER_ID { + return Err(format!( + "plugin id {plugin_id:?} is reserved for the built-in agent hook producer" + )); + } + producer_manifest(plugin_id).validate().map_err(|error| error.to_string()) +} + +fn configured_session_selector(session_name: &str) -> Result, String> { + if session_name.trim().is_empty() { + return Err("CMUX_TUI_SESSION_ID must not be empty".into()); + } + let value = session_name.to_owned(); + match SessionId::parse(value.clone()) { + Ok(id) => Ok(Selector::id(id)), + Err(_) => Ok(Selector::name(value)), + } +} + +fn register_manifest(session: &Session, plugin_id: &str) -> Result<(), String> { + let manifest = producer_manifest(plugin_id); + manifest.validate().map_err(|error| error.to_string())?; + session + .put_journal_producer_manifest( + &manifest, + MutationOptions::new(format!("manifest-{plugin_id}-{PLUGIN_VERSION}")) + .map_err(|error| error.to_string())?, + ) + .map(|_| ()) + .map_err(|error| error.to_string()) +} + +fn producer_manifest(plugin_id: &str) -> JournalProducerManifest { + let namespace = format!("plugin.{plugin_id}"); + JournalProducerManifest { + producer_id: plugin_id.to_string(), + namespace: namespace.clone(), + manifest_version: PLUGIN_VERSION, + max_sensitivity: JournalSensitivity::Sensitive, + permissions: vec![format!("journal.append.{namespace}")], + events: vec![ + event_schema(&format!("{namespace}.agent.state.changed")), + event_schema(&format!("{namespace}.agent.session.ended")), + ], + } +} + +fn event_schema(kind: &str) -> JournalEventSchema { + JournalEventSchema { + kind: kind.to_string(), + schema_version: 1, + class: JournalClass::State, + replay: JournalReplayPolicy::Required, + sensitivity: JournalSensitivity::Sensitive, + payload_schema: json!({ + "type":"object", + "required":["format","plugin","adapter","event","normalized"], + "properties":{ + "format":{"const":"cmux.agent-plugin.v1"}, + "plugin":{ + "type":"object", + "required":["id","version"], + "properties":{"id":{"type":"string"},"version":{"type":"integer","minimum":1}}, + "additionalProperties":false + }, + "adapter":{ + "type":"object", + "required":["id","version"], + "properties":{"id":{"type":"string"},"version":{"type":"integer","minimum":1}}, + "additionalProperties":false + }, + "event":{"enum":["state.changed","session.ended"]}, + "normalized":{ + "type":"object", + "required":["state","source_session","observed_at_ms"], + "properties":{ + "state":{"enum":["working","blocked","idle","done","unknown"]}, + "source_session":{"type":"string"}, + "plugin_generation":{"type":"string","pattern":"^[0-9]+$"}, + "observed_at_ms":{"type":"string","pattern":"^[0-9]+$"} + }, + "additionalProperties":false + }, + "native":{"type":"object"} + }, + "additionalProperties":false + }), + } +} + +fn scan_connection( + session: &Session, + plugin_id: &str, + plugin_generation: Option<&str>, + state: &mut ScannerState, +) -> Result<(), String> { + let manifests = match ManifestSet::from_environment() { + Ok(manifests) => manifests, + Err(error) => { + eprintln!("cmux-agent-screen-detection: optional manifest source ignored: {error}"); + ManifestSet::bundled().clone() + } + }; + // Subscribe before taking the initial inventory. Output/lifecycle changes + // that race the read are retained by the stream, so there is no lost wakeup. + // Agent reports are excluded: the plugin must never wake itself through + // its own journal-to-resource projection. + let mut events = session + .journal(SessionJournalOptions { + start: Some(JournalStart::Tail), + follow: Some(true), + kinds: vec![ + "terminal.*".into(), + "workspace.*".into(), + "tab.*".into(), + "pane.*".into(), + "screen.*".into(), + ], + max_sensitivity: Some(JournalSensitivity::Sensitive), + ..Default::default() + }) + .map_err(|error| error.to_string())?; + let snapshots = session.terminal_snapshots().map_err(|error| error.to_string())?; + let live_ids = snapshots.iter().map(|item| item.id.to_string()).collect::>(); + retry_pending_appends_without_live_terminal( + session, + &live_ids, + &mut state.tracker, + &mut state.pending_appends, + ); + state.retain_terminals(&live_ids); + let mut deadlines = HashMap::::new(); + for id in live_ids { + deadlines.insert(id, Instant::now()); + } + loop { + let now = Instant::now(); + let due = deadlines + .iter() + .filter(|(_, deadline)| **deadline <= now) + .map(|(id, _)| id.clone()) + .collect::>(); + for id in due { + deadlines.remove(&id); + let terminal = + session.terminal(TerminalId::parse(id.clone()).map_err(|error| error.to_string())?); + state.process_cache.entries.remove(&id); + state.process_info_cache.entries.remove(&id); + let snapshot = match terminal.refresh() { + Ok(snapshot) => snapshot, + Err(cmux::Error::Protocol { ref code, .. }) + if code == "selector.not_found" || code == "resource.not_found" => + { + state.tracker.retain_terminals(|candidate| candidate != id); + state.process_cache.entries.remove(&id); + state.process_info_cache.entries.remove(&id); + continue; + } + Err(error) => return Err(error.to_string()), + }; + scan_terminal(&terminal, &snapshot, plugin_id, &manifests, plugin_generation, state)?; + if let Some(deadline) = state.tracker.next_deadline(&id, Instant::now()) { + deadlines.insert(id.clone(), deadline); + } + if let Some(pending) = state.pending_appends.get(&id) { + deadlines + .entry(id) + .and_modify(|time| *time = (*time).min(pending.retry_not_before)) + .or_insert(pending.retry_not_before); + } + } + let event = match deadlines.values().min() { + Some(deadline) => match events + .next_timeout(deadline.saturating_duration_since(Instant::now())) + .map_err(|error| error.to_string())? + { + StreamPoll::Item(item) => Some(item), + StreamPoll::TimedOut => continue, + StreamPoll::End => return Err("terminal journal stream ended".into()), + }, + None => events.recv().map_err(|error| error.to_string())?, + }; + let Some(event) = event else { + return Err("terminal journal stream ended".into()); + }; + for subject in event.value.subjects.iter().filter(|subject| subject.kind == "terminal") { + // Coalesce a burst without deferring forever under flowing output. + // A quiet session blocks on recv and makes zero catalog/process/screen reads. + deadlines + .entry(subject.id.clone()) + .or_insert_with(|| Instant::now() + Duration::from_millis(25)); + state.process_cache.entries.remove(&subject.id); + state.process_info_cache.entries.remove(&subject.id); + } + } +} + +#[derive(Debug, Default)] +struct ProcessGroupCache { + entries: HashMap, +} + +#[derive(Debug, Default)] +struct ProcessInfoCache { + entries: HashMap, +} + +#[derive(Debug, Clone)] +struct CachedProcessInfo { + process: cmux::ProcessInfoResult, + checked_at: Instant, + stream_revision: Option, + identified: bool, + /// Short adaptive probing after an agent is first found catches a + /// hand-off or same-name replacement without making steady-state scans + /// expensive. + acquisition_started_at: Option, +} + +impl ProcessInfoCache { + fn get_or_refresh( + &mut self, + terminal: &Terminal, + terminal_id: &str, + stream_revision: Option, + now: Instant, + ) -> Result { + if let Some(cached) = self.entries.get(terminal_id) + && !process_info_refresh_due(cached, stream_revision, now) + { + return Ok(cached.process.clone()); + } + let process = terminal.process().map_err(|error| error.to_string())?; + let (identified, acquisition_started_at) = self + .entries + .get(terminal_id) + .map(|entry| (entry.identified, entry.acquisition_started_at)) + .unwrap_or((false, None)); + self.entries.insert( + terminal_id.to_string(), + CachedProcessInfo { + process: process.clone(), + checked_at: now, + stream_revision, + // Preserve the acquisition phase across refreshes. The + // scanner marks this value after it identifies the new + // process; resetting it here would make a steady agent pay + // the fast probe cost forever. + identified, + acquisition_started_at, + }, + ); + Ok(process) + } + + fn mark_identified(&mut self, terminal_id: &str, identified: bool, now: Instant) { + if let Some(cached) = self.entries.get_mut(terminal_id) { + if identified && !cached.identified { + cached.acquisition_started_at = Some(now); + } + if !identified { + cached.acquisition_started_at = None; + } + cached.identified = identified; + } + } + + fn retain_terminals(&mut self, live: impl Fn(&str) -> bool) { + self.entries.retain(|terminal_id, _| live(terminal_id)); + } +} + +fn process_info_refresh_due( + cached: &CachedProcessInfo, + stream_revision: Option, + now: Instant, +) -> bool { + let revision_changed = matches!((cached.stream_revision, stream_revision), (Some(previous), Some(current)) if previous != current); + // Older daemons do not expose a stream revision. Keep a bounded one-second + // probe in that mode because a same-name process replacement cannot be + // observed through output metadata. + let output_signal_missing = cached.stream_revision.is_none() || stream_revision.is_none(); + let interval = if cached.identified { + if revision_changed || output_signal_missing { + PROCESS_INFO_RECHECK_ON_OUTPUT + } else if let Some(started_at) = cached.acquisition_started_at { + let acquisition_age = now.duration_since(started_at); + if acquisition_age < PROCESS_ACQUISITION_FAST_WINDOW { + PROCESS_ACQUISITION_FAST_RECHECK + } else if acquisition_age < PROCESS_ACQUISITION_WINDOW { + PROCESS_ACQUISITION_SLOW_RECHECK + } else { + PROCESS_INFO_RECHECK_IDENTIFIED + } + } else { + PROCESS_INFO_RECHECK_IDENTIFIED + } + } else { + PROCESS_INFO_RECHECK_UNKNOWN + }; + now.duration_since(cached.checked_at) >= interval +} + +#[derive(Debug, Clone)] +struct CachedProcessGroup { + pid: u32, + foreground_name: Option, + executable: Option, + argv: Vec, + checked_at: Instant, + stream_revision: Option, + job: process_discovery::ForegroundJob, + /// A public process response is a useful fallback, but its pid is not a + /// verified foreground process-group id and must not create replacement + /// edges. + authoritative: bool, + identified: bool, + acquisition_started_at: Option, +} + +impl CachedProcessGroup { + fn matches_process(&self, process: &cmux::ProcessInfoResult) -> bool { + // These are the complete visible inputs used by fallback_job and + // identify_job. Comparing them avoids reusing a same-PID cache entry + // after an exec changes the runtime or its agent arguments. + self.pid == process.pid + && self.foreground_name.as_deref() == process.foreground_executable.as_deref() + && self.executable.as_deref() == process.executable.as_deref() + && self.argv == process.argv + } +} + +impl ProcessGroupCache { + fn job_for( + &mut self, + terminal_id: &str, + process: &cmux::ProcessInfoResult, + stream_revision: Option, + now: Instant, + ) -> process_discovery::ForegroundJob { + let foreground_name = process.foreground_executable.clone(); + if let Some(cached) = self.entries.get(terminal_id) + && cached.matches_process(process) + && !process_group_refresh_due(cached, stream_revision, now) + { + return cached.job.clone(); + } + let (identified, acquisition_started_at) = self + .entries + .get(terminal_id) + .map(|entry| (entry.identified, entry.acquisition_started_at)) + .unwrap_or((false, None)); + let native_job = process_discovery::foreground_job(process.pid); + let authoritative = native_job.is_some(); + let job = native_job.unwrap_or_else(|| process_discovery::fallback_job(process)); + self.entries.insert( + terminal_id.to_string(), + CachedProcessGroup { + pid: process.pid, + foreground_name, + executable: process.executable.clone(), + argv: process.argv.clone(), + checked_at: now, + stream_revision, + job: job.clone(), + authoritative, + identified, + acquisition_started_at, + }, + ); + job + } + + fn mark_identified(&mut self, terminal_id: &str, identified: bool, now: Instant) { + if let Some(cached) = self.entries.get_mut(terminal_id) { + if identified && !cached.identified { + cached.acquisition_started_at = Some(now); + } + if !identified { + cached.acquisition_started_at = None; + } + cached.identified = identified; + } + } + + fn authoritative_group_id(&self, terminal_id: &str) -> Option { + self.entries + .get(terminal_id) + .filter(|entry| entry.authoritative) + .map(|entry| entry.job.process_group_id) + } + + fn retain_terminals(&mut self, live: impl Fn(&str) -> bool) { + self.entries.retain(|terminal_id, _| live(terminal_id)); + } +} + +fn process_group_refresh_due( + cached: &CachedProcessGroup, + stream_revision: Option, + now: Instant, +) -> bool { + let revision_changed = matches!((cached.stream_revision, stream_revision), (Some(previous), Some(current)) if previous != current); + let output_signal_missing = cached.stream_revision.is_none() || stream_revision.is_none(); + let interval = if cached.identified { + if revision_changed || output_signal_missing { + PROCESS_GROUP_RECHECK_ON_OUTPUT + } else if let Some(started_at) = cached.acquisition_started_at { + let acquisition_age = now.duration_since(started_at); + if acquisition_age < PROCESS_ACQUISITION_FAST_WINDOW { + PROCESS_ACQUISITION_FAST_RECHECK + } else if acquisition_age < PROCESS_ACQUISITION_WINDOW { + PROCESS_ACQUISITION_SLOW_RECHECK + } else { + PROCESS_GROUP_RECHECK_IDENTIFIED + } + } else { + PROCESS_GROUP_RECHECK_IDENTIFIED + } + } else { + PROCESS_GROUP_RECHECK_UNKNOWN + }; + now.duration_since(cached.checked_at) >= interval +} + +fn scan_terminal( + terminal: &Terminal, + snapshot: &cmux::TerminalSnapshot, + plugin_id: &str, + manifests: &ManifestSet, + plugin_generation: Option<&str>, + state: &mut ScannerState, +) -> Result<(), String> { + let terminal_id = snapshot.id.as_str().to_string(); + // A transport failure after dispatch leaves the mutation outcome + // uncertain. Replay the retained envelope before taking a new process or + // screen sample, otherwise a newer edge could overtake the old one. + if retry_pending_append( + terminal.session(), + &terminal_id, + &mut state.tracker, + &mut state.pending_appends, + )? { + return Ok(()); + } + let now = Instant::now(); + if !snapshot.running { + // A terminal can remain in the catalog briefly after its PTY exits. + // Close the plugin-owned emission now instead of waiting for catalog + // pruning, so the roster does not show a dead agent during that gap. + if let Some(emission) = state.tracker.record_detection_at_with_revision( + &terminal_id, + None, + now, + true, + true, + snapshot.stream_revision, + ) { + // The process query is expected to fail after a PTY exits. Keep + // the terminal identity as the source session for this final + // event instead of issuing a second, racy process read. + if publish_emission(terminal, plugin_id, plugin_generation, &emission, None, state)? + == PublishResult::Deferred + { + return Ok(()); + } + } + return Ok(()); + } + let process = state.process_info_cache.get_or_refresh( + terminal, + &terminal_id, + snapshot.stream_revision, + now, + )?; + let job = state.process_cache.job_for(&terminal_id, &process, snapshot.stream_revision, now); + // `stream_revision` is a cheap coalesced PTY counter. New daemons expose + // it on the terminal snapshot, so unchanged screens do not cross the + // socket or invoke the terminal parser. Older daemons fall back to a + // local text hash below. + let revision_due = snapshot + .stream_revision + .map(|revision| state.tracker.observe_revision(&terminal_id, revision, now)); + let manifest = process_discovery::identify_job_with_process_fallback(manifests, &job, &process) + .map(|(manifest, _)| manifest); + let process_group_id = state.process_cache.authoritative_group_id(&terminal_id); + let identity_edge = state.tracker.note_foreground_job_at_with_revision( + &terminal_id, + manifest.map(|item| item.id()), + process_group_id, + snapshot.stream_revision, + now, + ); + state.process_cache.mark_identified(&terminal_id, manifest.is_some(), now); + state.process_info_cache.mark_identified(&terminal_id, manifest.is_some(), now); + if manifest.is_none() { + // Process inspection is best effort. The tracker keeps a known agent + // through a bounded miss-confirmation window, so do not emit Done or + // read a stale screen until it confirms the identity disappeared. + if state.tracker.foreground_agent(&terminal_id).is_some() { + return Ok(()); + } + if !identity_edge { + return Ok(()); + } + if let Some(emission) = state.tracker.record_detection_at_with_revision( + &terminal_id, + None, + now, + identity_edge, + true, + snapshot.stream_revision, + ) && publish_emission( + terminal, + plugin_id, + plugin_generation, + &emission, + Some(process.pid), + state, + )? == PublishResult::Deferred + { + return Ok(()); + } + return Ok(()); + } + + // Process identity is enough to publish presence immediately. During the + // startup grace window, do not read the viewport because it can still be + // the shell's old prompt or the previous agent's screen. The first + // post-grace read is forced even when the PTY revision did not change. + let agent = manifest.expect("checked above").id(); + if (identity_edge || state.tracker.needs_identity_presence(&terminal_id, agent)) + && let Some(emission) = state.tracker.record_identity_presence_at(&terminal_id, agent, now) + && publish_emission( + terminal, + plugin_id, + plugin_generation, + &emission, + Some(process.pid), + state, + )? == PublishResult::Deferred + { + return Ok(()); + } + let grace_finished = state.tracker.finish_startup_grace(&terminal_id, now); + if state.tracker.startup_grace_active(&terminal_id, now) { + return Ok(()); + } + if revision_due == Some(false) && !identity_edge && !grace_finished { + return Ok(()); + } + let screen = terminal.read_screen(ReadScreenOptions).map_err(|error| error.to_string())?; + // Keep the host revision separate from the local text hash. The hash is + // only a scheduling fallback; it is not evidence that retained OSC + // metadata belongs to the current process. + let metadata_revision = merged_stream_revision(snapshot.stream_revision, screen.revision); + let mut evaluation_revision = metadata_revision; + if evaluation_revision.is_none() { + let mut hasher = DefaultHasher::new(); + screen.text.hash(&mut hasher); + evaluation_revision = Some(hasher.finish()); + } + let revision_due_after_read = evaluation_revision + .map(|revision| state.tracker.observe_revision(&terminal_id, revision, now)); + if snapshot.stream_revision.is_none() + && revision_due_after_read == Some(false) + && !identity_edge + { + return Ok(()); + } + let manifest = manifest.expect("checked above"); + // The daemon exposes OSC title and progress as generic terminal + // metadata. It can retain those values across a foreground-process + // change, so the userland plugin fences replacement agents until a + // post-edge output revision. The first acquisition deliberately keeps + // evidence already emitted by the new agent, matching herdr's behavior. + // Older daemons do not expose revisions, and a terminal with no known + // fence keeps the compatibility path. A known fence fails closed while + // the current host revision is missing. + // A screen read can carry the first revision on hosts whose catalog + // snapshot did not. Enrich a replacement fence after the read before + // consulting OSC fields, closing that compatibility race without a daemon + // change. First acquisition remains intentionally unfenced. + let _ = state.tracker.note_foreground_job_at_with_revision( + &terminal_id, + Some(agent), + process_group_id, + metadata_revision, + now, + ); + let metadata_fresh = state.tracker.metadata_is_fresh(&terminal_id, metadata_revision); + let osc_title = if metadata_fresh { snapshot.title.as_str() } else { "" }; + let osc_progress = + if metadata_fresh { screen.osc_progress.as_deref().unwrap_or_default() } else { "" }; + let mut detection = + manifest.detect(DetectionInput { screen: &screen.text, osc_title, osc_progress }); + // Flowing PTY output is a working signal for the screen source. It only + // upgrades an idle read and owes one expiry re-evaluation; hooks still + // win in the core roster reducer. + if !detection.skip_state_update + && detection.state == crate::manifest::ScreenState::Idle + && state.tracker.output_active(&terminal_id, now) + { + detection.state = crate::manifest::ScreenState::Working; + state.tracker.note_activity_upgrade(&terminal_id); + } + if let Some(emission) = state.tracker.record_detection_at( + &terminal_id, + Some((manifest.id(), detection)), + now, + identity_edge, + false, + ) && publish_emission( + terminal, + plugin_id, + plugin_generation, + &emission, + Some(process.pid), + state, + )? == PublishResult::Deferred + { + return Ok(()); + } + Ok(()) +} + +fn prepare_emission( + terminal: &Terminal, + plugin_id: &str, + plugin_generation: Option<&str>, + emission: &crate::detect::ScreenDetectEmission, + process_pid: Option, + emission_nonce: &str, + emission_sequence: &mut u64, +) -> Result { + let terminal_id = terminal + .id() + .ok_or_else(|| "terminal selector did not resolve to an id".to_string())? + .as_str() + .to_string(); + let namespace = format!("plugin.{plugin_id}"); + let event_name = + if emission.state == AgentState::Done { "session.ended" } else { "state.changed" }; + let kind = format!("{namespace}.agent.{event_name}"); + let observed_at_ms = now_ms(); + let source_session = process_pid + .map(|pid| format!("pid:{pid}")) + .unwrap_or_else(|| format!("terminal:{terminal_id}")); + let mut normalized = json!({ + "state":emission.state.as_str(), + "source_session":source_session, + "observed_at_ms":observed_at_ms.to_string(), + }); + if let Some(generation) = plugin_generation { + normalized["plugin_generation"] = json!(generation); + } + let idempotency_key = emission_idempotency_key(emission_nonce, *emission_sequence); + *emission_sequence = (*emission_sequence).saturating_add(1); + let ingress = JournalIngress { + producer_id: plugin_id.to_string(), + manifest_version: PLUGIN_VERSION, + kind, + schema_version: 1, + occurred_at_ms: Some(observed_at_ms), + subjects: vec![JournalSubject { kind: "terminal".into(), id: terminal_id.clone() }], + sensitivity: Some(JournalSensitivity::Sensitive), + payload: json!({ + "format":"cmux.agent-plugin.v1", + "plugin":{"id":plugin_id,"version":PLUGIN_VERSION}, + "adapter":{"id":emission.agent,"version":1}, + "event":event_name, + "normalized":normalized, + "native":{ + "engine":"herdr-manifest-v3", + "matched_rule":emission.matched_rule, + "visible":{ + "idle":emission.visible_idle, + "blocker":emission.visible_blocker, + "working":emission.visible_working + } + }, + }), + causation_id: None, + correlation_id: None, + }; + Ok(PendingAppend { + emission: emission.clone(), + ingress, + idempotency_key, + attempts: 0, + retry_not_before: Instant::now(), + }) +} + +fn append_prepared( + session: &Session, + pending: &PendingAppend, +) -> Result { + let mutation = MutationOptions::new(pending.idempotency_key.clone()) + .map_err(|error| AppendError::Definite(error.to_string()))?; + session.append_journal_event(&pending.ingress, mutation).map(|result| result.value).map_err( + |error| { + let uncertain = matches!(&error, cmux::Error::MutationTransport { .. }); + let message = error.to_string(); + if uncertain { AppendError::Uncertain(message) } else { AppendError::Definite(message) } + }, + ) +} + +fn retry_backoff(attempts: u32) -> Duration { + match attempts { + 0 | 1 => RETRY_BACKOFF_MIN, + 2 => Duration::from_millis(500), + 3 => Duration::from_secs(1), + 4 => Duration::from_secs(2), + _ => RETRY_BACKOFF_MAX, + } +} + +/// Retry one retained envelope before the scanner observes a newer state. +/// Returns `true` while the terminal remains blocked on that retry. +fn retry_pending_append( + session: &Session, + terminal_id: &str, + tracker: &mut ScreenDetectTracker, + pending_appends: &mut HashMap, +) -> Result { + let Some(pending) = pending_appends.get(terminal_id).cloned() else { + return Ok(false); + }; + if Instant::now() < pending.retry_not_before { + return Ok(true); + } + match append_prepared(session, &pending) { + Ok(_) => { + pending_appends.remove(terminal_id); + tracker.commit_emission(&pending.emission); + Ok(false) + } + Err(error) if error.is_uncertain() => { + if let Some(current) = pending_appends.get_mut(terminal_id) { + current.attempts = current.attempts.saturating_add(1); + current.retry_not_before = Instant::now() + retry_backoff(current.attempts); + } + eprintln!( + "cmux-agent-screen-detection: journal append uncertain for {terminal_id}; retrying: {}", + error.message() + ); + Ok(true) + } + Err(error) => { + pending_appends.remove(terminal_id); + tracker.discard_emission(&pending.emission); + Err(error.message().to_string()) + } + } +} + +/// Retry exact envelopes for terminals absent from one catalog snapshot. A +/// terminal list can race with PTY closure or recreation; dropping the +/// envelope here would either lose a committed edge or publish a duplicate +/// edge with a new idempotency key when the terminal returns. +fn retry_pending_appends_without_live_terminal( + session: &Session, + live_ids: &HashSet, + tracker: &mut ScreenDetectTracker, + pending_appends: &mut HashMap, +) { + let absent_ids = pending_appends + .keys() + .filter(|terminal_id| !live_ids.contains(*terminal_id)) + .cloned() + .collect::>(); + for terminal_id in absent_ids { + if let Err(error) = retry_pending_append(session, &terminal_id, tracker, pending_appends) { + eprintln!( + "cmux-agent-screen-detection: dropping pending journal append for {terminal_id}: {error}" + ); + } + } +} + +/// Publish one edge and commit the tracker's in-memory state only after the +/// journal accepts it. An uncertain transport result keeps the exact request +/// for idempotent replay. +fn publish_emission( + terminal: &Terminal, + plugin_id: &str, + plugin_generation: Option<&str>, + emission: &crate::detect::ScreenDetectEmission, + process_pid: Option, + state: &mut ScannerState, +) -> Result { + let terminal_id = emission.terminal_id.clone(); + if let Some(existing) = state.pending_appends.get(&terminal_id) { + if existing.emission != *emission { + return Err(format!( + "terminal {terminal_id} has a different journal emission waiting for replay" + )); + } + } else { + let pending = match prepare_emission( + terminal, + plugin_id, + plugin_generation, + emission, + process_pid, + &state.emission_nonce, + &mut state.emission_sequence, + ) { + Ok(pending) => pending, + Err(error) => { + state.tracker.rollback_emission(emission); + state.tracker.discard_emission(emission); + return Err(error); + } + }; + state.pending_appends.insert(terminal_id.clone(), pending); + } + let pending = state + .pending_appends + .get(&terminal_id) + .cloned() + .expect("pending emission was inserted above"); + match append_prepared(terminal.session(), &pending) { + Ok(_) => { + state.pending_appends.remove(&terminal_id); + state.tracker.commit_emission(emission); + Ok(PublishResult::Committed) + } + Err(error) => { + state.tracker.rollback_emission(emission); + if error.is_uncertain() { + if let Some(current) = state.pending_appends.get_mut(&terminal_id) { + current.attempts = current.attempts.saturating_add(1); + current.retry_not_before = Instant::now() + retry_backoff(current.attempts); + } + eprintln!( + "cmux-agent-screen-detection: journal append uncertain for {terminal_id}; retrying: {}", + error.message() + ); + Ok(PublishResult::Deferred) + } else { + state.pending_appends.remove(&terminal_id); + state.tracker.discard_emission(emission); + Err(error.message().to_string()) + } + } + } +} + +fn now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis() as u64) + .unwrap_or_default() +} + +/// Pick the newest revision when the catalog and screen reads overlap. The +/// daemon contract makes both values monotonic, but either field can be +/// unavailable on an older host. +fn merged_stream_revision(catalog: Option, screen: Option) -> Option { + match (catalog, screen) { + (Some(catalog), Some(screen)) => Some(catalog.max(screen)), + (Some(revision), None) | (None, Some(revision)) => Some(revision), + (None, None) => None, + } +} + +fn now_nanos() -> u128 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or_default() +} + +fn emission_idempotency_key(nonce: &str, sequence: u64) -> String { + format!("agent-emission-{nonce}-{sequence}") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn configured_session_selector_does_not_fall_back_to_current() { + let id = SessionId::parse("session_00000000000000000000000000000001").unwrap(); + assert_eq!(configured_session_selector(id.as_str()).unwrap(), Selector::id(id)); + assert_eq!(configured_session_selector("secondary").unwrap(), Selector::name("secondary")); + assert!(configured_session_selector(" ").is_err()); + } + + fn cached( + checked_at: Instant, + identified: bool, + stream_revision: Option, + ) -> CachedProcessInfo { + CachedProcessInfo { + process: cmux::ProcessInfoResult { + pid: 42, + executable: Some("shell".into()), + argv: vec!["shell".into()], + cwd: None, + foreground_cwd: None, + foreground_executable: Some("shell".into()), + children: Vec::new(), + }, + checked_at, + stream_revision, + identified, + acquisition_started_at: None, + } + } + + fn cached_with_acquisition_start( + checked_at: Instant, + acquisition_started_at: Instant, + ) -> CachedProcessInfo { + let mut entry = cached(checked_at, true, Some(7)); + entry.acquisition_started_at = Some(acquisition_started_at); + entry + } + + fn cached_group( + checked_at: Instant, + identified: bool, + stream_revision: Option, + ) -> CachedProcessGroup { + CachedProcessGroup { + pid: 42, + foreground_name: Some("shell".into()), + executable: Some("shell".into()), + argv: vec!["shell".into()], + checked_at, + stream_revision, + job: process_discovery::ForegroundJob { process_group_id: 42, processes: Vec::new() }, + authoritative: true, + identified, + acquisition_started_at: None, + } + } + + #[test] + fn identified_processes_use_a_long_quiet_recheck_interval() { + let start = Instant::now(); + let entry = cached(start, true, Some(7)); + assert!(!process_info_refresh_due( + &entry, + Some(7), + start + PROCESS_INFO_RECHECK_IDENTIFIED - Duration::from_millis(1), + )); + assert!( + process_info_refresh_due(&entry, Some(7), start + PROCESS_INFO_RECHECK_IDENTIFIED,) + ); + } + + #[test] + fn output_changes_make_identified_processes_recheck_within_one_second() { + let start = Instant::now(); + let entry = cached(start, true, Some(7)); + assert!(!process_info_refresh_due( + &entry, + Some(8), + start + PROCESS_INFO_RECHECK_ON_OUTPUT - Duration::from_millis(1), + )); + assert!(process_info_refresh_due(&entry, Some(8), start + PROCESS_INFO_RECHECK_ON_OUTPUT,)); + } + + #[test] + fn missing_output_revision_keeps_process_probe_bounded() { + let start = Instant::now(); + let entry = cached(start, true, None); + assert!(!process_info_refresh_due( + &entry, + None, + start + PROCESS_INFO_RECHECK_ON_OUTPUT - Duration::from_millis(1), + )); + assert!(process_info_refresh_due(&entry, None, start + PROCESS_INFO_RECHECK_ON_OUTPUT,)); + } + + #[test] + fn newly_identified_processes_use_adaptive_acquisition_rechecks() { + let start = Instant::now(); + let entry = cached_with_acquisition_start(start, start); + assert!(!process_info_refresh_due( + &entry, + Some(7), + start + PROCESS_ACQUISITION_FAST_RECHECK - Duration::from_millis(1), + )); + assert!(process_info_refresh_due( + &entry, + Some(7), + start + PROCESS_ACQUISITION_FAST_RECHECK, + )); + let slow_check = + cached_with_acquisition_start(start + PROCESS_ACQUISITION_FAST_WINDOW, start); + assert!(!process_info_refresh_due( + &slow_check, + Some(7), + slow_check.checked_at + PROCESS_ACQUISITION_SLOW_RECHECK - Duration::from_millis(1), + )); + assert!(process_info_refresh_due( + &slow_check, + Some(7), + slow_check.checked_at + PROCESS_ACQUISITION_SLOW_RECHECK, + )); + } + + #[test] + fn unknown_processes_recheck_quickly_for_spawn_detection() { + let start = Instant::now(); + let entry = cached(start, false, Some(7)); + assert!(!process_info_refresh_due( + &entry, + Some(8), + start + PROCESS_INFO_RECHECK_UNKNOWN - Duration::from_millis(1), + )); + assert!(process_info_refresh_due(&entry, Some(8), start + PROCESS_INFO_RECHECK_UNKNOWN,)); + } + + #[test] + fn unknown_process_groups_recheck_quickly_for_spawn_detection() { + let start = Instant::now(); + let entry = cached_group(start, false, Some(7)); + assert!(!process_group_refresh_due( + &entry, + Some(8), + start + PROCESS_GROUP_RECHECK_UNKNOWN - Duration::from_millis(1), + )); + assert!(process_group_refresh_due(&entry, Some(8), start + PROCESS_GROUP_RECHECK_UNKNOWN,)); + } + + #[test] + fn identified_process_groups_recheck_on_output_within_one_second() { + let start = Instant::now(); + let entry = cached_group(start, true, Some(7)); + assert!(!process_group_refresh_due( + &entry, + Some(8), + start + PROCESS_GROUP_RECHECK_ON_OUTPUT - Duration::from_millis(1), + )); + assert!(process_group_refresh_due( + &entry, + Some(8), + start + PROCESS_GROUP_RECHECK_ON_OUTPUT, + )); + } + + #[test] + fn missing_output_revision_keeps_process_group_probe_bounded() { + let start = Instant::now(); + let entry = cached_group(start, true, None); + assert!(!process_group_refresh_due( + &entry, + None, + start + PROCESS_GROUP_RECHECK_ON_OUTPUT - Duration::from_millis(1), + )); + assert!(process_group_refresh_due(&entry, None, start + PROCESS_GROUP_RECHECK_ON_OUTPUT,)); + } + + #[test] + fn process_group_cache_refreshes_when_a_same_pid_runtime_reexecs() { + let now = Instant::now(); + let first = cmux::ProcessInfoResult { + pid: 0, + executable: Some("node".into()), + argv: vec!["node".into(), "/tmp/agent-a".into()], + cwd: None, + foreground_cwd: None, + foreground_executable: Some("node".into()), + children: Vec::new(), + }; + let second = cmux::ProcessInfoResult { + argv: vec!["node".into(), "/tmp/agent-b".into()], + ..first.clone() + }; + let mut cache = ProcessGroupCache::default(); + + let _ = cache.job_for("terminal-1", &first, Some(7), now); + let second_job = cache.job_for("terminal-1", &second, Some(7), now); + + assert_eq!(second_job.processes[0].argv, second.argv); + } + + #[test] + fn emission_keys_are_unique_for_one_connection() { + let first = emission_idempotency_key("17-123456789", 0); + let second = emission_idempotency_key("17-123456789", 1); + assert_ne!(first, second); + assert!(first.len() <= 128); + assert!(second.len() <= 128); + } + + #[test] + fn merged_stream_revision_uses_the_newest_available_host_value() { + assert_eq!(merged_stream_revision(None, None), None); + assert_eq!(merged_stream_revision(Some(7), None), Some(7)); + assert_eq!(merged_stream_revision(None, Some(8)), Some(8)); + assert_eq!(merged_stream_revision(Some(7), Some(8)), Some(8)); + assert_eq!(merged_stream_revision(Some(9), Some(8)), Some(9)); + } + + #[test] + fn terminal_retention_keeps_uncertain_appends_for_replay() { + let terminal_id = "terminal-1".to_string(); + let mut state = ScannerState::new(); + let now = Instant::now(); + state.tracker.note_foreground_agent_at(&terminal_id, Some("codex"), now); + let emission = + state.tracker.record_identity_presence_at(&terminal_id, "codex", now).unwrap(); + state.tracker.commit_emission(&emission); + state.pending_appends.insert( + terminal_id.clone(), + PendingAppend { + emission: crate::detect::ScreenDetectEmission { + terminal_id: terminal_id.clone(), + agent: "codex".into(), + state: AgentState::Working, + matched_rule: None, + visible_idle: false, + visible_blocker: false, + visible_working: true, + }, + ingress: JournalIngress { + producer_id: "plugin".into(), + manifest_version: 1, + kind: "plugin.agent.state.changed".into(), + schema_version: 1, + occurred_at_ms: None, + subjects: Vec::new(), + sensitivity: None, + payload: json!({}), + causation_id: None, + correlation_id: None, + }, + idempotency_key: "key".into(), + attempts: 1, + retry_not_before: Instant::now(), + }, + ); + + state.retain_terminals(&HashSet::new()); + + assert!(state.pending_appends.contains_key(&terminal_id)); + assert!(state.tracker.has_live_emission(&terminal_id)); + } +} diff --git a/cmux-tui/bindings/go/.cmux-resource-api.json b/cmux-tui/bindings/go/.cmux-resource-api.json index 4c4481343150..0667d105f967 100644 --- a/cmux-tui/bindings/go/.cmux-resource-api.json +++ b/cmux-tui/bindings/go/.cmux-resource-api.json @@ -1,5 +1,5 @@ { - "catalog_sha256": "beef8293ded489648261ccddfd31b3f796f9d7d10506f13f6c5d1577a3f4fbac", + "catalog_sha256": "08a8190787e1b38d0592b85856d791fb0fe098d58eddce85ee98ecf88cc5a1a7", "operations": { "agent.list": { "class": "read" diff --git a/cmux-tui/bindings/go/internal/wirev2/operations.go b/cmux-tui/bindings/go/internal/wirev2/operations.go index 6a8c99ec01f5..f3bcffdb635f 100644 --- a/cmux-tui/bindings/go/internal/wirev2/operations.go +++ b/cmux-tui/bindings/go/internal/wirev2/operations.go @@ -47,6 +47,9 @@ var ( SessionCreationResolve = Operation{"session.creation.resolve", Read} SessionEvents = Operation{"session.events", StreamOpen} SessionJournalSubscribe = Operation{"session.journal.subscribe", StreamOpen} + SessionJournalProducerList = Operation{"session.journal.producer.list", Read} + SessionJournalProducerPut = Operation{"session.journal.producer.put", Mutation} + SessionJournalAppend = Operation{"session.journal.append", Mutation} SessionPing = Operation{"session.ping", Read} SessionShutdown = Operation{"session.shutdown", Mutation} SessionReloadConfig = Operation{"session.reload_config", Mutation} diff --git a/cmux-tui/bindings/go/journal.go b/cmux-tui/bindings/go/journal.go new file mode 100644 index 000000000000..94ce8f1545ac --- /dev/null +++ b/cmux-tui/bindings/go/journal.go @@ -0,0 +1,227 @@ +package cmux + +import ( + "encoding/json" + "fmt" + "strings" + "unicode/utf8" +) + +const ( + maxJournalComponentBytes = 64 + maxJournalKindBytes = 128 + maxJournalEventIDBytes = 128 + maxJournalManifestBytes = 1024 * 1024 + maxJournalProducerCount = 1024 +) + +func validJournalComponent(value string) bool { + if !utf8.ValidString(value) || len(value) < 1 || len(value) > maxJournalComponentBytes { + return false + } + for index, character := range []byte(value) { + if index == 0 { + if !((character >= 'a' && character <= 'z') || (character >= '0' && character <= '9')) { + return false + } + continue + } + if !((character >= 'a' && character <= 'z') || + (character >= '0' && character <= '9') || character == '_' || character == '-') { + return false + } + } + return true +} + +func validJournalKind(value string) bool { + if !utf8.ValidString(value) || len(value) < 1 || len(value) > maxJournalKindBytes { + return false + } + parts := strings.Split(value, ".") + for _, part := range parts { + if !validJournalComponent(part) { + return false + } + } + return true +} + +func validJournalIdentifier(value string, maximumBytes int) bool { + return utf8.ValidString(value) && len(value) >= 1 && len(value) <= maximumBytes +} + +func journalSensitivityRank(value JournalSensitivity) (int, bool) { + switch value { + case JournalSensitivityPublic: + return 0, true + case JournalSensitivityMetadata: + return 1, true + case JournalSensitivitySensitive: + return 2, true + case JournalSensitivitySecret: + return 3, true + default: + return 0, false + } +} + +func validJournalJSON(value JSONValue) error { + if _, err := json.Marshal(value); err != nil { + return fmt.Errorf("value is not JSON: %w", err) + } + return nil +} + +func validateJournalEventSchema(event JournalEventSchema, namespace string, maxSensitivity JournalSensitivity, seen map[string]struct{}) error { + if !validJournalKind(event.Kind) || !strings.HasPrefix(event.Kind, namespace+".") { + return fmt.Errorf("event kind must be a dotted name inside the producer namespace") + } + if event.SchemaVersion == 0 { + return fmt.Errorf("event schema_version must be positive") + } + if _, ok := journalSensitivityRank(event.Sensitivity); !ok || event.Sensitivity == JournalSensitivitySecret { + return fmt.Errorf("event sensitivity is invalid or unavailable") + } + maxRank, ok := journalSensitivityRank(maxSensitivity) + eventRank, _ := journalSensitivityRank(event.Sensitivity) + if !ok || eventRank > maxRank { + return fmt.Errorf("event sensitivity exceeds producer authority") + } + if event.Class != JournalClassState && event.Class != JournalClassObservation && + event.Class != JournalClassEffect && event.Class != JournalClassCheckpoint { + return fmt.Errorf("event class is invalid") + } + if event.Replay != JournalReplayRequired && event.Replay != JournalReplayAdvisory && + event.Replay != JournalReplayNever { + return fmt.Errorf("event replay policy is invalid") + } + if err := validJournalJSON(event.PayloadSchema); err != nil { + return fmt.Errorf("event payload_schema: %w", err) + } + identity := fmt.Sprintf("%s:%d", event.Kind, event.SchemaVersion) + if _, exists := seen[identity]; exists { + return fmt.Errorf("producer declares a duplicate event schema") + } + seen[identity] = struct{}{} + return nil +} + +// Validate checks the local shape before a manifest is sent. The daemon is +// still authoritative for JSON Schema compilation and durable state. +func (manifest JournalProducerManifest) Validate() error { + if !validJournalComponent(manifest.ProducerID) { + return fmt.Errorf("%w: producer_id must match [a-z0-9][a-z0-9_-]*", ErrInvalidArgument) + } + if manifest.Namespace != "plugin."+manifest.ProducerID { + return fmt.Errorf("%w: namespace must equal plugin.", ErrInvalidArgument) + } + if manifest.ManifestVersion == 0 { + return fmt.Errorf("%w: manifest_version must be positive", ErrInvalidArgument) + } + if manifest.MaxSensitivity == JournalSensitivitySecret { + return fmt.Errorf("%w: secret journal payload storage is unavailable", ErrInvalidArgument) + } + if _, ok := journalSensitivityRank(manifest.MaxSensitivity); !ok { + return fmt.Errorf("%w: max_sensitivity is invalid", ErrInvalidArgument) + } + if len(manifest.Permissions) < 1 || len(manifest.Permissions) > 32 { + return fmt.Errorf("%w: permissions must contain 1 to 32 entries", ErrInvalidArgument) + } + requiredPermission := "journal.append." + manifest.Namespace + hasPermission := false + for _, permission := range manifest.Permissions { + if !utf8.ValidString(permission) || len(permission) < 1 || len(permission) > 128 { + return fmt.Errorf("%w: journal permission must contain 1 to 128 UTF-8 bytes", ErrInvalidArgument) + } + if permission == requiredPermission { + hasPermission = true + } + } + if !hasPermission { + return fmt.Errorf("%w: permissions must include %s", ErrInvalidArgument, requiredPermission) + } + if len(manifest.Events) < 1 || len(manifest.Events) > 64 { + return fmt.Errorf("%w: events must contain 1 to 64 entries", ErrInvalidArgument) + } + seen := make(map[string]struct{}, len(manifest.Events)) + for _, event := range manifest.Events { + if err := validateJournalEventSchema(event, manifest.Namespace, manifest.MaxSensitivity, seen); err != nil { + return fmt.Errorf("%w: %v", ErrInvalidArgument, err) + } + } + encoded, err := json.Marshal(manifest) + if err != nil { + return fmt.Errorf("%w: manifest is not encodable: %v", ErrInvalidArgument, err) + } + if len(encoded) > maxJournalManifestBytes { + return fmt.Errorf("%w: manifest exceeds %d bytes", ErrInvalidArgument, maxJournalManifestBytes) + } + return nil +} + +// Validate checks the local shape of one event envelope. The installed +// manifest remains authoritative for its event schema and sensitivity. +func (event JournalIngress) Validate() error { + if !validJournalComponent(event.ProducerID) { + return fmt.Errorf("%w: producer_id is invalid", ErrInvalidArgument) + } + if event.ManifestVersion == 0 || event.SchemaVersion == 0 { + return fmt.Errorf("%w: manifest_version and schema_version must be positive", ErrInvalidArgument) + } + if !validJournalKind(event.Kind) { + return fmt.Errorf("%w: kind must be a dotted lowercase name", ErrInvalidArgument) + } + if !strings.HasPrefix(event.Kind, "plugin."+event.ProducerID+".") { + return fmt.Errorf("%w: kind must be inside the producer namespace", ErrInvalidArgument) + } + if len(event.Subjects) > 64 { + return fmt.Errorf("%w: subjects must contain at most 64 entries", ErrInvalidArgument) + } + for _, subject := range event.Subjects { + if !validJournalComponent(subject.Kind) || !utf8.ValidString(subject.ID) || len(subject.ID) < 1 || len(subject.ID) > 512 { + return fmt.Errorf("%w: journal subject is invalid", ErrInvalidArgument) + } + } + if event.Sensitivity != nil { + if _, ok := journalSensitivityRank(*event.Sensitivity); !ok || *event.Sensitivity == JournalSensitivitySecret { + return fmt.Errorf("%w: sensitivity is invalid or unavailable", ErrInvalidArgument) + } + } + for name, value := range map[string]*string{ + "causation_id": event.CausationID, + "correlation_id": event.CorrelationID, + } { + if value != nil && (!utf8.ValidString(*value) || len(*value) < 1 || len(*value) > 128) { + return fmt.Errorf("%w: %s must contain 1 to 128 UTF-8 bytes", ErrInvalidArgument, name) + } + } + if err := validJournalJSON(event.Payload); err != nil { + return fmt.Errorf("%w: payload: %v", ErrInvalidArgument, err) + } + return nil +} + +func validateDecodedJournalEvent(event JournalEventSchema) error { + if !validJournalKind(event.Kind) || event.SchemaVersion == 0 { + return fmt.Errorf("journal event schema is invalid") + } + if event.Class != JournalClassState && event.Class != JournalClassObservation && + event.Class != JournalClassEffect && event.Class != JournalClassCheckpoint { + return fmt.Errorf("journal event class is invalid") + } + if event.Replay != JournalReplayRequired && event.Replay != JournalReplayAdvisory && event.Replay != JournalReplayNever { + return fmt.Errorf("journal event replay policy is invalid") + } + if _, ok := journalSensitivityRank(event.Sensitivity); !ok { + return fmt.Errorf("journal event sensitivity is invalid") + } + return validJournalJSON(event.PayloadSchema) +} + +func validateDecodedJournalManifest(manifest JournalProducerManifest) error { + if err := manifest.Validate(); err != nil { + return err + } + return nil +} diff --git a/cmux-tui/bindings/go/operations.go b/cmux-tui/bindings/go/operations.go index 1f690f776d87..fbcfce9b5eb0 100644 --- a/cmux-tui/bindings/go/operations.go +++ b/cmux-tui/bindings/go/operations.go @@ -415,6 +415,64 @@ func (s *Session) Journal(ctx context.Context, options SessionJournalOptions) (* }, ) } + +// JournalProducers lists generic userland journal producer manifests. +func (s *Session) JournalProducers(ctx context.Context, options SessionJournalProducerListOptions) (JournalProducerListResult, error) { + input := s.route.params() + merge(input, options.Extra) + return readValue[JournalProducerListResult]( + ctx, s.client, wirev2.SessionJournalProducerList, input, + "journal producer list", + ) +} + +// ListJournalProducers is the slice-oriented convenience form of JournalProducers. +func (s *Session) ListJournalProducers(ctx context.Context, options SessionJournalProducerListOptions) ([]JournalProducerManifest, error) { + result, err := s.JournalProducers(ctx, options) + if err != nil { + return nil, err + } + return result.Producers, nil +} + +// PutJournalProducer installs or replaces a generic userland journal producer. +func (s *Session) PutJournalProducer(ctx context.Context, manifest JournalProducerManifest, options MutationOptions) (MutationResult[JournalProducerPutResult], error) { + if err := manifest.Validate(); err != nil { + return MutationResult[JournalProducerPutResult]{}, err + } + input := s.route.params() + merge(input, options.Extra) + input["manifest"] = manifest + return mutationValue[JournalProducerPutResult]( + ctx, s.client, wirev2.SessionJournalProducerPut, input, options, + "journal producer result", + ) +} + +// PutJournalProducerManifest is a compatibility name for PutJournalProducer. +func (s *Session) PutJournalProducerManifest(ctx context.Context, manifest JournalProducerManifest, options MutationOptions) (MutationResult[JournalProducerPutResult], error) { + return s.PutJournalProducer(ctx, manifest, options) +} + +// AppendJournal appends one event from a registered userland producer. +func (s *Session) AppendJournal(ctx context.Context, event JournalIngress, options MutationOptions) (MutationResult[JournalAppendResult], error) { + if err := event.Validate(); err != nil { + return MutationResult[JournalAppendResult]{}, err + } + input := s.route.params() + merge(input, options.Extra) + input["event"] = event + return mutationValue[JournalAppendResult]( + ctx, s.client, wirev2.SessionJournalAppend, input, options, + "journal append result", + ) +} + +// AppendJournalEvent is a compatibility name for AppendJournal. +func (s *Session) AppendJournalEvent(ctx context.Context, event JournalIngress, options MutationOptions) (MutationResult[JournalAppendResult], error) { + return s.AppendJournal(ctx, event, options) +} + func (s *Session) Ping(ctx context.Context, options SessionPingOptions) (PingResult, error) { input := s.route.params() merge(input, options.Extra) @@ -833,6 +891,27 @@ func validateDecodedValue(raw json.RawMessage, value any) error { required = []string{ "text", "cols", "rows", "cursor_row", "cursor_col", "cursor_visible", } + case *JournalEventSchema: + required = []string{ + "kind", "schema_version", "class", "replay", "sensitivity", "payload_schema", + } + case *JournalProducerManifest: + required = []string{ + "producer_id", "namespace", "manifest_version", "max_sensitivity", + "permissions", "events", + } + case *JournalIngress: + required = []string{ + "producer_id", "manifest_version", "kind", "schema_version", "payload", + } + case *JournalProducerPutResult: + required = []string{ + "producer_id", "manifest_version", "namespace", "sequence", "event_id", + } + case *JournalProducerListResult: + required = []string{"producers"} + case *JournalAppendResult: + required = []string{"producer_id", "sequence", "event_id"} case *TerminalStateResult: required = []string{"state_base64", "cols", "rows"} case *TerminalHistoryResult: @@ -1016,7 +1095,7 @@ func validateDecodedValue(raw json.RawMessage, value any) error { return fmt.Errorf("invalid agent state %q", decoded.State) } switch decoded.Source { - case "hook", "socket", "detected": + case "hook", "socket", "detected", "plugin": default: return fmt.Errorf("invalid agent source %q", decoded.Source) } @@ -1070,6 +1149,44 @@ func validateDecodedValue(raw json.RawMessage, value any) error { if decoded.Cols == 0 || decoded.Rows == 0 { return fmt.Errorf("terminal screen dimensions must be non-zero") } + case *JournalEventSchema: + return validateDecodedJournalEvent(*decoded) + case *JournalProducerManifest: + return validateDecodedJournalManifest(*decoded) + case *JournalIngress: + return decoded.Validate() + case *JournalProducerListResult: + if decoded.Producers == nil { + return fmt.Errorf("journal producer list must be an array") + } + if len(decoded.Producers) > maxJournalProducerCount { + return fmt.Errorf("journal producer list contains too many entries") + } + for _, manifest := range decoded.Producers { + if err := validateDecodedJournalManifest(manifest); err != nil { + return err + } + } + case *JournalProducerPutResult: + if !validJournalComponent(decoded.ProducerID) { + return fmt.Errorf("journal producer result has an invalid producer_id") + } + if decoded.Namespace != "plugin."+decoded.ProducerID { + return fmt.Errorf("journal producer result has an invalid namespace") + } + if decoded.ManifestVersion == 0 { + return fmt.Errorf("journal producer result has an invalid manifest_version") + } + if !validJournalIdentifier(decoded.EventID, maxJournalEventIDBytes) { + return fmt.Errorf("journal producer result has an invalid event_id") + } + case *JournalAppendResult: + if !validJournalComponent(decoded.ProducerID) { + return fmt.Errorf("journal append result has an invalid producer_id") + } + if !validJournalIdentifier(decoded.EventID, maxJournalEventIDBytes) { + return fmt.Errorf("journal append result has an invalid event_id") + } case *TerminalStateResult: if decoded.Cols == 0 || decoded.Rows == 0 { return fmt.Errorf("terminal state dimensions must be non-zero") diff --git a/cmux-tui/bindings/go/options.go b/cmux-tui/bindings/go/options.go index 5296b9661858..eeeb570893c3 100644 --- a/cmux-tui/bindings/go/options.go +++ b/cmux-tui/bindings/go/options.go @@ -77,6 +77,7 @@ type SessionEventsOptions struct { StreamOptions Cursor *Cursor } +type SessionJournalProducerListOptions struct{ ReadOptions } type JournalStart string const ( diff --git a/cmux-tui/bindings/go/raw/.cmux-sdk-manifest.json b/cmux-tui/bindings/go/raw/.cmux-sdk-manifest.json index aa99c6c09eb9..27a3c3f2df16 100644 --- a/cmux-tui/bindings/go/raw/.cmux-sdk-manifest.json +++ b/cmux-tui/bindings/go/raw/.cmux-sdk-manifest.json @@ -2,32 +2,32 @@ "files": [ { "path": "generated_commands.go", - "sha256": "49ea038e1e014c4d8b6b74f8a88f61e85138582cd43044c2bd56e4549f48808c", + "sha256": "633d9669e67e16b1ea3b9871db74d8970a25f86b2d369cb56339febebe273576", "size": 379461 }, { "path": "generated_events.go", - "sha256": "424d40aea346231d909923ad1cbcbd897faffa605efcb38cb3d8cf463981c5d6", - "size": 113127 + "sha256": "0a9e1cfef79247daf530a86d3a13f4cffa3b4207380f3c93f8be7a410d0da2c7", + "size": 113743 }, { "path": "generated_metadata.go", - "sha256": "cf2517b4dad8132540cfd3d91e79c5d2c2882267562cf7f7810048f84f98028d", + "sha256": "582d4e0bb080b71874622734c983610d029803b6ba8e0ba6efa91442560d7453", "size": 51563 }, { "path": "generated_presence_test.go", - "sha256": "d1bb3432e6240eaad75438022715aaac765aae588e884e03bc4a5aa833314d2b", - "size": 1091931 + "sha256": "7273945841970437387b72466218bbb38fee69be0d4b3f329ed248c177404c11", + "size": 1094292 }, { "path": "generated_types.go", - "sha256": "e33ef2425772b6ac6f32a05eed874ff928ddbc7eaa49efd872628e70e57f48b4", - "size": 302165 + "sha256": "3744cefb511f5550b6dd65967fd0a01333a5b3d91259c0431dbfddc224436600", + "size": 303112 } ], "format": 1, - "ir_sha256": "7042c629f34d3606581d07b2d2c03b65116c2467810724163c54674865825cc0", + "ir_sha256": "133bac0154f8f94aa30e40c11ff7ed38b10dd4d82974aec87c02d404fcd12619", "language": "go", "mux_protocol": 12, "schema_version": 2 diff --git a/cmux-tui/bindings/go/raw/generated_commands.go b/cmux-tui/bindings/go/raw/generated_commands.go index ada254adba5e..2567797f0161 100644 --- a/cmux-tui/bindings/go/raw/generated_commands.go +++ b/cmux-tui/bindings/go/raw/generated_commands.go @@ -1,5 +1,5 @@ // Code generated by cmux-tui SDK codegen. DO NOT EDIT. -// Mux protocol 12; IR SHA-256 7042c629f34d3606581d07b2d2c03b65116c2467810724163c54674865825cc0. +// Mux protocol 12; IR SHA-256 133bac0154f8f94aa30e40c11ff7ed38b10dd4d82974aec87c02d404fcd12619. package raw diff --git a/cmux-tui/bindings/go/raw/generated_events.go b/cmux-tui/bindings/go/raw/generated_events.go index 6b1650dee58f..6420761dc72f 100644 --- a/cmux-tui/bindings/go/raw/generated_events.go +++ b/cmux-tui/bindings/go/raw/generated_events.go @@ -1,5 +1,5 @@ // Code generated by cmux-tui SDK codegen. DO NOT EDIT. -// Mux protocol 12; IR SHA-256 7042c629f34d3606581d07b2d2c03b65116c2467810724163c54674865825cc0. +// Mux protocol 12; IR SHA-256 133bac0154f8f94aa30e40c11ff7ed38b10dd4d82974aec87c02d404fcd12619. package raw @@ -40,6 +40,8 @@ type BrowserAttachEvent interface { // AgentChangedEvent is emitted by protocol v11. type AgentChangedEvent struct { + // Adapter identity when the producer knows it; absent from protocol-11 event senders and null when no adapter was identified. + Agent Presence[string] `json:"-"` Session RequiredNullable[string] `json:"-"` Source AgentSource `json:"source"` State AgentState `json:"state"` @@ -57,6 +59,18 @@ func (value AgentChangedEvent) MarshalJSON() ([]byte, error) { if err := json.Unmarshal(encoded, &object); err != nil { return nil, err } + if value.Agent.IsAbsent() { + delete(object, "agent") + } else if value.Agent.IsNull() { + object["agent"] = json.RawMessage("null") + } else { + fieldValue, _ := value.Agent.Get() + encodedField, err := json.Marshal(fieldValue) + if err != nil { + return nil, fmt.Errorf("encode AgentChangedEvent.Agent: %w", err) + } + object["agent"] = encodedField + } if !value.Session.IsSet() { return nil, fmt.Errorf("encode AgentChangedEvent: required nullable field session is missing") } @@ -78,6 +92,7 @@ func (value *AgentChangedEvent) UnmarshalJSON(data []byte) error { return fmt.Errorf("decode AgentChangedEvent: expected object") } var fields struct { + Agent Presence[string] `json:"agent"` Session RequiredNullable[string] `json:"session"` Source *AgentSource `json:"source"` State *AgentState `json:"state"` @@ -89,6 +104,7 @@ func (value *AgentChangedEvent) UnmarshalJSON(data []byte) error { } type wire AgentChangedEvent var decoded wire + decoded.Agent = fields.Agent if !fields.Session.IsSet() { return fmt.Errorf("decode AgentChangedEvent: required field session is missing") } diff --git a/cmux-tui/bindings/go/raw/generated_metadata.go b/cmux-tui/bindings/go/raw/generated_metadata.go index 66b84fed4d0c..83a93efb05fe 100644 --- a/cmux-tui/bindings/go/raw/generated_metadata.go +++ b/cmux-tui/bindings/go/raw/generated_metadata.go @@ -1,12 +1,12 @@ // Code generated by cmux-tui SDK codegen. DO NOT EDIT. -// Mux protocol 12; IR SHA-256 7042c629f34d3606581d07b2d2c03b65116c2467810724163c54674865825cc0. +// Mux protocol 12; IR SHA-256 133bac0154f8f94aa30e40c11ff7ed38b10dd4d82974aec87c02d404fcd12619. package raw const ( SDKSchemaVersion = 2 MuxProtocolVersion = 12 - SDKIRSHA256 = "7042c629f34d3606581d07b2d2c03b65116c2467810724163c54674865825cc0" + SDKIRSHA256 = "133bac0154f8f94aa30e40c11ff7ed38b10dd4d82974aec87c02d404fcd12619" ) type Authority string diff --git a/cmux-tui/bindings/go/raw/generated_presence_test.go b/cmux-tui/bindings/go/raw/generated_presence_test.go index 3f2069e53318..4f8c96b4e360 100644 --- a/cmux-tui/bindings/go/raw/generated_presence_test.go +++ b/cmux-tui/bindings/go/raw/generated_presence_test.go @@ -1,5 +1,5 @@ // Code generated by cmux-tui SDK codegen. DO NOT EDIT. -// Mux protocol 12; IR SHA-256 7042c629f34d3606581d07b2d2c03b65116c2467810724163c54674865825cc0. +// Mux protocol 12; IR SHA-256 133bac0154f8f94aa30e40c11ff7ed38b10dd4d82974aec87c02d404fcd12619. package raw @@ -47,11 +47,11 @@ func assertGeneratedFieldJSON( func TestGeneratedSchemaPresenceRoundTrips(t *testing.T) { t.Run("AgentRecord.Session", func(t *testing.T) { var missing AgentRecord - if err := json.Unmarshal([]byte("{\"source\":\"detected\",\"state\":\"working\",\"surface\":1,\"updated_at_ms\":1}"), &missing); err == nil { + if err := json.Unmarshal([]byte("{\"source\":\"plugin\",\"state\":\"working\",\"surface\":1,\"updated_at_ms\":1}"), &missing); err == nil { t.Fatal("missing required nullable field session decoded successfully") } var nullValue AgentRecord - if err := json.Unmarshal([]byte("{\"session\":null,\"source\":\"detected\",\"state\":\"working\",\"surface\":1,\"updated_at_ms\":1}"), &nullValue); err != nil { + if err := json.Unmarshal([]byte("{\"session\":null,\"source\":\"plugin\",\"state\":\"working\",\"surface\":1,\"updated_at_ms\":1}"), &nullValue); err != nil { t.Fatal(err) } if !nullValue.Session.IsSet() || !nullValue.Session.IsNull() { @@ -59,7 +59,7 @@ func TestGeneratedSchemaPresenceRoundTrips(t *testing.T) { } assertGeneratedFieldJSON(t, nullValue, "session", true, "null") var presentValue AgentRecord - if err := json.Unmarshal([]byte("{\"session\":\"value\",\"source\":\"detected\",\"state\":\"working\",\"surface\":1,\"updated_at_ms\":1}"), &presentValue); err != nil { + if err := json.Unmarshal([]byte("{\"session\":\"value\",\"source\":\"plugin\",\"state\":\"working\",\"surface\":1,\"updated_at_ms\":1}"), &presentValue); err != nil { t.Fatal(err) } if _, ok := presentValue.Session.Get(); !ok { @@ -1065,6 +1065,32 @@ func TestGeneratedSchemaPresenceRoundTrips(t *testing.T) { } assertGeneratedFieldJSON(t, presentValue, "foreground_cwd", true, "\"value\"") }) + t.Run("ProcessInfoResult.ForegroundExecutable", func(t *testing.T) { + var missing ProcessInfoResult + if err := json.Unmarshal([]byte("{\"command\":null,\"cwd\":null,\"pid\":null}"), &missing); err != nil { + t.Fatal(err) + } + if !missing.ForegroundExecutable.IsAbsent() { + t.Fatal("ForegroundExecutable did not preserve absence") + } + assertGeneratedFieldJSON(t, missing, "foreground_executable", false, "") + var nullValue ProcessInfoResult + if err := json.Unmarshal([]byte("{\"command\":null,\"cwd\":null,\"pid\":null,\"foreground_executable\":null}"), &nullValue); err != nil { + t.Fatal(err) + } + if !nullValue.ForegroundExecutable.IsNull() { + t.Fatal("ForegroundExecutable did not preserve null") + } + assertGeneratedFieldJSON(t, nullValue, "foreground_executable", true, "null") + var presentValue ProcessInfoResult + if err := json.Unmarshal([]byte("{\"command\":null,\"cwd\":null,\"pid\":null,\"foreground_executable\":\"value\"}"), &presentValue); err != nil { + t.Fatal(err) + } + if _, ok := presentValue.ForegroundExecutable.Get(); !ok { + t.Fatal("ForegroundExecutable did not preserve a value") + } + assertGeneratedFieldJSON(t, presentValue, "foreground_executable", true, "\"value\"") + }) t.Run("ProcessInfoResult.PID", func(t *testing.T) { var missing ProcessInfoResult if err := json.Unmarshal([]byte("{\"command\":null,\"cwd\":null}"), &missing); err == nil { @@ -11691,13 +11717,39 @@ func TestGeneratedSchemaPresenceRoundTrips(t *testing.T) { } assertGeneratedFieldJSON(t, presentValue, "pane", true, "1") }) + t.Run("AgentChangedEvent.Agent", func(t *testing.T) { + var missing AgentChangedEvent + if err := json.Unmarshal([]byte("{\"session\":null,\"source\":\"plugin\",\"state\":\"working\",\"surface\":1,\"updated_at_ms\":1}"), &missing); err != nil { + t.Fatal(err) + } + if !missing.Agent.IsAbsent() { + t.Fatal("Agent did not preserve absence") + } + assertGeneratedFieldJSON(t, missing, "agent", false, "") + var nullValue AgentChangedEvent + if err := json.Unmarshal([]byte("{\"session\":null,\"source\":\"plugin\",\"state\":\"working\",\"surface\":1,\"updated_at_ms\":1,\"agent\":null}"), &nullValue); err != nil { + t.Fatal(err) + } + if !nullValue.Agent.IsNull() { + t.Fatal("Agent did not preserve null") + } + assertGeneratedFieldJSON(t, nullValue, "agent", true, "null") + var presentValue AgentChangedEvent + if err := json.Unmarshal([]byte("{\"session\":null,\"source\":\"plugin\",\"state\":\"working\",\"surface\":1,\"updated_at_ms\":1,\"agent\":\"value\"}"), &presentValue); err != nil { + t.Fatal(err) + } + if _, ok := presentValue.Agent.Get(); !ok { + t.Fatal("Agent did not preserve a value") + } + assertGeneratedFieldJSON(t, presentValue, "agent", true, "\"value\"") + }) t.Run("AgentChangedEvent.Session", func(t *testing.T) { var missing AgentChangedEvent - if err := json.Unmarshal([]byte("{\"source\":\"detected\",\"state\":\"working\",\"surface\":1,\"updated_at_ms\":1}"), &missing); err == nil { + if err := json.Unmarshal([]byte("{\"source\":\"plugin\",\"state\":\"working\",\"surface\":1,\"updated_at_ms\":1}"), &missing); err == nil { t.Fatal("missing required nullable field session decoded successfully") } var nullValue AgentChangedEvent - if err := json.Unmarshal([]byte("{\"session\":null,\"source\":\"detected\",\"state\":\"working\",\"surface\":1,\"updated_at_ms\":1}"), &nullValue); err != nil { + if err := json.Unmarshal([]byte("{\"session\":null,\"source\":\"plugin\",\"state\":\"working\",\"surface\":1,\"updated_at_ms\":1}"), &nullValue); err != nil { t.Fatal(err) } if !nullValue.Session.IsSet() || !nullValue.Session.IsNull() { @@ -11705,7 +11757,7 @@ func TestGeneratedSchemaPresenceRoundTrips(t *testing.T) { } assertGeneratedFieldJSON(t, nullValue, "session", true, "null") var presentValue AgentChangedEvent - if err := json.Unmarshal([]byte("{\"session\":\"value\",\"source\":\"detected\",\"state\":\"working\",\"surface\":1,\"updated_at_ms\":1}"), &presentValue); err != nil { + if err := json.Unmarshal([]byte("{\"session\":\"value\",\"source\":\"plugin\",\"state\":\"working\",\"surface\":1,\"updated_at_ms\":1}"), &presentValue); err != nil { t.Fatal(err) } if _, ok := presentValue.Session.Get(); !ok { @@ -12944,7 +12996,7 @@ func TestGeneratedSchemaPresenceRoundTrips(t *testing.T) { func TestGeneratedRequiredFieldsRejectOmission(t *testing.T) { t.Run("AgentRecord.Session", func(t *testing.T) { var decoded AgentRecord - if err := json.Unmarshal([]byte("{\"source\":\"detected\",\"state\":\"working\",\"surface\":1,\"updated_at_ms\":1}"), &decoded); err == nil { + if err := json.Unmarshal([]byte("{\"source\":\"plugin\",\"state\":\"working\",\"surface\":1,\"updated_at_ms\":1}"), &decoded); err == nil { t.Fatal("missing required field session decoded successfully") } }) @@ -12956,19 +13008,19 @@ func TestGeneratedRequiredFieldsRejectOmission(t *testing.T) { }) t.Run("AgentRecord.State", func(t *testing.T) { var decoded AgentRecord - if err := json.Unmarshal([]byte("{\"session\":null,\"source\":\"detected\",\"surface\":1,\"updated_at_ms\":1}"), &decoded); err == nil { + if err := json.Unmarshal([]byte("{\"session\":null,\"source\":\"plugin\",\"surface\":1,\"updated_at_ms\":1}"), &decoded); err == nil { t.Fatal("missing required field state decoded successfully") } }) t.Run("AgentRecord.Surface", func(t *testing.T) { var decoded AgentRecord - if err := json.Unmarshal([]byte("{\"session\":null,\"source\":\"detected\",\"state\":\"working\",\"updated_at_ms\":1}"), &decoded); err == nil { + if err := json.Unmarshal([]byte("{\"session\":null,\"source\":\"plugin\",\"state\":\"working\",\"updated_at_ms\":1}"), &decoded); err == nil { t.Fatal("missing required field surface decoded successfully") } }) t.Run("AgentRecord.UpdatedAtMs", func(t *testing.T) { var decoded AgentRecord - if err := json.Unmarshal([]byte("{\"session\":null,\"source\":\"detected\",\"state\":\"working\",\"surface\":1}"), &decoded); err == nil { + if err := json.Unmarshal([]byte("{\"session\":null,\"source\":\"plugin\",\"state\":\"working\",\"surface\":1}"), &decoded); err == nil { t.Fatal("missing required field updated_at_ms decoded successfully") } }) @@ -16454,7 +16506,7 @@ func TestGeneratedRequiredFieldsRejectOmission(t *testing.T) { }) t.Run("AgentChangedEvent.Session", func(t *testing.T) { var decoded AgentChangedEvent - if err := json.Unmarshal([]byte("{\"source\":\"detected\",\"state\":\"working\",\"surface\":1,\"updated_at_ms\":1}"), &decoded); err == nil { + if err := json.Unmarshal([]byte("{\"source\":\"plugin\",\"state\":\"working\",\"surface\":1,\"updated_at_ms\":1}"), &decoded); err == nil { t.Fatal("missing required field session decoded successfully") } }) @@ -16466,19 +16518,19 @@ func TestGeneratedRequiredFieldsRejectOmission(t *testing.T) { }) t.Run("AgentChangedEvent.State", func(t *testing.T) { var decoded AgentChangedEvent - if err := json.Unmarshal([]byte("{\"session\":null,\"source\":\"detected\",\"surface\":1,\"updated_at_ms\":1}"), &decoded); err == nil { + if err := json.Unmarshal([]byte("{\"session\":null,\"source\":\"plugin\",\"surface\":1,\"updated_at_ms\":1}"), &decoded); err == nil { t.Fatal("missing required field state decoded successfully") } }) t.Run("AgentChangedEvent.Surface", func(t *testing.T) { var decoded AgentChangedEvent - if err := json.Unmarshal([]byte("{\"session\":null,\"source\":\"detected\",\"state\":\"working\",\"updated_at_ms\":1}"), &decoded); err == nil { + if err := json.Unmarshal([]byte("{\"session\":null,\"source\":\"plugin\",\"state\":\"working\",\"updated_at_ms\":1}"), &decoded); err == nil { t.Fatal("missing required field surface decoded successfully") } }) t.Run("AgentChangedEvent.UpdatedAtMs", func(t *testing.T) { var decoded AgentChangedEvent - if err := json.Unmarshal([]byte("{\"session\":null,\"source\":\"detected\",\"state\":\"working\",\"surface\":1}"), &decoded); err == nil { + if err := json.Unmarshal([]byte("{\"session\":null,\"source\":\"plugin\",\"state\":\"working\",\"surface\":1}"), &decoded); err == nil { t.Fatal("missing required field updated_at_ms decoded successfully") } }) @@ -17417,19 +17469,19 @@ func TestGeneratedRequiredNonnullableFieldsRejectNull(t *testing.T) { }) t.Run("AgentRecord.State", func(t *testing.T) { var decoded AgentRecord - if err := json.Unmarshal([]byte("{\"session\":null,\"source\":\"detected\",\"state\":null,\"surface\":1,\"updated_at_ms\":1}"), &decoded); err == nil { + if err := json.Unmarshal([]byte("{\"session\":null,\"source\":\"plugin\",\"state\":null,\"surface\":1,\"updated_at_ms\":1}"), &decoded); err == nil { t.Fatal("required non-nullable field state accepted null") } }) t.Run("AgentRecord.Surface", func(t *testing.T) { var decoded AgentRecord - if err := json.Unmarshal([]byte("{\"session\":null,\"source\":\"detected\",\"state\":\"working\",\"surface\":null,\"updated_at_ms\":1}"), &decoded); err == nil { + if err := json.Unmarshal([]byte("{\"session\":null,\"source\":\"plugin\",\"state\":\"working\",\"surface\":null,\"updated_at_ms\":1}"), &decoded); err == nil { t.Fatal("required non-nullable field surface accepted null") } }) t.Run("AgentRecord.UpdatedAtMs", func(t *testing.T) { var decoded AgentRecord - if err := json.Unmarshal([]byte("{\"session\":null,\"source\":\"detected\",\"state\":\"working\",\"surface\":1,\"updated_at_ms\":null}"), &decoded); err == nil { + if err := json.Unmarshal([]byte("{\"session\":null,\"source\":\"plugin\",\"state\":\"working\",\"surface\":1,\"updated_at_ms\":null}"), &decoded); err == nil { t.Fatal("required non-nullable field updated_at_ms accepted null") } }) @@ -20525,19 +20577,19 @@ func TestGeneratedRequiredNonnullableFieldsRejectNull(t *testing.T) { }) t.Run("AgentChangedEvent.State", func(t *testing.T) { var decoded AgentChangedEvent - if err := json.Unmarshal([]byte("{\"session\":null,\"source\":\"detected\",\"state\":null,\"surface\":1,\"updated_at_ms\":1}"), &decoded); err == nil { + if err := json.Unmarshal([]byte("{\"session\":null,\"source\":\"plugin\",\"state\":null,\"surface\":1,\"updated_at_ms\":1}"), &decoded); err == nil { t.Fatal("required non-nullable field state accepted null") } }) t.Run("AgentChangedEvent.Surface", func(t *testing.T) { var decoded AgentChangedEvent - if err := json.Unmarshal([]byte("{\"session\":null,\"source\":\"detected\",\"state\":\"working\",\"surface\":null,\"updated_at_ms\":1}"), &decoded); err == nil { + if err := json.Unmarshal([]byte("{\"session\":null,\"source\":\"plugin\",\"state\":\"working\",\"surface\":null,\"updated_at_ms\":1}"), &decoded); err == nil { t.Fatal("required non-nullable field surface accepted null") } }) t.Run("AgentChangedEvent.UpdatedAtMs", func(t *testing.T) { var decoded AgentChangedEvent - if err := json.Unmarshal([]byte("{\"session\":null,\"source\":\"detected\",\"state\":\"working\",\"surface\":1,\"updated_at_ms\":null}"), &decoded); err == nil { + if err := json.Unmarshal([]byte("{\"session\":null,\"source\":\"plugin\",\"state\":\"working\",\"surface\":1,\"updated_at_ms\":null}"), &decoded); err == nil { t.Fatal("required non-nullable field updated_at_ms accepted null") } }) @@ -21392,7 +21444,7 @@ func TestGeneratedConstrainedFieldsRejectUnknownValues(t *testing.T) { }) t.Run("AgentRecord.State", func(t *testing.T) { var decoded AgentRecord - if err := json.Unmarshal([]byte("{\"session\":null,\"source\":\"detected\",\"state\":\"__cmux_invalid__\",\"surface\":1,\"updated_at_ms\":1}"), &decoded); err == nil { + if err := json.Unmarshal([]byte("{\"session\":null,\"source\":\"plugin\",\"state\":\"__cmux_invalid__\",\"surface\":1,\"updated_at_ms\":1}"), &decoded); err == nil { t.Fatal("invalid constrained field state decoded successfully") } }) @@ -21866,7 +21918,7 @@ func TestGeneratedConstrainedFieldsRejectUnknownValues(t *testing.T) { }) t.Run("AgentChangedEvent.State", func(t *testing.T) { var decoded AgentChangedEvent - if err := json.Unmarshal([]byte("{\"session\":null,\"source\":\"detected\",\"state\":\"__cmux_invalid__\",\"surface\":1,\"updated_at_ms\":1}"), &decoded); err == nil { + if err := json.Unmarshal([]byte("{\"session\":null,\"source\":\"plugin\",\"state\":\"__cmux_invalid__\",\"surface\":1,\"updated_at_ms\":1}"), &decoded); err == nil { t.Fatal("invalid constrained field state decoded successfully") } }) @@ -21917,7 +21969,7 @@ func TestGeneratedConstrainedFieldsRejectUnknownValues(t *testing.T) { func TestGeneratedConstrainedFieldsRejectUnknownValuesOnMarshal(t *testing.T) { t.Run("AgentRecord.Source", func(t *testing.T) { var decoded AgentRecord - if err := json.Unmarshal([]byte("{\"session\":null,\"source\":\"detected\",\"state\":\"working\",\"surface\":1,\"updated_at_ms\":1}"), &decoded); err != nil { + if err := json.Unmarshal([]byte("{\"session\":null,\"source\":\"plugin\",\"state\":\"working\",\"surface\":1,\"updated_at_ms\":1}"), &decoded); err != nil { t.Fatal(err) } decoded.Source = AgentSource("__cmux_invalid__") @@ -21927,7 +21979,7 @@ func TestGeneratedConstrainedFieldsRejectUnknownValuesOnMarshal(t *testing.T) { }) t.Run("AgentRecord.State", func(t *testing.T) { var decoded AgentRecord - if err := json.Unmarshal([]byte("{\"session\":null,\"source\":\"detected\",\"state\":\"working\",\"surface\":1,\"updated_at_ms\":1}"), &decoded); err != nil { + if err := json.Unmarshal([]byte("{\"session\":null,\"source\":\"plugin\",\"state\":\"working\",\"surface\":1,\"updated_at_ms\":1}"), &decoded); err != nil { t.Fatal(err) } decoded.State = AgentState("__cmux_invalid__") @@ -22710,7 +22762,7 @@ func TestGeneratedConstrainedFieldsRejectUnknownValuesOnMarshal(t *testing.T) { }) t.Run("AgentChangedEvent.Source", func(t *testing.T) { var decoded AgentChangedEvent - if err := json.Unmarshal([]byte("{\"session\":null,\"source\":\"detected\",\"state\":\"working\",\"surface\":1,\"updated_at_ms\":1}"), &decoded); err != nil { + if err := json.Unmarshal([]byte("{\"session\":null,\"source\":\"plugin\",\"state\":\"working\",\"surface\":1,\"updated_at_ms\":1}"), &decoded); err != nil { t.Fatal(err) } decoded.Source = AgentSource("__cmux_invalid__") @@ -22720,7 +22772,7 @@ func TestGeneratedConstrainedFieldsRejectUnknownValuesOnMarshal(t *testing.T) { }) t.Run("AgentChangedEvent.State", func(t *testing.T) { var decoded AgentChangedEvent - if err := json.Unmarshal([]byte("{\"session\":null,\"source\":\"detected\",\"state\":\"working\",\"surface\":1,\"updated_at_ms\":1}"), &decoded); err != nil { + if err := json.Unmarshal([]byte("{\"session\":null,\"source\":\"plugin\",\"state\":\"working\",\"surface\":1,\"updated_at_ms\":1}"), &decoded); err != nil { t.Fatal(err) } decoded.State = AgentState("__cmux_invalid__") @@ -22802,8 +22854,8 @@ func TestGeneratedConstrainedFieldsRejectUnknownValuesOnMarshal(t *testing.T) { } const ( - generatedFieldShapeCount = 523 - generatedOptionalNullableFieldCount = 347 + generatedFieldShapeCount = 525 + generatedOptionalNullableFieldCount = 349 generatedRequiredNullableFieldCount = 82 generatedOptionalNonnullableFieldCount = 94 generatedRequiredFieldCount = 744 diff --git a/cmux-tui/bindings/go/raw/generated_types.go b/cmux-tui/bindings/go/raw/generated_types.go index 8caa0a08277b..f95844f79057 100644 --- a/cmux-tui/bindings/go/raw/generated_types.go +++ b/cmux-tui/bindings/go/raw/generated_types.go @@ -1,5 +1,5 @@ // Code generated by cmux-tui SDK codegen. DO NOT EDIT. -// Mux protocol 12; IR SHA-256 7042c629f34d3606581d07b2d2c03b65116c2467810724163c54674865825cc0. +// Mux protocol 12; IR SHA-256 133bac0154f8f94aa30e40c11ff7ed38b10dd4d82974aec87c02d404fcd12619. package raw @@ -248,6 +248,7 @@ func (value *AgentReportSource) UnmarshalJSON(data []byte) error { type AgentSource string const ( + AgentSourcePlugin AgentSource = "plugin" AgentSourceDetected AgentSource = "detected" AgentSourceSocket AgentSource = "socket" AgentSourceHook AgentSource = "hook" @@ -255,7 +256,7 @@ const ( func (value AgentSource) valid() bool { switch value { - case AgentSourceDetected, AgentSourceSocket, AgentSourceHook: + case AgentSourcePlugin, AgentSourceDetected, AgentSourceSocket, AgentSourceHook: return true default: return false @@ -4445,8 +4446,10 @@ type ProcessInfoResult struct { Command RequiredNullable[string] `json:"-"` Cwd RequiredNullable[string] `json:"-"` // Working directory of the process group that owns the PTY, read at request time. Null when the lookup fails; absent from daemons that predate the field. Clients treat absence as null. - ForegroundCwd Presence[string] `json:"-"` - PID RequiredNullable[uint32] `json:"-"` + ForegroundCwd Presence[string] `json:"-"` + // Executable path or name of the PTY foreground process-group leader, read at request time. Null when the lookup fails; absent from daemons that predate the field. Clients treat absence as null. + ForegroundExecutable Presence[string] `json:"-"` + PID RequiredNullable[uint32] `json:"-"` } func (value ProcessInfoResult) MarshalJSON() ([]byte, error) { @@ -4497,6 +4500,18 @@ func (value ProcessInfoResult) MarshalJSON() ([]byte, error) { } object["foreground_cwd"] = encodedField } + if value.ForegroundExecutable.IsAbsent() { + delete(object, "foreground_executable") + } else if value.ForegroundExecutable.IsNull() { + object["foreground_executable"] = json.RawMessage("null") + } else { + fieldValue, _ := value.ForegroundExecutable.Get() + encodedField, err := json.Marshal(fieldValue) + if err != nil { + return nil, fmt.Errorf("encode ProcessInfoResult.ForegroundExecutable: %w", err) + } + object["foreground_executable"] = encodedField + } if !value.PID.IsSet() { return nil, fmt.Errorf("encode ProcessInfoResult: required nullable field pid is missing") } @@ -4518,10 +4533,11 @@ func (value *ProcessInfoResult) UnmarshalJSON(data []byte) error { return fmt.Errorf("decode ProcessInfoResult: expected object") } var fields struct { - Command RequiredNullable[string] `json:"command"` - Cwd RequiredNullable[string] `json:"cwd"` - ForegroundCwd Presence[string] `json:"foreground_cwd"` - PID RequiredNullable[uint32] `json:"pid"` + Command RequiredNullable[string] `json:"command"` + Cwd RequiredNullable[string] `json:"cwd"` + ForegroundCwd Presence[string] `json:"foreground_cwd"` + ForegroundExecutable Presence[string] `json:"foreground_executable"` + PID RequiredNullable[uint32] `json:"pid"` } if err := json.Unmarshal(data, &fields); err != nil { return fmt.Errorf("decode ProcessInfoResult: %w", err) @@ -4537,6 +4553,7 @@ func (value *ProcessInfoResult) UnmarshalJSON(data []byte) error { } decoded.Cwd = fields.Cwd decoded.ForegroundCwd = fields.ForegroundCwd + decoded.ForegroundExecutable = fields.ForegroundExecutable if !fields.PID.IsSet() { return fmt.Errorf("decode ProcessInfoResult: required field pid is missing") } diff --git a/cmux-tui/bindings/go/resource_api_test.go b/cmux-tui/bindings/go/resource_api_test.go index 97ebae02739b..a8e8d0421d73 100644 --- a/cmux-tui/bindings/go/resource_api_test.go +++ b/cmux-tui/bindings/go/resource_api_test.go @@ -471,6 +471,56 @@ func TestCatalogResultsDecodeStrictly(t *testing.T) { } } +func TestJournalResultDecodingEnforcesProducerBoundsAndIdentity(t *testing.T) { + manifest := JournalProducerManifest{ + ProducerID: "screen-detector", + Namespace: "plugin.screen-detector", + ManifestVersion: 1, + MaxSensitivity: JournalSensitivityMetadata, + Permissions: []string{"journal.append.plugin.screen-detector"}, + Events: []JournalEventSchema{{ + Kind: "plugin.screen-detector.agent.state.changed", + SchemaVersion: 1, + Class: JournalClassState, + Replay: JournalReplayRequired, + Sensitivity: JournalSensitivityMetadata, + PayloadSchema: map[string]any{"type": "object"}, + }}, + } + tooMany := make([]JournalProducerManifest, maxJournalProducerCount+1) + for index := range tooMany { + tooMany[index] = manifest + } + raw, err := json.Marshal(JournalProducerListResult{Producers: tooMany}) + if err != nil { + t.Fatalf("marshal oversized producer list: %v", err) + } + if _, err := decodeValue[JournalProducerListResult](raw, "journal producer list"); !errors.Is(err, ErrProtocol) { + t.Fatalf("oversized producer list error = %T %v", err, err) + } + + if _, err := decodeValue[JournalProducerPutResult](json.RawMessage( + `{"producer_id":"screen-detector","manifest_version":1,"namespace":"plugin.other","sequence":"1","event_id":"event-1"}`, + ), "journal producer result"); !errors.Is(err, ErrProtocol) { + t.Fatalf("malformed put result error = %T %v", err, err) + } + if _, err := decodeValue[JournalAppendResult](json.RawMessage( + `{"producer_id":"screen!detector","sequence":"1","event_id":"event-1"}`, + ), "journal append result"); !errors.Is(err, ErrProtocol) { + t.Fatalf("malformed append result error = %T %v", err, err) + } + invalidIngress := JournalIngress{ + ProducerID: "screen-detector", + ManifestVersion: 1, + Kind: "agent.state.changed", + SchemaVersion: 1, + Payload: map[string]any{}, + } + if err := invalidIngress.Validate(); !errors.Is(err, ErrInvalidArgument) { + t.Fatalf("out-of-namespace ingress validation = %T %v", err, err) + } +} + func TestTerminalSnapshotsRejectMalformedTabIdentities(t *testing.T) { const tabID = "tab_00000000000000000000000000000006" tests := []struct { @@ -1104,6 +1154,131 @@ func TestSessionReportAgentUsesOnlySessionRoute(t *testing.T) { } } +func TestUserlandAgentPluginUsesGenericJournalContract(t *testing.T) { + manifest := JournalProducerManifest{ + ProducerID: "screen-detector", + Namespace: "plugin.screen-detector", + ManifestVersion: 1, + MaxSensitivity: JournalSensitivityMetadata, + Permissions: []string{"journal.append.plugin.screen-detector"}, + Events: []JournalEventSchema{{ + Kind: "plugin.screen-detector.agent.state.changed", + SchemaVersion: 1, + Class: JournalClassState, + Replay: JournalReplayRequired, + Sensitivity: JournalSensitivityMetadata, + PayloadSchema: map[string]any{"type": "object"}, + }}, + } + if err := manifest.Validate(); err != nil { + t.Fatalf("manifest validation: %v", err) + } + + agent, err := decodeValue[AgentSnapshot](json.RawMessage( + `{"id":"agent_00000000000000000000000000000008",`+ + `"session_id":"session_00000000000000000000000000000002",`+ + `"terminal_id":"term_00000000000000000000000000000007",`+ + `"state":"working","source":"plugin","updated_at_ms":"10",`+ + `"source_session":"pid:42"}`, + ), "agent snapshot") + if err != nil || agent.Source != AgentSourcePlugin { + t.Fatalf("plugin agent snapshot = %#v, %v", agent, err) + } + screen, err := decodeValue[TerminalScreenResult](json.RawMessage( + `{"text":"working","revision":"42","osc_progress":"4;1;50",`+ + `"cols":80,"rows":24,"cursor_row":0,"cursor_col":7,"cursor_visible":true}`, + ), "terminal screen") + if err != nil || screen.Revision == nil || screen.Revision.String() != "42" || + screen.OSCProgress == nil || *screen.OSCProgress != "4;1;50" { + t.Fatalf("plugin terminal metadata = %#v, %v", screen, err) + } + + client, requests := pipeClient(t, nil, 3) + defer client.Close(context.Background()) //nolint:errcheck + session := client.Machine(SelectID(testMachineID)).Session(SelectID(testSessionID)) + producers, err := session.ListJournalProducers( + context.Background(), SessionJournalProducerListOptions{}, + ) + if err != nil || len(producers) != 1 || producers[0].ProducerID != manifest.ProducerID { + t.Fatalf("producer list = %#v, %v", producers, err) + } + put, err := session.PutJournalProducer( + context.Background(), manifest, + MutationOptions{ + IdempotencyKey: "producer-put", + Extra: map[string]JSONValue{ + "future_put": "kept", + // Typed fields must win over forward-compatible extras. + "manifest": map[string]any{"wrong": true}, + }, + }, + ) + if err != nil || put.Value.EventID != "event-11" { + t.Fatalf("producer put = %#v, %v", put, err) + } + event := JournalIngress{ + ProducerID: manifest.ProducerID, + ManifestVersion: 1, + Kind: manifest.Events[0].Kind, + SchemaVersion: 1, + OccurredAtMS: func() *Decimal { value := Decimal(10); return &value }(), + Subjects: []JournalSubject{{Kind: "agent", ID: string(testAgentID)}}, + Payload: map[string]any{"state": "working"}, + } + appendResult, err := session.AppendJournal( + context.Background(), + event, + MutationOptions{ + IdempotencyKey: "event-append", + Extra: map[string]JSONValue{ + "future_append": "kept", + // Typed fields must win over forward-compatible extras. + "event": map[string]any{"wrong": true}, + }, + }, + ) + if err != nil || appendResult.Value.EventID != "event-13" { + t.Fatalf("journal append = %#v, %v", appendResult, err) + } + + requestsByOperation := make(map[string]map[string]any, 3) + for index := 0; index < 3; index++ { + request := <-requests + if request["operation"] == nil { + t.Fatalf("request %d omitted operation: %#v", index, request) + } + requestsByOperation[request["operation"].(string)] = request + } + putRequest := requestsByOperation["session.journal.producer.put"] + if putRequest == nil { + t.Fatalf("journal producer put request was not observed: %#v", requestsByOperation) + } + if params := requestParams(t, putRequest); params["future_put"] != "kept" { + t.Fatalf("put extra field = %#v, want kept", params["future_put"]) + } + manifestValue, ok := requestParams(t, putRequest)["manifest"].(map[string]any) + if !ok { + t.Fatalf("put typed manifest was replaced by Extra: %#v", putRequest) + } + if manifestValue["producer_id"] != manifest.ProducerID { + t.Fatalf("put manifest producer_id = %#v, want %q", manifestValue["producer_id"], manifest.ProducerID) + } + appendRequest := requestsByOperation["session.journal.append"] + if appendRequest == nil { + t.Fatalf("journal append request was not observed: %#v", requestsByOperation) + } + if params := requestParams(t, appendRequest); params["future_append"] != "kept" { + t.Fatalf("append extra field = %#v, want kept", params["future_append"]) + } + eventValue, ok := requestParams(t, appendRequest)["event"].(map[string]any) + if !ok { + t.Fatalf("append typed event was replaced by Extra: %#v", appendRequest) + } + if eventValue["producer_id"] != event.ProducerID { + t.Fatalf("append event producer_id = %#v, want %q", eventValue["producer_id"], event.ProducerID) + } +} + func TestKnownResourceChangesAreTypedAndNeverDowngradeToUnknown(t *testing.T) { machine := map[string]any{ "id": testMachineID, @@ -4451,6 +4626,63 @@ func pipeClient( "source_session": "codex-task-42", }, } + case "session.journal.producer.list": + result = map[string]any{ + "producers": []any{ + map[string]any{ + "producer_id": "screen-detector", + "namespace": "plugin.screen-detector", + "manifest_version": 1, + "max_sensitivity": "metadata", + "permissions": []string{"journal.append.plugin.screen-detector"}, + "events": []any{ + map[string]any{ + "kind": "plugin.screen-detector.agent.state.changed", + "schema_version": 1, + "class": "state", + "replay": "required", + "sensitivity": "metadata", + "payload_schema": map[string]any{"type": "object"}, + }, + }, + }, + }, + } + case "session.journal.producer.put": + result = map[string]any{ + "generation": "g", + "revision": "12", + "replayed": false, + "value": map[string]any{ + "producer_id": "screen-detector", + "manifest_version": 1, + "namespace": "plugin.screen-detector", + "sequence": "11", + "event_id": "event-11", + }, + } + case "session.journal.append": + result = map[string]any{ + "generation": "g", + "revision": "14", + "replayed": false, + "value": map[string]any{ + "producer_id": "screen-detector", + "sequence": "13", + "event_id": "event-13", + }, + } + case "terminal.screen.read": + result = map[string]any{ + "text": "working", + "revision": "42", + "osc_progress": "4;1;50", + "cols": 80, + "rows": 24, + "cursor_row": 0, + "cursor_col": 7, + "cursor_visible": true, + } case "terminal.project": result = map[string]any{ "generation": "g", diff --git a/cmux-tui/bindings/go/resources.go b/cmux-tui/bindings/go/resources.go index 43926cb0e510..a2181e10b7c1 100644 --- a/cmux-tui/bindings/go/resources.go +++ b/cmux-tui/bindings/go/resources.go @@ -563,6 +563,7 @@ const ( AgentSourceHook AgentSource = "hook" AgentSourceSocket AgentSource = "socket" AgentSourceDetected AgentSource = "detected" + AgentSourcePlugin AgentSource = "plugin" ) // AgentReportSource excludes detected, which is server-owned discovery state. diff --git a/cmux-tui/bindings/go/values.go b/cmux-tui/bindings/go/values.go index e2b6f9043b91..417e87f956cb 100644 --- a/cmux-tui/bindings/go/values.go +++ b/cmux-tui/bindings/go/values.go @@ -326,7 +326,12 @@ type PaneNeighborResult struct { } type TerminalScreenResult struct { - Text string `json:"text"` + Text string `json:"text"` + // Revision is the coalesced PTY output counter. Older servers may omit it or send null. + Revision *Decimal `json:"revision,omitempty"` + // OSCProgress is bounded terminal metadata. The daemon does not assign + // agent meaning to this value. + OSCProgress *string `json:"osc_progress,omitempty"` Cols uint16 `json:"cols"` Rows uint16 `json:"rows"` CursorRow uint16 `json:"cursor_row"` @@ -623,6 +628,9 @@ type ProcessInfoResult struct { // when an older server omits the field. ForegroundCWD *string `json:"foreground_cwd,omitempty"` Children []uint32 `json:"children"` + // ForegroundExecutable is the path or name of the PTY foreground process-group + // leader. It is nil when the lookup fails or an older server omits it. + ForegroundExecutable *string `json:"foreground_executable,omitempty"` } type CellPixelsResult struct { @@ -843,6 +851,59 @@ type SessionJournalRecord struct { PreviousResourceRevision *Decimal } +// JournalEventSchema declares one event kind owned by a userland producer. +type JournalEventSchema struct { + Kind string `json:"kind"` + SchemaVersion uint32 `json:"schema_version"` + Class JournalClass `json:"class"` + Replay JournalReplayPolicy `json:"replay"` + Sensitivity JournalSensitivity `json:"sensitivity"` + PayloadSchema JSONValue `json:"payload_schema"` +} + +// JournalProducerManifest is the generic registration contract for a +// userland journal producer. It is intentionally independent of agent names. +type JournalProducerManifest struct { + ProducerID string `json:"producer_id"` + Namespace string `json:"namespace"` + ManifestVersion uint32 `json:"manifest_version"` + MaxSensitivity JournalSensitivity `json:"max_sensitivity"` + Permissions []string `json:"permissions"` + Events []JournalEventSchema `json:"events"` +} + +// JournalIngress is one event submitted by a registered producer. +type JournalIngress struct { + ProducerID string `json:"producer_id"` + ManifestVersion uint32 `json:"manifest_version"` + Kind string `json:"kind"` + SchemaVersion uint32 `json:"schema_version"` + OccurredAtMS *Decimal `json:"occurred_at_ms,omitempty"` + Subjects []JournalSubject `json:"subjects,omitempty"` + Sensitivity *JournalSensitivity `json:"sensitivity,omitempty"` + Payload JSONValue `json:"payload"` + CausationID *string `json:"causation_id,omitempty"` + CorrelationID *string `json:"correlation_id,omitempty"` +} + +type JournalProducerPutResult struct { + ProducerID string `json:"producer_id"` + ManifestVersion uint32 `json:"manifest_version"` + Namespace string `json:"namespace"` + Sequence Decimal `json:"sequence"` + EventID string `json:"event_id"` +} + +type JournalProducerListResult struct { + Producers []JournalProducerManifest `json:"producers"` +} + +type JournalAppendResult struct { + ProducerID string `json:"producer_id"` + Sequence Decimal `json:"sequence"` + EventID string `json:"event_id"` +} + type TerminalAttachmentItem struct { Kind string TerminalID TerminalID diff --git a/cmux-tui/bindings/java/.cmux-resource-api.json b/cmux-tui/bindings/java/.cmux-resource-api.json index 4c4481343150..0667d105f967 100644 --- a/cmux-tui/bindings/java/.cmux-resource-api.json +++ b/cmux-tui/bindings/java/.cmux-resource-api.json @@ -1,5 +1,5 @@ { - "catalog_sha256": "beef8293ded489648261ccddfd31b3f796f9d7d10506f13f6c5d1577a3f4fbac", + "catalog_sha256": "08a8190787e1b38d0592b85856d791fb0fe098d58eddce85ee98ecf88cc5a1a7", "operations": { "agent.list": { "class": "read" diff --git a/cmux-tui/bindings/java/src/com/cmux/Client.java b/cmux-tui/bindings/java/src/com/cmux/Client.java index efd0b1259364..c37138608b39 100644 --- a/cmux-tui/bindings/java/src/com/cmux/Client.java +++ b/cmux-tui/bindings/java/src/com/cmux/Client.java @@ -3092,6 +3092,8 @@ static Results.TerminalScreenResult decodeTerminalScreen(Object value) { fields, "terminal screen result", Wire.TEXT, + Wire.REVISION, + "osc_progress", Wire.COLS, Wire.ROWS, "cursor_row", @@ -3101,6 +3103,12 @@ static Results.TerminalScreenResult decodeTerminalScreen(Object value) { ); return new Results.TerminalScreenResult( Wire.string(fields.get(Wire.TEXT), "terminal screen text"), + fields.get(Wire.REVISION) == null + ? Optional.empty() + : Optional.of(Wire.decimal(fields.get(Wire.REVISION), "terminal screen revision")), + fields.get("osc_progress") == null + ? Optional.empty() + : Optional.of(Wire.string(fields.get("osc_progress"), "terminal screen osc_progress")), positiveUint16(fields, Wire.COLS), positiveUint16(fields, Wire.ROWS), uint16(fields, "cursor_row"), @@ -3320,6 +3328,7 @@ static Results.ProcessInfoResult decodeProcessInfo(Object value) { Wire.ARGV, Wire.CWD, "foreground_cwd", + "foreground_executable", "children" ); return new Results.ProcessInfoResult( @@ -3334,7 +3343,10 @@ static Results.ProcessInfoResult decodeProcessInfo(Object value) { : Optional.empty(), Wire.array(fields.get("children"), "process children").stream() .map(item -> uint32(item, "process child")) - .toList() + .toList(), + fields.containsKey("foreground_executable") + ? requiredNullableString(fields, "foreground_executable") + : Optional.empty() ); } diff --git a/cmux-tui/bindings/java/src/com/cmux/JournalAppendResult.java b/cmux-tui/bindings/java/src/com/cmux/JournalAppendResult.java new file mode 100644 index 000000000000..ae53715db1ed --- /dev/null +++ b/cmux-tui/bindings/java/src/com/cmux/JournalAppendResult.java @@ -0,0 +1,16 @@ +package com.cmux; + +import java.util.Objects; + +/** Receipt returned after a journal event is appended. */ +public record JournalAppendResult( + String producerId, + Decimal sequence, + String eventId +) { + public JournalAppendResult { + Objects.requireNonNull(producerId, "producerId"); + Objects.requireNonNull(sequence, "sequence"); + Objects.requireNonNull(eventId, "eventId"); + } +} diff --git a/cmux-tui/bindings/java/src/com/cmux/JournalEventSchema.java b/cmux-tui/bindings/java/src/com/cmux/JournalEventSchema.java new file mode 100644 index 000000000000..178f9d53d5e3 --- /dev/null +++ b/cmux-tui/bindings/java/src/com/cmux/JournalEventSchema.java @@ -0,0 +1,21 @@ +package com.cmux; + +import java.util.Objects; + +/** One event schema declared by a userland journal producer. */ +public record JournalEventSchema( + String kind, + long schemaVersion, + SessionJournalRecord.JournalClass journalClass, + SessionJournalRecord.ReplayPolicy replay, + SessionJournalRecord.Sensitivity sensitivity, + JsonValue payloadSchema +) { + public JournalEventSchema { + Objects.requireNonNull(kind, "kind"); + Objects.requireNonNull(journalClass, "journalClass"); + Objects.requireNonNull(replay, "replay"); + Objects.requireNonNull(sensitivity, "sensitivity"); + Objects.requireNonNull(payloadSchema, "payloadSchema"); + } +} diff --git a/cmux-tui/bindings/java/src/com/cmux/JournalIngress.java b/cmux-tui/bindings/java/src/com/cmux/JournalIngress.java new file mode 100644 index 000000000000..12ce750fd2e8 --- /dev/null +++ b/cmux-tui/bindings/java/src/com/cmux/JournalIngress.java @@ -0,0 +1,34 @@ +package com.cmux; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +/** Generic journal event envelope emitted by a userland producer. */ +public record JournalIngress( + String producerId, + long manifestVersion, + String kind, + long schemaVersion, + Optional occurredAtMs, + List subjects, + Optional sensitivity, + JsonValue payload, + Optional causationId, + Optional correlationId +) { + public JournalIngress { + Objects.requireNonNull(producerId, "producerId"); + Objects.requireNonNull(kind, "kind"); + occurredAtMs = occurredAtMs == null ? Optional.empty() : occurredAtMs; + subjects = subjects == null ? List.of() : List.copyOf(subjects); + sensitivity = sensitivity == null ? Optional.empty() : sensitivity; + Objects.requireNonNull(payload, "payload"); + causationId = causationId == null ? Optional.empty() : causationId; + correlationId = correlationId == null ? Optional.empty() : correlationId; + } + + public java.util.Map toWire() { + return JournalWire.ingress(this); + } +} diff --git a/cmux-tui/bindings/java/src/com/cmux/JournalProducerListResult.java b/cmux-tui/bindings/java/src/com/cmux/JournalProducerListResult.java new file mode 100644 index 000000000000..d6f3ce70d6ed --- /dev/null +++ b/cmux-tui/bindings/java/src/com/cmux/JournalProducerListResult.java @@ -0,0 +1,13 @@ +package com.cmux; + +import java.util.List; + +/** Installed generic journal producer manifests. */ +public record JournalProducerListResult(List producers) { + public JournalProducerListResult { + producers = producers == null ? List.of() : List.copyOf(producers); + if (producers.size() > 1024) { + throw new IllegalArgumentException("journal producer list contains too many entries"); + } + } +} diff --git a/cmux-tui/bindings/java/src/com/cmux/JournalProducerManifest.java b/cmux-tui/bindings/java/src/com/cmux/JournalProducerManifest.java new file mode 100644 index 000000000000..8739aedebf7f --- /dev/null +++ b/cmux-tui/bindings/java/src/com/cmux/JournalProducerManifest.java @@ -0,0 +1,26 @@ +package com.cmux; + +import java.util.List; +import java.util.Objects; + +/** Manifest installed by a userland journal producer. */ +public record JournalProducerManifest( + String producerId, + String namespace, + long manifestVersion, + SessionJournalRecord.Sensitivity maxSensitivity, + List permissions, + List events +) { + public JournalProducerManifest { + Objects.requireNonNull(producerId, "producerId"); + Objects.requireNonNull(namespace, "namespace"); + Objects.requireNonNull(maxSensitivity, "maxSensitivity"); + permissions = permissions == null ? List.of() : List.copyOf(permissions); + events = events == null ? List.of() : List.copyOf(events); + } + + public java.util.Map toWire() { + return JournalWire.manifest(this); + } +} diff --git a/cmux-tui/bindings/java/src/com/cmux/JournalProducerPutResult.java b/cmux-tui/bindings/java/src/com/cmux/JournalProducerPutResult.java new file mode 100644 index 000000000000..fe517a2a7705 --- /dev/null +++ b/cmux-tui/bindings/java/src/com/cmux/JournalProducerPutResult.java @@ -0,0 +1,19 @@ +package com.cmux; + +import java.util.Objects; + +/** Receipt returned after a producer manifest is installed. */ +public record JournalProducerPutResult( + String producerId, + long manifestVersion, + String namespace, + Decimal sequence, + String eventId +) { + public JournalProducerPutResult { + Objects.requireNonNull(producerId, "producerId"); + Objects.requireNonNull(namespace, "namespace"); + Objects.requireNonNull(sequence, "sequence"); + Objects.requireNonNull(eventId, "eventId"); + } +} diff --git a/cmux-tui/bindings/java/src/com/cmux/JournalWire.java b/cmux-tui/bindings/java/src/com/cmux/JournalWire.java new file mode 100644 index 000000000000..e9d588d1d08e --- /dev/null +++ b/cmux-tui/bindings/java/src/com/cmux/JournalWire.java @@ -0,0 +1,303 @@ +package com.cmux; + +import com.cmux.internal.Wire; +import com.cmux.raw.Json; +import java.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** Internal codec and structural validation for generic journal producers. */ +final class JournalWire { + private static final int MAX_MANIFEST_BYTES = 1_048_576; + + private JournalWire() {} + + static Map manifest(JournalProducerManifest value) { + validate(value); + return manifestFields(value); + } + + private static Map manifestFields(JournalProducerManifest value) { + Map result = Wire.map(); + result.put("producer_id", value.producerId()); + result.put("namespace", value.namespace()); + result.put("manifest_version", value.manifestVersion()); + result.put("max_sensitivity", value.maxSensitivity().name().toLowerCase(java.util.Locale.ROOT)); + result.put("permissions", value.permissions()); + result.put("events", value.events().stream().map(JournalWire::eventSchema).toList()); + return result; + } + + static Map ingress(JournalIngress value) { + validate(value); + Map result = Wire.map(); + result.put("producer_id", value.producerId()); + result.put("manifest_version", value.manifestVersion()); + result.put("kind", value.kind()); + result.put("schema_version", value.schemaVersion()); + value.occurredAtMs().ifPresent(item -> result.put("occurred_at_ms", item)); + if (!value.subjects().isEmpty()) { + result.put("subjects", value.subjects().stream().map(subject -> Map.of( + "kind", subject.kind(), "id", subject.id() + )).toList()); + } + value.sensitivity().ifPresent(item -> result.put( + "sensitivity", item.name().toLowerCase(java.util.Locale.ROOT) + )); + result.put("payload", value.payload().value()); + value.causationId().ifPresent(item -> result.put("causation_id", item)); + value.correlationId().ifPresent(item -> result.put("correlation_id", item)); + return result; + } + + private static Map eventSchema(JournalEventSchema value) { + Map result = Wire.map(); + result.put("kind", value.kind()); + result.put("schema_version", value.schemaVersion()); + result.put("class", value.journalClass().name().toLowerCase(java.util.Locale.ROOT)); + result.put("replay", value.replay().name().toLowerCase(java.util.Locale.ROOT)); + result.put("sensitivity", value.sensitivity().name().toLowerCase(java.util.Locale.ROOT)); + result.put("payload_schema", value.payloadSchema().value()); + return result; + } + + static JournalProducerManifest decodeManifest(Object value) { + Map fields = Wire.object(value, "journal producer manifest"); + Client.requireExactFields(fields, "journal producer manifest", + "producer_id", "namespace", "manifest_version", "max_sensitivity", + "permissions", "events"); + JournalProducerManifest result = new JournalProducerManifest( + Wire.string(fields.get("producer_id"), "journal producer id"), + Wire.string(fields.get("namespace"), "journal producer namespace"), + positiveUint32(fields.get("manifest_version"), "manifest_version"), + sensitivity(fields.get("max_sensitivity"), "max_sensitivity"), + strings(fields.get("permissions"), "permissions"), + Wire.array(fields.get("events"), "events").stream() + .map(JournalWire::decodeEventSchema).toList() + ); + validate(result); + return result; + } + + private static JournalEventSchema decodeEventSchema(Object value) { + Map fields = Wire.object(value, "journal event schema"); + Client.requireExactFields(fields, "journal event schema", + "kind", "schema_version", "class", "replay", "sensitivity", "payload_schema"); + return new JournalEventSchema( + Wire.string(fields.get("kind"), "journal event kind"), + positiveUint32(fields.get("schema_version"), "schema_version"), + journalClass(fields.get("class")), + replay(fields.get("replay")), + sensitivity(fields.get("sensitivity"), "sensitivity"), + JsonValue.of(fields.get("payload_schema")) + ); + } + + static JournalProducerListResult decodeList(Object value) { + Map fields = Wire.object(value, "journal producer list result"); + Client.requireExactFields(fields, "journal producer list result", "producers"); + List producers = Wire.array(fields.get("producers"), "producers"); + if (producers.size() > 1024) { + throw new IllegalArgumentException("journal producer list contains too many entries"); + } + return new JournalProducerListResult( + producers.stream() + .map(JournalWire::decodeManifest).toList() + ); + } + + static JournalProducerPutResult decodePut(Object value) { + Map fields = Wire.object(value, "journal producer put result"); + Client.requireExactFields(fields, "journal producer put result", + "producer_id", "manifest_version", "namespace", "sequence", "event_id"); + String producerId = boundedString(fields.get("producer_id"), "producer_id", 1, 64); + if (!component(producerId)) { + throw new IllegalArgumentException("producer_id must match the lowercase component grammar"); + } + String namespace = boundedString(fields.get("namespace"), "namespace", 1, 128); + if (!namespace.equals("plugin." + producerId)) { + throw new IllegalArgumentException("namespace must equal plugin."); + } + String eventId = boundedString(fields.get("event_id"), "event_id", 1, 128); + return new JournalProducerPutResult( + producerId, + positiveUint32(fields.get("manifest_version"), "manifest_version"), + namespace, + Wire.decimal(fields.get("sequence"), "sequence"), + eventId + ); + } + + static JournalAppendResult decodeAppend(Object value) { + Map fields = Wire.object(value, "journal append result"); + Client.requireExactFields(fields, "journal append result", + "producer_id", "sequence", "event_id"); + String producerId = boundedString(fields.get("producer_id"), "producer_id", 1, 64); + if (!component(producerId)) { + throw new IllegalArgumentException("producer_id must match the lowercase component grammar"); + } + String eventId = boundedString(fields.get("event_id"), "event_id", 1, 128); + return new JournalAppendResult( + producerId, + Wire.decimal(fields.get("sequence"), "sequence"), + eventId + ); + } + + static void validate(JournalProducerManifest value) { + if (!component(value.producerId()) || + !value.namespace().equals("plugin." + value.producerId()) || + !uint32Positive(value.manifestVersion()) || value.events().isEmpty() || + value.events().size() > 64 || + value.maxSensitivity() == SessionJournalRecord.Sensitivity.SECRET) { + throw new IllegalArgumentException("journal producer manifest is invalid"); + } + if (value.permissions().size() > 32 || value.permissions().isEmpty() || + value.permissions().stream().anyMatch(item -> !bounded(item, 1, 128)) || + !value.permissions().contains("journal.append." + value.namespace())) { + throw new IllegalArgumentException("journal producer append permission is required"); + } + Set identities = new HashSet<>(); + for (JournalEventSchema event : value.events()) { + if (!kind(event.kind()) || !event.kind().startsWith(value.namespace() + ".") || + event.schemaVersion() <= 0 || event.schemaVersion() > 0xffff_ffffL || + event.sensitivity() == SessionJournalRecord.Sensitivity.SECRET || + rank(event.sensitivity()) > rank(value.maxSensitivity()) || + !identities.add(event.kind() + "\u0000" + event.schemaVersion())) { + throw new IllegalArgumentException("journal event schema is invalid"); + } + } + int encodedBytes = Json.stringify(Wire.encode(manifestFields(value))) + .getBytes(StandardCharsets.UTF_8).length; + if (encodedBytes < 1 || encodedBytes > MAX_MANIFEST_BYTES) { + throw new IllegalArgumentException("journal producer manifest exceeds 1 MiB"); + } + } + + static void validate(JournalIngress value) { + if (!component(value.producerId()) || !uint32Positive(value.manifestVersion()) || + !uint32Positive(value.schemaVersion()) || !kind(value.kind()) || + !value.kind().startsWith("plugin." + value.producerId() + ".") || + value.sensitivity().orElse(null) == SessionJournalRecord.Sensitivity.SECRET) { + throw new IllegalArgumentException("journal event envelope is invalid"); + } + if (value.subjects().size() > 64) { + throw new IllegalArgumentException("journal event has too many subjects"); + } + for (SessionJournalRecord.Subject subject : value.subjects()) { + if (!component(subject.kind()) || !bounded(subject.id(), 1, 512)) { + throw new IllegalArgumentException("journal event subject is invalid"); + } + } + value.occurredAtMs().ifPresent(item -> { + if (item == null) throw new IllegalArgumentException("occurred_at_ms is invalid"); + }); + value.causationId().ifPresent(item -> { + if (!bounded(item, 1, 128)) throw new IllegalArgumentException("causation_id is invalid"); + }); + value.correlationId().ifPresent(item -> { + if (!bounded(item, 1, 128)) throw new IllegalArgumentException("correlation_id is invalid"); + }); + } + + private static boolean component(String value) { + if (!bounded(value, 1, 64) || + !((value.charAt(0) >= 'a' && value.charAt(0) <= 'z') || + (value.charAt(0) >= '0' && value.charAt(0) <= '9'))) return false; + for (int index = 0; index < value.length(); index++) { + char item = value.charAt(index); + if (!((item >= 'a' && item <= 'z') || (item >= '0' && item <= '9') || + item == '_' || item == '-')) return false; + } + return true; + } + + private static boolean kind(String value) { + if (!bounded(value, 1, 128)) return false; + String[] parts = value.split("\\.", -1); + for (String part : parts) if (!component(part)) return false; + return true; + } + + private static int rank(SessionJournalRecord.Sensitivity value) { + return switch (value) { + case PUBLIC -> 0; + case METADATA -> 1; + case SENSITIVE -> 2; + case SECRET -> 3; + }; + } + + private static long positiveUint32(Object value, String context) { + if (!(value instanceof Number number) || !uint32Positive(number.longValue()) || + number.doubleValue() != number.longValue()) { + throw new IllegalArgumentException(context + " must be a positive uint32"); + } + return number.longValue(); + } + + private static boolean uint32Positive(long value) { + return value >= 1 && value <= 0xffff_ffffL; + } + + private static boolean bounded(String value, int minimumBytes, int maximumBytes) { + if (value == null) return false; + try { + ByteBuffer encoded = StandardCharsets.UTF_8.newEncoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .encode(CharBuffer.wrap(value)); + int length = encoded.remaining(); + return length >= minimumBytes && length <= maximumBytes; + } catch (CharacterCodingException error) { + return false; + } + } + + private static String boundedString(Object value, String context, int minimumBytes, int maximumBytes) { + String result = Wire.string(value, context); + if (!bounded(result, minimumBytes, maximumBytes)) { + throw new IllegalArgumentException(context + " length is outside protocol bounds"); + } + return result; + } + + private static List strings(Object value, String context) { + return Wire.array(value, context).stream().map(item -> Wire.string(item, context + " item")).toList(); + } + + private static SessionJournalRecord.JournalClass journalClass(Object value) { + return switch (Wire.string(value, "journal class")) { + case "state" -> SessionJournalRecord.JournalClass.STATE; + case "observation" -> SessionJournalRecord.JournalClass.OBSERVATION; + case "effect" -> SessionJournalRecord.JournalClass.EFFECT; + case "checkpoint" -> SessionJournalRecord.JournalClass.CHECKPOINT; + default -> throw new IllegalArgumentException("journal class is invalid"); + }; + } + + private static SessionJournalRecord.ReplayPolicy replay(Object value) { + return switch (Wire.string(value, "journal replay")) { + case "required" -> SessionJournalRecord.ReplayPolicy.REQUIRED; + case "advisory" -> SessionJournalRecord.ReplayPolicy.ADVISORY; + case "never" -> SessionJournalRecord.ReplayPolicy.NEVER; + default -> throw new IllegalArgumentException("journal replay is invalid"); + }; + } + + private static SessionJournalRecord.Sensitivity sensitivity(Object value, String context) { + return switch (Wire.string(value, context)) { + case "public" -> SessionJournalRecord.Sensitivity.PUBLIC; + case "metadata" -> SessionJournalRecord.Sensitivity.METADATA; + case "sensitive" -> SessionJournalRecord.Sensitivity.SENSITIVE; + case "secret" -> SessionJournalRecord.Sensitivity.SECRET; + default -> throw new IllegalArgumentException(context + " is invalid"); + }; + } +} diff --git a/cmux-tui/bindings/java/src/com/cmux/Options.java b/cmux-tui/bindings/java/src/com/cmux/Options.java index dae763a28c94..ad70051b3f33 100644 --- a/cmux-tui/bindings/java/src/com/cmux/Options.java +++ b/cmux-tui/bindings/java/src/com/cmux/Options.java @@ -24,6 +24,8 @@ public enum AgentState { WORKING, BLOCKED, IDLE, DONE, UNKNOWN; public String toWire() { return name().toLowerCase(java.util.Locale.ROOT); } } + // Agent reports are limited to caller-owned sources. Plugin and detected + // are server-owned projection sources and belong to the snapshot model. public enum AgentSource { HOOK, SOCKET; public String toWire() { return name().toLowerCase(java.util.Locale.ROOT); } } diff --git a/cmux-tui/bindings/java/src/com/cmux/Results.java b/cmux-tui/bindings/java/src/com/cmux/Results.java index 84c058ea8a09..bda8b7df6cd6 100644 --- a/cmux-tui/bindings/java/src/com/cmux/Results.java +++ b/cmux-tui/bindings/java/src/com/cmux/Results.java @@ -95,6 +95,8 @@ public record PaneNeighborResult(Optional pane) { public record TerminalScreenResult( String text, + Optional revision, + Optional oscProgress, int cols, int rows, int cursorRow, @@ -104,6 +106,8 @@ public record TerminalScreenResult( ) { public TerminalScreenResult { Objects.requireNonNull(text, "text"); + revision = revision == null ? Optional.empty() : revision; + oscProgress = oscProgress == null ? Optional.empty() : oscProgress; positiveUint16(cols, "cols"); positiveUint16(rows, "rows"); uint16(cursorRow, "cursorRow"); @@ -112,6 +116,20 @@ public record TerminalScreenResult( ? Map.of() : JsonValue.immutableObject(extra, "terminal screen extra"); } + + /** Compatibility constructor for clients compiled against the first SDK. */ + public TerminalScreenResult( + String text, + int cols, + int rows, + int cursorRow, + int cursorCol, + boolean cursorVisible, + Map extra + ) { + this(text, Optional.empty(), Optional.empty(), cols, rows, + cursorRow, cursorCol, cursorVisible, extra); + } } public record TerminalHistoryResult( @@ -296,7 +314,8 @@ public record ProcessInfoResult( List argv, Optional cwd, Optional foregroundCwd, - List children + List children, + Optional foregroundExecutable ) { public ProcessInfoResult { uint32(pid, "pid"); @@ -306,6 +325,21 @@ public record ProcessInfoResult( foregroundCwd = foregroundCwd == null ? Optional.empty() : foregroundCwd; children = List.copyOf(children); children.forEach(value -> uint32(value, "child pid")); + foregroundExecutable = foregroundExecutable == null + ? Optional.empty() + : foregroundExecutable; + } + + /** Compatibility constructor for clients compiled against the first SDK. */ + public ProcessInfoResult( + long pid, + Optional executable, + List argv, + Optional cwd, + Optional foregroundCwd, + List children + ) { + this(pid, executable, argv, cwd, foregroundCwd, children, Optional.empty()); } } diff --git a/cmux-tui/bindings/java/src/com/cmux/Session.java b/cmux-tui/bindings/java/src/com/cmux/Session.java index b1f51cb5a8af..71b76bc04b67 100644 --- a/cmux-tui/bindings/java/src/com/cmux/Session.java +++ b/cmux-tui/bindings/java/src/com/cmux/Session.java @@ -112,6 +112,75 @@ public ResourceStream journal(Options.SessionJournal optio ); } + /** Lists generic journal producers installed for this session. */ + public JournalProducerListResult journalProducers(Options.Read options) { + Object value = client.requestValue( + Operations.SESSION_JOURNAL_PRODUCER_LIST, + withExtra(route.params(), options == null ? Map.of() : options.extra()), + null + ); + return JournalWire.decodeList(value); + } + + public JournalProducerListResult journalProducers() { + return journalProducers(Options.Read.defaults()); + } + + public List listJournalProducers(Options.Read options) { + return journalProducers(options).producers(); + } + + public MutationResult putJournalProducer( + JournalProducerManifest manifest, + Options.Mutation options + ) { + Objects.requireNonNull(manifest, "manifest"); + options = options == null ? Options.Mutation.defaults() : options; + Map params = withExtra(route.params(), options.extra()); + params.put("manifest", JournalWire.manifest(manifest)); + Client.MutationResponse response = client.mutation( + Operations.SESSION_JOURNAL_PRODUCER_PUT, + params, + options + ); + return response.parts().withValue( + JournalWire.decodePut(response.result().get(Wire.VALUE)) + ); + } + + /** Compatibility alias for the first agent-plugin SDK preview. */ + public MutationResult putJournalProducerManifest( + JournalProducerManifest manifest, + Options.Mutation options + ) { + return putJournalProducer(manifest, options); + } + + public MutationResult appendJournal( + JournalIngress event, + Options.Mutation options + ) { + Objects.requireNonNull(event, "event"); + options = options == null ? Options.Mutation.defaults() : options; + Map params = withExtra(route.params(), options.extra()); + params.put("event", JournalWire.ingress(event)); + Client.MutationResponse response = client.mutation( + Operations.SESSION_JOURNAL_APPEND, + params, + options + ); + return response.parts().withValue( + JournalWire.decodeAppend(response.result().get(Wire.VALUE)) + ); + } + + public MutationResult appendJournalEvent( + JournalIngress event, + Options.Mutation options + ) { + return appendJournal(event, options); + } + public Results.PingResult ping(Options.Read options) { return Client.decodePingResult(client.requestValue( Operations.SESSION_PING, diff --git a/cmux-tui/bindings/java/src/com/cmux/Snapshots.java b/cmux-tui/bindings/java/src/com/cmux/Snapshots.java index 3b3123dbed4a..04020ad090ab 100644 --- a/cmux-tui/bindings/java/src/com/cmux/Snapshots.java +++ b/cmux-tui/bindings/java/src/com/cmux/Snapshots.java @@ -315,7 +315,7 @@ public record AgentSnapshot( Objects.requireNonNull(sessionId, "sessionId"); Objects.requireNonNull(terminalId, "terminalId"); oneOf(state, "state", "working", "blocked", "idle", "done", "unknown"); - oneOf(source, "source", "hook", "socket", "detected"); + oneOf(source, "source", "hook", "socket", "detected", "plugin"); Objects.requireNonNull(updatedAtMS, "updatedAtMS"); sourceSession = opt(sourceSession); extra = copy(extra); diff --git a/cmux-tui/bindings/java/src/com/cmux/internal/Operations.java b/cmux-tui/bindings/java/src/com/cmux/internal/Operations.java index 133632bafdba..30b5612db63d 100644 --- a/cmux-tui/bindings/java/src/com/cmux/internal/Operations.java +++ b/cmux-tui/bindings/java/src/com/cmux/internal/Operations.java @@ -11,6 +11,9 @@ public enum Operations { SESSION_CREATION_RESOLVE("session.creation.resolve", Class.READ), SESSION_EVENTS("session.events", Class.STREAM_OPEN), SESSION_JOURNAL_SUBSCRIBE("session.journal.subscribe", Class.STREAM_OPEN), + SESSION_JOURNAL_PRODUCER_LIST("session.journal.producer.list", Class.READ), + SESSION_JOURNAL_PRODUCER_PUT("session.journal.producer.put", Class.MUTATION), + SESSION_JOURNAL_APPEND("session.journal.append", Class.MUTATION), SESSION_PING("session.ping", Class.READ), SESSION_SHUTDOWN("session.shutdown", Class.MUTATION), SESSION_RELOAD_CONFIG("session.reload_config", Class.MUTATION), diff --git a/cmux-tui/bindings/java/src/com/cmux/internal/Wire.java b/cmux-tui/bindings/java/src/com/cmux/internal/Wire.java index dd3364e080a7..4a5a9f74c810 100644 --- a/cmux-tui/bindings/java/src/com/cmux/internal/Wire.java +++ b/cmux-tui/bindings/java/src/com/cmux/internal/Wire.java @@ -6,6 +6,7 @@ import com.cmux.Secret; import com.cmux.Selector; import com.cmux.raw.Json; +import java.math.BigDecimal; import java.math.BigInteger; import java.util.ArrayList; import java.util.Base64; @@ -134,7 +135,7 @@ public static Decimal decimal(Object value, String context) { public static Object encode(Object value) { if (value == null || value instanceof String || value instanceof Boolean || value instanceof Byte || value instanceof Short || value instanceof Integer || - value instanceof Long || value instanceof BigInteger || + value instanceof Long || value instanceof BigInteger || value instanceof BigDecimal || value instanceof Float || value instanceof Double) { return value; } diff --git a/cmux-tui/bindings/java/src/com/cmux/raw/.cmux-sdk-manifest.json b/cmux-tui/bindings/java/src/com/cmux/raw/.cmux-sdk-manifest.json index 84def553defb..7f105f8475da 100644 --- a/cmux-tui/bindings/java/src/com/cmux/raw/.cmux-sdk-manifest.json +++ b/cmux-tui/bindings/java/src/com/cmux/raw/.cmux-sdk-manifest.json @@ -2,8 +2,8 @@ "files": [ { "path": "AgentChangedEvent.java", - "sha256": "541d7a39ee461d670c8827a7dc33d61a67f35fc3c4a62194cd5f44db5b2ff837", - "size": 5113 + "sha256": "1bf1faa342e0c77bb7f1cb4345924165ab99ff4764ba67da50dc7f2846f7dc3f", + "size": 5857 }, { "path": "AgentRecord.java", @@ -17,8 +17,8 @@ }, { "path": "AgentSource.java", - "sha256": "12a0632899112247c99f6ed82d4441fa215437f458a6d4550b95131a779a2494", - "size": 901 + "sha256": "829094bc433a541ae8a1ba9737126fa64ac1edf2b2b48c990ad4f0c07b6b3b5f", + "size": 923 }, { "path": "AgentState.java", @@ -902,12 +902,12 @@ }, { "path": "ProcessInfoResult.java", - "sha256": "5542d342c3efb421b8273d234ecf029b51a1ec853f7e78cd80cf7c273cbb3035", - "size": 4130 + "sha256": "fd81701ff61b93580df865739f5642228b8653d7fdda34af2d1bd8ff2f102439", + "size": 5246 }, { "path": "Protocol.java", - "sha256": "235b874bbf9936690ced787ea3c6f3f5e2620cc653d3aa015f9840bfcdf6f665", + "sha256": "c452418563aeb3348e089c321e692005234671a4e9fbebce438661c44b5f3712", "size": 4260 }, { @@ -1622,7 +1622,7 @@ } ], "format": 1, - "ir_sha256": "7042c629f34d3606581d07b2d2c03b65116c2467810724163c54674865825cc0", + "ir_sha256": "133bac0154f8f94aa30e40c11ff7ed38b10dd4d82974aec87c02d404fcd12619", "language": "java", "mux_protocol": 12, "schema_version": 2 diff --git a/cmux-tui/bindings/java/src/com/cmux/raw/AgentChangedEvent.java b/cmux-tui/bindings/java/src/com/cmux/raw/AgentChangedEvent.java index 2f1ea304057b..ad4baa30fa58 100644 --- a/cmux-tui/bindings/java/src/com/cmux/raw/AgentChangedEvent.java +++ b/cmux-tui/bindings/java/src/com/cmux/raw/AgentChangedEvent.java @@ -12,6 +12,8 @@ /** Immutable agent-changed event. Protocol v11; streams: subscribe. */ public final class AgentChangedEvent implements WireValue, DeltaStreamEvent, ProtocolEvent, SubscribeEvent { + /** Adapter identity when the producer knows it; absent from protocol-11 event senders and null when no adapter was identified. */ + private final Field agent; private final String session; private final AgentSource source; private final AgentState state; @@ -19,6 +21,7 @@ public final class AgentChangedEvent implements WireValue, DeltaStreamEvent, Pro private final UInt64 updatedAtMs; private AgentChangedEvent(Builder builder) { + this.agent = builder.agent; if (!builder.sessionSet) throw new IllegalArgumentException("session is required"); this.session = builder.session; if (!builder.sourceSet) throw new IllegalArgumentException("source is required"); @@ -33,6 +36,7 @@ private AgentChangedEvent(Builder builder) { public static Builder builder() { return new Builder(); } + public Field agent() { return agent; } public String session() { return session; } public AgentSource source() { return source; } public AgentState state() { return state; } @@ -44,6 +48,10 @@ public static AgentChangedEvent fromWire(Object value) { Map object = Wire.object(value, "AgentChangedEvent"); Builder builder = builder(); ProtocolSupport.literal(Wire.required(object, "event"), "agent-changed", "AgentChangedEvent.event"); + Object rawAgent = Wire.optional(object, "agent"); + if (!Wire.isMissing(rawAgent)) { + builder.agent(rawAgent == null ? null : Wire.string(rawAgent, "AgentChangedEvent.agent")); + } Object rawSession = Wire.required(object, "session"); builder.session(rawSession == null ? null : Wire.string(rawSession, "AgentChangedEvent.session")); Object rawSource = Wire.required(object, "source"); @@ -61,6 +69,7 @@ public static AgentChangedEvent fromWire(Object value) { public Map toWire() { LinkedHashMap object = new LinkedHashMap<>(); object.put("event", "agent-changed"); + Wire.put(object, "agent", agent); Wire.put(object, "session", session); Wire.put(object, "source", source); Wire.put(object, "state", state); @@ -72,16 +81,17 @@ public Map toWire() { @Override public boolean equals(Object other) { if (!(other instanceof AgentChangedEvent that)) return false; - return Objects.equals(session, that.session) && Objects.equals(source, that.source) && Objects.equals(state, that.state) && Objects.equals(surface, that.surface) && Objects.equals(updatedAtMs, that.updatedAtMs); + return Objects.equals(agent, that.agent) && Objects.equals(session, that.session) && Objects.equals(source, that.source) && Objects.equals(state, that.state) && Objects.equals(surface, that.surface) && Objects.equals(updatedAtMs, that.updatedAtMs); } @Override - public int hashCode() { return Objects.hash(session, source, state, surface, updatedAtMs); } + public int hashCode() { return Objects.hash(agent, session, source, state, surface, updatedAtMs); } @Override public String toString() { return "AgentChangedEvent" + toWire(); } public static final class Builder { + private Field agent = Field.omitted(); private String session; private boolean sessionSet; private AgentSource source; @@ -93,6 +103,10 @@ public static final class Builder { private UInt64 updatedAtMs; private boolean updatedAtMsSet; + public Builder agent(String value) { + this.agent = Field.ofNullable(value); + return this; + } public Builder session(String value) { this.session = value; this.sessionSet = true; diff --git a/cmux-tui/bindings/java/src/com/cmux/raw/AgentSource.java b/cmux-tui/bindings/java/src/com/cmux/raw/AgentSource.java index 78c0e5c857e7..80452c54762b 100644 --- a/cmux-tui/bindings/java/src/com/cmux/raw/AgentSource.java +++ b/cmux-tui/bindings/java/src/com/cmux/raw/AgentSource.java @@ -4,6 +4,7 @@ import java.util.Objects; public enum AgentSource implements WireEnum { + PLUGIN("plugin"), DETECTED("detected"), SOCKET("socket"), HOOK("hook"); diff --git a/cmux-tui/bindings/java/src/com/cmux/raw/ProcessInfoResult.java b/cmux-tui/bindings/java/src/com/cmux/raw/ProcessInfoResult.java index b9b16a01594b..fd74fb4b4da6 100644 --- a/cmux-tui/bindings/java/src/com/cmux/raw/ProcessInfoResult.java +++ b/cmux-tui/bindings/java/src/com/cmux/raw/ProcessInfoResult.java @@ -15,6 +15,8 @@ public final class ProcessInfoResult implements WireValue { private final String cwd; /** Working directory of the process group that owns the PTY, read at request time. Null when the lookup fails; absent from daemons that predate the field. Clients treat absence as null. */ private final Field foregroundCwd; + /** Executable path or name of the PTY foreground process-group leader, read at request time. Null when the lookup fails; absent from daemons that predate the field. Clients treat absence as null. */ + private final Field foregroundExecutable; private final Long pid; private ProcessInfoResult(Builder builder) { @@ -23,6 +25,7 @@ private ProcessInfoResult(Builder builder) { if (!builder.cwdSet) throw new IllegalArgumentException("cwd is required"); this.cwd = builder.cwd; this.foregroundCwd = builder.foregroundCwd; + this.foregroundExecutable = builder.foregroundExecutable; if (!builder.pidSet) throw new IllegalArgumentException("pid is required"); this.pid = builder.pid; } @@ -32,6 +35,7 @@ private ProcessInfoResult(Builder builder) { public String command() { return command; } public String cwd() { return cwd; } public Field foregroundCwd() { return foregroundCwd; } + public Field foregroundExecutable() { return foregroundExecutable; } public Long pid() { return pid; } public static ProcessInfoResult fromWire(Object value) { @@ -45,6 +49,10 @@ public static ProcessInfoResult fromWire(Object value) { if (!Wire.isMissing(rawForegroundCwd)) { builder.foregroundCwd(rawForegroundCwd == null ? null : Wire.string(rawForegroundCwd, "ProcessInfoResult.foreground_cwd")); } + Object rawForegroundExecutable = Wire.optional(object, "foreground_executable"); + if (!Wire.isMissing(rawForegroundExecutable)) { + builder.foregroundExecutable(rawForegroundExecutable == null ? null : Wire.string(rawForegroundExecutable, "ProcessInfoResult.foreground_executable")); + } Object rawPid = Wire.required(object, "pid"); builder.pid(rawPid == null ? null : Wire.uint32(rawPid, "ProcessInfoResult.pid")); return builder.build(); @@ -56,6 +64,7 @@ public Map toWire() { Wire.put(object, "command", command); Wire.put(object, "cwd", cwd); Wire.put(object, "foreground_cwd", foregroundCwd); + Wire.put(object, "foreground_executable", foregroundExecutable); Wire.put(object, "pid", pid); return Collections.unmodifiableMap(object); } @@ -63,11 +72,11 @@ public Map toWire() { @Override public boolean equals(Object other) { if (!(other instanceof ProcessInfoResult that)) return false; - return Objects.equals(command, that.command) && Objects.equals(cwd, that.cwd) && Objects.equals(foregroundCwd, that.foregroundCwd) && Objects.equals(pid, that.pid); + return Objects.equals(command, that.command) && Objects.equals(cwd, that.cwd) && Objects.equals(foregroundCwd, that.foregroundCwd) && Objects.equals(foregroundExecutable, that.foregroundExecutable) && Objects.equals(pid, that.pid); } @Override - public int hashCode() { return Objects.hash(command, cwd, foregroundCwd, pid); } + public int hashCode() { return Objects.hash(command, cwd, foregroundCwd, foregroundExecutable, pid); } @Override public String toString() { return "ProcessInfoResult" + toWire(); } @@ -78,6 +87,7 @@ public static final class Builder { private String cwd; private boolean cwdSet; private Field foregroundCwd = Field.omitted(); + private Field foregroundExecutable = Field.omitted(); private Long pid; private boolean pidSet; @@ -95,6 +105,10 @@ public Builder foregroundCwd(String value) { this.foregroundCwd = Field.ofNullable(value); return this; } + public Builder foregroundExecutable(String value) { + this.foregroundExecutable = Field.ofNullable(value); + return this; + } public Builder pid(Long value) { this.pid = value; this.pidSet = true; diff --git a/cmux-tui/bindings/java/src/com/cmux/raw/Protocol.java b/cmux-tui/bindings/java/src/com/cmux/raw/Protocol.java index 59a6deea1056..bf6c64c22ebd 100644 --- a/cmux-tui/bindings/java/src/com/cmux/raw/Protocol.java +++ b/cmux-tui/bindings/java/src/com/cmux/raw/Protocol.java @@ -9,7 +9,7 @@ public final class Protocol { public static final String SDK_VERSION = "1.0.0"; public static final int VERSION = 12; public static final int SCHEMA_VERSION = 2; - public static final String IR_SHA256 = "7042c629f34d3606581d07b2d2c03b65116c2467810724163c54674865825cc0"; + public static final String IR_SHA256 = "133bac0154f8f94aa30e40c11ff7ed38b10dd4d82974aec87c02d404fcd12619"; private Protocol() {} public static ProtocolEvent decodeEvent(Object value) { diff --git a/cmux-tui/bindings/java/tests/com/cmux/ResourceApiTest.java b/cmux-tui/bindings/java/tests/com/cmux/ResourceApiTest.java index 554a974e8ac4..67d579a90475 100644 --- a/cmux-tui/bindings/java/tests/com/cmux/ResourceApiTest.java +++ b/cmux-tui/bindings/java/tests/com/cmux/ResourceApiTest.java @@ -29,6 +29,7 @@ public static void main(String[] args) { exactCommandAndRouting(); creationCorrelationIsFirstClass(); nullableMetadata(); + journalBoundaryAndNullableTerminalMetadata(); notificationTargetingIsOptionalAndTyped(); strictTypedModels(); layoutUndoUsesTypedConfirmation(); @@ -265,6 +266,49 @@ private static void nullableMetadata() { } } + private static void journalBoundaryAndNullableTerminalMetadata() { + Map screen = new LinkedHashMap<>(); + screen.put("text", "unavailable"); + screen.put("revision", null); + screen.put("osc_progress", null); + screen.put("cols", 80); + screen.put("rows", 24); + screen.put("cursor_row", 0); + screen.put("cursor_col", 0); + screen.put("cursor_visible", true); + Results.TerminalScreenResult decoded = Client.decodeTerminalScreen(screen); + require(decoded.revision().isEmpty(), "null terminal revision is unavailable"); + require(decoded.oscProgress().isEmpty(), "null terminal progress is unavailable"); + + Map malformedPut = new LinkedHashMap<>(); + malformedPut.put("producer_id", "screen!detector"); + malformedPut.put("manifest_version", 1); + malformedPut.put("namespace", "plugin.screen!detector"); + malformedPut.put("sequence", "1"); + malformedPut.put("event_id", "event-1"); + expect(IllegalArgumentException.class, () -> JournalWire.decodePut(malformedPut)); + + Map malformedAppend = new LinkedHashMap<>(); + malformedAppend.put("producer_id", "screen!detector"); + malformedAppend.put("sequence", "1"); + malformedAppend.put("event_id", "event-1"); + expect(IllegalArgumentException.class, () -> JournalWire.decodeAppend(malformedAppend)); + + JournalIngress invalidIngress = new JournalIngress( + "screen-detector", + 1, + "agent.state.changed", + 1, + Optional.empty(), + List.of(), + Optional.empty(), + JsonValue.of(Map.of("state", "working")), + Optional.empty(), + Optional.empty() + ); + expect(IllegalArgumentException.class, invalidIngress::toWire); + } + private static void notificationTargetingIsOptionalAndTyped() { FakeTransport transport = new FakeTransport(); try (Client client = client(transport)) { diff --git a/cmux-tui/bindings/java/tests/com/cmux/raw/CodecTest.java b/cmux-tui/bindings/java/tests/com/cmux/raw/CodecTest.java index 1d1c19fff5e1..d57306bd49f9 100644 --- a/cmux-tui/bindings/java/tests/com/cmux/raw/CodecTest.java +++ b/cmux-tui/bindings/java/tests/com/cmux/raw/CodecTest.java @@ -22,6 +22,11 @@ private static void roundTripsLosslessNumbers() { ); check(parsed.get("id").equals(new BigInteger(maximum)), "uint64 JSON precision"); check(parsed.get("fraction").equals(new BigDecimal("1.2500")), "decimal JSON precision"); + check( + com.cmux.internal.Wire.json(Map.of("fraction", new BigDecimal("1.2500"))) + .equals("{\"fraction\":1.2500}"), + "decimal JSON encode" + ); UInt64 unsigned = Wire.uint64(parsed.get("id"), "id"); check(unsigned.equals(UInt64.MAX_VALUE), "uint64 maximum decode"); check(Json.stringify(Map.of("id", unsigned)).equals("{\"id\":" + maximum + "}"), "uint64 encode"); diff --git a/cmux-tui/bindings/python/.cmux-resource-api.json b/cmux-tui/bindings/python/.cmux-resource-api.json index 4c4481343150..0667d105f967 100644 --- a/cmux-tui/bindings/python/.cmux-resource-api.json +++ b/cmux-tui/bindings/python/.cmux-resource-api.json @@ -1,5 +1,5 @@ { - "catalog_sha256": "beef8293ded489648261ccddfd31b3f796f9d7d10506f13f6c5d1577a3f4fbac", + "catalog_sha256": "08a8190787e1b38d0592b85856d791fb0fe098d58eddce85ee98ecf88cc5a1a7", "operations": { "agent.list": { "class": "read" diff --git a/cmux-tui/bindings/python/cmux/_operations.py b/cmux-tui/bindings/python/cmux/_operations.py index 2fddd2ed18d2..9470fc292cd8 100644 --- a/cmux-tui/bindings/python/cmux/_operations.py +++ b/cmux-tui/bindings/python/cmux/_operations.py @@ -55,6 +55,15 @@ class Operations: SESSION_JOURNAL_SUBSCRIBE = _op( "session.journal.subscribe", "stream_open", ("session",), "stream" ) + SESSION_JOURNAL_PRODUCER_LIST = _op( + "session.journal.producer.list", "read", ("session",), "journal_producer_list" + ) + SESSION_JOURNAL_PRODUCER_PUT = _op( + "session.journal.producer.put", "mutation", ("session",), "journal_producer_put" + ) + SESSION_JOURNAL_APPEND = _op( + "session.journal.append", "mutation", ("session",), "journal_append" + ) SESSION_PING = _op("session.ping", "read", ("session",)) SESSION_SHUTDOWN = _op("session.shutdown", "mutation", ("session",)) SESSION_RELOAD_CONFIG = _op("session.reload_config", "mutation", ("session",)) diff --git a/cmux-tui/bindings/python/cmux/models.py b/cmux-tui/bindings/python/cmux/models.py index 54863e78958e..e73af7e45faf 100644 --- a/cmux-tui/bindings/python/cmux/models.py +++ b/cmux-tui/bindings/python/cmux/models.py @@ -204,7 +204,7 @@ class AgentSnapshot(Snapshot[AgentId]): session_id: SessionId terminal_id: TerminalId state: Literal["working", "blocked", "idle", "done", "unknown"] - source: Literal["hook", "socket", "detected"] + source: Literal["hook", "socket", "detected", "plugin"] updated_at_ms: str source_session: Optional[str] extra: JsonObject = field(default_factory=dict) @@ -333,6 +333,10 @@ class TerminalScreenResult: cursor_col: int cursor_visible: bool extra: JsonObject = field(default_factory=dict) + # Optional metadata was appended to preserve positional construction for + # clients of the original screen-result shape. + revision: Optional[str] = None + osc_progress: Optional[str] = None @dataclass(frozen=True) @@ -392,6 +396,9 @@ class ProcessInfoResult: cwd: Optional[str] foreground_cwd: Optional[str] children: Tuple[int, ...] + # Executable path or name of the PTY foreground process-group leader. Older + # servers may omit this field. + foreground_executable: Optional[str] = None @dataclass(frozen=True) @@ -797,6 +804,70 @@ class JournalSubject: id: str +@dataclass(frozen=True) +class JournalEventSchema: + kind: str + schema_version: int + class_: JournalClass + replay: JournalReplayPolicy + sensitivity: JournalSensitivity + payload_schema: Any + + +@dataclass(frozen=True) +class JournalProducerManifest: + producer_id: str + namespace: str + manifest_version: int + max_sensitivity: JournalSensitivity + permissions: Tuple[str, ...] + events: Tuple[JournalEventSchema, ...] + + +@dataclass(frozen=True) +class JournalIngress: + producer_id: str + manifest_version: int + kind: str + schema_version: int + payload: Any + occurred_at_ms: Optional[str] = None + subjects: Tuple[JournalSubject, ...] = () + sensitivity: Optional[JournalSensitivity] = None + causation_id: Optional[str] = None + correlation_id: Optional[str] = None + + +@dataclass(frozen=True) +class JournalProducerPutResult: + producer_id: str + manifest_version: int + namespace: str + sequence: str + event_id: str + + +@dataclass(frozen=True) +class JournalProducerListResult: + producers: Tuple[JournalProducerManifest, ...] + + +@dataclass(frozen=True) +class JournalAppendResult: + producer_id: str + sequence: str + event_id: str + + +# Compatibility names from the first agent-plugin SDK preview. +AgentPluginEventSchema = JournalEventSchema +AgentPluginManifest = JournalProducerManifest +AgentPluginSubject = JournalSubject +AgentPluginIngress = JournalIngress +AgentPluginListResult = JournalProducerListResult +JournalEventSubject = JournalSubject + + @dataclass(frozen=True) class SessionJournalRecord: sequence: str @@ -967,6 +1038,11 @@ class SidebarAttachScroll: __all__ = [ "AgentSnapshot", + "AgentPluginEventSchema", + "AgentPluginIngress", + "AgentPluginListResult", + "AgentPluginManifest", + "AgentPluginSubject", "BrowserAttachFrame", "BrowserAttachItem", "BrowserAttachSnapshot", @@ -1023,9 +1099,16 @@ class SidebarAttachScroll: "SessionSnapshotItem", "SessionDelta", "SessionEvent", + "JournalAppendResult", "JournalAuthority", "JournalClass", + "JournalEventSchema", + "JournalEventSubject", + "JournalIngress", "JournalProducer", + "JournalProducerListResult", + "JournalProducerManifest", + "JournalProducerPutResult", "JournalReplayPolicy", "JournalSensitivity", "JournalSubject", diff --git a/cmux-tui/bindings/python/cmux/raw/_generated/.cmux-sdk-manifest.json b/cmux-tui/bindings/python/cmux/raw/_generated/.cmux-sdk-manifest.json index 5f0a43eb8ab9..98efd4fa9067 100644 --- a/cmux-tui/bindings/python/cmux/raw/_generated/.cmux-sdk-manifest.json +++ b/cmux-tui/bindings/python/cmux/raw/_generated/.cmux-sdk-manifest.json @@ -7,8 +7,8 @@ }, { "path": "_schema.py", - "sha256": "dca45c1a3102f7af7b7a25b0d9e1a826d7e05171aa3c75914940cc7c36f68dcd", - "size": 170916 + "sha256": "2b0851e7efe28b065059bd3724ac76eedcc433679ceb171c51cd5b540723ebfc", + "size": 171490 }, { "path": "client.py", @@ -22,17 +22,17 @@ }, { "path": "metadata.py", - "sha256": "eeedaa6c21196914e8fb9a5d39fe3a7f6fd7e207a5af2b9bb304967bd78ba825", + "sha256": "fef085735dc3c33944480ff2b1ed4c6d22913be2d32779237377982a08067d29", "size": 48660 }, { "path": "models.py", - "sha256": "b9302f1c261a78f11b47cb8bcd8a9b0fcc04b625031b9c08ce071de17ebc092d", - "size": 93033 + "sha256": "66b74abe924032dfc322205517750221d8f24f8f9d9a68d473f0a58496dfb6ef", + "size": 93203 } ], "format": 1, - "ir_sha256": "7042c629f34d3606581d07b2d2c03b65116c2467810724163c54674865825cc0", + "ir_sha256": "133bac0154f8f94aa30e40c11ff7ed38b10dd4d82974aec87c02d404fcd12619", "language": "python", "mux_protocol": 12, "schema_version": 2 diff --git a/cmux-tui/bindings/python/cmux/raw/_generated/_schema.py b/cmux-tui/bindings/python/cmux/raw/_generated/_schema.py index 92af26e81b01..630cd95ad75b 100644 --- a/cmux-tui/bindings/python/cmux/raw/_generated/_schema.py +++ b/cmux-tui/bindings/python/cmux/raw/_generated/_schema.py @@ -5,4 +5,4 @@ import json -SCHEMA = json.loads('{"$schema":"./sdk-schema.schema.json","commands":{"apply-layout":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["layout must contain at least one leaf or stack member.","cols and rows affect sizing only when both are present."],"fields":{"cols":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"layout":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"DeclarativeLayout"}},"name":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"rows":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"workspace":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"ApplyLayoutResult"},"since":6,"stream":null},"attach-surface":{"authority":"frontend","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["cols and rows must be supplied together.","Browser surfaces reject mode:render.","expected_generation and expected_terminal_id must be supplied together and match the current daemon and terminal."],"fields":{"cols":{"capability":"attach-initial-size","default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"expected_generation":{"capability":"attach-identity-v1","default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"expected_terminal_id":{"capability":"attach-identity-v1","default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"mode":{"default":"bytes","nullable":true,"presence":"optional","since":7,"type":{"kind":"enum","values":["bytes","render"]}},"rows":{"capability":"attach-initial-size","default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"surface":{"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":{"event_names":["browser-state","colors-changed","detached","frame","notification","output","overflow","render-delta","render-state","resized","scroll-changed","vt-state"],"kind":"attach","mode_field":"mode","modes":{"browser":["browser-state","frame","notification","scroll-changed","overflow","detached"],"bytes":["vt-state","output","resized","colors-changed","scroll-changed","notification","overflow","detached"],"render":["render-state","render-delta","scroll-changed","overflow","detached"]},"ordering":"Initial state precedes the command response. Later surface events preserve order. A resized replay replaces the previous byte mirror; overflow ends that surface stream.","terminal_event":"detached"}},"browser-activate":{"authority":"frontend","capability":null,"constraints":["Browser surfaces only."],"request":{"additional_properties":false,"fields":{"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"browser-back":{"authority":"frontend","capability":null,"constraints":["Browser surfaces only; queue acknowledgement is not page-load success."],"request":{"additional_properties":false,"fields":{"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"browser-forward":{"authority":"frontend","capability":null,"constraints":["Browser surfaces only; queue acknowledgement is not page-load success."],"request":{"additional_properties":false,"fields":{"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"browser-frame-presented":{"authority":"frontend","capability":"browser-pointer-frame-guard-v1","constraints":["Acknowledges the exact rendered browser frame for this connection.","Requires browser-pointer-frame-guard-v1."],"request":{"additional_properties":false,"fields":{"frame_seq":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":10,"stream":null},"browser-insert-text":{"authority":"frontend","capability":null,"constraints":["Browser surfaces only.","The bounded disposable input queue drops newest input when full."],"request":{"additional_properties":false,"fields":{"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"text":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"browser-key":{"authority":"frontend","capability":null,"constraints":["Browser surfaces only.","The bounded disposable input queue drops newest input when full."],"request":{"additional_properties":false,"fields":{"code":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"kind":{"nullable":false,"presence":"required","type":{"kind":"enum","values":["down","up"]}},"modifiers":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"text":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"windows_virtual_key_code":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"browser-key-press":{"authority":"frontend","capability":null,"constraints":["Browser surfaces only.","One request preserves the atomic press sequence through the bounded input queue."],"request":{"additional_properties":false,"fields":{"code":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"modifiers":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"text":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"windows_virtual_key_code":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":10,"stream":null},"browser-mouse":{"authority":"frontend","capability":null,"constraints":["Browser surfaces only; coordinates are CSS pixels.","The bounded disposable input queue drops newest input when full."],"request":{"additional_properties":false,"fields":{"button":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"click_count":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint32"}},"frame_seq":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint64"}},"kind":{"nullable":false,"presence":"required","type":{"kind":"enum","values":["down","up","move"]}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"x_px":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float64"}},"y_px":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float64"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"browser-mouse-guarded":{"authority":"frontend","capability":"browser-pointer-frame-guard-v1","constraints":["Browser surfaces only; coordinates are CSS pixels.","The frame sequence must be the exact presented token for this connection.","Requires browser-pointer-frame-guard-v1."],"request":{"additional_properties":false,"fields":{"button":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"click_count":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint32"}},"frame_seq":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"kind":{"nullable":false,"presence":"required","type":{"kind":"enum","values":["down","up","move"]}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"x_px":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float64"}},"y_px":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float64"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":10,"stream":null},"browser-navigate":{"authority":"frontend","capability":null,"constraints":["Queue acknowledgement only; observe browser-state for outcome.","Navigation is latest-wins."],"request":{"additional_properties":false,"fields":{"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"url":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"browser-reload":{"authority":"frontend","capability":null,"constraints":["Browser surfaces only; queue acknowledgement is not page-load success."],"request":{"additional_properties":false,"fields":{"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"browser-wheel":{"authority":"frontend","capability":null,"constraints":["Browser surfaces only; values are CSS pixels.","The bounded disposable input queue drops newest input when full."],"request":{"additional_properties":false,"fields":{"delta_y_px":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float64"}},"frame_seq":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint64"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"x_px":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float64"}},"y_px":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float64"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"browser-wheel-guarded":{"authority":"frontend","capability":"browser-pointer-frame-guard-v1","constraints":["Browser surfaces only; values are CSS pixels.","The frame sequence must be the exact presented token for this connection.","Requires browser-pointer-frame-guard-v1."],"request":{"additional_properties":false,"fields":{"delta_y_px":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float64"}},"frame_seq":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"x_px":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float64"}},"y_px":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float64"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":10,"stream":null},"clear-history":{"authority":"control","capability":"clear-history-v1","constraints":["PTY surfaces only.","Failed responses classify error_delivery as known-not-delivered or ambiguous."],"request":{"additional_properties":false,"fields":{"fallback_key":{"capability":"clear-history-key-v1","default":null,"nullable":true,"presence":"optional","since":9,"type":{"kind":"ref","name":"TerminalKeyInput"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":9,"stream":null},"clear-window-title":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"client-focus":{"authority":"control","capability":"client-focus-v1","constraints":[],"request":{"additional_properties":false,"fields":{"client_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"additional_properties":false,"fields":{"pane":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}},"tab":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"since":12,"stream":null},"close-pane":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"close-provider-managed-workspace":{"authority":"provider-authority","capability":"provider-managed-workspace-authority-v2","constraints":["Call only after the external provider durably accepts the close."],"request":{"additional_properties":false,"constraints":["workspace and key must identify the same live provider-managed workspace."],"fields":{"authority":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"ProviderWorkspaceMutationResult"},"since":9,"stream":null},"close-screen":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"close-surface":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"close-terminal":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["origin and mutation_id are either both present or both absent."],"fields":{"expected_generation":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"expected_revision":{"aliases":["expected_terminal_revision"],"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint64"}},"mutation_id":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"origin":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"terminal_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_incarnation":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"CloseTerminalResult"},"since":9,"stream":null},"close-workspace":{"authority":"control","capability":null,"constraints":["Provider-managed workspaces reject this ordinary mutation."],"request":{"additional_properties":false,"constraints":["At least one of workspace and key must be supplied; both must identify the same workspace when supplied.","origin and mutation_id are either both present or both absent."],"fields":{"expected_generation":{"default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"expected_revision":{"aliases":["expected_terminal_revision"],"default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"uint64"}},"key":{"capability":"workspace-registry-v1","default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"mutation_id":{"default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"origin":{"default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"workspace":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"WorkspaceMutationResult"},"since":5,"stream":null},"copy":{"authority":"control","capability":null,"constraints":["PTY surfaces only."],"request":{"additional_properties":false,"fields":{"mode":{"nullable":false,"presence":"required","type":{"kind":"enum","values":["screen","selection","scrollback"]}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"CopyResult"},"since":6,"stream":null},"create-surface-with-receipt":{"authority":"control","capability":"creation-receipts-v1","constraints":["Repeating one origin and receipt with identical fields returns the original creation result.","A new idempotency_key is valid only when durable creation resolution instructs retry_new_idempotency_key."],"request":{"additional_properties":false,"constraints":["operation is one of new-tab, run-command, new-browser-tab, new-workspace, new-screen, new-pane, new-pane-right, split-right, or split-down.","Each operation admits only its documented selector and option fields.","idempotency_key names one execution attempt and defaults to receipt.","cols and rows must be supplied together."],"fields":{"argv":{"default":null,"nullable":true,"presence":"optional","type":{"items":{"kind":"scalar","name":"string"},"kind":"array"}},"cols":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"cwd":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"idempotency_key":{"capability":"creation-attempt-keys-v1","default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"operation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"origin":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"pane":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}},"receipt":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"rows":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"selector_fallbacks":{"default":[],"nullable":false,"presence":"optional","type":{"items":{"kind":"ref","name":"ResourceSelectors"},"kind":"array","max_items":7}},"selectors":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"ResourceSelectors"}},"url":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"width":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"float32"}},"workspace":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"JsonValue"},"since":10,"stream":null},"create-terminal":{"authority":"control","capability":"workspace-registry-v1","constraints":[],"request":{"additional_properties":false,"constraints":["At least one of workspace and key must be supplied; when both are supplied they must identify the same workspace.","argv and command are mutually exclusive and must be nonempty when supplied.","cols and rows must be supplied together.","origin and mutation_id are either both present or both absent.","terminal_id may be supplied only when origin and mutation_id are both present."],"fields":{"argv":{"default":null,"nullable":true,"presence":"optional","type":{"items":{"kind":"scalar","name":"string"},"kind":"array","min_items":1}},"cols":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"command":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"cwd":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"expected_generation":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"expected_revision":{"aliases":["expected_terminal_revision"],"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint64"}},"key":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"mutation_id":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"name":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"origin":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"rows":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"terminal_id":{"constraints":[{"format":"32-character lowercase UUIDv4 hex without dashes","pattern":"^[0-9a-f]{12}4[0-9a-f]{3}[89ab][0-9a-f]{15}$"}],"default":null,"nullable":true,"presence":"optional","since":9,"type":{"kind":"scalar","name":"string"}},"workspace":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"TerminalPlacement"},"since":7,"stream":null},"create-workspace":{"authority":"control","capability":"workspace-registry-v1","constraints":[],"request":{"additional_properties":false,"constraints":["origin and mutation_id are either both present or both absent.","At most 4096 live workspaces may exist; tombstoned keys cannot be reused."],"fields":{"expected_generation":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"expected_revision":{"aliases":["expected_terminal_revision"],"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint64"}},"key":{"constraints":[{"format":"lowercase canonical UUID"}],"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"mutation_id":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"name":{"constraints":[{"max_utf8_bytes":1024}],"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"origin":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"WorkspaceMutationResult"},"since":7,"stream":null},"detach-attached-view":{"authority":"frontend","capability":"view-attachment-detach-v1","constraints":["The command closes only the named view stream and releases its size contribution.","A retired lease returns outcome:superseded."],"request":{"additional_properties":false,"fields":{"lease":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"AttachedViewOutcomeResult"},"since":10,"stream":null},"detach-client":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"client":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"export-layout":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"screen":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"ExportLayoutResult"},"since":6,"stream":null},"focus-direction":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"dir":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"PaneDirection"}},"pane":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"FocusDirectionResult"},"since":6,"stream":null},"focus-pane":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"get-browser-provider":{"authority":"local-admin","capability":"browser-provider-v1","constraints":["Provider endpoints and targets are disclosed only over a trusted local transport; bearer credentials are accepted only during registration and are never returned.","Automation must select a target by stable tab id instead of treating CDP discovery as topology authority."],"request":{"additional_properties":false,"fields":{},"kind":"object"},"result":{"kind":"ref","name":"BrowserProviderSnapshot"},"since":10,"stream":null},"get-cell-pixels":{"authority":"frontend","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{},"kind":"object"},"result":{"kind":"ref","name":"GetCellPixelsResult"},"since":6,"stream":null},"get-frontend-projection":{"authority":"control","capability":null,"constraints":["Each identifier is nonempty, contains no control character, and is at most 128 bytes."],"request":{"additional_properties":false,"fields":{"frontend":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"scope":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"subject_key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"FrontendProjection"},"since":7,"stream":null},"identify":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{},"kind":"object"},"result":{"kind":"ref","name":"IdentifyResult"},"since":5,"stream":null},"ids":{"authority":"control","capability":null,"constraints":["Short ids are snapshot-local labels; command parameters accept numeric ids only."],"request":{"additional_properties":false,"fields":{"kind":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"enum","values":["workspace","screen","pane","surface"]}}},"kind":"object"},"result":{"kind":"ref","name":"IdsResult"},"since":6,"stream":null},"journal-frontend-event":{"authority":"control","capability":"frontend-journal-v1","constraints":["The server derives producer identity from the authenticated control client."],"request":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"FrontendJournalEvent"}}},"kind":"object"},"result":{"additional_properties":false,"fields":{"committed":{"nullable":false,"presence":"required","type":{"kind":"literal","value":true}}},"kind":"object"},"since":10,"stream":null},"list-agents":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"state":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"AgentState"}},"surface":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"ListAgentsResult"},"since":6,"stream":null},"list-clients":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{},"kind":"object"},"result":{"items":{"kind":"ref","name":"ClientInfo"},"kind":"array"},"since":6,"stream":null},"list-terminals":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{},"kind":"object"},"result":{"kind":"ref","name":"ListTerminalsResult"},"since":9,"stream":null},"list-workspaces":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{},"kind":"object"},"result":{"kind":"ref","name":"Tree"},"since":5,"stream":null},"machine-listening-tcp":{"authority":"control","capability":"machine-listening-tcp-v1","constraints":["Routine Cloud port inventory uses this command over the authenticated private cmux-tui link."],"request":{"additional_properties":false,"fields":{},"kind":"object"},"result":{"kind":"ref","name":"MachineListeningTcpResult"},"since":12,"stream":null},"machine-usage":{"authority":"control","capability":"machine-usage-v1","constraints":[],"request":{"additional_properties":false,"fields":{},"kind":"object"},"result":{"kind":"ref","name":"MachineUsageResult"},"since":12,"stream":null},"mark-workspaces-provider-managed":{"authority":"provider-authority","capability":"provider-managed-workspace-authority-v2","constraints":["Authority must match the value provisioned before this mux generation accepted control clients."],"request":{"additional_properties":false,"fields":{"authority":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":9,"stream":null},"mint-terminal-renderer":{"authority":"frontend","capability":null,"constraints":["Only terminal-host-backed PTYs can mint one-use renderer credentials."],"request":{"additional_properties":false,"fields":{"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"ttl_ms":{"constraints":[{"maximum":60000,"minimum":1}],"default":30000,"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"result":{"kind":"ref","name":"MintTerminalRendererResult"},"since":9,"stream":null},"mint-terminal-renderer-by-terminal":{"authority":"frontend","capability":null,"constraints":["The terminal resource ID is resolved atomically to the live terminal-host-backed PTY before minting a one-use renderer credential."],"request":{"additional_properties":false,"fields":{"terminal":{"constraints":[{"pattern":"^term_[0-9a-f]{32}$"}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"ttl_ms":{"constraints":[{"maximum":60000,"minimum":1}],"default":30000,"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"result":{"kind":"ref","name":"MintTerminalRendererResult"},"since":11,"stream":null},"move-tab":{"authority":"control","capability":null,"constraints":["An out-of-range index clamps to the destination end."],"request":{"additional_properties":false,"fields":{"index":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"move-tab-to-workspace":{"authority":"control","capability":"tab-workspace-move-v1","constraints":["Moves the existing tab to the selected workspace or atomically creates a workspace when workspace is omitted."],"request":{"additional_properties":false,"fields":{"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":12,"stream":null},"move-terminal":{"authority":"control","capability":null,"constraints":["A move to the current workspace still commits a terminal revision with changed:false."],"request":{"additional_properties":false,"constraints":["origin and mutation_id are either both present or both absent."],"fields":{"expected_generation":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"expected_revision":{"aliases":["expected_terminal_revision"],"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint64"}},"mutation_id":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"origin":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"terminal_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_incarnation":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"workspace_key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"MoveTerminalResult"},"since":9,"stream":null},"move-workspace":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["At least one of workspace and key must be supplied; both must identify the same workspace when supplied.","origin and mutation_id are either both present or both absent."],"fields":{"expected_generation":{"default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"expected_revision":{"aliases":["expected_terminal_revision"],"default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"uint64"}},"index":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"key":{"capability":"workspace-registry-v1","default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"mutation_id":{"default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"origin":{"default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"workspace":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"WorkspaceMutationResult"},"since":5,"stream":null},"new-browser-tab":{"authority":"control","capability":null,"constraints":["Bootstrap and navigation failures are asynchronous browser-state outcomes."],"request":{"additional_properties":false,"constraints":["cols and rows affect sizing only when both are present."],"fields":{"cols":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"pane":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}},"rows":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"url":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"SurfaceResult"},"since":5,"stream":null},"new-pane":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["cols and rows affect sizing only when both are present."],"fields":{"cols":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"rows":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}}},"kind":"object"},"result":{"kind":"ref","name":"SurfaceResult"},"since":9,"stream":null},"new-pane-right":{"authority":"control","capability":"viewport-splits-v1","constraints":[],"request":{"additional_properties":false,"constraints":["Omitted width defaults to two thirds.","cols and rows affect sizing only when both are present."],"fields":{"cols":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"rows":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"width":{"constraints":[{"maximum":1.0,"minimum":0.1}],"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"float32"}}},"kind":"object"},"result":{"kind":"ref","name":"SurfaceResult"},"since":9,"stream":null},"new-screen":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["cols and rows affect sizing only when both are present."],"fields":{"cols":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"rows":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"workspace":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"SurfaceResult"},"since":5,"stream":null},"new-tab":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["cols and rows affect sizing only when both are present."],"fields":{"cols":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"cwd":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"pane":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}},"rows":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}}},"kind":"object"},"result":{"kind":"ref","name":"SurfaceResult"},"since":5,"stream":null},"new-workspace":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["cols and rows affect sizing only when both are present."],"fields":{"cols":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"name":{"constraints":[{"max_utf8_bytes":1024}],"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"rows":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}}},"kind":"object"},"result":{"kind":"ref","name":"SurfaceResult"},"since":5,"stream":null},"notify":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"body":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"level":{"default":"info","nullable":true,"presence":"optional","type":{"kind":"ref","name":"NotificationLevel"}},"surface":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}},"title":{"constraints":[{"min_length":1}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"NotifyResult"},"since":6,"stream":null},"pairing-response":{"authority":"local-admin","capability":null,"constraints":["The request id must identify a live, unexpired pairing challenge."],"request":{"additional_properties":false,"fields":{"approve":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"request":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":7,"stream":null},"pane-neighbor":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"dir":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"PaneDirection"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"PaneNeighborResult"},"since":6,"stream":null},"paste-image":{"authority":"control","capability":"terminal-image-paste-v1","constraints":["Requires terminal-image-paste-v1 and a current connection-owned attachment lease for the exact public terminal. Begin(mime,size), chunk(offset,data), commit, cancel. No caller-supplied paths. 20 MiB per image; 48 KiB chunks; generated temporary files expire after 600 seconds."],"request":{"additional_properties":false,"fields":{"data":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"lease":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"mime":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"offset":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint64"}},"op":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"size":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint64"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"terminal_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"upload_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"additional_properties":false,"fields":{"accepted":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}}},"kind":"object"},"since":12,"stream":null},"ping":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{},"kind":"object"},"result":{"kind":"ref","name":"PingResult"},"since":6,"stream":null},"process-info":{"authority":"control","capability":null,"constraints":["PTY surfaces only."],"request":{"additional_properties":false,"fields":{"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"ProcessInfoResult"},"since":6,"stream":null},"put-frontend-projection":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["origin and mutation_id are either both present or both absent.","Serialized projection must be at most 1048576 bytes."],"fields":{"expected_generation":{"default":null,"description":"Accepted by the current decoder but ignored for projection writes.","nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"expected_projection_revision":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint64"}},"expected_revision":{"aliases":["expected_terminal_revision"],"default":null,"description":"Accepted by the current decoder but ignored for projection writes.","nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint64"}},"frontend":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"mutation_id":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"origin":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"projection":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"JsonValue"}},"schema_version":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"scope":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"subject_key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"FrontendProjection"},"since":7,"stream":null},"read-screen":{"authority":"control","capability":null,"constraints":["PTY surfaces only."],"request":{"additional_properties":false,"fields":{"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"ReadScreenResult"},"since":5,"stream":null},"read-scrollback":{"authority":"control","capability":null,"constraints":["PTY surfaces only; row indexes are snapshot-relative and not durable."],"request":{"additional_properties":false,"fields":{"count":{"constraints":[{"maximum":65535,"minimum":0}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"start":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"ReadScrollbackResult"},"since":7,"stream":null},"register-browser-provider":{"authority":"local-admin","capability":"browser-provider-v1","constraints":["The lease is scoped to the trusted local control connection and is released on disconnect.","The endpoint must be an explicit loopback ws URL with no credentials or fragment.","Bearer authentication is optional and sends the token only in the CDP WebSocket upgrade Authorization header.","Each registration replaces that connection\'s complete target set; target ids are never journaled."],"request":{"additional_properties":false,"fields":{"authentication":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"BrowserProviderAuthentication"}},"bearer_token":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"endpoint":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"provider_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"targets":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"BrowserProviderTarget"},"kind":"array"}}},"kind":"object"},"result":{"kind":"ref","name":"BrowserProviderSnapshot"},"since":10,"stream":null},"release-attached-view-size":{"authority":"frontend","capability":"view-attachment-lease-v1","constraints":["The attach stream remains live for cached rendering."],"request":{"additional_properties":false,"fields":{"lease":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"AttachedViewOutcomeResult"},"since":10,"stream":null},"release-surface-size":{"authority":"control","capability":null,"constraints":["An absent lease is a successful no-op."],"request":{"additional_properties":false,"fields":{"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":7,"stream":null},"reload-config":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{},"kind":"object"},"result":{"additional_properties":false,"fields":{"path":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"reloaded":{"nullable":false,"presence":"required","type":{"kind":"literal","value":true}}},"kind":"object"},"since":6,"stream":null},"rename-pane":{"authority":"control","capability":null,"constraints":["An empty name clears the pane name."],"request":{"additional_properties":false,"fields":{"name":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"rename-provider-managed-workspace":{"authority":"provider-authority","capability":"provider-managed-workspace-authority-v2","constraints":["Call only after the external provider durably accepts the rename."],"request":{"additional_properties":false,"constraints":["workspace and key must identify the same live provider-managed workspace."],"fields":{"authority":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"name":{"constraints":[{"max_utf8_bytes":1024}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"ProviderWorkspaceMutationResult"},"since":9,"stream":null},"rename-screen":{"authority":"control","capability":null,"constraints":["An empty name clears the screen name."],"request":{"additional_properties":false,"fields":{"name":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"rename-surface":{"authority":"control","capability":null,"constraints":["An empty name clears the surface name."],"request":{"additional_properties":false,"fields":{"name":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"rename-workspace":{"authority":"control","capability":null,"constraints":["Provider-managed workspaces reject this ordinary mutation."],"request":{"additional_properties":false,"constraints":["At least one of workspace and key must be supplied; both must identify the same workspace when supplied.","origin and mutation_id are either both present or both absent."],"fields":{"expected_generation":{"default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"expected_revision":{"aliases":["expected_terminal_revision"],"default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"uint64"}},"key":{"capability":"workspace-registry-v1","default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"mutation_id":{"default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"name":{"constraints":[{"max_utf8_bytes":1024}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"origin":{"default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"workspace":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"WorkspaceMutationResult"},"since":5,"stream":null},"report-agent":{"authority":"control","capability":null,"constraints":["A stored hook report outranks later socket reports until another hook report or surface close."],"request":{"additional_properties":false,"fields":{"session":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"source":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"AgentReportSource"}},"state":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"AgentState"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"ReportAgentResult"},"since":6,"stream":null},"report-focus":{"authority":"control","capability":"client-focus-v1","constraints":[],"request":{"additional_properties":false,"fields":{"client_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"tab":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":12,"stream":null},"resize-attached-view":{"authority":"frontend","capability":"view-attachment-lease-v1","constraints":["The lease must belong to this connection and surface.","A retired lease returns outcome:superseded without changing replacement views."],"request":{"additional_properties":false,"fields":{"cols":{"constraints":[{"clamped_maximum":10000,"clamped_minimum":1}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"lease":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"rows":{"constraints":[{"clamped_maximum":10000,"clamped_minimum":1}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"AttachedViewResizeResult"},"since":10,"stream":null},"resize-surface":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"cols":{"constraints":[{"clamped_maximum":10000,"clamped_minimum":1}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"rows":{"constraints":[{"clamped_maximum":10000,"clamped_minimum":1}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"ResizeSurfaceResult"},"since":5,"stream":null},"resolve-terminal":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"terminal_id":{"constraints":[{"format":"terminal host id (UUIDv4 hex without dashes) or public term_ resource id","pattern":"^(term_)?[0-9a-f]{32}$"}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"ResolveTerminalResult"},"since":9,"stream":null},"run":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["Exactly one of argv and command must be supplied.","pane and new_workspace:true are mutually exclusive.","key is valid only with new_workspace:true.","cols and rows affect sizing only when both are present."],"fields":{"argv":{"default":null,"nullable":true,"presence":"optional","type":{"items":{"kind":"scalar","name":"string"},"kind":"array","min_items":1}},"cols":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"command":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"cwd":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"key":{"default":null,"nullable":true,"presence":"optional","since":9,"type":{"kind":"scalar","name":"string"}},"name":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"new_workspace":{"default":false,"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"boolean"}},"pane":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}},"rows":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}}},"kind":"object"},"result":{"kind":"ref","name":"RunResult"},"since":6,"stream":null},"scroll-surface":{"authority":"control","capability":null,"constraints":["PTY surfaces only; negative values scroll up."],"request":{"additional_properties":false,"fields":{"delta":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"int64"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"select-screen":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["When both index and delta are supplied, index wins."],"fields":{"delta":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"int64"}},"index":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"select-tab":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["When both index and delta are supplied, index wins."],"fields":{"delta":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"int64"}},"index":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint64"}},"pane":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"select-workspace":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["When both index and delta are supplied, index wins."],"fields":{"delta":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"int64"}},"index":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"send":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["When both are present, UTF-8 text bytes precede decoded bytes."],"fields":{"bytes":{"constraints":[{"encoding":"standard base64"}],"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Base64"}},"paste":{"default":false,"nullable":false,"presence":"optional","since":7,"type":{"kind":"scalar","name":"boolean"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"text":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"send-key":{"authority":"control","capability":null,"constraints":["PTY surfaces only."],"request":{"additional_properties":false,"fields":{"keys":{"constraints":[{"syntax":"lowercase modifier+key chords"}],"nullable":false,"presence":"required","type":{"items":{"kind":"scalar","name":"string"},"kind":"array","min_items":1}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"server-stats":{"authority":"local-admin","capability":"server-stats-v1","constraints":["Owner-only diagnostics; never journaled and safe to poll."],"request":{"additional_properties":false,"fields":{},"kind":"object"},"result":{"kind":"ref","name":"ServerStatsResult"},"since":12,"stream":null},"set-cell-pixels":{"authority":"frontend","capability":null,"constraints":["Accepted browser resizes complete asynchronously."],"request":{"additional_properties":false,"fields":{"height_px":{"constraints":[{"clamped_minimum":1}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"width_px":{"constraints":[{"clamped_minimum":1}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}}},"kind":"object"},"result":{"kind":"ref","name":"SetCellPixelsResult"},"since":6,"stream":null},"set-client-info":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"capabilities":{"constraints":["Advertises additive client capabilities for this connection."],"default":null,"nullable":true,"presence":"optional","type":{"items":{"kind":"scalar","name":"string"},"kind":"array"}},"kind":{"constraints":["Control characters become spaces; at most 64 Unicode characters are retained."],"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"name":{"constraints":["Control characters become spaces; at most 64 Unicode characters are retained."],"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"set-client-sizing":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["exclusive:true requires client and enabled:true.","Omitting client is valid only with enabled:true and restores all clients for the surface."],"fields":{"client":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint64"}},"enabled":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"exclusive":{"default":false,"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"boolean"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":10,"stream":null},"set-default-colors":{"authority":"control","capability":null,"constraints":["Color strings are exactly #rrggbb.","With complete:true, absent optional values reset to built-in defaults."],"request":{"additional_properties":false,"fields":{"bg":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"ColorHex"}},"complete":{"default":false,"nullable":false,"presence":"optional","since":9,"type":{"kind":"scalar","name":"boolean"}},"cursor":{"default":null,"nullable":true,"presence":"optional","since":9,"type":{"kind":"ref","name":"ColorHex"}},"cursor_blink":{"default":null,"nullable":true,"presence":"optional","since":9,"type":{"kind":"scalar","name":"boolean"}},"cursor_style":{"default":null,"nullable":true,"presence":"optional","since":9,"type":{"kind":"ref","name":"CursorStyle"}},"fg":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"ColorHex"}},"palette":{"constraints":["Decimal string keys are palette indexes 0 through 255."],"default":null,"nullable":true,"presence":"optional","since":9,"type":{"kind":"map","values":{"kind":"ref","name":"ColorHex"}}},"selection_bg":{"default":null,"nullable":true,"presence":"optional","since":9,"type":{"kind":"ref","name":"ColorHex"}},"selection_fg":{"default":null,"nullable":true,"presence":"optional","since":9,"type":{"kind":"ref","name":"ColorHex"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"set-ratio":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"dir":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"SplitDirection"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"ratio":{"constraints":[{"clamped_maximum":0.95,"clamped_minimum":0.05}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float32"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"set-split-ratio":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"ratio":{"constraints":[{"clamped_maximum":0.95,"clamped_minimum":0.05}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float32"}},"split":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"transaction":{"capability":"layout-undo-v1","default":null,"nullable":true,"presence":"optional","since":9,"type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":8,"stream":null},"set-viewport-pane-width":{"authority":"control","capability":"viewport-column-resize-v1","constraints":["width must be finite."],"request":{"additional_properties":false,"fields":{"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"transaction":{"capability":"layout-undo-v1","default":null,"nullable":true,"presence":"optional","since":9,"type":{"kind":"scalar","name":"uint64"}},"width":{"constraints":[{"maximum":1.0,"minimum":0.1}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float32"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":9,"stream":null},"set-window-title":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"title":{"constraints":["C0 controls are sanitized before OSC output."],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"shutdown-daemon":{"authority":"local-admin","capability":null,"constraints":["pid and generation must match the latest identify result.","force bypasses native-browser ownership only; the identity fence and trusted-local authority still apply.","Clients must require daemon-handoff-force-v1 before sending force:true.","The daemon exits only after the success response is queued."],"request":{"additional_properties":false,"fields":{"force":{"capability":"daemon-handoff-force-v1","default":false,"nullable":false,"presence":"optional","since":10,"type":{"kind":"scalar","name":"boolean"}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"pid":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}}},"kind":"object"},"result":{"kind":"ref","name":"ShutdownDaemonResult"},"since":9,"stream":null},"sidebar-plugin":{"authority":"frontend","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"cols":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"relaunch":{"default":false,"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"boolean"}},"rows":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}}},"kind":"object"},"result":{"kind":"ref","name":"SidebarPluginResult"},"since":6,"stream":null},"split":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["cols and rows affect sizing only when both are present."],"fields":{"cols":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"dir":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"SplitDirection"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"rows":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}}},"kind":"object"},"result":{"kind":"ref","name":"SurfaceResult"},"since":5,"stream":null},"subscribe":{"authority":"frontend","capability":null,"constraints":["subscribe sends no initial tree snapshot.","surface filtering occurs before the bounded mailbox."],"request":{"additional_properties":false,"fields":{"surface":{"capability":"surface-subscribe-filter","default":null,"nullable":true,"presence":"optional","since":9,"type":{"kind":"ref","name":"Id"}},"tree_events":{"default":"coarse","nullable":true,"presence":"optional","since":7,"type":{"kind":"enum","values":["coarse","deltas"]}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":{"event_names":["agent-changed","bell","client-attached","client-changed","client-detached","client-list-invalidated","config-reload-requested","empty","frontend-projection-changed","layout-changed","notification","overflow","pairing-requested","pairing-resolved","pane-added","pane-closed","screen-added","screen-closed","screen-renamed","scroll-changed","status","surface-exited","surface-output","surface-resize-failed","surface-resized","tab-added","tab-closed","tab-renamed","terminal-registry-changed","title-changed","tree-changed","window-title-requested","workspace-added","workspace-closed","workspace-moved","workspace-renamed"],"kind":"subscribe","mode_field":"tree_events","modes":{"coarse":["tree-changed"],"deltas":["workspace-added","workspace-closed","workspace-renamed","workspace-moved","screen-added","screen-closed","screen-renamed","pane-added","pane-closed","tab-added","tab-closed","tab-renamed","tree-changed"]},"ordering":"Response and event objects may interleave. Events preserve enqueue order per subscription. Delta workspace revisions are serialized in durable commit order; overflow ends the stream and requires resubscribe plus snapshot.","terminal_event":null}},"swap-pane":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["Exactly one of dir and target must be supplied."],"fields":{"dir":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"PaneDirection"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"target":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"terminal-events":{"authority":"control","capability":null,"constraints":["Consumers apply only contiguous revisions for one registry_id and generation."],"request":{"additional_properties":false,"fields":{"after_revision":{"default":0,"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"result":{"kind":"ref","name":"TerminalEventsResult"},"since":9,"stream":null},"undo-layout":{"authority":"control","capability":"layout-undo-v1","constraints":["Clients must reject incomplete or contradictory result variants."],"request":{"additional_properties":false,"constraints":["confirm_close requires the exact preview revision."],"fields":{"confirm_close":{"default":false,"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"boolean"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"revision":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"result":{"kind":"ref","name":"LayoutUndoResult"},"since":9,"stream":null},"unregister-browser-provider":{"authority":"local-admin","capability":"browser-provider-v1","constraints":["Only the calling connection\'s provider lease is removed."],"request":{"additional_properties":false,"fields":{},"kind":"object"},"result":{"kind":"ref","name":"BrowserProviderUnregisterResult"},"since":10,"stream":null},"url-open":{"authority":"local-admin","capability":null,"constraints":["Private frontend URL delivery; no resource or journal mutation. HTTP(S) only, exact projected terminal identity, 16 pending requests maximum, five-second expiry. A request ID is an ephemeral acknowledgement capability."],"request":{"additional_properties":false,"fields":{"terminal_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"url":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"GuestUrlOpenResult"},"since":12,"stream":null},"url-open-claim":{"authority":"frontend","capability":null,"constraints":["Private frontend URL delivery; no resource or journal mutation. HTTP(S) only, exact projected terminal identity, 16 pending requests maximum, five-second expiry. A request ID is an ephemeral acknowledgement capability."],"request":{"additional_properties":false,"fields":{"request_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"GuestUrlClaimResult"},"since":12,"stream":null},"url-open-result":{"authority":"frontend","capability":null,"constraints":["Private frontend URL delivery; no resource or journal mutation. HTTP(S) only, exact projected terminal identity, 16 pending requests maximum, five-second expiry. A request ID is an ephemeral acknowledgement capability."],"request":{"additional_properties":false,"fields":{"opened":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"request_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"GuestUrlAcknowledgeResult"},"since":12,"stream":null},"url-open-subscribe":{"authority":"frontend","capability":null,"constraints":["Private frontend URL delivery; no resource or journal mutation. HTTP(S) only, exact projected terminal identity, 16 pending requests maximum, five-second expiry. A request ID is an ephemeral acknowledgement capability."],"request":{"additional_properties":false,"fields":{"terminal_ids":{"nullable":false,"presence":"required","type":{"items":{"kind":"scalar","name":"string"},"kind":"array"}}},"kind":"object"},"result":{"kind":"ref","name":"GuestUrlSubscribeResult"},"since":12,"stream":{"event_names":["url-open"],"kind":"subscribe","ordering":"Registration response followed by targeted requests; no replay. Closing the connection rejects its pending requests.","terminal_event":null}},"vt-state":{"authority":"control","capability":null,"constraints":["PTY surfaces only."],"request":{"additional_properties":false,"fields":{"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"VtStateResult"},"since":5,"stream":null},"wait-for":{"authority":"control","capability":null,"constraints":["Blocks subsequent requests on this connection; SDKs should use a dedicated connection."],"request":{"additional_properties":false,"fields":{"pattern":{"constraints":[{"syntax":"Rust regex"}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"timeout_ms":{"description":"Zero performs one immediate check.","nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"result":{"kind":"ref","name":"WaitForResult"},"since":6,"stream":null},"zoom-pane":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"mode":{"default":"toggle","nullable":true,"presence":"optional","type":{"kind":"enum","values":["toggle","on","off"]}},"pane":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"ZoomPaneResult"},"since":6,"stream":null}},"events":{"agent-changed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"agent-changed"}},"session":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"source":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"AgentSource"}},"state":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"AgentState"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"updated_at_ms":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"since":11,"streams":["subscribe"]},"bell":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"bell"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":5,"streams":["subscribe"]},"browser-state":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"cols":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"error":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"browser-state"}},"frame":{"description":"The initial browser-state includes the latest frame when one exists; later state updates omit it.","nullable":true,"presence":"optional","type":{"kind":"ref","name":"BrowserFrame"}},"frames_stalled":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"rows":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"status":{"nullable":false,"presence":"required","type":{"kind":"enum","values":["starting","live","failed"]}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"title":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"url":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"since":6,"streams":["attach-browser"]},"client-attached":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"client":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"client-attached"}},"kind":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"name":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"transport":{"nullable":false,"presence":"required","type":{"kind":"enum","values":["unix","ws"]}}},"kind":"object"},"since":6,"streams":["subscribe"]},"client-changed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"client":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"client-changed"}},"kind":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"name":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"since":6,"streams":["subscribe"]},"client-detached":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"client":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"client-detached"}}},"kind":"object"},"since":6,"streams":["subscribe"]},"client-list-invalidated":{"capability":null,"emission":"serialized-never-emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"client-list-invalidated"}}},"kind":"object"},"since":9,"streams":["subscribe"]},"colors-changed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"bg":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"ColorHex"}},"cursor":{"nullable":true,"presence":"optional","type":{"kind":"ref","name":"ColorHex"}},"cursor_blink":{"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"boolean"}},"cursor_style":{"nullable":true,"presence":"optional","type":{"kind":"ref","name":"CursorStyle"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"colors-changed"}},"fg":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"ColorHex"}},"overrides":{"nullable":false,"presence":"optional","since":12,"type":{"kind":"ref","name":"TerminalColorOverrides"}},"palette":{"nullable":false,"presence":"optional","since":7,"type":{"kind":"map","values":{"kind":"ref","name":"ColorHex"}}},"selection_bg":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"ColorHex"}},"selection_fg":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"ColorHex"}},"surface":{"nullable":false,"presence":"optional","since":7,"type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":6,"streams":["attach-byte"]},"config-reload-requested":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"config-reload-requested"}}},"kind":"object"},"since":6,"streams":["subscribe"]},"daemon-shutdown":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"daemon-shutdown"}}},"kind":"object"},"since":12,"streams":["control"]},"detached":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"detached"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":5,"streams":["attach-byte","attach-render","attach-browser"]},"empty":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"empty"}}},"kind":"object"},"since":5,"streams":["subscribe"]},"frame":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"data":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Base64"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"frame"}},"height":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"seq":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"width":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}}},"kind":"object"},"since":6,"streams":["attach-browser"]},"frontend-projection-changed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"frontend-projection-changed"}},"frontend":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"mutation_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"origin":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"projection_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"scope":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"subject_key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"since":7,"streams":["subscribe"]},"graphics-status":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"constraints":["kitty-image-budget-worker-start-failed carries error.","kitty-image-budget-update-failed carries retry_exhausted and summary.","cell-pixel-update-retries-exhausted carries attempts, remaining, cell_width, and cell_height."],"fields":{"attempts":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"cell_height":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"cell_width":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"error":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"string"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"graphics-status"}},"kind":{"nullable":false,"presence":"required","type":{"kind":"enum","values":["kitty-image-budget-worker-start-failed","kitty-image-budget-update-failed","cell-pixel-update-retries-exhausted"]}},"remaining":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"uint64"}},"retry_exhausted":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"boolean"}},"summary":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"since":10,"streams":["subscribe"]},"layout-changed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"layout-changed"}},"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":6,"streams":["subscribe"]},"machine-usage-changed":{"capability":"machine-usage-v1","emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"machine-usage-changed"}},"usage":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"MachineUsage"}}},"kind":"object"},"since":12,"streams":["subscribe"]},"notification":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"body":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"notification"}},"level":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"NotificationLevel"}},"notification":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"surface":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}},"title":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"since":6,"streams":["subscribe","attach-byte","attach-browser"]},"output":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"colors":{"nullable":false,"presence":"optional","since":7,"type":{"kind":"ref","name":"TerminalColors"}},"data":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Base64"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"output"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":5,"streams":["attach-byte"]},"overflow":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"constraints":["scope and surface are either both present for attach overflow or both absent for subscribe overflow."],"fields":{"error":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"overflow"}},"scope":{"nullable":false,"presence":"optional","type":{"kind":"literal","value":"surface"}},"surface":{"nullable":false,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":7,"streams":["subscribe","attach-byte","attach-render","attach-browser"]},"pairing-requested":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"code":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"pairing-requested"}},"expires_in":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"peer":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"request":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"since":7,"streams":["subscribe"]},"pairing-resolved":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"pairing-resolved"}},"request":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"since":7,"streams":["subscribe"]},"pane-added":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"entity":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Pane"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"pane-added"}},"index":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":7,"streams":["subscribe-deltas"]},"pane-closed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"entity":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Pane"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"pane-closed"}},"index":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":7,"streams":["subscribe-deltas"]},"render-delta":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"constraints":["size is present if and only if the surface resized; every resize has full:true."],"fields":{"cursor":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"RenderCursor"}},"default_bg":{"nullable":false,"presence":"optional","type":{"kind":"ref","name":"ColorHex"}},"default_fg":{"nullable":false,"presence":"optional","type":{"kind":"ref","name":"ColorHex"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"render-delta"}},"full":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"graphics":{"nullable":false,"presence":"optional","type":{"kind":"ref","name":"RenderGraphicsDelta"}},"history_epoch":{"nullable":false,"presence":"optional","since":10,"type":{"kind":"scalar","name":"uint64"}},"rows":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"RenderRow"},"kind":"array"}},"scrollback_rows":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"uint32"}},"size":{"nullable":false,"presence":"optional","type":{"kind":"ref","name":"Size"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":7,"streams":["attach-render"]},"render-state":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"cursor":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"RenderCursor"}},"default_bg":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"ColorHex"}},"default_fg":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"ColorHex"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"render-state"}},"graphics":{"nullable":false,"presence":"optional","type":{"kind":"ref","name":"RenderGraphics"}},"history_epoch":{"nullable":false,"presence":"required","since":10,"type":{"kind":"scalar","name":"uint64"}},"rows":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"RenderRow"},"kind":"array"}},"scrollback_rows":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"size":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Size"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":7,"streams":["attach-render"]},"resized":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"constraints":["At least one of replay and data is present; replay is canonical from protocol 7."],"fields":{"colors":{"nullable":false,"presence":"optional","since":7,"type":{"kind":"ref","name":"TerminalColors"}},"cols":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"data":{"description":"Protocol 6 compatibility field.","nullable":false,"presence":"optional","type":{"kind":"ref","name":"Base64"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"resized"}},"kitty_graphics_state":{"nullable":false,"presence":"optional","since":10,"type":{"kind":"ref","name":"KittyGraphicsState"}},"kitty_image_aliases":{"nullable":false,"presence":"optional","since":9,"type":{"items":{"kind":"ref","name":"KittyImageAlias"},"kind":"array"}},"replay":{"nullable":false,"presence":"optional","since":7,"type":{"kind":"ref","name":"Base64"}},"rows":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":6,"streams":["attach-byte"]},"screen-added":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"entity":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Screen"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"screen-added"}},"index":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":7,"streams":["subscribe-deltas"]},"screen-closed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"entity":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Screen"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"screen-closed"}},"index":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":7,"streams":["subscribe-deltas"]},"screen-renamed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"entity":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Screen"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"screen-renamed"}},"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":7,"streams":["subscribe-deltas"]},"scroll-changed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"at_bottom":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"scroll-changed"}},"offset":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":6,"streams":["subscribe","attach-byte","attach-render","attach-browser"]},"status":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"status"}},"message":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"since":5,"streams":["subscribe"]},"surface-exited":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"surface-exited"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":5,"streams":["subscribe"]},"surface-output":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"surface-output"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":5,"streams":["subscribe"]},"surface-resize-failed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"cols":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"error":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"surface-resize-failed"}},"reservation_id":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"retry_after_ms":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"rows":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":7,"streams":["subscribe"]},"surface-resized":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"cols":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"surface-resized"}},"reservation_id":{"nullable":true,"presence":"required","since":7,"type":{"kind":"scalar","name":"uint64"}},"rows":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":5,"streams":["subscribe"]},"tab-added":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"entity":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Tab"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"tab-added"}},"index":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":7,"streams":["subscribe-deltas"]},"tab-closed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"entity":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Tab"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"tab-closed"}},"index":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":7,"streams":["subscribe-deltas"]},"tab-renamed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"entity":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Tab"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"tab-renamed"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":7,"streams":["subscribe-deltas"]},"terminal-registry-changed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"terminal-registry-changed"}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"refetch":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"terminal-events-or-list-terminals"}},"registry_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"since":9,"streams":["subscribe"]},"title-changed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"title-changed"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"title":{"nullable":false,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}}},"kind":"object"},"since":5,"streams":["subscribe"]},"tree-changed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"tree-changed"}}},"kind":"object"},"since":5,"streams":["subscribe"]},"url-open":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"url-open"}},"request_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"url":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"since":12,"streams":["control"]},"vt-state":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"colors":{"nullable":false,"presence":"optional","since":6,"type":{"kind":"ref","name":"TerminalColors"}},"cols":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"data":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Base64"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"vt-state"}},"kitty_graphics_state":{"nullable":false,"presence":"optional","since":10,"type":{"kind":"ref","name":"KittyGraphicsState"}},"kitty_image_aliases":{"nullable":false,"presence":"optional","since":9,"type":{"items":{"kind":"ref","name":"KittyImageAlias"},"kind":"array"}},"rows":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":5,"streams":["attach-byte"]},"window-title-requested":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"window-title-requested"}},"title":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"since":6,"streams":["subscribe"]},"workspace-added":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"constraints":["origin and mutation_id are either both present or both absent."],"fields":{"entity":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Workspace"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"workspace-added"}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"index":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"mutation_id":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"string"}},"origin":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"string"}},"registry_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"since":7,"streams":["subscribe-deltas"]},"workspace-closed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"constraints":["origin and mutation_id are either both present or both absent."],"fields":{"entity":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Workspace"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"workspace-closed"}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"index":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"mutation_id":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"string"}},"origin":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"string"}},"registry_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"since":7,"streams":["subscribe-deltas"]},"workspace-moved":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"constraints":["origin and mutation_id are either both present or both absent."],"fields":{"entity":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Workspace"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"workspace-moved"}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"index":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"mutation_id":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"string"}},"origin":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"string"}},"registry_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"since":7,"streams":["subscribe-deltas"]},"workspace-renamed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"constraints":["origin and mutation_id are either both present or both absent."],"fields":{"entity":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Workspace"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"workspace-renamed"}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"mutation_id":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"string"}},"origin":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"string"}},"registry_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"since":7,"streams":["subscribe-deltas"]}},"ir_sha256":"7042c629f34d3606581d07b2d2c03b65116c2467810724163c54674865825cc0","profiles":{"control":{"description":"Base authenticated session-control commands available to ordinary SDK clients.","inherits":[]},"frontend":{"description":"Rendering, input, presentation, subscribe, and attach commands.","inherits":["control"]},"local-admin":{"description":"Trusted local administration commands.","inherits":["control"],"transport":"Unix-classified transport, including direct Unix and the current stdio relay"},"provider-authority":{"description":"Provider-owned workspace mutation commands.","inherits":["control"],"requires_authority":true}},"protocol":{"id_type":"uint64","javascript_id_policy":"All protocol identifiers are uint64 JSON numbers. JavaScript and TypeScript SDKs must decode them losslessly as bigint (or validated decimal strings at their public boundary), and must not expose IEEE-754 number ids. Pairing request ids, revisions, timestamps, frame sequences, and reservation ids follow the same rule.","name":"cmux-tui-mux","version":12},"schema_version":2,"types":{"AgentRecord":{"additional_properties":false,"fields":{"session":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"source":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"AgentSource"}},"state":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"AgentState"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"updated_at_ms":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"AgentReportSource":{"kind":"enum","values":["socket","hook"]},"AgentSource":{"kind":"enum","values":["detected","socket","hook"]},"AgentState":{"kind":"enum","values":["working","blocked","idle","done","unknown"]},"AppliedPane":{"additional_properties":false,"fields":{"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"ApplyLayoutResult":{"additional_properties":false,"fields":{"panes":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"AppliedPane"},"kind":"array"}},"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"AttachedViewOutcomeResult":{"additional_properties":false,"fields":{"outcome":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"ViewAttachmentOutcome"}}},"kind":"object"},"AttachedViewResizeResult":{"additional_properties":false,"fields":{"accepted":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"outcome":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"ViewAttachmentOutcome"}},"reservation_id":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"Base64":{"kind":"alias","target":{"kind":"scalar","name":"string"}},"BrowserFrame":{"additional_properties":false,"fields":{"data":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Base64"}},"height":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"seq":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"width":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}}},"kind":"object"},"BrowserProviderAuthentication":{"kind":"enum","values":["none","bearer"]},"BrowserProviderSnapshot":{"additional_properties":false,"constraints":["available is true exactly when provider_id, endpoint, authentication, and clients are present.","Provider bearer tokens are accepted only during registration and are never returned."],"fields":{"authentication":{"nullable":false,"presence":"optional","type":{"kind":"ref","name":"BrowserProviderAuthentication"}},"available":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"clients":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"uint64"}},"endpoint":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"string"}},"provider_id":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"string"}},"revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"targets":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"BrowserProviderTarget"},"kind":"array"}}},"kind":"object"},"BrowserProviderTarget":{"additional_properties":false,"fields":{"tab_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"target_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"BrowserProviderUnregisterResult":{"additional_properties":false,"fields":{"removed":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}}},"kind":"object"},"CellPixelFailure":{"additional_properties":false,"fields":{"error":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"CellPixelResize":{"additional_properties":false,"fields":{"cols":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"reservation_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"rows":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"CellPixelSurface":{"additional_properties":false,"fields":{"height_px":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"width_px":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}}},"kind":"object"},"ClientInfo":{"additional_properties":false,"fields":{"attached":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"Id"},"kind":"array"}},"client":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"connected_seconds":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"kind":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"name":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"self":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"sizes":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"ClientSize"},"kind":"array"}},"transport":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"ClientTransport"}}},"kind":"object"},"ClientSize":{"additional_properties":false,"fields":{"cols":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"rows":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"size_participating":{"nullable":false,"presence":"required","since":10,"type":{"kind":"scalar","name":"boolean"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"ClientTransport":{"kind":"enum","values":["local","unix","ws"]},"CloseTerminalResult":{"additional_properties":false,"fields":{"already_closed":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"closed":{"nullable":false,"presence":"required","type":{"kind":"literal","value":true}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"registry_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"surface":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}},"terminal_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_incarnation":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"ColorHex":{"kind":"alias","target":{"kind":"scalar","name":"string"}},"CopyResult":{"additional_properties":false,"fields":{"mode":{"nullable":false,"presence":"required","type":{"kind":"enum","values":["screen","selection","scrollback"]}},"text":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"CursorStyle":{"kind":"enum","values":["block","underline","bar"]},"DeadPane":{"additional_properties":false,"fields":{"dead":{"nullable":false,"presence":"required","type":{"kind":"literal","value":true}},"id":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"DeclarativeLayout":{"kind":"tagged_union","tag":"type","variants":{"leaf":{"additional_properties":false,"fields":{"command":{"default":null,"nullable":true,"presence":"optional","type":{"items":{"kind":"scalar","name":"string"},"kind":"array","min_items":1}},"cwd":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"type":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"leaf"}}},"kind":"object"},"split":{"additional_properties":false,"fields":{"a":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"DeclarativeLayout"}},"b":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"DeclarativeLayout"}},"dir":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"SplitDirection"}},"ratio":{"constraints":[{"clamped_maximum":0.95,"clamped_minimum":0.05}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float32"}},"type":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"split"}}},"kind":"object"},"stack":{"additional_properties":false,"fields":{"expanded":{"constraints":["Must identify a member of panes."],"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"panes":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"Id"},"kind":"array","min_items":1}},"type":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"stack"}}},"kind":"object"}}},"EmptyResult":{"additional_properties":false,"fields":{},"kind":"object"},"ExportLayoutResult":{"additional_properties":false,"fields":{"layout":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Layout"}},"panes":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"ExportedPane"},"kind":"array"}}},"kind":"object"},"ExportedPane":{"additional_properties":false,"fields":{"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"surfaces":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"Id"},"kind":"array"}}},"kind":"object"},"FocusDirectionResult":{"additional_properties":false,"fields":{"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"FrontendFocusTarget":{"kind":"enum","values":["pane","machine_rail","workspace_rail","tabs_rail","projection_rail"]},"FrontendJournalEvent":{"kind":"tagged_union","tag":"kind","variants":{"focus":{"additional_properties":false,"fields":{"content_id":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"event_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"frontend_projection_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"kind":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"focus"}},"pane_id":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"screen_id":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"tab_id":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"target":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"FrontendFocusTarget"}},"workspace_id":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"resize":{"additional_properties":false,"fields":{"cell_height":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"cell_width":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"cols":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"event_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"frontend_projection_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"kind":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"resize"}},"rows":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}}},"kind":"object"},"viewport":{"additional_properties":false,"fields":{"event_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"frontend_projection_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"kind":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"viewport"}},"offset":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"screen_id":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"settled":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"target":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"}}},"FrontendProjection":{"additional_properties":false,"fields":{"frontend":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"projection":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"JsonValue"}},"projection_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"replayed":{"default":false,"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"boolean"}},"schema_version":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"scope":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"subject_key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"GetCellPixelsResult":{"additional_properties":false,"fields":{"height_px":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"surfaces":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"CellPixelSurface"},"kind":"array"}},"width_px":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}}},"kind":"object"},"GuestUrlAcknowledgeResult":{"additional_properties":false,"fields":{"accepted":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}}},"kind":"object"},"GuestUrlClaimResult":{"additional_properties":false,"fields":{"claimed":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}}},"kind":"object"},"GuestUrlOpenResult":{"additional_properties":false,"fields":{"opened":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}}},"kind":"object"},"GuestUrlSubscribeResult":{"additional_properties":false,"fields":{"url_open_ready":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}}},"kind":"object"},"Id":{"kind":"alias","target":{"kind":"scalar","name":"uint64"}},"IdMapping":{"additional_properties":false,"fields":{"id":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"kind":{"nullable":false,"presence":"required","type":{"kind":"enum","values":["workspace","screen","pane","surface"]}},"short_id":{"constraints":[{"pattern":"^[a-z0-9]{6}$"}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"IdentifyResult":{"additional_properties":false,"fields":{"app":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"cmux-tui"}},"build_commit":{"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"capabilities":{"default":[],"nullable":false,"presence":"optional","type":{"items":{"kind":"scalar","name":"string"},"kind":"array"}},"daemon_handoff":{"nullable":false,"presence":"required","since":9,"type":{"kind":"literal","value":1}},"generation":{"nullable":false,"presence":"required","since":7,"type":{"kind":"scalar","name":"string"}},"ghostty_commit":{"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"lifecycle_ready":{"default":true,"nullable":false,"presence":"optional","since":12,"type":{"kind":"scalar","name":"boolean"}},"pid":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"protocol":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"registry_id":{"nullable":false,"presence":"required","since":7,"type":{"kind":"scalar","name":"string"}},"session":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_revision":{"nullable":false,"presence":"required","since":9,"type":{"kind":"scalar","name":"uint64"}},"version":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"workspace_revision":{"nullable":false,"presence":"required","since":7,"type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"IdsResult":{"additional_properties":false,"fields":{"ids":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"IdMapping"},"kind":"array"}}},"kind":"object"},"JsonValue":{"kind":"opaque_json","reason":"The wire field intentionally carries a frontend-authored or runtime-authored arbitrary JSON document."},"KittyGraphicsState":{"additional_properties":false,"fields":{"alternate_next_image_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"alternate_replay_next_image_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"image_bytes":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"images":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"inflight_bytes":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"placements":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"primary_next_image_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"primary_replay_next_image_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"replay_cursor_offset":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}}},"kind":"object"},"KittyImageAlias":{"additional_properties":false,"fields":{"image_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"image_number":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}}},"kind":"object"},"Layout":{"kind":"tagged_union","tag":"type","variants":{"leaf":{"additional_properties":false,"fields":{"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"type":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"leaf"}}},"kind":"object"},"split":{"additional_properties":false,"fields":{"a":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Layout"}},"b":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Layout"}},"dir":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"SplitDirection"}},"ratio":{"constraints":[{"maximum":0.95,"minimum":0.05}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float32"}},"split":{"description":"Stable for the lifetime of this split node.","nullable":false,"presence":"optional","since":8,"type":{"kind":"ref","name":"Id"}},"type":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"split"}}},"kind":"object"},"stack":{"additional_properties":false,"fields":{"expanded":{"constraints":["Must identify a member of panes."],"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"panes":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"Id"},"kind":"array","min_items":1}},"type":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"stack"}}},"kind":"object"}}},"LayoutUndoConfirmationRequired":{"additional_properties":false,"fields":{"closes_panes":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"Id"},"kind":"array"}},"confirmation_required":{"nullable":false,"presence":"required","type":{"kind":"literal","value":true}},"revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"undone":{"nullable":false,"presence":"required","type":{"kind":"literal","value":false}}},"kind":"object"},"LayoutUndoResult":{"kind":"untagged_union","variants":[{"kind":"ref","name":"LayoutUndoUndone"},{"kind":"ref","name":"LayoutUndoConfirmationRequired"}]},"LayoutUndoUndone":{"additional_properties":false,"fields":{"confirmation_required":{"default":false,"nullable":false,"presence":"optional","type":{"kind":"literal","value":false}},"revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"undone":{"nullable":false,"presence":"required","type":{"kind":"literal","value":true}}},"kind":"object"},"ListAgentsResult":{"additional_properties":false,"fields":{"agents":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"AgentRecord"},"kind":"array"}}},"kind":"object"},"ListTerminalsResult":{"additional_properties":false,"fields":{"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"registry_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"terminals":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"TerminalRecord"},"kind":"array"}}},"kind":"object"},"LivePane":{"additional_properties":false,"fields":{"active_tab":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"focused_at":{"default":0,"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"uint64"}},"id":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"name":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"short_id":{"constraints":[{"pattern":"^[a-z0-9]{6}$"}],"nullable":false,"presence":"optional","since":6,"type":{"kind":"scalar","name":"string"}},"tabs":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"Tab"},"kind":"array"}}},"kind":"object"},"MachineListeningTcpResult":{"additional_properties":false,"constraints":["The daemon runs only a fixed socket-listing command; callers cannot supply command text.","The output is limited to 524288 bytes."],"fields":{"stdout":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"MachineUsage":{"additional_properties":false,"constraints":["period_days is the trailing window length in days.","api_equivalent_usd is the list-price equivalent of the machine\'s model traffic in that window."],"fields":{"api_equivalent_usd":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float64"}},"as_of":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"period_days":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"total_tokens":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"vm_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"MachineUsageResult":{"additional_properties":false,"constraints":["usage is null when the daemon has no readout (not a Cloud VM, endpoint unavailable, or usage not ready)."],"fields":{"usage":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"MachineUsage"}}},"kind":"object"},"MintTerminalRendererResult":{"additional_properties":false,"fields":{"endpoint":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"incarnation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"protocol_version":{"nullable":false,"presence":"required","since":11,"type":{"kind":"scalar","name":"uint16"}},"rights":{"constraints":[{"current_value":7}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"terminal_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"token":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"ttl_ms":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"MoveTerminalResult":{"additional_properties":false,"fields":{"changed":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"lifecycle":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"TerminalLifecycle"}},"pane":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}},"registry_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"replayed":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"screen":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}},"surface":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}},"terminal_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_incarnation":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"workspace":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace_key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"NotificationLevel":{"kind":"enum","values":["info","warning","error"]},"NotificationMarker":{"additional_properties":false,"fields":{"level":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"NotificationLevel"}},"notification":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"unread":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}}},"kind":"object"},"NotifyResult":{"additional_properties":false,"fields":{"notification":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"Pane":{"kind":"untagged_union","variants":[{"kind":"ref","name":"LivePane"},{"kind":"ref","name":"DeadPane"}]},"PaneDirection":{"kind":"enum","values":["left","right","up","down"]},"PaneNeighborResult":{"additional_properties":false,"fields":{"pane":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"PingResult":{"additional_properties":false,"fields":{"build_commit":{"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"ghostty_commit":{"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"ok":{"nullable":false,"presence":"required","type":{"kind":"literal","value":true}},"protocol":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"version":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"ProcessInfoResult":{"additional_properties":false,"fields":{"command":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"cwd":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"foreground_cwd":{"description":"Working directory of the process group that owns the PTY, read at request time. Null when the lookup fails; absent from daemons that predate the field. Clients treat absence as null.","nullable":true,"presence":"optional","since":12,"type":{"kind":"scalar","name":"string"}},"pid":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"uint32"}}},"kind":"object"},"ProviderWorkspaceMutationResult":{"additional_properties":false,"fields":{"key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"ReadScreenResult":{"additional_properties":false,"fields":{"text":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"ReadScrollbackResult":{"additional_properties":false,"fields":{"epoch":{"nullable":false,"presence":"required","since":10,"type":{"kind":"scalar","name":"uint64"}},"rows":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"RenderRow"},"kind":"array"}},"start":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"total":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}}},"kind":"object"},"RenderCursor":{"additional_properties":false,"fields":{"blink":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"color":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"ColorHex"}},"style":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"CursorStyle"}},"visible":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"x":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"y":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}}},"kind":"object"},"RenderGraphicFormat":{"kind":"enum","values":["rgb","rgba"]},"RenderGraphicImage":{"additional_properties":false,"fields":{"data":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Base64"}},"format":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"RenderGraphicFormat"}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"height":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"width":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}}},"kind":"object"},"RenderGraphicPlacement":{"additional_properties":false,"fields":{"anchor_col":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"anchor_row":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"uint32"}},"columns":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"grid_cols":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"grid_rows":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"image_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"ordinal":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"pixel_height":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"pixel_width":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"placement_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"rows":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"source_height":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"source_width":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"source_x":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"source_y":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"viewport_col":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"int32"}},"viewport_row":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"int32"}},"viewport_visible":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"x_offset":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"y_offset":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"z":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"int32"}}},"kind":"object"},"RenderGraphics":{"additional_properties":false,"fields":{"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"images":{"nullable":false,"presence":"optional","type":{"items":{"kind":"ref","name":"RenderGraphicImage"},"kind":"array"}},"placements":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"RenderGraphicPlacement"},"kind":"array"}},"removed_image_ids":{"nullable":false,"presence":"optional","type":{"items":{"kind":"scalar","name":"uint32"},"kind":"array"}}},"kind":"object"},"RenderGraphicsDelta":{"additional_properties":false,"fields":{"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"images":{"nullable":false,"presence":"optional","type":{"items":{"kind":"ref","name":"RenderGraphicImage"},"kind":"array"}},"placements":{"nullable":false,"presence":"optional","type":{"items":{"kind":"ref","name":"RenderGraphicPlacement"},"kind":"array"}},"removed_image_ids":{"nullable":false,"presence":"optional","type":{"items":{"kind":"scalar","name":"uint32"},"kind":"array"}}},"kind":"object"},"RenderRow":{"additional_properties":false,"fields":{"row":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"runs":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"RenderRun"},"kind":"array"}}},"kind":"object"},"RenderRun":{"additional_properties":false,"fields":{"attrs":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"bg":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"ColorHex"}},"fg":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"ColorHex"}},"text":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"underline":{"nullable":false,"presence":"optional","type":{"kind":"ref","name":"RenderUnderline"}},"width_hint":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"uint16"}}},"kind":"object"},"RenderUnderline":{"kind":"enum","values":["single","double","curly","dotted","dashed"]},"ReportAgentResult":{"additional_properties":false,"fields":{"session":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"source":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"AgentReportSource"}},"state":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"AgentState"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"ResizeSurfaceResult":{"additional_properties":false,"fields":{"accepted":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"reservation_id":{"nullable":true,"presence":"required","since":7,"type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"ResolveTerminalResult":{"additional_properties":false,"fields":{"exit":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"TerminalExit"}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"launch_spec":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"JsonValue"}},"lifecycle":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"TerminalLifecycle"}},"registry_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"surface":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}},"terminal_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_incarnation":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"workspace_key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"ResourceSelectors":{"additional_properties":false,"fields":{"agent":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"browser":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"client":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"frontend_projection":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"machine":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"notification":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"pairing_request":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"pane":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"screen":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"session":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"sidebar_view":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"split":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"stream":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"tab":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"terminal":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"workspace":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"RunResult":{"additional_properties":false,"fields":{"already_exited":{"nullable":false,"presence":"required","since":11,"type":{"kind":"scalar","name":"boolean"}},"exit":{"nullable":true,"presence":"required","since":11,"type":{"kind":"ref","name":"TerminalExit"}},"lifecycle":{"nullable":false,"presence":"required","since":11,"type":{"kind":"ref","name":"TerminalLifecycle"}},"pane":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}},"screen":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}},"surface":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}},"terminal_id":{"nullable":false,"presence":"required","since":9,"type":{"kind":"scalar","name":"string"}},"terminal_incarnation":{"nullable":true,"presence":"required","since":9,"type":{"kind":"scalar","name":"string"}},"terminal_revision":{"nullable":false,"presence":"required","since":11,"type":{"kind":"scalar","name":"uint64"}},"workspace":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"Screen":{"additional_properties":false,"fields":{"active":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"active_pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"id":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"layout":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Layout"}},"name":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"panes":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"Pane"},"kind":"array"}},"short_id":{"constraints":[{"pattern":"^[a-z0-9]{6}$"}],"nullable":false,"presence":"optional","since":6,"type":{"kind":"scalar","name":"string"}},"zoomed_pane":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"ServerStatsConnections":{"additional_properties":false,"constraints":["refused counts sockets dropped at limit; for hook producers each one is a lost event."],"fields":{"accepted":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"active":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"limit":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"peak":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"refused":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"ServerStatsHistogram":{"additional_properties":false,"constraints":["Percentiles are log-linear bucket upper bounds and overestimate the true sample by at most 25%.","Latency histograms are in microseconds; batch_size counts events."],"fields":{"count":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"max":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"mean":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"p50":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"p90":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"p99":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"ServerStatsJournalWriter":{"additional_properties":false,"constraints":["commit_us excludes lock wait; commit_lock_wait_us is the writer waiting for the registry lock.","terminal_queued and durable_queued are live lane depths."],"fields":{"batch_size":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"ServerStatsHistogram"}},"batches":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"commit_failures":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"commit_lock_wait_us":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"ServerStatsHistogram"}},"commit_us":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"ServerStatsHistogram"}},"deadline_expiries":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"durable_events":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"durable_queued":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"phase":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"ServerStatsWriterPhase"}},"phase_for_us":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"receipt_wait_us":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"ServerStatsHistogram"}},"terminal_events":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"terminal_queued":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"ServerStatsLockHolder":{"additional_properties":false,"constraints":["site is the file:line that acquired the registry lock."],"fields":{"held_for_us":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"site":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"ServerStatsLockSite":{"additional_properties":false,"constraints":[],"fields":{"acquisitions":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"hold_max_us":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"hold_total_us":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"site":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"ServerStatsLockStall":{"additional_properties":false,"constraints":["blocker is the site holding the lock when the waiter\'s wait began, or null when it was free."],"fields":{"blocker":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"waited_us":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"waiter":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"ServerStatsRegistryLock":{"additional_properties":false,"constraints":["contended_acquisitions counts waits of at least 1 ms; stalls counts waits of at least 100 ms.","top_sites is ordered by hold_total_us descending and holds at most eight entries."],"fields":{"contended_acquisitions":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"hold_us":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"ServerStatsHistogram"}},"holder":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"ServerStatsLockHolder"}},"last_stall":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"ServerStatsLockStall"}},"stalls":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"top_sites":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"ServerStatsLockSite"},"kind":"array"}},"wait_us":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"ServerStatsHistogram"}}},"kind":"object"},"ServerStatsResult":{"additional_properties":false,"constraints":["schema is 1.","journal_writer is null for ephemeral sessions without a durable journal.","Counters accumulate since daemon start; reading them never touches SQLite or the journal."],"fields":{"connections":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"ServerStatsConnections"}},"journal_writer":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"ServerStatsJournalWriter"}},"registry_lock":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"ServerStatsRegistryLock"}},"schema":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"uptime_ms":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"ServerStatsWriterPhase":{"kind":"enum","values":["idle","waiting_lock","committing"]},"SetCellPixelsResult":{"additional_properties":false,"fields":{"failures":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"CellPixelFailure"},"kind":"array"}},"resizes":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"CellPixelResize"},"kind":"array"}}},"kind":"object"},"ShutdownDaemonResult":{"additional_properties":false,"fields":{"accepted":{"nullable":false,"presence":"required","type":{"kind":"literal","value":true}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"pid":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}}},"kind":"object"},"SidebarPluginResult":{"additional_properties":false,"fields":{"error":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"retry_after_ms":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"surface":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"Size":{"additional_properties":false,"fields":{"cols":{"constraints":[{"maximum":10000,"minimum":1}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"rows":{"constraints":[{"maximum":10000,"minimum":1}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}}},"kind":"object"},"SplitDirection":{"kind":"enum","values":["right","down"]},"SurfaceResult":{"additional_properties":false,"fields":{"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"terminal_id":{"nullable":true,"presence":"optional","since":9,"type":{"kind":"scalar","name":"string"}},"terminal_incarnation":{"nullable":true,"presence":"optional","since":9,"type":{"kind":"scalar","name":"string"}}},"kind":"object"},"Tab":{"additional_properties":false,"fields":{"browser_error":{"nullable":true,"presence":"optional","since":6,"type":{"kind":"scalar","name":"string"}},"browser_frames_stalled":{"nullable":true,"presence":"optional","since":6,"type":{"kind":"scalar","name":"boolean"}},"browser_source":{"nullable":true,"presence":"required","type":{"kind":"enum","values":["external","launched"]}},"browser_status":{"nullable":true,"presence":"optional","since":6,"type":{"kind":"enum","values":["starting","live","failed"]}},"dead":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"kind":{"nullable":false,"presence":"required","type":{"kind":"enum","values":["pty","browser"]}},"name":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"notification":{"nullable":true,"presence":"optional","since":6,"type":{"kind":"ref","name":"NotificationMarker"}},"short_id":{"constraints":[{"pattern":"^[a-z0-9]{6}$"}],"nullable":false,"presence":"optional","since":6,"type":{"kind":"scalar","name":"string"}},"size":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Size"}},"supports_clear_history_key_fallback":{"capability":"clear-history-key-v1","nullable":false,"presence":"optional","since":9,"type":{"kind":"scalar","name":"boolean"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"terminal_id":{"nullable":true,"presence":"optional","since":9,"type":{"kind":"scalar","name":"string"}},"terminal_incarnation":{"nullable":true,"presence":"optional","since":9,"type":{"kind":"scalar","name":"string"}},"terminal_resource_id":{"constraints":[{"pattern":"^term_[0-9a-f]{32}$"}],"nullable":true,"presence":"optional","since":10,"type":{"kind":"scalar","name":"string"}},"title":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"TerminalColorOverrides":{"additional_properties":false,"fields":{"bg":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"ColorHex"}},"cursor":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"ColorHex"}},"fg":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"ColorHex"}}},"kind":"object"},"TerminalColors":{"additional_properties":false,"fields":{"bg":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"ColorHex"}},"cursor":{"nullable":true,"presence":"optional","since":6,"type":{"kind":"ref","name":"ColorHex"}},"cursor_blink":{"nullable":true,"presence":"optional","since":6,"type":{"kind":"scalar","name":"boolean"}},"cursor_style":{"nullable":true,"presence":"optional","since":6,"type":{"kind":"ref","name":"CursorStyle"}},"fg":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"ColorHex"}},"overrides":{"nullable":false,"presence":"optional","since":12,"type":{"kind":"ref","name":"TerminalColorOverrides"}},"palette":{"constraints":["Decimal string keys are palette indexes 0 through 255."],"nullable":false,"presence":"optional","since":7,"type":{"kind":"map","values":{"kind":"ref","name":"ColorHex"}}},"selection_bg":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"ColorHex"}},"selection_fg":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"ColorHex"}}},"kind":"object"},"TerminalEventsResult":{"additional_properties":false,"fields":{"events":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"TerminalRegistryEvent"},"kind":"array"}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"registry_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"TerminalExit":{"additional_properties":false,"fields":{"exited_at_ms":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"outcome":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"TerminalExitOutcome"}}},"kind":"object"},"TerminalExitOutcome":{"kind":"tagged_union","tag":"kind","variants":{"exit":{"additional_properties":false,"fields":{"code":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"int32"}},"kind":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"exit"}}},"kind":"object"},"signal":{"additional_properties":false,"fields":{"core_dumped":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"kind":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"signal"}},"signal":{"constraints":[{"minimum":1}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"int32"}}},"kind":"object"},"unknown":{"additional_properties":false,"fields":{"kind":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"unknown"}},"reason":{"constraints":[{"min_length":1}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"}}},"TerminalKey":{"kind":"enum","values":["unidentified","backquote","backslash","bracket-left","bracket-right","comma","digit0","digit1","digit2","digit3","digit4","digit5","digit6","digit7","digit8","digit9","equal","a","b","c","d","e","f","g","h","i","j","k","l","m","n","o","p","q","r","s","t","u","v","w","x","y","z","minus","period","quote","semicolon","slash","backspace","enter","space","tab","delete","end","home","insert","page-down","page-up","arrow-down","arrow-left","arrow-right","arrow-up","numpad0","numpad1","numpad2","numpad3","numpad4","numpad5","numpad6","numpad7","numpad8","numpad9","numpad-add","numpad-backspace","numpad-comma","numpad-decimal","numpad-divide","numpad-enter","numpad-equal","numpad-multiply","numpad-subtract","numpad-up","numpad-down","numpad-right","numpad-left","numpad-begin","numpad-home","numpad-end","numpad-insert","numpad-delete","numpad-page-up","numpad-page-down","escape","f1","f2","f3","f4","f5","f6","f7","f8","f9","f10","f11","f12","f13","f14","f15","f16","f17","f18","f19","f20"]},"TerminalKeyAction":{"kind":"enum","values":["press","release","repeat"]},"TerminalKeyInput":{"additional_properties":false,"constraints":["consumed_mods must be a subset of mods.","unshifted_codepoint, shifted_codepoint, and base_layout_codepoint contain exactly one Unicode scalar when present.","utf8 contains no control characters.","macos_option_as_alt may be false only when Alt is active and consumed."],"fields":{"action":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"TerminalKeyAction"}},"base_layout_codepoint":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"composing":{"default":false,"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"boolean"}},"consumed_mods":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"TerminalModifiers"}},"key":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"TerminalKey"}},"macos_option_as_alt":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"mods":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"TerminalModifiers"}},"shifted_codepoint":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"unshifted_codepoint":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"utf8":{"constraints":[{"max_length":4096}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"TerminalLifecycle":{"kind":"enum","values":["launching","adopting","running","exited","tombstoned"]},"TerminalModifiers":{"additional_properties":false,"fields":{"alt":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"caps_lock":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"control":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"num_lock":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"shift":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"super":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}}},"kind":"object"},"TerminalPlacement":{"additional_properties":false,"fields":{"already_exited":{"nullable":false,"presence":"required","since":11,"type":{"kind":"scalar","name":"boolean"}},"exit":{"nullable":true,"presence":"required","since":11,"type":{"kind":"ref","name":"TerminalExit"}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"lifecycle":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"TerminalLifecycle"}},"pane":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}},"registry_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"replayed":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"screen":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}},"surface":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}},"terminal_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_incarnation":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"workspace":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"TerminalRecord":{"additional_properties":false,"fields":{"exit":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"TerminalExit"}},"launch_spec":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"JsonValue"}},"lifecycle":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"TerminalLifecycle"}},"terminal_id":{"constraints":[{"format":"UUIDv4 hex without dashes","pattern":"^[0-9a-f]{32}$"}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_incarnation":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"workspace_key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"TerminalRegistryEvent":{"additional_properties":false,"fields":{"kind":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"mutation_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"origin":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"result":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"JsonValue"}},"terminal_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"workspace_key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"Tree":{"additional_properties":false,"fields":{"generation":{"capability":"workspace-registry-v1","nullable":false,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"pane_revision":{"nullable":false,"presence":"optional","since":9,"type":{"kind":"scalar","name":"uint64"}},"registry_id":{"capability":"workspace-registry-v1","nullable":false,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"terminal_revision":{"nullable":false,"presence":"optional","since":9,"type":{"kind":"scalar","name":"uint64"}},"workspace_revision":{"capability":"workspace-registry-v1","nullable":false,"presence":"optional","since":7,"type":{"kind":"scalar","name":"uint64"}},"workspaces":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"Workspace"},"kind":"array"}}},"kind":"object"},"ViewAttachmentOutcome":{"kind":"enum","values":["applied","passive","superseded"]},"VtStateResult":{"additional_properties":false,"fields":{"cols":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"data":{"constraints":[{"encoding":"standard base64"}],"nullable":false,"presence":"required","type":{"kind":"ref","name":"Base64"}},"kitty_graphics_state":{"nullable":false,"presence":"optional","since":10,"type":{"kind":"ref","name":"KittyGraphicsState"}},"kitty_image_aliases":{"nullable":false,"presence":"optional","since":9,"type":{"items":{"kind":"ref","name":"KittyImageAlias"},"kind":"array"}},"rows":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}}},"kind":"object"},"WaitForResult":{"additional_properties":false,"fields":{"elapsed_ms":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"matched":{"nullable":false,"presence":"required","type":{"kind":"literal","value":true}},"text":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"Workspace":{"additional_properties":false,"fields":{"active":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"id":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"key":{"capability":"workspace-registry-v1","constraints":[{"format":"lowercase canonical UUID"}],"nullable":false,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"name":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"screens":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"Screen"},"kind":"array"}},"short_id":{"constraints":[{"pattern":"^[a-z0-9]{6}$"}],"nullable":false,"presence":"optional","since":6,"type":{"kind":"scalar","name":"string"}}},"kind":"object"},"WorkspaceMutationResult":{"additional_properties":false,"fields":{"changed":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"boolean"}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"index":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"registry_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"replayed":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"ZoomPaneResult":{"additional_properties":false,"fields":{"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"zoomed":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"zoomed_pane":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"}}}') +SCHEMA = json.loads('{"$schema":"./sdk-schema.schema.json","commands":{"apply-layout":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["layout must contain at least one leaf or stack member.","cols and rows affect sizing only when both are present."],"fields":{"cols":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"layout":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"DeclarativeLayout"}},"name":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"rows":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"workspace":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"ApplyLayoutResult"},"since":6,"stream":null},"attach-surface":{"authority":"frontend","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["cols and rows must be supplied together.","Browser surfaces reject mode:render.","expected_generation and expected_terminal_id must be supplied together and match the current daemon and terminal."],"fields":{"cols":{"capability":"attach-initial-size","default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"expected_generation":{"capability":"attach-identity-v1","default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"expected_terminal_id":{"capability":"attach-identity-v1","default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"mode":{"default":"bytes","nullable":true,"presence":"optional","since":7,"type":{"kind":"enum","values":["bytes","render"]}},"rows":{"capability":"attach-initial-size","default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"surface":{"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":{"event_names":["browser-state","colors-changed","detached","frame","notification","output","overflow","render-delta","render-state","resized","scroll-changed","vt-state"],"kind":"attach","mode_field":"mode","modes":{"browser":["browser-state","frame","notification","scroll-changed","overflow","detached"],"bytes":["vt-state","output","resized","colors-changed","scroll-changed","notification","overflow","detached"],"render":["render-state","render-delta","scroll-changed","overflow","detached"]},"ordering":"Initial state precedes the command response. Later surface events preserve order. A resized replay replaces the previous byte mirror; overflow ends that surface stream.","terminal_event":"detached"}},"browser-activate":{"authority":"frontend","capability":null,"constraints":["Browser surfaces only."],"request":{"additional_properties":false,"fields":{"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"browser-back":{"authority":"frontend","capability":null,"constraints":["Browser surfaces only; queue acknowledgement is not page-load success."],"request":{"additional_properties":false,"fields":{"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"browser-forward":{"authority":"frontend","capability":null,"constraints":["Browser surfaces only; queue acknowledgement is not page-load success."],"request":{"additional_properties":false,"fields":{"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"browser-frame-presented":{"authority":"frontend","capability":"browser-pointer-frame-guard-v1","constraints":["Acknowledges the exact rendered browser frame for this connection.","Requires browser-pointer-frame-guard-v1."],"request":{"additional_properties":false,"fields":{"frame_seq":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":10,"stream":null},"browser-insert-text":{"authority":"frontend","capability":null,"constraints":["Browser surfaces only.","The bounded disposable input queue drops newest input when full."],"request":{"additional_properties":false,"fields":{"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"text":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"browser-key":{"authority":"frontend","capability":null,"constraints":["Browser surfaces only.","The bounded disposable input queue drops newest input when full."],"request":{"additional_properties":false,"fields":{"code":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"kind":{"nullable":false,"presence":"required","type":{"kind":"enum","values":["down","up"]}},"modifiers":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"text":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"windows_virtual_key_code":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"browser-key-press":{"authority":"frontend","capability":null,"constraints":["Browser surfaces only.","One request preserves the atomic press sequence through the bounded input queue."],"request":{"additional_properties":false,"fields":{"code":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"modifiers":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"text":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"windows_virtual_key_code":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":10,"stream":null},"browser-mouse":{"authority":"frontend","capability":null,"constraints":["Browser surfaces only; coordinates are CSS pixels.","The bounded disposable input queue drops newest input when full."],"request":{"additional_properties":false,"fields":{"button":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"click_count":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint32"}},"frame_seq":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint64"}},"kind":{"nullable":false,"presence":"required","type":{"kind":"enum","values":["down","up","move"]}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"x_px":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float64"}},"y_px":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float64"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"browser-mouse-guarded":{"authority":"frontend","capability":"browser-pointer-frame-guard-v1","constraints":["Browser surfaces only; coordinates are CSS pixels.","The frame sequence must be the exact presented token for this connection.","Requires browser-pointer-frame-guard-v1."],"request":{"additional_properties":false,"fields":{"button":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"click_count":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint32"}},"frame_seq":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"kind":{"nullable":false,"presence":"required","type":{"kind":"enum","values":["down","up","move"]}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"x_px":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float64"}},"y_px":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float64"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":10,"stream":null},"browser-navigate":{"authority":"frontend","capability":null,"constraints":["Queue acknowledgement only; observe browser-state for outcome.","Navigation is latest-wins."],"request":{"additional_properties":false,"fields":{"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"url":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"browser-reload":{"authority":"frontend","capability":null,"constraints":["Browser surfaces only; queue acknowledgement is not page-load success."],"request":{"additional_properties":false,"fields":{"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"browser-wheel":{"authority":"frontend","capability":null,"constraints":["Browser surfaces only; values are CSS pixels.","The bounded disposable input queue drops newest input when full."],"request":{"additional_properties":false,"fields":{"delta_y_px":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float64"}},"frame_seq":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint64"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"x_px":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float64"}},"y_px":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float64"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"browser-wheel-guarded":{"authority":"frontend","capability":"browser-pointer-frame-guard-v1","constraints":["Browser surfaces only; values are CSS pixels.","The frame sequence must be the exact presented token for this connection.","Requires browser-pointer-frame-guard-v1."],"request":{"additional_properties":false,"fields":{"delta_y_px":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float64"}},"frame_seq":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"x_px":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float64"}},"y_px":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float64"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":10,"stream":null},"clear-history":{"authority":"control","capability":"clear-history-v1","constraints":["PTY surfaces only.","Failed responses classify error_delivery as known-not-delivered or ambiguous."],"request":{"additional_properties":false,"fields":{"fallback_key":{"capability":"clear-history-key-v1","default":null,"nullable":true,"presence":"optional","since":9,"type":{"kind":"ref","name":"TerminalKeyInput"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":9,"stream":null},"clear-window-title":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"client-focus":{"authority":"control","capability":"client-focus-v1","constraints":[],"request":{"additional_properties":false,"fields":{"client_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"additional_properties":false,"fields":{"pane":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}},"tab":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"since":12,"stream":null},"close-pane":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"close-provider-managed-workspace":{"authority":"provider-authority","capability":"provider-managed-workspace-authority-v2","constraints":["Call only after the external provider durably accepts the close."],"request":{"additional_properties":false,"constraints":["workspace and key must identify the same live provider-managed workspace."],"fields":{"authority":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"ProviderWorkspaceMutationResult"},"since":9,"stream":null},"close-screen":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"close-surface":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"close-terminal":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["origin and mutation_id are either both present or both absent."],"fields":{"expected_generation":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"expected_revision":{"aliases":["expected_terminal_revision"],"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint64"}},"mutation_id":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"origin":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"terminal_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_incarnation":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"CloseTerminalResult"},"since":9,"stream":null},"close-workspace":{"authority":"control","capability":null,"constraints":["Provider-managed workspaces reject this ordinary mutation."],"request":{"additional_properties":false,"constraints":["At least one of workspace and key must be supplied; both must identify the same workspace when supplied.","origin and mutation_id are either both present or both absent."],"fields":{"expected_generation":{"default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"expected_revision":{"aliases":["expected_terminal_revision"],"default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"uint64"}},"key":{"capability":"workspace-registry-v1","default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"mutation_id":{"default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"origin":{"default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"workspace":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"WorkspaceMutationResult"},"since":5,"stream":null},"copy":{"authority":"control","capability":null,"constraints":["PTY surfaces only."],"request":{"additional_properties":false,"fields":{"mode":{"nullable":false,"presence":"required","type":{"kind":"enum","values":["screen","selection","scrollback"]}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"CopyResult"},"since":6,"stream":null},"create-surface-with-receipt":{"authority":"control","capability":"creation-receipts-v1","constraints":["Repeating one origin and receipt with identical fields returns the original creation result.","A new idempotency_key is valid only when durable creation resolution instructs retry_new_idempotency_key."],"request":{"additional_properties":false,"constraints":["operation is one of new-tab, run-command, new-browser-tab, new-workspace, new-screen, new-pane, new-pane-right, split-right, or split-down.","Each operation admits only its documented selector and option fields.","idempotency_key names one execution attempt and defaults to receipt.","cols and rows must be supplied together."],"fields":{"argv":{"default":null,"nullable":true,"presence":"optional","type":{"items":{"kind":"scalar","name":"string"},"kind":"array"}},"cols":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"cwd":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"idempotency_key":{"capability":"creation-attempt-keys-v1","default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"operation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"origin":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"pane":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}},"receipt":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"rows":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"selector_fallbacks":{"default":[],"nullable":false,"presence":"optional","type":{"items":{"kind":"ref","name":"ResourceSelectors"},"kind":"array","max_items":7}},"selectors":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"ResourceSelectors"}},"url":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"width":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"float32"}},"workspace":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"JsonValue"},"since":10,"stream":null},"create-terminal":{"authority":"control","capability":"workspace-registry-v1","constraints":[],"request":{"additional_properties":false,"constraints":["At least one of workspace and key must be supplied; when both are supplied they must identify the same workspace.","argv and command are mutually exclusive and must be nonempty when supplied.","cols and rows must be supplied together.","origin and mutation_id are either both present or both absent.","terminal_id may be supplied only when origin and mutation_id are both present."],"fields":{"argv":{"default":null,"nullable":true,"presence":"optional","type":{"items":{"kind":"scalar","name":"string"},"kind":"array","min_items":1}},"cols":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"command":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"cwd":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"expected_generation":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"expected_revision":{"aliases":["expected_terminal_revision"],"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint64"}},"key":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"mutation_id":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"name":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"origin":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"rows":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"terminal_id":{"constraints":[{"format":"32-character lowercase UUIDv4 hex without dashes","pattern":"^[0-9a-f]{12}4[0-9a-f]{3}[89ab][0-9a-f]{15}$"}],"default":null,"nullable":true,"presence":"optional","since":9,"type":{"kind":"scalar","name":"string"}},"workspace":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"TerminalPlacement"},"since":7,"stream":null},"create-workspace":{"authority":"control","capability":"workspace-registry-v1","constraints":[],"request":{"additional_properties":false,"constraints":["origin and mutation_id are either both present or both absent.","At most 4096 live workspaces may exist; tombstoned keys cannot be reused."],"fields":{"expected_generation":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"expected_revision":{"aliases":["expected_terminal_revision"],"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint64"}},"key":{"constraints":[{"format":"lowercase canonical UUID"}],"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"mutation_id":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"name":{"constraints":[{"max_utf8_bytes":1024}],"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"origin":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"WorkspaceMutationResult"},"since":7,"stream":null},"detach-attached-view":{"authority":"frontend","capability":"view-attachment-detach-v1","constraints":["The command closes only the named view stream and releases its size contribution.","A retired lease returns outcome:superseded."],"request":{"additional_properties":false,"fields":{"lease":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"AttachedViewOutcomeResult"},"since":10,"stream":null},"detach-client":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"client":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"export-layout":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"screen":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"ExportLayoutResult"},"since":6,"stream":null},"focus-direction":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"dir":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"PaneDirection"}},"pane":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"FocusDirectionResult"},"since":6,"stream":null},"focus-pane":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"get-browser-provider":{"authority":"local-admin","capability":"browser-provider-v1","constraints":["Provider endpoints and targets are disclosed only over a trusted local transport; bearer credentials are accepted only during registration and are never returned.","Automation must select a target by stable tab id instead of treating CDP discovery as topology authority."],"request":{"additional_properties":false,"fields":{},"kind":"object"},"result":{"kind":"ref","name":"BrowserProviderSnapshot"},"since":10,"stream":null},"get-cell-pixels":{"authority":"frontend","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{},"kind":"object"},"result":{"kind":"ref","name":"GetCellPixelsResult"},"since":6,"stream":null},"get-frontend-projection":{"authority":"control","capability":null,"constraints":["Each identifier is nonempty, contains no control character, and is at most 128 bytes."],"request":{"additional_properties":false,"fields":{"frontend":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"scope":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"subject_key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"FrontendProjection"},"since":7,"stream":null},"identify":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{},"kind":"object"},"result":{"kind":"ref","name":"IdentifyResult"},"since":5,"stream":null},"ids":{"authority":"control","capability":null,"constraints":["Short ids are snapshot-local labels; command parameters accept numeric ids only."],"request":{"additional_properties":false,"fields":{"kind":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"enum","values":["workspace","screen","pane","surface"]}}},"kind":"object"},"result":{"kind":"ref","name":"IdsResult"},"since":6,"stream":null},"journal-frontend-event":{"authority":"control","capability":"frontend-journal-v1","constraints":["The server derives producer identity from the authenticated control client."],"request":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"FrontendJournalEvent"}}},"kind":"object"},"result":{"additional_properties":false,"fields":{"committed":{"nullable":false,"presence":"required","type":{"kind":"literal","value":true}}},"kind":"object"},"since":10,"stream":null},"list-agents":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"state":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"AgentState"}},"surface":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"ListAgentsResult"},"since":6,"stream":null},"list-clients":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{},"kind":"object"},"result":{"items":{"kind":"ref","name":"ClientInfo"},"kind":"array"},"since":6,"stream":null},"list-terminals":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{},"kind":"object"},"result":{"kind":"ref","name":"ListTerminalsResult"},"since":9,"stream":null},"list-workspaces":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{},"kind":"object"},"result":{"kind":"ref","name":"Tree"},"since":5,"stream":null},"machine-listening-tcp":{"authority":"control","capability":"machine-listening-tcp-v1","constraints":["Routine Cloud port inventory uses this command over the authenticated private cmux-tui link."],"request":{"additional_properties":false,"fields":{},"kind":"object"},"result":{"kind":"ref","name":"MachineListeningTcpResult"},"since":12,"stream":null},"machine-usage":{"authority":"control","capability":"machine-usage-v1","constraints":[],"request":{"additional_properties":false,"fields":{},"kind":"object"},"result":{"kind":"ref","name":"MachineUsageResult"},"since":12,"stream":null},"mark-workspaces-provider-managed":{"authority":"provider-authority","capability":"provider-managed-workspace-authority-v2","constraints":["Authority must match the value provisioned before this mux generation accepted control clients."],"request":{"additional_properties":false,"fields":{"authority":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":9,"stream":null},"mint-terminal-renderer":{"authority":"frontend","capability":null,"constraints":["Only terminal-host-backed PTYs can mint one-use renderer credentials."],"request":{"additional_properties":false,"fields":{"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"ttl_ms":{"constraints":[{"maximum":60000,"minimum":1}],"default":30000,"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"result":{"kind":"ref","name":"MintTerminalRendererResult"},"since":9,"stream":null},"mint-terminal-renderer-by-terminal":{"authority":"frontend","capability":null,"constraints":["The terminal resource ID is resolved atomically to the live terminal-host-backed PTY before minting a one-use renderer credential."],"request":{"additional_properties":false,"fields":{"terminal":{"constraints":[{"pattern":"^term_[0-9a-f]{32}$"}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"ttl_ms":{"constraints":[{"maximum":60000,"minimum":1}],"default":30000,"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"result":{"kind":"ref","name":"MintTerminalRendererResult"},"since":11,"stream":null},"move-tab":{"authority":"control","capability":null,"constraints":["An out-of-range index clamps to the destination end."],"request":{"additional_properties":false,"fields":{"index":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"move-tab-to-workspace":{"authority":"control","capability":"tab-workspace-move-v1","constraints":["Moves the existing tab to the selected workspace or atomically creates a workspace when workspace is omitted."],"request":{"additional_properties":false,"fields":{"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":12,"stream":null},"move-terminal":{"authority":"control","capability":null,"constraints":["A move to the current workspace still commits a terminal revision with changed:false."],"request":{"additional_properties":false,"constraints":["origin and mutation_id are either both present or both absent."],"fields":{"expected_generation":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"expected_revision":{"aliases":["expected_terminal_revision"],"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint64"}},"mutation_id":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"origin":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"terminal_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_incarnation":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"workspace_key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"MoveTerminalResult"},"since":9,"stream":null},"move-workspace":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["At least one of workspace and key must be supplied; both must identify the same workspace when supplied.","origin and mutation_id are either both present or both absent."],"fields":{"expected_generation":{"default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"expected_revision":{"aliases":["expected_terminal_revision"],"default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"uint64"}},"index":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"key":{"capability":"workspace-registry-v1","default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"mutation_id":{"default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"origin":{"default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"workspace":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"WorkspaceMutationResult"},"since":5,"stream":null},"new-browser-tab":{"authority":"control","capability":null,"constraints":["Bootstrap and navigation failures are asynchronous browser-state outcomes."],"request":{"additional_properties":false,"constraints":["cols and rows affect sizing only when both are present."],"fields":{"cols":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"pane":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}},"rows":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"url":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"SurfaceResult"},"since":5,"stream":null},"new-pane":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["cols and rows affect sizing only when both are present."],"fields":{"cols":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"rows":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}}},"kind":"object"},"result":{"kind":"ref","name":"SurfaceResult"},"since":9,"stream":null},"new-pane-right":{"authority":"control","capability":"viewport-splits-v1","constraints":[],"request":{"additional_properties":false,"constraints":["Omitted width defaults to two thirds.","cols and rows affect sizing only when both are present."],"fields":{"cols":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"rows":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"width":{"constraints":[{"maximum":1.0,"minimum":0.1}],"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"float32"}}},"kind":"object"},"result":{"kind":"ref","name":"SurfaceResult"},"since":9,"stream":null},"new-screen":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["cols and rows affect sizing only when both are present."],"fields":{"cols":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"rows":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"workspace":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"SurfaceResult"},"since":5,"stream":null},"new-tab":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["cols and rows affect sizing only when both are present."],"fields":{"cols":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"cwd":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"pane":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}},"rows":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}}},"kind":"object"},"result":{"kind":"ref","name":"SurfaceResult"},"since":5,"stream":null},"new-workspace":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["cols and rows affect sizing only when both are present."],"fields":{"cols":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"name":{"constraints":[{"max_utf8_bytes":1024}],"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"rows":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}}},"kind":"object"},"result":{"kind":"ref","name":"SurfaceResult"},"since":5,"stream":null},"notify":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"body":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"level":{"default":"info","nullable":true,"presence":"optional","type":{"kind":"ref","name":"NotificationLevel"}},"surface":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}},"title":{"constraints":[{"min_length":1}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"NotifyResult"},"since":6,"stream":null},"pairing-response":{"authority":"local-admin","capability":null,"constraints":["The request id must identify a live, unexpired pairing challenge."],"request":{"additional_properties":false,"fields":{"approve":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"request":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":7,"stream":null},"pane-neighbor":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"dir":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"PaneDirection"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"PaneNeighborResult"},"since":6,"stream":null},"paste-image":{"authority":"control","capability":"terminal-image-paste-v1","constraints":["Requires terminal-image-paste-v1 and a current connection-owned attachment lease for the exact public terminal. Begin(mime,size), chunk(offset,data), commit, cancel. No caller-supplied paths. 20 MiB per image; 48 KiB chunks; generated temporary files expire after 600 seconds."],"request":{"additional_properties":false,"fields":{"data":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"lease":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"mime":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"offset":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint64"}},"op":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"size":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint64"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"terminal_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"upload_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"additional_properties":false,"fields":{"accepted":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}}},"kind":"object"},"since":12,"stream":null},"ping":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{},"kind":"object"},"result":{"kind":"ref","name":"PingResult"},"since":6,"stream":null},"process-info":{"authority":"control","capability":null,"constraints":["PTY surfaces only."],"request":{"additional_properties":false,"fields":{"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"ProcessInfoResult"},"since":6,"stream":null},"put-frontend-projection":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["origin and mutation_id are either both present or both absent.","Serialized projection must be at most 1048576 bytes."],"fields":{"expected_generation":{"default":null,"description":"Accepted by the current decoder but ignored for projection writes.","nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"expected_projection_revision":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint64"}},"expected_revision":{"aliases":["expected_terminal_revision"],"default":null,"description":"Accepted by the current decoder but ignored for projection writes.","nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint64"}},"frontend":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"mutation_id":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"origin":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"projection":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"JsonValue"}},"schema_version":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"scope":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"subject_key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"FrontendProjection"},"since":7,"stream":null},"read-screen":{"authority":"control","capability":null,"constraints":["PTY surfaces only."],"request":{"additional_properties":false,"fields":{"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"ReadScreenResult"},"since":5,"stream":null},"read-scrollback":{"authority":"control","capability":null,"constraints":["PTY surfaces only; row indexes are snapshot-relative and not durable."],"request":{"additional_properties":false,"fields":{"count":{"constraints":[{"maximum":65535,"minimum":0}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"start":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"ReadScrollbackResult"},"since":7,"stream":null},"register-browser-provider":{"authority":"local-admin","capability":"browser-provider-v1","constraints":["The lease is scoped to the trusted local control connection and is released on disconnect.","The endpoint must be an explicit loopback ws URL with no credentials or fragment.","Bearer authentication is optional and sends the token only in the CDP WebSocket upgrade Authorization header.","Each registration replaces that connection\'s complete target set; target ids are never journaled."],"request":{"additional_properties":false,"fields":{"authentication":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"BrowserProviderAuthentication"}},"bearer_token":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"endpoint":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"provider_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"targets":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"BrowserProviderTarget"},"kind":"array"}}},"kind":"object"},"result":{"kind":"ref","name":"BrowserProviderSnapshot"},"since":10,"stream":null},"release-attached-view-size":{"authority":"frontend","capability":"view-attachment-lease-v1","constraints":["The attach stream remains live for cached rendering."],"request":{"additional_properties":false,"fields":{"lease":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"AttachedViewOutcomeResult"},"since":10,"stream":null},"release-surface-size":{"authority":"control","capability":null,"constraints":["An absent lease is a successful no-op."],"request":{"additional_properties":false,"fields":{"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":7,"stream":null},"reload-config":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{},"kind":"object"},"result":{"additional_properties":false,"fields":{"path":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"reloaded":{"nullable":false,"presence":"required","type":{"kind":"literal","value":true}}},"kind":"object"},"since":6,"stream":null},"rename-pane":{"authority":"control","capability":null,"constraints":["An empty name clears the pane name."],"request":{"additional_properties":false,"fields":{"name":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"rename-provider-managed-workspace":{"authority":"provider-authority","capability":"provider-managed-workspace-authority-v2","constraints":["Call only after the external provider durably accepts the rename."],"request":{"additional_properties":false,"constraints":["workspace and key must identify the same live provider-managed workspace."],"fields":{"authority":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"name":{"constraints":[{"max_utf8_bytes":1024}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"ProviderWorkspaceMutationResult"},"since":9,"stream":null},"rename-screen":{"authority":"control","capability":null,"constraints":["An empty name clears the screen name."],"request":{"additional_properties":false,"fields":{"name":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"rename-surface":{"authority":"control","capability":null,"constraints":["An empty name clears the surface name."],"request":{"additional_properties":false,"fields":{"name":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"rename-workspace":{"authority":"control","capability":null,"constraints":["Provider-managed workspaces reject this ordinary mutation."],"request":{"additional_properties":false,"constraints":["At least one of workspace and key must be supplied; both must identify the same workspace when supplied.","origin and mutation_id are either both present or both absent."],"fields":{"expected_generation":{"default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"expected_revision":{"aliases":["expected_terminal_revision"],"default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"uint64"}},"key":{"capability":"workspace-registry-v1","default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"mutation_id":{"default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"name":{"constraints":[{"max_utf8_bytes":1024}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"origin":{"default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"workspace":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"WorkspaceMutationResult"},"since":5,"stream":null},"report-agent":{"authority":"control","capability":null,"constraints":["A stored hook report outranks later socket reports until another hook report or surface close."],"request":{"additional_properties":false,"fields":{"session":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"source":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"AgentReportSource"}},"state":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"AgentState"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"ReportAgentResult"},"since":6,"stream":null},"report-focus":{"authority":"control","capability":"client-focus-v1","constraints":[],"request":{"additional_properties":false,"fields":{"client_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"tab":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":12,"stream":null},"resize-attached-view":{"authority":"frontend","capability":"view-attachment-lease-v1","constraints":["The lease must belong to this connection and surface.","A retired lease returns outcome:superseded without changing replacement views."],"request":{"additional_properties":false,"fields":{"cols":{"constraints":[{"clamped_maximum":10000,"clamped_minimum":1}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"lease":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"rows":{"constraints":[{"clamped_maximum":10000,"clamped_minimum":1}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"AttachedViewResizeResult"},"since":10,"stream":null},"resize-surface":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"cols":{"constraints":[{"clamped_maximum":10000,"clamped_minimum":1}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"rows":{"constraints":[{"clamped_maximum":10000,"clamped_minimum":1}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"ResizeSurfaceResult"},"since":5,"stream":null},"resolve-terminal":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"terminal_id":{"constraints":[{"format":"terminal host id (UUIDv4 hex without dashes) or public term_ resource id","pattern":"^(term_)?[0-9a-f]{32}$"}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"ResolveTerminalResult"},"since":9,"stream":null},"run":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["Exactly one of argv and command must be supplied.","pane and new_workspace:true are mutually exclusive.","key is valid only with new_workspace:true.","cols and rows affect sizing only when both are present."],"fields":{"argv":{"default":null,"nullable":true,"presence":"optional","type":{"items":{"kind":"scalar","name":"string"},"kind":"array","min_items":1}},"cols":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"command":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"cwd":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"key":{"default":null,"nullable":true,"presence":"optional","since":9,"type":{"kind":"scalar","name":"string"}},"name":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"new_workspace":{"default":false,"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"boolean"}},"pane":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}},"rows":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}}},"kind":"object"},"result":{"kind":"ref","name":"RunResult"},"since":6,"stream":null},"scroll-surface":{"authority":"control","capability":null,"constraints":["PTY surfaces only; negative values scroll up."],"request":{"additional_properties":false,"fields":{"delta":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"int64"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"select-screen":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["When both index and delta are supplied, index wins."],"fields":{"delta":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"int64"}},"index":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"select-tab":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["When both index and delta are supplied, index wins."],"fields":{"delta":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"int64"}},"index":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint64"}},"pane":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"select-workspace":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["When both index and delta are supplied, index wins."],"fields":{"delta":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"int64"}},"index":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"send":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["When both are present, UTF-8 text bytes precede decoded bytes."],"fields":{"bytes":{"constraints":[{"encoding":"standard base64"}],"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Base64"}},"paste":{"default":false,"nullable":false,"presence":"optional","since":7,"type":{"kind":"scalar","name":"boolean"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"text":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"send-key":{"authority":"control","capability":null,"constraints":["PTY surfaces only."],"request":{"additional_properties":false,"fields":{"keys":{"constraints":[{"syntax":"lowercase modifier+key chords"}],"nullable":false,"presence":"required","type":{"items":{"kind":"scalar","name":"string"},"kind":"array","min_items":1}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"server-stats":{"authority":"local-admin","capability":"server-stats-v1","constraints":["Owner-only diagnostics; never journaled and safe to poll."],"request":{"additional_properties":false,"fields":{},"kind":"object"},"result":{"kind":"ref","name":"ServerStatsResult"},"since":12,"stream":null},"set-cell-pixels":{"authority":"frontend","capability":null,"constraints":["Accepted browser resizes complete asynchronously."],"request":{"additional_properties":false,"fields":{"height_px":{"constraints":[{"clamped_minimum":1}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"width_px":{"constraints":[{"clamped_minimum":1}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}}},"kind":"object"},"result":{"kind":"ref","name":"SetCellPixelsResult"},"since":6,"stream":null},"set-client-info":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"capabilities":{"constraints":["Advertises additive client capabilities for this connection."],"default":null,"nullable":true,"presence":"optional","type":{"items":{"kind":"scalar","name":"string"},"kind":"array"}},"kind":{"constraints":["Control characters become spaces; at most 64 Unicode characters are retained."],"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"name":{"constraints":["Control characters become spaces; at most 64 Unicode characters are retained."],"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"set-client-sizing":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["exclusive:true requires client and enabled:true.","Omitting client is valid only with enabled:true and restores all clients for the surface."],"fields":{"client":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint64"}},"enabled":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"exclusive":{"default":false,"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"boolean"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":10,"stream":null},"set-default-colors":{"authority":"control","capability":null,"constraints":["Color strings are exactly #rrggbb.","With complete:true, absent optional values reset to built-in defaults."],"request":{"additional_properties":false,"fields":{"bg":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"ColorHex"}},"complete":{"default":false,"nullable":false,"presence":"optional","since":9,"type":{"kind":"scalar","name":"boolean"}},"cursor":{"default":null,"nullable":true,"presence":"optional","since":9,"type":{"kind":"ref","name":"ColorHex"}},"cursor_blink":{"default":null,"nullable":true,"presence":"optional","since":9,"type":{"kind":"scalar","name":"boolean"}},"cursor_style":{"default":null,"nullable":true,"presence":"optional","since":9,"type":{"kind":"ref","name":"CursorStyle"}},"fg":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"ColorHex"}},"palette":{"constraints":["Decimal string keys are palette indexes 0 through 255."],"default":null,"nullable":true,"presence":"optional","since":9,"type":{"kind":"map","values":{"kind":"ref","name":"ColorHex"}}},"selection_bg":{"default":null,"nullable":true,"presence":"optional","since":9,"type":{"kind":"ref","name":"ColorHex"}},"selection_fg":{"default":null,"nullable":true,"presence":"optional","since":9,"type":{"kind":"ref","name":"ColorHex"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"set-ratio":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"dir":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"SplitDirection"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"ratio":{"constraints":[{"clamped_maximum":0.95,"clamped_minimum":0.05}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float32"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"set-split-ratio":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"ratio":{"constraints":[{"clamped_maximum":0.95,"clamped_minimum":0.05}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float32"}},"split":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"transaction":{"capability":"layout-undo-v1","default":null,"nullable":true,"presence":"optional","since":9,"type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":8,"stream":null},"set-viewport-pane-width":{"authority":"control","capability":"viewport-column-resize-v1","constraints":["width must be finite."],"request":{"additional_properties":false,"fields":{"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"transaction":{"capability":"layout-undo-v1","default":null,"nullable":true,"presence":"optional","since":9,"type":{"kind":"scalar","name":"uint64"}},"width":{"constraints":[{"maximum":1.0,"minimum":0.1}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float32"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":9,"stream":null},"set-window-title":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"title":{"constraints":["C0 controls are sanitized before OSC output."],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"shutdown-daemon":{"authority":"local-admin","capability":null,"constraints":["pid and generation must match the latest identify result.","force bypasses native-browser ownership only; the identity fence and trusted-local authority still apply.","Clients must require daemon-handoff-force-v1 before sending force:true.","The daemon exits only after the success response is queued."],"request":{"additional_properties":false,"fields":{"force":{"capability":"daemon-handoff-force-v1","default":false,"nullable":false,"presence":"optional","since":10,"type":{"kind":"scalar","name":"boolean"}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"pid":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}}},"kind":"object"},"result":{"kind":"ref","name":"ShutdownDaemonResult"},"since":9,"stream":null},"sidebar-plugin":{"authority":"frontend","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"cols":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"relaunch":{"default":false,"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"boolean"}},"rows":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}}},"kind":"object"},"result":{"kind":"ref","name":"SidebarPluginResult"},"since":6,"stream":null},"split":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["cols and rows affect sizing only when both are present."],"fields":{"cols":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"dir":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"SplitDirection"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"rows":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}}},"kind":"object"},"result":{"kind":"ref","name":"SurfaceResult"},"since":5,"stream":null},"subscribe":{"authority":"frontend","capability":null,"constraints":["subscribe sends no initial tree snapshot.","surface filtering occurs before the bounded mailbox."],"request":{"additional_properties":false,"fields":{"surface":{"capability":"surface-subscribe-filter","default":null,"nullable":true,"presence":"optional","since":9,"type":{"kind":"ref","name":"Id"}},"tree_events":{"default":"coarse","nullable":true,"presence":"optional","since":7,"type":{"kind":"enum","values":["coarse","deltas"]}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":{"event_names":["agent-changed","bell","client-attached","client-changed","client-detached","client-list-invalidated","config-reload-requested","empty","frontend-projection-changed","layout-changed","notification","overflow","pairing-requested","pairing-resolved","pane-added","pane-closed","screen-added","screen-closed","screen-renamed","scroll-changed","status","surface-exited","surface-output","surface-resize-failed","surface-resized","tab-added","tab-closed","tab-renamed","terminal-registry-changed","title-changed","tree-changed","window-title-requested","workspace-added","workspace-closed","workspace-moved","workspace-renamed"],"kind":"subscribe","mode_field":"tree_events","modes":{"coarse":["tree-changed"],"deltas":["workspace-added","workspace-closed","workspace-renamed","workspace-moved","screen-added","screen-closed","screen-renamed","pane-added","pane-closed","tab-added","tab-closed","tab-renamed","tree-changed"]},"ordering":"Response and event objects may interleave. Events preserve enqueue order per subscription. Delta workspace revisions are serialized in durable commit order; overflow ends the stream and requires resubscribe plus snapshot.","terminal_event":null}},"swap-pane":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["Exactly one of dir and target must be supplied."],"fields":{"dir":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"PaneDirection"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"target":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"terminal-events":{"authority":"control","capability":null,"constraints":["Consumers apply only contiguous revisions for one registry_id and generation."],"request":{"additional_properties":false,"fields":{"after_revision":{"default":0,"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"result":{"kind":"ref","name":"TerminalEventsResult"},"since":9,"stream":null},"undo-layout":{"authority":"control","capability":"layout-undo-v1","constraints":["Clients must reject incomplete or contradictory result variants."],"request":{"additional_properties":false,"constraints":["confirm_close requires the exact preview revision."],"fields":{"confirm_close":{"default":false,"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"boolean"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"revision":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"result":{"kind":"ref","name":"LayoutUndoResult"},"since":9,"stream":null},"unregister-browser-provider":{"authority":"local-admin","capability":"browser-provider-v1","constraints":["Only the calling connection\'s provider lease is removed."],"request":{"additional_properties":false,"fields":{},"kind":"object"},"result":{"kind":"ref","name":"BrowserProviderUnregisterResult"},"since":10,"stream":null},"url-open":{"authority":"local-admin","capability":null,"constraints":["Private frontend URL delivery; no resource or journal mutation. HTTP(S) only, exact projected terminal identity, 16 pending requests maximum, five-second expiry. A request ID is an ephemeral acknowledgement capability."],"request":{"additional_properties":false,"fields":{"terminal_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"url":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"GuestUrlOpenResult"},"since":12,"stream":null},"url-open-claim":{"authority":"frontend","capability":null,"constraints":["Private frontend URL delivery; no resource or journal mutation. HTTP(S) only, exact projected terminal identity, 16 pending requests maximum, five-second expiry. A request ID is an ephemeral acknowledgement capability."],"request":{"additional_properties":false,"fields":{"request_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"GuestUrlClaimResult"},"since":12,"stream":null},"url-open-result":{"authority":"frontend","capability":null,"constraints":["Private frontend URL delivery; no resource or journal mutation. HTTP(S) only, exact projected terminal identity, 16 pending requests maximum, five-second expiry. A request ID is an ephemeral acknowledgement capability."],"request":{"additional_properties":false,"fields":{"opened":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"request_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"GuestUrlAcknowledgeResult"},"since":12,"stream":null},"url-open-subscribe":{"authority":"frontend","capability":null,"constraints":["Private frontend URL delivery; no resource or journal mutation. HTTP(S) only, exact projected terminal identity, 16 pending requests maximum, five-second expiry. A request ID is an ephemeral acknowledgement capability."],"request":{"additional_properties":false,"fields":{"terminal_ids":{"nullable":false,"presence":"required","type":{"items":{"kind":"scalar","name":"string"},"kind":"array"}}},"kind":"object"},"result":{"kind":"ref","name":"GuestUrlSubscribeResult"},"since":12,"stream":{"event_names":["url-open"],"kind":"subscribe","ordering":"Registration response followed by targeted requests; no replay. Closing the connection rejects its pending requests.","terminal_event":null}},"vt-state":{"authority":"control","capability":null,"constraints":["PTY surfaces only."],"request":{"additional_properties":false,"fields":{"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"VtStateResult"},"since":5,"stream":null},"wait-for":{"authority":"control","capability":null,"constraints":["Blocks subsequent requests on this connection; SDKs should use a dedicated connection."],"request":{"additional_properties":false,"fields":{"pattern":{"constraints":[{"syntax":"Rust regex"}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"timeout_ms":{"description":"Zero performs one immediate check.","nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"result":{"kind":"ref","name":"WaitForResult"},"since":6,"stream":null},"zoom-pane":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"mode":{"default":"toggle","nullable":true,"presence":"optional","type":{"kind":"enum","values":["toggle","on","off"]}},"pane":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"ZoomPaneResult"},"since":6,"stream":null}},"events":{"agent-changed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"agent":{"description":"Adapter identity when the producer knows it; absent from protocol-11 event senders and null when no adapter was identified.","nullable":true,"presence":"optional","since":12,"type":{"kind":"scalar","name":"string"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"agent-changed"}},"session":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"source":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"AgentSource"}},"state":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"AgentState"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"updated_at_ms":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"since":11,"streams":["subscribe"]},"bell":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"bell"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":5,"streams":["subscribe"]},"browser-state":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"cols":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"error":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"browser-state"}},"frame":{"description":"The initial browser-state includes the latest frame when one exists; later state updates omit it.","nullable":true,"presence":"optional","type":{"kind":"ref","name":"BrowserFrame"}},"frames_stalled":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"rows":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"status":{"nullable":false,"presence":"required","type":{"kind":"enum","values":["starting","live","failed"]}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"title":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"url":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"since":6,"streams":["attach-browser"]},"client-attached":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"client":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"client-attached"}},"kind":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"name":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"transport":{"nullable":false,"presence":"required","type":{"kind":"enum","values":["unix","ws"]}}},"kind":"object"},"since":6,"streams":["subscribe"]},"client-changed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"client":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"client-changed"}},"kind":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"name":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"since":6,"streams":["subscribe"]},"client-detached":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"client":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"client-detached"}}},"kind":"object"},"since":6,"streams":["subscribe"]},"client-list-invalidated":{"capability":null,"emission":"serialized-never-emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"client-list-invalidated"}}},"kind":"object"},"since":9,"streams":["subscribe"]},"colors-changed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"bg":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"ColorHex"}},"cursor":{"nullable":true,"presence":"optional","type":{"kind":"ref","name":"ColorHex"}},"cursor_blink":{"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"boolean"}},"cursor_style":{"nullable":true,"presence":"optional","type":{"kind":"ref","name":"CursorStyle"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"colors-changed"}},"fg":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"ColorHex"}},"overrides":{"nullable":false,"presence":"optional","since":12,"type":{"kind":"ref","name":"TerminalColorOverrides"}},"palette":{"nullable":false,"presence":"optional","since":7,"type":{"kind":"map","values":{"kind":"ref","name":"ColorHex"}}},"selection_bg":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"ColorHex"}},"selection_fg":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"ColorHex"}},"surface":{"nullable":false,"presence":"optional","since":7,"type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":6,"streams":["attach-byte"]},"config-reload-requested":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"config-reload-requested"}}},"kind":"object"},"since":6,"streams":["subscribe"]},"daemon-shutdown":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"daemon-shutdown"}}},"kind":"object"},"since":12,"streams":["control"]},"detached":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"detached"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":5,"streams":["attach-byte","attach-render","attach-browser"]},"empty":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"empty"}}},"kind":"object"},"since":5,"streams":["subscribe"]},"frame":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"data":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Base64"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"frame"}},"height":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"seq":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"width":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}}},"kind":"object"},"since":6,"streams":["attach-browser"]},"frontend-projection-changed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"frontend-projection-changed"}},"frontend":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"mutation_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"origin":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"projection_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"scope":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"subject_key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"since":7,"streams":["subscribe"]},"graphics-status":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"constraints":["kitty-image-budget-worker-start-failed carries error.","kitty-image-budget-update-failed carries retry_exhausted and summary.","cell-pixel-update-retries-exhausted carries attempts, remaining, cell_width, and cell_height."],"fields":{"attempts":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"cell_height":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"cell_width":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"error":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"string"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"graphics-status"}},"kind":{"nullable":false,"presence":"required","type":{"kind":"enum","values":["kitty-image-budget-worker-start-failed","kitty-image-budget-update-failed","cell-pixel-update-retries-exhausted"]}},"remaining":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"uint64"}},"retry_exhausted":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"boolean"}},"summary":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"since":10,"streams":["subscribe"]},"layout-changed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"layout-changed"}},"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":6,"streams":["subscribe"]},"machine-usage-changed":{"capability":"machine-usage-v1","emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"machine-usage-changed"}},"usage":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"MachineUsage"}}},"kind":"object"},"since":12,"streams":["subscribe"]},"notification":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"body":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"notification"}},"level":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"NotificationLevel"}},"notification":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"surface":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}},"title":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"since":6,"streams":["subscribe","attach-byte","attach-browser"]},"output":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"colors":{"nullable":false,"presence":"optional","since":7,"type":{"kind":"ref","name":"TerminalColors"}},"data":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Base64"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"output"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":5,"streams":["attach-byte"]},"overflow":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"constraints":["scope and surface are either both present for attach overflow or both absent for subscribe overflow."],"fields":{"error":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"overflow"}},"scope":{"nullable":false,"presence":"optional","type":{"kind":"literal","value":"surface"}},"surface":{"nullable":false,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":7,"streams":["subscribe","attach-byte","attach-render","attach-browser"]},"pairing-requested":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"code":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"pairing-requested"}},"expires_in":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"peer":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"request":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"since":7,"streams":["subscribe"]},"pairing-resolved":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"pairing-resolved"}},"request":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"since":7,"streams":["subscribe"]},"pane-added":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"entity":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Pane"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"pane-added"}},"index":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":7,"streams":["subscribe-deltas"]},"pane-closed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"entity":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Pane"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"pane-closed"}},"index":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":7,"streams":["subscribe-deltas"]},"render-delta":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"constraints":["size is present if and only if the surface resized; every resize has full:true."],"fields":{"cursor":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"RenderCursor"}},"default_bg":{"nullable":false,"presence":"optional","type":{"kind":"ref","name":"ColorHex"}},"default_fg":{"nullable":false,"presence":"optional","type":{"kind":"ref","name":"ColorHex"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"render-delta"}},"full":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"graphics":{"nullable":false,"presence":"optional","type":{"kind":"ref","name":"RenderGraphicsDelta"}},"history_epoch":{"nullable":false,"presence":"optional","since":10,"type":{"kind":"scalar","name":"uint64"}},"rows":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"RenderRow"},"kind":"array"}},"scrollback_rows":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"uint32"}},"size":{"nullable":false,"presence":"optional","type":{"kind":"ref","name":"Size"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":7,"streams":["attach-render"]},"render-state":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"cursor":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"RenderCursor"}},"default_bg":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"ColorHex"}},"default_fg":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"ColorHex"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"render-state"}},"graphics":{"nullable":false,"presence":"optional","type":{"kind":"ref","name":"RenderGraphics"}},"history_epoch":{"nullable":false,"presence":"required","since":10,"type":{"kind":"scalar","name":"uint64"}},"rows":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"RenderRow"},"kind":"array"}},"scrollback_rows":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"size":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Size"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":7,"streams":["attach-render"]},"resized":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"constraints":["At least one of replay and data is present; replay is canonical from protocol 7."],"fields":{"colors":{"nullable":false,"presence":"optional","since":7,"type":{"kind":"ref","name":"TerminalColors"}},"cols":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"data":{"description":"Protocol 6 compatibility field.","nullable":false,"presence":"optional","type":{"kind":"ref","name":"Base64"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"resized"}},"kitty_graphics_state":{"nullable":false,"presence":"optional","since":10,"type":{"kind":"ref","name":"KittyGraphicsState"}},"kitty_image_aliases":{"nullable":false,"presence":"optional","since":9,"type":{"items":{"kind":"ref","name":"KittyImageAlias"},"kind":"array"}},"replay":{"nullable":false,"presence":"optional","since":7,"type":{"kind":"ref","name":"Base64"}},"rows":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":6,"streams":["attach-byte"]},"screen-added":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"entity":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Screen"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"screen-added"}},"index":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":7,"streams":["subscribe-deltas"]},"screen-closed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"entity":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Screen"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"screen-closed"}},"index":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":7,"streams":["subscribe-deltas"]},"screen-renamed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"entity":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Screen"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"screen-renamed"}},"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":7,"streams":["subscribe-deltas"]},"scroll-changed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"at_bottom":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"scroll-changed"}},"offset":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":6,"streams":["subscribe","attach-byte","attach-render","attach-browser"]},"status":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"status"}},"message":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"since":5,"streams":["subscribe"]},"surface-exited":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"surface-exited"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":5,"streams":["subscribe"]},"surface-output":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"surface-output"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":5,"streams":["subscribe"]},"surface-resize-failed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"cols":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"error":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"surface-resize-failed"}},"reservation_id":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"retry_after_ms":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"rows":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":7,"streams":["subscribe"]},"surface-resized":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"cols":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"surface-resized"}},"reservation_id":{"nullable":true,"presence":"required","since":7,"type":{"kind":"scalar","name":"uint64"}},"rows":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":5,"streams":["subscribe"]},"tab-added":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"entity":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Tab"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"tab-added"}},"index":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":7,"streams":["subscribe-deltas"]},"tab-closed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"entity":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Tab"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"tab-closed"}},"index":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":7,"streams":["subscribe-deltas"]},"tab-renamed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"entity":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Tab"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"tab-renamed"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":7,"streams":["subscribe-deltas"]},"terminal-registry-changed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"terminal-registry-changed"}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"refetch":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"terminal-events-or-list-terminals"}},"registry_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"since":9,"streams":["subscribe"]},"title-changed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"title-changed"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"title":{"nullable":false,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}}},"kind":"object"},"since":5,"streams":["subscribe"]},"tree-changed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"tree-changed"}}},"kind":"object"},"since":5,"streams":["subscribe"]},"url-open":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"url-open"}},"request_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"url":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"since":12,"streams":["control"]},"vt-state":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"colors":{"nullable":false,"presence":"optional","since":6,"type":{"kind":"ref","name":"TerminalColors"}},"cols":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"data":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Base64"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"vt-state"}},"kitty_graphics_state":{"nullable":false,"presence":"optional","since":10,"type":{"kind":"ref","name":"KittyGraphicsState"}},"kitty_image_aliases":{"nullable":false,"presence":"optional","since":9,"type":{"items":{"kind":"ref","name":"KittyImageAlias"},"kind":"array"}},"rows":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":5,"streams":["attach-byte"]},"window-title-requested":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"window-title-requested"}},"title":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"since":6,"streams":["subscribe"]},"workspace-added":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"constraints":["origin and mutation_id are either both present or both absent."],"fields":{"entity":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Workspace"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"workspace-added"}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"index":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"mutation_id":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"string"}},"origin":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"string"}},"registry_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"since":7,"streams":["subscribe-deltas"]},"workspace-closed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"constraints":["origin and mutation_id are either both present or both absent."],"fields":{"entity":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Workspace"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"workspace-closed"}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"index":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"mutation_id":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"string"}},"origin":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"string"}},"registry_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"since":7,"streams":["subscribe-deltas"]},"workspace-moved":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"constraints":["origin and mutation_id are either both present or both absent."],"fields":{"entity":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Workspace"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"workspace-moved"}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"index":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"mutation_id":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"string"}},"origin":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"string"}},"registry_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"since":7,"streams":["subscribe-deltas"]},"workspace-renamed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"constraints":["origin and mutation_id are either both present or both absent."],"fields":{"entity":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Workspace"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"workspace-renamed"}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"mutation_id":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"string"}},"origin":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"string"}},"registry_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"since":7,"streams":["subscribe-deltas"]}},"ir_sha256":"133bac0154f8f94aa30e40c11ff7ed38b10dd4d82974aec87c02d404fcd12619","profiles":{"control":{"description":"Base authenticated session-control commands available to ordinary SDK clients.","inherits":[]},"frontend":{"description":"Rendering, input, presentation, subscribe, and attach commands.","inherits":["control"]},"local-admin":{"description":"Trusted local administration commands.","inherits":["control"],"transport":"Unix-classified transport, including direct Unix and the current stdio relay"},"provider-authority":{"description":"Provider-owned workspace mutation commands.","inherits":["control"],"requires_authority":true}},"protocol":{"id_type":"uint64","javascript_id_policy":"All protocol identifiers are uint64 JSON numbers. JavaScript and TypeScript SDKs must decode them losslessly as bigint (or validated decimal strings at their public boundary), and must not expose IEEE-754 number ids. Pairing request ids, revisions, timestamps, frame sequences, and reservation ids follow the same rule.","name":"cmux-tui-mux","version":12},"schema_version":2,"types":{"AgentRecord":{"additional_properties":false,"fields":{"session":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"source":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"AgentSource"}},"state":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"AgentState"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"updated_at_ms":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"AgentReportSource":{"kind":"enum","values":["socket","hook"]},"AgentSource":{"kind":"enum","values":["plugin","detected","socket","hook"]},"AgentState":{"kind":"enum","values":["working","blocked","idle","done","unknown"]},"AppliedPane":{"additional_properties":false,"fields":{"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"ApplyLayoutResult":{"additional_properties":false,"fields":{"panes":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"AppliedPane"},"kind":"array"}},"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"AttachedViewOutcomeResult":{"additional_properties":false,"fields":{"outcome":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"ViewAttachmentOutcome"}}},"kind":"object"},"AttachedViewResizeResult":{"additional_properties":false,"fields":{"accepted":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"outcome":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"ViewAttachmentOutcome"}},"reservation_id":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"Base64":{"kind":"alias","target":{"kind":"scalar","name":"string"}},"BrowserFrame":{"additional_properties":false,"fields":{"data":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Base64"}},"height":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"seq":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"width":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}}},"kind":"object"},"BrowserProviderAuthentication":{"kind":"enum","values":["none","bearer"]},"BrowserProviderSnapshot":{"additional_properties":false,"constraints":["available is true exactly when provider_id, endpoint, authentication, and clients are present.","Provider bearer tokens are accepted only during registration and are never returned."],"fields":{"authentication":{"nullable":false,"presence":"optional","type":{"kind":"ref","name":"BrowserProviderAuthentication"}},"available":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"clients":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"uint64"}},"endpoint":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"string"}},"provider_id":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"string"}},"revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"targets":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"BrowserProviderTarget"},"kind":"array"}}},"kind":"object"},"BrowserProviderTarget":{"additional_properties":false,"fields":{"tab_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"target_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"BrowserProviderUnregisterResult":{"additional_properties":false,"fields":{"removed":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}}},"kind":"object"},"CellPixelFailure":{"additional_properties":false,"fields":{"error":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"CellPixelResize":{"additional_properties":false,"fields":{"cols":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"reservation_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"rows":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"CellPixelSurface":{"additional_properties":false,"fields":{"height_px":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"width_px":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}}},"kind":"object"},"ClientInfo":{"additional_properties":false,"fields":{"attached":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"Id"},"kind":"array"}},"client":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"connected_seconds":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"kind":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"name":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"self":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"sizes":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"ClientSize"},"kind":"array"}},"transport":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"ClientTransport"}}},"kind":"object"},"ClientSize":{"additional_properties":false,"fields":{"cols":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"rows":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"size_participating":{"nullable":false,"presence":"required","since":10,"type":{"kind":"scalar","name":"boolean"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"ClientTransport":{"kind":"enum","values":["local","unix","ws"]},"CloseTerminalResult":{"additional_properties":false,"fields":{"already_closed":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"closed":{"nullable":false,"presence":"required","type":{"kind":"literal","value":true}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"registry_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"surface":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}},"terminal_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_incarnation":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"ColorHex":{"kind":"alias","target":{"kind":"scalar","name":"string"}},"CopyResult":{"additional_properties":false,"fields":{"mode":{"nullable":false,"presence":"required","type":{"kind":"enum","values":["screen","selection","scrollback"]}},"text":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"CursorStyle":{"kind":"enum","values":["block","underline","bar"]},"DeadPane":{"additional_properties":false,"fields":{"dead":{"nullable":false,"presence":"required","type":{"kind":"literal","value":true}},"id":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"DeclarativeLayout":{"kind":"tagged_union","tag":"type","variants":{"leaf":{"additional_properties":false,"fields":{"command":{"default":null,"nullable":true,"presence":"optional","type":{"items":{"kind":"scalar","name":"string"},"kind":"array","min_items":1}},"cwd":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"type":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"leaf"}}},"kind":"object"},"split":{"additional_properties":false,"fields":{"a":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"DeclarativeLayout"}},"b":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"DeclarativeLayout"}},"dir":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"SplitDirection"}},"ratio":{"constraints":[{"clamped_maximum":0.95,"clamped_minimum":0.05}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float32"}},"type":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"split"}}},"kind":"object"},"stack":{"additional_properties":false,"fields":{"expanded":{"constraints":["Must identify a member of panes."],"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"panes":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"Id"},"kind":"array","min_items":1}},"type":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"stack"}}},"kind":"object"}}},"EmptyResult":{"additional_properties":false,"fields":{},"kind":"object"},"ExportLayoutResult":{"additional_properties":false,"fields":{"layout":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Layout"}},"panes":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"ExportedPane"},"kind":"array"}}},"kind":"object"},"ExportedPane":{"additional_properties":false,"fields":{"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"surfaces":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"Id"},"kind":"array"}}},"kind":"object"},"FocusDirectionResult":{"additional_properties":false,"fields":{"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"FrontendFocusTarget":{"kind":"enum","values":["pane","machine_rail","workspace_rail","tabs_rail","projection_rail"]},"FrontendJournalEvent":{"kind":"tagged_union","tag":"kind","variants":{"focus":{"additional_properties":false,"fields":{"content_id":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"event_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"frontend_projection_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"kind":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"focus"}},"pane_id":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"screen_id":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"tab_id":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"target":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"FrontendFocusTarget"}},"workspace_id":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"resize":{"additional_properties":false,"fields":{"cell_height":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"cell_width":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"cols":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"event_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"frontend_projection_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"kind":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"resize"}},"rows":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}}},"kind":"object"},"viewport":{"additional_properties":false,"fields":{"event_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"frontend_projection_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"kind":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"viewport"}},"offset":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"screen_id":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"settled":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"target":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"}}},"FrontendProjection":{"additional_properties":false,"fields":{"frontend":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"projection":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"JsonValue"}},"projection_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"replayed":{"default":false,"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"boolean"}},"schema_version":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"scope":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"subject_key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"GetCellPixelsResult":{"additional_properties":false,"fields":{"height_px":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"surfaces":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"CellPixelSurface"},"kind":"array"}},"width_px":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}}},"kind":"object"},"GuestUrlAcknowledgeResult":{"additional_properties":false,"fields":{"accepted":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}}},"kind":"object"},"GuestUrlClaimResult":{"additional_properties":false,"fields":{"claimed":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}}},"kind":"object"},"GuestUrlOpenResult":{"additional_properties":false,"fields":{"opened":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}}},"kind":"object"},"GuestUrlSubscribeResult":{"additional_properties":false,"fields":{"url_open_ready":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}}},"kind":"object"},"Id":{"kind":"alias","target":{"kind":"scalar","name":"uint64"}},"IdMapping":{"additional_properties":false,"fields":{"id":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"kind":{"nullable":false,"presence":"required","type":{"kind":"enum","values":["workspace","screen","pane","surface"]}},"short_id":{"constraints":[{"pattern":"^[a-z0-9]{6}$"}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"IdentifyResult":{"additional_properties":false,"fields":{"app":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"cmux-tui"}},"build_commit":{"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"capabilities":{"default":[],"nullable":false,"presence":"optional","type":{"items":{"kind":"scalar","name":"string"},"kind":"array"}},"daemon_handoff":{"nullable":false,"presence":"required","since":9,"type":{"kind":"literal","value":1}},"generation":{"nullable":false,"presence":"required","since":7,"type":{"kind":"scalar","name":"string"}},"ghostty_commit":{"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"lifecycle_ready":{"default":true,"nullable":false,"presence":"optional","since":12,"type":{"kind":"scalar","name":"boolean"}},"pid":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"protocol":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"registry_id":{"nullable":false,"presence":"required","since":7,"type":{"kind":"scalar","name":"string"}},"session":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_revision":{"nullable":false,"presence":"required","since":9,"type":{"kind":"scalar","name":"uint64"}},"version":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"workspace_revision":{"nullable":false,"presence":"required","since":7,"type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"IdsResult":{"additional_properties":false,"fields":{"ids":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"IdMapping"},"kind":"array"}}},"kind":"object"},"JsonValue":{"kind":"opaque_json","reason":"The wire field intentionally carries a frontend-authored or runtime-authored arbitrary JSON document."},"KittyGraphicsState":{"additional_properties":false,"fields":{"alternate_next_image_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"alternate_replay_next_image_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"image_bytes":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"images":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"inflight_bytes":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"placements":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"primary_next_image_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"primary_replay_next_image_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"replay_cursor_offset":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}}},"kind":"object"},"KittyImageAlias":{"additional_properties":false,"fields":{"image_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"image_number":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}}},"kind":"object"},"Layout":{"kind":"tagged_union","tag":"type","variants":{"leaf":{"additional_properties":false,"fields":{"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"type":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"leaf"}}},"kind":"object"},"split":{"additional_properties":false,"fields":{"a":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Layout"}},"b":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Layout"}},"dir":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"SplitDirection"}},"ratio":{"constraints":[{"maximum":0.95,"minimum":0.05}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float32"}},"split":{"description":"Stable for the lifetime of this split node.","nullable":false,"presence":"optional","since":8,"type":{"kind":"ref","name":"Id"}},"type":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"split"}}},"kind":"object"},"stack":{"additional_properties":false,"fields":{"expanded":{"constraints":["Must identify a member of panes."],"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"panes":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"Id"},"kind":"array","min_items":1}},"type":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"stack"}}},"kind":"object"}}},"LayoutUndoConfirmationRequired":{"additional_properties":false,"fields":{"closes_panes":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"Id"},"kind":"array"}},"confirmation_required":{"nullable":false,"presence":"required","type":{"kind":"literal","value":true}},"revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"undone":{"nullable":false,"presence":"required","type":{"kind":"literal","value":false}}},"kind":"object"},"LayoutUndoResult":{"kind":"untagged_union","variants":[{"kind":"ref","name":"LayoutUndoUndone"},{"kind":"ref","name":"LayoutUndoConfirmationRequired"}]},"LayoutUndoUndone":{"additional_properties":false,"fields":{"confirmation_required":{"default":false,"nullable":false,"presence":"optional","type":{"kind":"literal","value":false}},"revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"undone":{"nullable":false,"presence":"required","type":{"kind":"literal","value":true}}},"kind":"object"},"ListAgentsResult":{"additional_properties":false,"fields":{"agents":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"AgentRecord"},"kind":"array"}}},"kind":"object"},"ListTerminalsResult":{"additional_properties":false,"fields":{"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"registry_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"terminals":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"TerminalRecord"},"kind":"array"}}},"kind":"object"},"LivePane":{"additional_properties":false,"fields":{"active_tab":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"focused_at":{"default":0,"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"uint64"}},"id":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"name":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"short_id":{"constraints":[{"pattern":"^[a-z0-9]{6}$"}],"nullable":false,"presence":"optional","since":6,"type":{"kind":"scalar","name":"string"}},"tabs":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"Tab"},"kind":"array"}}},"kind":"object"},"MachineListeningTcpResult":{"additional_properties":false,"constraints":["The daemon runs only a fixed socket-listing command; callers cannot supply command text.","The output is limited to 524288 bytes."],"fields":{"stdout":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"MachineUsage":{"additional_properties":false,"constraints":["period_days is the trailing window length in days.","api_equivalent_usd is the list-price equivalent of the machine\'s model traffic in that window."],"fields":{"api_equivalent_usd":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float64"}},"as_of":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"period_days":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"total_tokens":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"vm_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"MachineUsageResult":{"additional_properties":false,"constraints":["usage is null when the daemon has no readout (not a Cloud VM, endpoint unavailable, or usage not ready)."],"fields":{"usage":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"MachineUsage"}}},"kind":"object"},"MintTerminalRendererResult":{"additional_properties":false,"fields":{"endpoint":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"incarnation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"protocol_version":{"nullable":false,"presence":"required","since":11,"type":{"kind":"scalar","name":"uint16"}},"rights":{"constraints":[{"current_value":7}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"terminal_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"token":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"ttl_ms":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"MoveTerminalResult":{"additional_properties":false,"fields":{"changed":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"lifecycle":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"TerminalLifecycle"}},"pane":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}},"registry_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"replayed":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"screen":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}},"surface":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}},"terminal_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_incarnation":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"workspace":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace_key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"NotificationLevel":{"kind":"enum","values":["info","warning","error"]},"NotificationMarker":{"additional_properties":false,"fields":{"level":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"NotificationLevel"}},"notification":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"unread":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}}},"kind":"object"},"NotifyResult":{"additional_properties":false,"fields":{"notification":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"Pane":{"kind":"untagged_union","variants":[{"kind":"ref","name":"LivePane"},{"kind":"ref","name":"DeadPane"}]},"PaneDirection":{"kind":"enum","values":["left","right","up","down"]},"PaneNeighborResult":{"additional_properties":false,"fields":{"pane":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"PingResult":{"additional_properties":false,"fields":{"build_commit":{"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"ghostty_commit":{"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"ok":{"nullable":false,"presence":"required","type":{"kind":"literal","value":true}},"protocol":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"version":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"ProcessInfoResult":{"additional_properties":false,"fields":{"command":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"cwd":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"foreground_cwd":{"description":"Working directory of the process group that owns the PTY, read at request time. Null when the lookup fails; absent from daemons that predate the field. Clients treat absence as null.","nullable":true,"presence":"optional","since":12,"type":{"kind":"scalar","name":"string"}},"foreground_executable":{"description":"Executable path or name of the PTY foreground process-group leader, read at request time. Null when the lookup fails; absent from daemons that predate the field. Clients treat absence as null.","nullable":true,"presence":"optional","since":12,"type":{"kind":"scalar","name":"string"}},"pid":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"uint32"}}},"kind":"object"},"ProviderWorkspaceMutationResult":{"additional_properties":false,"fields":{"key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"ReadScreenResult":{"additional_properties":false,"fields":{"text":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"ReadScrollbackResult":{"additional_properties":false,"fields":{"epoch":{"nullable":false,"presence":"required","since":10,"type":{"kind":"scalar","name":"uint64"}},"rows":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"RenderRow"},"kind":"array"}},"start":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"total":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}}},"kind":"object"},"RenderCursor":{"additional_properties":false,"fields":{"blink":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"color":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"ColorHex"}},"style":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"CursorStyle"}},"visible":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"x":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"y":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}}},"kind":"object"},"RenderGraphicFormat":{"kind":"enum","values":["rgb","rgba"]},"RenderGraphicImage":{"additional_properties":false,"fields":{"data":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Base64"}},"format":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"RenderGraphicFormat"}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"height":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"width":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}}},"kind":"object"},"RenderGraphicPlacement":{"additional_properties":false,"fields":{"anchor_col":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"anchor_row":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"uint32"}},"columns":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"grid_cols":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"grid_rows":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"image_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"ordinal":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"pixel_height":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"pixel_width":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"placement_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"rows":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"source_height":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"source_width":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"source_x":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"source_y":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"viewport_col":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"int32"}},"viewport_row":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"int32"}},"viewport_visible":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"x_offset":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"y_offset":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"z":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"int32"}}},"kind":"object"},"RenderGraphics":{"additional_properties":false,"fields":{"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"images":{"nullable":false,"presence":"optional","type":{"items":{"kind":"ref","name":"RenderGraphicImage"},"kind":"array"}},"placements":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"RenderGraphicPlacement"},"kind":"array"}},"removed_image_ids":{"nullable":false,"presence":"optional","type":{"items":{"kind":"scalar","name":"uint32"},"kind":"array"}}},"kind":"object"},"RenderGraphicsDelta":{"additional_properties":false,"fields":{"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"images":{"nullable":false,"presence":"optional","type":{"items":{"kind":"ref","name":"RenderGraphicImage"},"kind":"array"}},"placements":{"nullable":false,"presence":"optional","type":{"items":{"kind":"ref","name":"RenderGraphicPlacement"},"kind":"array"}},"removed_image_ids":{"nullable":false,"presence":"optional","type":{"items":{"kind":"scalar","name":"uint32"},"kind":"array"}}},"kind":"object"},"RenderRow":{"additional_properties":false,"fields":{"row":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"runs":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"RenderRun"},"kind":"array"}}},"kind":"object"},"RenderRun":{"additional_properties":false,"fields":{"attrs":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"bg":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"ColorHex"}},"fg":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"ColorHex"}},"text":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"underline":{"nullable":false,"presence":"optional","type":{"kind":"ref","name":"RenderUnderline"}},"width_hint":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"uint16"}}},"kind":"object"},"RenderUnderline":{"kind":"enum","values":["single","double","curly","dotted","dashed"]},"ReportAgentResult":{"additional_properties":false,"fields":{"session":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"source":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"AgentReportSource"}},"state":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"AgentState"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"ResizeSurfaceResult":{"additional_properties":false,"fields":{"accepted":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"reservation_id":{"nullable":true,"presence":"required","since":7,"type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"ResolveTerminalResult":{"additional_properties":false,"fields":{"exit":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"TerminalExit"}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"launch_spec":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"JsonValue"}},"lifecycle":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"TerminalLifecycle"}},"registry_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"surface":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}},"terminal_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_incarnation":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"workspace_key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"ResourceSelectors":{"additional_properties":false,"fields":{"agent":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"browser":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"client":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"frontend_projection":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"machine":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"notification":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"pairing_request":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"pane":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"screen":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"session":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"sidebar_view":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"split":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"stream":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"tab":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"terminal":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"workspace":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"RunResult":{"additional_properties":false,"fields":{"already_exited":{"nullable":false,"presence":"required","since":11,"type":{"kind":"scalar","name":"boolean"}},"exit":{"nullable":true,"presence":"required","since":11,"type":{"kind":"ref","name":"TerminalExit"}},"lifecycle":{"nullable":false,"presence":"required","since":11,"type":{"kind":"ref","name":"TerminalLifecycle"}},"pane":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}},"screen":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}},"surface":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}},"terminal_id":{"nullable":false,"presence":"required","since":9,"type":{"kind":"scalar","name":"string"}},"terminal_incarnation":{"nullable":true,"presence":"required","since":9,"type":{"kind":"scalar","name":"string"}},"terminal_revision":{"nullable":false,"presence":"required","since":11,"type":{"kind":"scalar","name":"uint64"}},"workspace":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"Screen":{"additional_properties":false,"fields":{"active":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"active_pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"id":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"layout":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Layout"}},"name":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"panes":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"Pane"},"kind":"array"}},"short_id":{"constraints":[{"pattern":"^[a-z0-9]{6}$"}],"nullable":false,"presence":"optional","since":6,"type":{"kind":"scalar","name":"string"}},"zoomed_pane":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"ServerStatsConnections":{"additional_properties":false,"constraints":["refused counts sockets dropped at limit; for hook producers each one is a lost event."],"fields":{"accepted":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"active":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"limit":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"peak":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"refused":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"ServerStatsHistogram":{"additional_properties":false,"constraints":["Percentiles are log-linear bucket upper bounds and overestimate the true sample by at most 25%.","Latency histograms are in microseconds; batch_size counts events."],"fields":{"count":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"max":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"mean":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"p50":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"p90":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"p99":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"ServerStatsJournalWriter":{"additional_properties":false,"constraints":["commit_us excludes lock wait; commit_lock_wait_us is the writer waiting for the registry lock.","terminal_queued and durable_queued are live lane depths."],"fields":{"batch_size":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"ServerStatsHistogram"}},"batches":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"commit_failures":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"commit_lock_wait_us":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"ServerStatsHistogram"}},"commit_us":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"ServerStatsHistogram"}},"deadline_expiries":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"durable_events":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"durable_queued":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"phase":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"ServerStatsWriterPhase"}},"phase_for_us":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"receipt_wait_us":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"ServerStatsHistogram"}},"terminal_events":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"terminal_queued":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"ServerStatsLockHolder":{"additional_properties":false,"constraints":["site is the file:line that acquired the registry lock."],"fields":{"held_for_us":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"site":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"ServerStatsLockSite":{"additional_properties":false,"constraints":[],"fields":{"acquisitions":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"hold_max_us":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"hold_total_us":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"site":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"ServerStatsLockStall":{"additional_properties":false,"constraints":["blocker is the site holding the lock when the waiter\'s wait began, or null when it was free."],"fields":{"blocker":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"waited_us":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"waiter":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"ServerStatsRegistryLock":{"additional_properties":false,"constraints":["contended_acquisitions counts waits of at least 1 ms; stalls counts waits of at least 100 ms.","top_sites is ordered by hold_total_us descending and holds at most eight entries."],"fields":{"contended_acquisitions":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"hold_us":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"ServerStatsHistogram"}},"holder":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"ServerStatsLockHolder"}},"last_stall":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"ServerStatsLockStall"}},"stalls":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"top_sites":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"ServerStatsLockSite"},"kind":"array"}},"wait_us":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"ServerStatsHistogram"}}},"kind":"object"},"ServerStatsResult":{"additional_properties":false,"constraints":["schema is 1.","journal_writer is null for ephemeral sessions without a durable journal.","Counters accumulate since daemon start; reading them never touches SQLite or the journal."],"fields":{"connections":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"ServerStatsConnections"}},"journal_writer":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"ServerStatsJournalWriter"}},"registry_lock":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"ServerStatsRegistryLock"}},"schema":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"uptime_ms":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"ServerStatsWriterPhase":{"kind":"enum","values":["idle","waiting_lock","committing"]},"SetCellPixelsResult":{"additional_properties":false,"fields":{"failures":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"CellPixelFailure"},"kind":"array"}},"resizes":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"CellPixelResize"},"kind":"array"}}},"kind":"object"},"ShutdownDaemonResult":{"additional_properties":false,"fields":{"accepted":{"nullable":false,"presence":"required","type":{"kind":"literal","value":true}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"pid":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}}},"kind":"object"},"SidebarPluginResult":{"additional_properties":false,"fields":{"error":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"retry_after_ms":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"surface":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"Size":{"additional_properties":false,"fields":{"cols":{"constraints":[{"maximum":10000,"minimum":1}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"rows":{"constraints":[{"maximum":10000,"minimum":1}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}}},"kind":"object"},"SplitDirection":{"kind":"enum","values":["right","down"]},"SurfaceResult":{"additional_properties":false,"fields":{"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"terminal_id":{"nullable":true,"presence":"optional","since":9,"type":{"kind":"scalar","name":"string"}},"terminal_incarnation":{"nullable":true,"presence":"optional","since":9,"type":{"kind":"scalar","name":"string"}}},"kind":"object"},"Tab":{"additional_properties":false,"fields":{"browser_error":{"nullable":true,"presence":"optional","since":6,"type":{"kind":"scalar","name":"string"}},"browser_frames_stalled":{"nullable":true,"presence":"optional","since":6,"type":{"kind":"scalar","name":"boolean"}},"browser_source":{"nullable":true,"presence":"required","type":{"kind":"enum","values":["external","launched"]}},"browser_status":{"nullable":true,"presence":"optional","since":6,"type":{"kind":"enum","values":["starting","live","failed"]}},"dead":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"kind":{"nullable":false,"presence":"required","type":{"kind":"enum","values":["pty","browser"]}},"name":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"notification":{"nullable":true,"presence":"optional","since":6,"type":{"kind":"ref","name":"NotificationMarker"}},"short_id":{"constraints":[{"pattern":"^[a-z0-9]{6}$"}],"nullable":false,"presence":"optional","since":6,"type":{"kind":"scalar","name":"string"}},"size":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Size"}},"supports_clear_history_key_fallback":{"capability":"clear-history-key-v1","nullable":false,"presence":"optional","since":9,"type":{"kind":"scalar","name":"boolean"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"terminal_id":{"nullable":true,"presence":"optional","since":9,"type":{"kind":"scalar","name":"string"}},"terminal_incarnation":{"nullable":true,"presence":"optional","since":9,"type":{"kind":"scalar","name":"string"}},"terminal_resource_id":{"constraints":[{"pattern":"^term_[0-9a-f]{32}$"}],"nullable":true,"presence":"optional","since":10,"type":{"kind":"scalar","name":"string"}},"title":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"TerminalColorOverrides":{"additional_properties":false,"fields":{"bg":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"ColorHex"}},"cursor":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"ColorHex"}},"fg":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"ColorHex"}}},"kind":"object"},"TerminalColors":{"additional_properties":false,"fields":{"bg":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"ColorHex"}},"cursor":{"nullable":true,"presence":"optional","since":6,"type":{"kind":"ref","name":"ColorHex"}},"cursor_blink":{"nullable":true,"presence":"optional","since":6,"type":{"kind":"scalar","name":"boolean"}},"cursor_style":{"nullable":true,"presence":"optional","since":6,"type":{"kind":"ref","name":"CursorStyle"}},"fg":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"ColorHex"}},"overrides":{"nullable":false,"presence":"optional","since":12,"type":{"kind":"ref","name":"TerminalColorOverrides"}},"palette":{"constraints":["Decimal string keys are palette indexes 0 through 255."],"nullable":false,"presence":"optional","since":7,"type":{"kind":"map","values":{"kind":"ref","name":"ColorHex"}}},"selection_bg":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"ColorHex"}},"selection_fg":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"ColorHex"}}},"kind":"object"},"TerminalEventsResult":{"additional_properties":false,"fields":{"events":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"TerminalRegistryEvent"},"kind":"array"}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"registry_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"TerminalExit":{"additional_properties":false,"fields":{"exited_at_ms":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"outcome":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"TerminalExitOutcome"}}},"kind":"object"},"TerminalExitOutcome":{"kind":"tagged_union","tag":"kind","variants":{"exit":{"additional_properties":false,"fields":{"code":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"int32"}},"kind":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"exit"}}},"kind":"object"},"signal":{"additional_properties":false,"fields":{"core_dumped":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"kind":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"signal"}},"signal":{"constraints":[{"minimum":1}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"int32"}}},"kind":"object"},"unknown":{"additional_properties":false,"fields":{"kind":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"unknown"}},"reason":{"constraints":[{"min_length":1}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"}}},"TerminalKey":{"kind":"enum","values":["unidentified","backquote","backslash","bracket-left","bracket-right","comma","digit0","digit1","digit2","digit3","digit4","digit5","digit6","digit7","digit8","digit9","equal","a","b","c","d","e","f","g","h","i","j","k","l","m","n","o","p","q","r","s","t","u","v","w","x","y","z","minus","period","quote","semicolon","slash","backspace","enter","space","tab","delete","end","home","insert","page-down","page-up","arrow-down","arrow-left","arrow-right","arrow-up","numpad0","numpad1","numpad2","numpad3","numpad4","numpad5","numpad6","numpad7","numpad8","numpad9","numpad-add","numpad-backspace","numpad-comma","numpad-decimal","numpad-divide","numpad-enter","numpad-equal","numpad-multiply","numpad-subtract","numpad-up","numpad-down","numpad-right","numpad-left","numpad-begin","numpad-home","numpad-end","numpad-insert","numpad-delete","numpad-page-up","numpad-page-down","escape","f1","f2","f3","f4","f5","f6","f7","f8","f9","f10","f11","f12","f13","f14","f15","f16","f17","f18","f19","f20"]},"TerminalKeyAction":{"kind":"enum","values":["press","release","repeat"]},"TerminalKeyInput":{"additional_properties":false,"constraints":["consumed_mods must be a subset of mods.","unshifted_codepoint, shifted_codepoint, and base_layout_codepoint contain exactly one Unicode scalar when present.","utf8 contains no control characters.","macos_option_as_alt may be false only when Alt is active and consumed."],"fields":{"action":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"TerminalKeyAction"}},"base_layout_codepoint":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"composing":{"default":false,"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"boolean"}},"consumed_mods":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"TerminalModifiers"}},"key":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"TerminalKey"}},"macos_option_as_alt":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"mods":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"TerminalModifiers"}},"shifted_codepoint":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"unshifted_codepoint":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"utf8":{"constraints":[{"max_length":4096}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"TerminalLifecycle":{"kind":"enum","values":["launching","adopting","running","exited","tombstoned"]},"TerminalModifiers":{"additional_properties":false,"fields":{"alt":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"caps_lock":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"control":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"num_lock":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"shift":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"super":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}}},"kind":"object"},"TerminalPlacement":{"additional_properties":false,"fields":{"already_exited":{"nullable":false,"presence":"required","since":11,"type":{"kind":"scalar","name":"boolean"}},"exit":{"nullable":true,"presence":"required","since":11,"type":{"kind":"ref","name":"TerminalExit"}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"lifecycle":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"TerminalLifecycle"}},"pane":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}},"registry_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"replayed":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"screen":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}},"surface":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}},"terminal_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_incarnation":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"workspace":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"TerminalRecord":{"additional_properties":false,"fields":{"exit":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"TerminalExit"}},"launch_spec":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"JsonValue"}},"lifecycle":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"TerminalLifecycle"}},"terminal_id":{"constraints":[{"format":"UUIDv4 hex without dashes","pattern":"^[0-9a-f]{32}$"}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_incarnation":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"workspace_key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"TerminalRegistryEvent":{"additional_properties":false,"fields":{"kind":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"mutation_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"origin":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"result":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"JsonValue"}},"terminal_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"workspace_key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"Tree":{"additional_properties":false,"fields":{"generation":{"capability":"workspace-registry-v1","nullable":false,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"pane_revision":{"nullable":false,"presence":"optional","since":9,"type":{"kind":"scalar","name":"uint64"}},"registry_id":{"capability":"workspace-registry-v1","nullable":false,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"terminal_revision":{"nullable":false,"presence":"optional","since":9,"type":{"kind":"scalar","name":"uint64"}},"workspace_revision":{"capability":"workspace-registry-v1","nullable":false,"presence":"optional","since":7,"type":{"kind":"scalar","name":"uint64"}},"workspaces":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"Workspace"},"kind":"array"}}},"kind":"object"},"ViewAttachmentOutcome":{"kind":"enum","values":["applied","passive","superseded"]},"VtStateResult":{"additional_properties":false,"fields":{"cols":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"data":{"constraints":[{"encoding":"standard base64"}],"nullable":false,"presence":"required","type":{"kind":"ref","name":"Base64"}},"kitty_graphics_state":{"nullable":false,"presence":"optional","since":10,"type":{"kind":"ref","name":"KittyGraphicsState"}},"kitty_image_aliases":{"nullable":false,"presence":"optional","since":9,"type":{"items":{"kind":"ref","name":"KittyImageAlias"},"kind":"array"}},"rows":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}}},"kind":"object"},"WaitForResult":{"additional_properties":false,"fields":{"elapsed_ms":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"matched":{"nullable":false,"presence":"required","type":{"kind":"literal","value":true}},"text":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"Workspace":{"additional_properties":false,"fields":{"active":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"id":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"key":{"capability":"workspace-registry-v1","constraints":[{"format":"lowercase canonical UUID"}],"nullable":false,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"name":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"screens":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"Screen"},"kind":"array"}},"short_id":{"constraints":[{"pattern":"^[a-z0-9]{6}$"}],"nullable":false,"presence":"optional","since":6,"type":{"kind":"scalar","name":"string"}}},"kind":"object"},"WorkspaceMutationResult":{"additional_properties":false,"fields":{"changed":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"boolean"}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"index":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"registry_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"replayed":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"ZoomPaneResult":{"additional_properties":false,"fields":{"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"zoomed":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"zoomed_pane":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"}}}') diff --git a/cmux-tui/bindings/python/cmux/raw/_generated/metadata.py b/cmux-tui/bindings/python/cmux/raw/_generated/metadata.py index bd24fd47fa1a..f3299a3f91ee 100644 --- a/cmux-tui/bindings/python/cmux/raw/_generated/metadata.py +++ b/cmux-tui/bindings/python/cmux/raw/_generated/metadata.py @@ -8,7 +8,7 @@ SCHEMA_VERSION = 2 MUX_PROTOCOL = 12 -IR_SHA256 = '7042c629f34d3606581d07b2d2c03b65116c2467810724163c54674865825cc0' +IR_SHA256 = '133bac0154f8f94aa30e40c11ff7ed38b10dd4d82974aec87c02d404fcd12619' @dataclass(frozen=True) diff --git a/cmux-tui/bindings/python/cmux/raw/_generated/models.py b/cmux-tui/bindings/python/cmux/raw/_generated/models.py index 2f381e61de25..fa54a70ee96d 100644 --- a/cmux-tui/bindings/python/cmux/raw/_generated/models.py +++ b/cmux-tui/bindings/python/cmux/raw/_generated/models.py @@ -42,6 +42,7 @@ class AgentReportSource(str, Enum): HOOK = 'hook' class AgentSource(str, Enum): + PLUGIN = 'plugin' DETECTED = 'detected' SOCKET = 'socket' HOOK = 'hook' @@ -734,6 +735,7 @@ class ProcessInfoResult: cwd: Union[str, None] pid: Union[int, None] foreground_cwd: Union[str, None, MissingType] = field(default=MISSING) + foreground_executable: Union[str, None, MissingType] = field(default=MISSING) @dataclass(frozen=True) @@ -2192,6 +2194,7 @@ class AgentChangedEvent(EventBase): source: AgentSource state: AgentState updated_at_ms: int + agent: Union[str, None, MissingType] = field(default=MISSING) raw: Mapping[str, Any] = field(default_factory=dict, repr=False, compare=False, metadata={'cmux_skip': True}) diff --git a/cmux-tui/bindings/python/cmux/resources.py b/cmux-tui/bindings/python/cmux/resources.py index 7d4c42a53927..74f084878233 100644 --- a/cmux-tui/bindings/python/cmux/resources.py +++ b/cmux-tui/bindings/python/cmux/resources.py @@ -4,7 +4,9 @@ import math import base64 import binascii +import json import os +import re import threading from dataclasses import asdict, dataclass, fields from typing import ( @@ -109,7 +111,16 @@ SessionDelta, SessionEvent, JournalAuthority, + JournalAppendResult, + JournalClass, + JournalEventSchema, + JournalIngress, JournalProducer, + JournalProducerListResult, + JournalProducerManifest, + JournalProducerPutResult, + JournalReplayPolicy, + JournalSensitivity, JournalSubject, SessionJournalRecord, SessionSnapshotItem, @@ -1090,7 +1101,7 @@ def _aux_snapshot( source=_required_enum( payload, "source", - ("hook", "socket", "detected"), + ("hook", "socket", "detected", "plugin"), ), updated_at_ms=_required_decimal(payload, "updated_at_ms"), source_session=_required_nullable_string( @@ -1214,6 +1225,8 @@ def _terminal_screen_result(value: Any) -> TerminalScreenResult: payload, ( "text", + "revision", + "osc_progress", "cols", "rows", "cursor_row", @@ -1226,14 +1239,26 @@ def _terminal_screen_result(value: Any) -> TerminalScreenResult: extra = payload.get("extra", {}) if not isinstance(extra, Mapping): raise ProtocolError("terminal screen extra must be an object") + revision = ( + _required_nullable_decimal(payload, "revision") + if "revision" in payload + else None + ) + osc_progress = ( + _required_nullable_string(payload, "osc_progress") + if "osc_progress" in payload + else None + ) return TerminalScreenResult( - _required_string(payload, "text"), - _required_positive_uint16(payload, "cols"), - _required_positive_uint16(payload, "rows"), - _required_uint16(payload, "cursor_row"), - _required_uint16(payload, "cursor_col"), - _required_bool(payload, "cursor_visible"), - dict(extra), + text=_required_string(payload, "text"), + cols=_required_positive_uint16(payload, "cols"), + rows=_required_positive_uint16(payload, "rows"), + cursor_row=_required_uint16(payload, "cursor_row"), + cursor_col=_required_uint16(payload, "cursor_col"), + cursor_visible=_required_bool(payload, "cursor_visible"), + extra=dict(extra), + revision=revision, + osc_progress=osc_progress, ) @@ -1386,7 +1411,15 @@ def _process_info_result(value: Any) -> ProcessInfoResult: payload = _mapping(value, "process info result") _strict_object( payload, - ("pid", "executable", "argv", "cwd", "foreground_cwd", "children"), + ( + "pid", + "executable", + "argv", + "cwd", + "foreground_cwd", + "foreground_executable", + "children", + ), "process info result", ) argv = payload.get("argv") @@ -1405,6 +1438,7 @@ def _process_info_result(value: Any) -> ProcessInfoResult: _optional_present_string(payload, "cwd"), _optional_string(payload, "foreground_cwd"), decoded_children, + _optional_string(payload, "foreground_executable"), ) @@ -1821,16 +1855,16 @@ def _journal_record(value: Any) -> SessionJournalRecord: subject_values = payload.get("subjects") if not isinstance(subject_values, list): raise ProtocolError("journal subjects must be an array") + if len(subject_values) > 64: + raise ProtocolError("journal subjects must contain at most 64 entries") subjects = [] for subject_value in subject_values: - subject = _mapping(subject_value, "journal subject") - _strict_object(subject, ("kind", "id"), "journal subject") - subjects.append( - JournalSubject( - _required_string(subject, "kind"), - _required_string(subject, "id"), - ) - ) + try: + subjects.append(_journal_subject(subject_value)) + except ProtocolError: + raise + except (TypeError, ValueError) as error: + raise ProtocolError(f"journal subject is invalid: {error}") from error return SessionJournalRecord( _required_decimal(payload, "sequence"), _required_string(payload, "event_id"), @@ -1860,6 +1894,409 @@ def _journal_record(value: Any) -> SessionJournalRecord: ) +_JOURNAL_CLASSES = ("state", "observation", "effect", "checkpoint") +_JOURNAL_REPLAY_POLICIES = ("required", "advisory", "never") +_JOURNAL_SENSITIVITIES = ("public", "metadata", "sensitive", "secret") + + +def _journal_json_value(value: Any, label: str) -> Any: + """Validate a value before putting it in a JSON protocol field.""" + try: + json.dumps(value, ensure_ascii=False, allow_nan=False) + except (TypeError, ValueError) as error: + raise TypeError(f"{label} must be a JSON value") from error + if isinstance(value, Mapping) and not all( + isinstance(key, str) for key in value + ): + raise TypeError(f"{label} object keys must be strings") + if isinstance(value, Mapping): + return { + key: _journal_json_value(item, f"{label}.{key}") + for key, item in value.items() + } + if isinstance(value, (list, tuple)): + return [ + _journal_json_value(item, f"{label}[{index}]") + for index, item in enumerate(value) + ] + return value + + +def _journal_text(value: Any, label: str, maximum: int) -> str: + if not isinstance(value, str): + raise TypeError(f"{label} must be a string") + try: + length = len(value.encode("utf-8")) + except UnicodeEncodeError as error: + raise ValueError(f"{label} must contain valid Unicode") from error + if length < 1 or length > maximum: + raise ValueError( + f"{label} must contain 1 to {maximum} UTF-8 bytes" + ) + return value + + +def _journal_component(value: Any, label: str) -> str: + value = _journal_text(value, label, 64) + if re.fullmatch(r"[a-z0-9][a-z0-9_-]*", value) is None: + raise ValueError( + f"{label} must match [a-z0-9][a-z0-9_-]*" + ) + return value + + +def _journal_kind(value: Any, label: str) -> str: + value = _journal_text(value, label, 128) + if any( + re.fullmatch(r"[a-z0-9][a-z0-9_-]*", part) is None + for part in value.split(".") + ): + raise ValueError( + f"{label} must be a dotted lowercase name" + ) + return value + + +def _journal_decimal(value: Any, label: str) -> str: + if isinstance(value, bool): + raise TypeError(f"{label} must be a decimal string") + if isinstance(value, int): + if value < 0 or value > 18_446_744_073_709_551_615: + raise ValueError(f"{label} must be an unsigned 64-bit decimal") + value = str(value) + if not isinstance(value, str): + raise TypeError(f"{label} must be a decimal string") + return _required_decimal({label: value}, label) + + +def _journal_sensitivity_rank(value: str) -> int: + return _JOURNAL_SENSITIVITIES.index(value) + + +def _journal_event_fields( + event: JournalEventSchema, + namespace: str, + max_sensitivity: str, + seen: set[tuple[str, int]], +) -> Dict[str, Any]: + if not isinstance(event, JournalEventSchema): + raise TypeError("events must contain JournalEventSchema values") + kind = _journal_kind(event.kind, "event.kind") + prefix = f"{namespace}." + if not kind.startswith(prefix): + raise ValueError("event.kind must be inside the producer namespace") + if ( + not isinstance(event.schema_version, int) + or isinstance(event.schema_version, bool) + or not 1 <= event.schema_version <= 4_294_967_295 + ): + raise ValueError("event.schema_version must be a positive uint32") + if event.class_ not in _JOURNAL_CLASSES: + raise ValueError("event.class is invalid") + if event.replay not in _JOURNAL_REPLAY_POLICIES: + raise ValueError("event.replay is invalid") + if event.sensitivity not in _JOURNAL_SENSITIVITIES: + raise ValueError("event.sensitivity is invalid") + if ( + event.sensitivity == "secret" + or _journal_sensitivity_rank(event.sensitivity) + > _journal_sensitivity_rank(max_sensitivity) + ): + raise ValueError("event sensitivity exceeds producer authority") + identity = (kind, event.schema_version) + if identity in seen: + raise ValueError("events must not declare duplicates") + seen.add(identity) + return { + "kind": kind, + "schema_version": event.schema_version, + "class": event.class_, + "replay": event.replay, + "sensitivity": event.sensitivity, + "payload_schema": _journal_json_value( + event.payload_schema, + "event.payload_schema", + ), + } + + +def _journal_manifest_fields( + manifest: JournalProducerManifest, +) -> Dict[str, Any]: + if not isinstance(manifest, JournalProducerManifest): + raise TypeError("manifest must be a JournalProducerManifest") + producer_id = _journal_component(manifest.producer_id, "producer_id") + namespace = _journal_text(manifest.namespace, "namespace", 72) + if namespace != f"plugin.{producer_id}": + raise ValueError("namespace must equal plugin.") + if ( + not isinstance(manifest.manifest_version, int) + or isinstance(manifest.manifest_version, bool) + or not 1 <= manifest.manifest_version <= 4_294_967_295 + ): + raise ValueError("manifest_version must be a positive uint32") + if manifest.max_sensitivity not in _JOURNAL_SENSITIVITIES: + raise ValueError("max_sensitivity is invalid") + if manifest.max_sensitivity == "secret": + raise ValueError("secret journal payload storage is unavailable") + permissions = tuple(manifest.permissions) + if not 1 <= len(permissions) <= 32: + raise ValueError("permissions must contain 1 to 32 strings") + if not all(isinstance(permission, str) for permission in permissions): + raise TypeError("permissions must contain strings") + permissions_wire = tuple( + _journal_text(permission, "permission", 128) + for permission in permissions + ) + required_permission = f"journal.append.{namespace}" + if required_permission not in permissions_wire: + raise ValueError( + f"permissions must include {required_permission}" + ) + events = tuple(manifest.events) + if not 1 <= len(events) <= 64: + raise ValueError("events must contain 1 to 64 entries") + if not all(isinstance(event, JournalEventSchema) for event in events): + raise TypeError("events must contain JournalEventSchema values") + seen: set[tuple[str, int]] = set() + events_wire = tuple( + _journal_event_fields( + event, + namespace, + manifest.max_sensitivity, + seen, + ) + for event in events + ) + wire = { + "producer_id": producer_id, + "namespace": namespace, + "manifest_version": manifest.manifest_version, + "max_sensitivity": manifest.max_sensitivity, + "permissions": list(permissions_wire), + "events": list(events_wire), + } + try: + encoded = json.dumps( + wire, + ensure_ascii=False, + allow_nan=False, + separators=(",", ":"), + ).encode("utf-8") + except (TypeError, ValueError) as error: + raise TypeError("manifest contains an invalid JSON value") from error + if len(encoded) > 1024 * 1024: + raise ValueError("journal producer manifest exceeds 1048576 bytes") + return wire + + +def _journal_ingress_fields(event: JournalIngress) -> Dict[str, Any]: + if not isinstance(event, JournalIngress): + raise TypeError("event must be a JournalIngress") + fields: Dict[str, Any] = { + "producer_id": _journal_component(event.producer_id, "producer_id"), + "manifest_version": event.manifest_version, + "kind": _journal_kind(event.kind, "kind"), + "schema_version": event.schema_version, + "payload": _journal_json_value(event.payload, "payload"), + } + if not fields["kind"].startswith(f"plugin.{fields['producer_id']}."): + raise ValueError("kind must be inside the producer namespace") + for name in ("manifest_version", "schema_version"): + value = fields[name] + if ( + not isinstance(value, int) + or isinstance(value, bool) + or not 1 <= value <= 4_294_967_295 + ): + raise ValueError(f"{name} must be a positive uint32") + if event.occurred_at_ms is not None: + fields["occurred_at_ms"] = _journal_decimal( + event.occurred_at_ms, + "occurred_at_ms", + ) + if len(event.subjects) > 64: + raise ValueError("subjects must contain at most 64 entries") + if event.subjects: + subjects = [] + for subject in event.subjects: + if not isinstance(subject, JournalSubject): + raise TypeError("subjects must contain JournalSubject values") + subjects.append( + { + "kind": _journal_component(subject.kind, "subject.kind"), + "id": _journal_text(subject.id, "subject.id", 512), + } + ) + fields["subjects"] = subjects + if event.sensitivity is not None: + if ( + event.sensitivity not in _JOURNAL_SENSITIVITIES + or event.sensitivity == "secret" + ): + raise ValueError("sensitivity is invalid or unavailable") + fields["sensitivity"] = event.sensitivity + for name in ("causation_id", "correlation_id"): + value = getattr(event, name) + if value is not None: + fields[name] = _journal_text(value, name, 128) + return fields + + +def _journal_subject(value: Any) -> JournalSubject: + payload = _mapping(value, "journal subject") + _strict_object(payload, ("kind", "id"), "journal subject") + return JournalSubject( + _journal_component(_required_string(payload, "kind"), "subject.kind"), + _journal_text(_required_string(payload, "id"), "subject.id", 512), + ) + + +def _journal_event_schema(value: Any) -> JournalEventSchema: + payload = _mapping(value, "journal event schema") + _strict_object( + payload, + ( + "kind", + "schema_version", + "class", + "replay", + "sensitivity", + "payload_schema", + ), + "journal event schema", + ) + if "payload_schema" not in payload: + raise ProtocolError("journal event schema omitted payload_schema") + return JournalEventSchema( + _required_string(payload, "kind"), + _required_positive_uint32(payload, "schema_version"), + _required_enum(payload, "class", _JOURNAL_CLASSES), # type: ignore[arg-type] + _required_enum(payload, "replay", _JOURNAL_REPLAY_POLICIES), # type: ignore[arg-type] + _required_enum(payload, "sensitivity", _JOURNAL_SENSITIVITIES), # type: ignore[arg-type] + _journal_json_value(payload["payload_schema"], "payload_schema"), + ) + + +def _journal_producer_manifest(value: Any) -> JournalProducerManifest: + payload = _mapping(value, "journal producer manifest") + _strict_object( + payload, + ( + "producer_id", + "namespace", + "manifest_version", + "max_sensitivity", + "permissions", + "events", + ), + "journal producer manifest", + ) + permissions = payload.get("permissions") + events = payload.get("events") + if not isinstance(permissions, list) or not all( + isinstance(item, str) for item in permissions + ): + raise ProtocolError("journal producer permissions must be an array of strings") + if not isinstance(events, list): + raise ProtocolError("journal producer events must be an array") + manifest = JournalProducerManifest( + _required_string(payload, "producer_id"), + _required_string(payload, "namespace"), + _required_positive_uint32(payload, "manifest_version"), + _required_enum( + payload, + "max_sensitivity", + _JOURNAL_SENSITIVITIES, + ), # type: ignore[arg-type] + tuple(permissions), + tuple(_journal_event_schema(item) for item in events), + ) + try: + _journal_manifest_fields(manifest) + except (TypeError, ValueError) as error: + raise ProtocolError( + f"journal producer manifest is invalid: {error}" + ) from error + return manifest + + +def _journal_producer_list_result(value: Any) -> JournalProducerListResult: + payload = _mapping(value, "journal producer list result") + _strict_object(payload, ("producers",), "journal producer list result") + producers = payload.get("producers") + if not isinstance(producers, list): + raise ProtocolError("journal producer list must be an array") + if len(producers) > 1024: + raise ProtocolError("journal producer list contains too many entries") + return JournalProducerListResult( + tuple(_journal_producer_manifest(item) for item in producers) + ) + + +def _journal_producer_put_result(value: Any) -> JournalProducerPutResult: + payload = _mapping(value, "journal producer result") + _strict_object( + payload, + ("producer_id", "manifest_version", "namespace", "sequence", "event_id"), + "journal producer result", + ) + try: + producer_id = _journal_component( + _required_string(payload, "producer_id"), + "producer_id", + ) + namespace = _journal_text( + _required_string(payload, "namespace"), + "namespace", + 128, + ) + if namespace != f"plugin.{producer_id}": + raise ValueError( + "namespace must equal plugin." + ) + event_id = _journal_text( + _required_string(payload, "event_id"), + "event_id", + 128, + ) + except (TypeError, ValueError) as error: + raise ProtocolError(f"journal producer result is invalid: {error}") from error + return JournalProducerPutResult( + producer_id, + _required_positive_uint32(payload, "manifest_version"), + namespace, + _required_decimal(payload, "sequence"), + event_id, + ) + + +def _journal_append_result(value: Any) -> JournalAppendResult: + payload = _mapping(value, "journal append result") + _strict_object( + payload, + ("producer_id", "sequence", "event_id"), + "journal append result", + ) + try: + producer_id = _journal_component( + _required_string(payload, "producer_id"), + "producer_id", + ) + event_id = _journal_text( + _required_string(payload, "event_id"), + "event_id", + 128, + ) + except (TypeError, ValueError) as error: + raise ProtocolError(f"journal append result is invalid: {error}") from error + return JournalAppendResult( + producer_id, + _required_decimal(payload, "sequence"), + event_id, + ) + + def _validate_journal_stream_item( record: SessionJournalRecord, cursor: Optional[Cursor], @@ -2915,6 +3352,85 @@ def journal( validate_item=_validate_journal_stream_item, ) + def list_journal_producers(self) -> List[JournalProducerManifest]: + """List generic journal producers installed for this session.""" + result = _journal_producer_list_result( + self._client._read( + Operations.SESSION_JOURNAL_PRODUCER_LIST, + self._params(), + ) + ) + return list(result.producers) + + def journal_producers(self) -> JournalProducerListResult: + """Return the typed producer-list result for diagnostic callers.""" + return _journal_producer_list_result( + self._client._read( + Operations.SESSION_JOURNAL_PRODUCER_LIST, + self._params(), + ) + ) + + def put_journal_producer( + self, + manifest: JournalProducerManifest, + *, + idempotency_key: Optional[str] = None, + expected_revision: Optional[str] = None, + ) -> MutationResult[JournalProducerPutResult]: + """Install or replace one userland journal producer manifest.""" + return self._client._mutation( + Operations.SESSION_JOURNAL_PRODUCER_PUT, + {**self._params(), "manifest": _journal_manifest_fields(manifest)}, + idempotency_key, + expected_revision, + _journal_producer_put_result, + ) + + def put_journal_producer_manifest( + self, + manifest: JournalProducerManifest, + *, + idempotency_key: Optional[str] = None, + expected_revision: Optional[str] = None, + ) -> MutationResult[JournalProducerPutResult]: + """Compatibility name for put_journal_producer.""" + return self.put_journal_producer( + manifest, + idempotency_key=idempotency_key, + expected_revision=expected_revision, + ) + + def append_journal( + self, + event: JournalIngress, + *, + idempotency_key: Optional[str] = None, + expected_revision: Optional[str] = None, + ) -> MutationResult[JournalAppendResult]: + """Append one event from a userland journal producer.""" + return self._client._mutation( + Operations.SESSION_JOURNAL_APPEND, + {**self._params(), "event": _journal_ingress_fields(event)}, + idempotency_key, + expected_revision, + _journal_append_result, + ) + + def append_journal_event( + self, + event: JournalIngress, + *, + idempotency_key: Optional[str] = None, + expected_revision: Optional[str] = None, + ) -> MutationResult[JournalAppendResult]: + """Compatibility name for append_journal.""" + return self.append_journal( + event, + idempotency_key=idempotency_key, + expected_revision=expected_revision, + ) + def close(self, *, idempotency_key: Optional[str] = None, expected_revision: Optional[str] = None) -> MutationResult[ShutdownResult]: return self.shutdown( idempotency_key=idempotency_key, diff --git a/cmux-tui/bindings/python/tests/test_events.py b/cmux-tui/bindings/python/tests/test_events.py index f8a54373db74..ee4d62ca96ae 100644 --- a/cmux-tui/bindings/python/tests/test_events.py +++ b/cmux-tui/bindings/python/tests/test_events.py @@ -35,6 +35,21 @@ def test_nullable_event_field_remains_none(self) -> None: self.assertIsNone(event.surface) self.assertEqual(event.title, "build") + def test_agent_changed_keeps_optional_adapter_identity(self) -> None: + event = decode_event( + { + "event": "agent-changed", + "surface": 7, + "state": "working", + "source": "plugin", + "session": None, + "agent": "codex", + "updated_at_ms": 41, + } + ) + + self.assertEqual(event.agent, "codex") + if __name__ == "__main__": unittest.main() diff --git a/cmux-tui/bindings/python/tests/test_resource_api.py b/cmux-tui/bindings/python/tests/test_resource_api.py index fe5fbcad5d33..e032f3448469 100644 --- a/cmux-tui/bindings/python/tests/test_resource_api.py +++ b/cmux-tui/bindings/python/tests/test_resource_api.py @@ -12,6 +12,7 @@ import cmux._protocol as resource_protocol import cmux.aio import cmux.raw +from cmux.resources import _journal_record from cmux import ( AgentId, BrowserId, @@ -106,6 +107,39 @@ def canceled_end(connection, stream_id, **fields): class ResourceApiTests(unittest.TestCase): + def test_public_models_export_journal_append_result(self) -> None: + self.assertIn("JournalAppendResult", cmux.__all__) + + def test_journal_record_subjects_use_the_declared_wire_grammar(self) -> None: + record = { + "sequence": "1", + "event_id": "event-1", + "schema_version": 1, + "kind": "plugin.screen-detector.agent.state.changed", + "class": "state", + "replay": "required", + "occurred_at_ms": "1", + "committed_at_ms": "2", + "producer": {"kind": "plugin", "id": "screen-detector"}, + "authority": None, + "causation_id": None, + "correlation_id": None, + "causation_depth": 0, + "subjects": [], + "sensitivity": "metadata", + "payload": {}, + "resource_revision": None, + "previous_resource_revision": None, + } + for subject in ( + {"kind": "Agent", "id": "agent-1"}, + {"kind": "agent", "id": ""}, + {"kind": "agent", "id": "agent-1", "extra": True}, + ): + with self.subTest(subject=subject): + with self.assertRaises(cmux.ProtocolError): + _journal_record({**record, "subjects": [subject]}) + def test_root_is_resource_api_and_legacy_is_raw_only(self) -> None: self.assertIs(cmux.Client, Client) self.assertFalse(hasattr(cmux, "CmuxClient")) @@ -592,6 +626,292 @@ def handler(connection, _index): ) self.assertNotIn("agent", by_operation["agent.report"]["params"]) + def test_userland_agent_plugins_use_generic_journal_contract(self) -> None: + manifest = cmux.JournalProducerManifest( + producer_id="screen-detector", + namespace="plugin.screen-detector", + manifest_version=1, + max_sensitivity="metadata", + permissions=("journal.append.plugin.screen-detector",), + events=( + cmux.JournalEventSchema( + kind="plugin.screen-detector.agent.state.changed", + schema_version=1, + class_="state", + replay="required", + sensitivity="metadata", + payload_schema={"type": "object"}, + ), + ), + ) + observed = [] + + def handler(connection, _index): + for request in frames(connection): + observed.append(request) + operation = request["operation"] + if operation == "agent.list": + ok( + connection, + request, + [ + { + "id": str(AGENT), + "session_id": str(SESSION), + "terminal_id": str(TERMINAL), + "state": "working", + "source": "plugin", + "updated_at_ms": "10", + "source_session": "pid:42", + } + ], + ) + elif operation == "terminal.screen.read": + ok( + connection, + request, + { + "text": "working", + "revision": "42", + "osc_progress": "4;1;50", + "cols": 80, + "rows": 24, + "cursor_row": 0, + "cursor_col": 7, + "cursor_visible": True, + }, + ) + elif operation == "session.journal.producer.list": + ok( + connection, + request, + { + "producers": [ + { + "producer_id": manifest.producer_id, + "namespace": manifest.namespace, + "manifest_version": 1, + "max_sensitivity": "metadata", + "permissions": list(manifest.permissions), + "events": [ + { + "kind": manifest.events[0].kind, + "schema_version": 1, + "class": "state", + "replay": "required", + "sensitivity": "metadata", + "payload_schema": {"type": "object"}, + } + ], + } + ] + }, + ) + elif operation == "session.journal.producer.put": + ok( + connection, + request, + { + "value": { + "producer_id": "screen-detector", + "manifest_version": 1, + "namespace": "plugin.screen-detector", + "sequence": "11", + "event_id": "event-11", + }, + "generation": "generation-a", + "revision": "12", + "replayed": False, + }, + ) + elif operation == "session.journal.append": + ok( + connection, + request, + { + "value": { + "producer_id": "screen-detector", + "sequence": "13", + "event_id": "event-13", + }, + "generation": "generation-a", + "revision": "14", + "replayed": False, + }, + ) + else: + raise AssertionError(f"unexpected operation {operation}") + + with UnixJsonServer(handler) as server: + with Client(server.path) as client: + session = client.session(SESSION) + terminal = session.terminal(TERMINAL) + self.assertEqual(session.list_agents()[0].snapshot.source, "plugin") + self.assertEqual(terminal.read_screen().revision, "42") + self.assertEqual(terminal.read_screen().osc_progress, "4;1;50") + listed = session.list_journal_producers() + self.assertEqual(listed[0].producer_id, "screen-detector") + put = session.put_journal_producer( + manifest, + idempotency_key="producer-put", + ) + self.assertEqual(put.value.sequence, "11") + appended = session.append_journal( + cmux.JournalIngress( + producer_id="screen-detector", + manifest_version=1, + kind="plugin.screen-detector.agent.state.changed", + schema_version=1, + payload={"state": "working"}, + occurred_at_ms="10", + subjects=( + cmux.JournalSubject(kind="agent", id=str(AGENT)), + ), + ), + idempotency_key="event-append", + ) + self.assertEqual(appended.value.event_id, "event-13") + with self.assertRaises(ValueError): + session.append_journal( + cmux.JournalIngress( + producer_id="screen-detector", + manifest_version=1, + kind="agent.state.changed", + schema_version=1, + payload={"state": "working"}, + ) + ) + + by_operation = {item["operation"]: item for item in observed} + self.assertEqual( + by_operation["session.journal.producer.put"]["params"]["manifest"], + { + "producer_id": "screen-detector", + "namespace": "plugin.screen-detector", + "manifest_version": 1, + "max_sensitivity": "metadata", + "permissions": ["journal.append.plugin.screen-detector"], + "events": [ + { + "kind": "plugin.screen-detector.agent.state.changed", + "schema_version": 1, + "class": "state", + "replay": "required", + "sensitivity": "metadata", + "payload_schema": {"type": "object"}, + } + ], + }, + ) + self.assertEqual( + by_operation["session.journal.append"]["params"]["event"], + { + "producer_id": "screen-detector", + "manifest_version": 1, + "kind": "plugin.screen-detector.agent.state.changed", + "schema_version": 1, + "occurred_at_ms": "10", + "subjects": [{"kind": "agent", "id": str(AGENT)}], + "payload": {"state": "working"}, + }, + ) + + def test_terminal_screen_metadata_accepts_null_from_older_servers(self) -> None: + def handler(connection, _index): + request = next(frames(connection)) + ok( + connection, + request, + { + "text": "unavailable", + "revision": None, + "osc_progress": None, + "cols": 80, + "rows": 24, + "cursor_row": 0, + "cursor_col": 0, + "cursor_visible": True, + }, + ) + + with UnixJsonServer(handler) as server: + with Client(server.path) as client: + result = client.session(SESSION).terminal(TERMINAL).read_screen() + self.assertIsNone(result.revision) + self.assertIsNone(result.osc_progress) + + def test_journal_result_decoders_reject_invalid_identity(self) -> None: + def put_handler(connection, _index): + request = next(frames(connection)) + ok( + connection, + request, + { + "value": { + "producer_id": "screen!detector", + "manifest_version": 1, + "namespace": "plugin.screen!detector", + "sequence": "1", + "event_id": "event-1", + }, + "generation": "generation-a", + "revision": "1", + "replayed": False, + }, + ) + + with UnixJsonServer(put_handler) as server: + with Client(server.path) as client: + manifest = cmux.JournalProducerManifest( + producer_id="screen-detector", + namespace="plugin.screen-detector", + manifest_version=1, + max_sensitivity="metadata", + permissions=("journal.append.plugin.screen-detector",), + events=( + cmux.JournalEventSchema( + kind="plugin.screen-detector.state.changed", + schema_version=1, + class_="state", + replay="required", + sensitivity="metadata", + payload_schema={"type": "object"}, + ), + ), + ) + with self.assertRaises(cmux.ProtocolError): + client.session(SESSION).put_journal_producer(manifest) + + def append_handler(connection, _index): + request = next(frames(connection)) + ok( + connection, + request, + { + "value": { + "producer_id": "screen!detector", + "sequence": "1", + "event_id": "event-1", + }, + "generation": "generation-a", + "revision": "1", + "replayed": False, + }, + ) + + with UnixJsonServer(append_handler) as server: + with Client(server.path) as client: + with self.assertRaises(cmux.ProtocolError): + client.session(SESSION).append_journal( + cmux.JournalIngress( + producer_id="screen-detector", + manifest_version=1, + kind="plugin.screen-detector.state.changed", + schema_version=1, + payload={"state": "working"}, + ) + ) + def test_browser_pointer_frame_tokens_are_exact_decimal_strings(self) -> None: observed = [] @@ -878,6 +1198,7 @@ def test_catalog_results_decode_to_exact_types(self) -> None: "executable": "/bin/zsh", "argv": ["/bin/zsh", "-l"], "cwd": "/tmp", + "foreground_executable": "/usr/bin/codex", "children": [43], }, "terminal.viewer.resize": { @@ -965,6 +1286,7 @@ def handler(connection, _index): self.assertEqual(process.children, (43,)) # Older servers omit foreground_cwd; decoders treat it as null. self.assertIsNone(process.foreground_cwd) + self.assertEqual(process.foreground_executable, "/usr/bin/codex") self.assertEqual( terminal.resize_viewer( "terminal-lease", diff --git a/cmux-tui/bindings/rust/.cmux-resource-api.json b/cmux-tui/bindings/rust/.cmux-resource-api.json index 4c4481343150..0667d105f967 100644 --- a/cmux-tui/bindings/rust/.cmux-resource-api.json +++ b/cmux-tui/bindings/rust/.cmux-resource-api.json @@ -1,5 +1,5 @@ { - "catalog_sha256": "beef8293ded489648261ccddfd31b3f796f9d7d10506f13f6c5d1577a3f4fbac", + "catalog_sha256": "08a8190787e1b38d0592b85856d791fb0fe098d58eddce85ee98ecf88cc5a1a7", "operations": { "agent.list": { "class": "read" diff --git a/cmux-tui/bindings/rust/src/generated/.cmux-sdk-manifest.json b/cmux-tui/bindings/rust/src/generated/.cmux-sdk-manifest.json index 7d99f5712a41..cf7189200613 100644 --- a/cmux-tui/bindings/rust/src/generated/.cmux-sdk-manifest.json +++ b/cmux-tui/bindings/rust/src/generated/.cmux-sdk-manifest.json @@ -2,32 +2,32 @@ "files": [ { "path": "commands.rs", - "sha256": "6fae8ccc88d697f0ad85abcdce4bbd5e4eb8ed04fc99c611f4fd809f8fbf0419", + "sha256": "91fcd5554d153518715042c3c05a2df59f0f9ad3f65740a3b2c079c8b0e4f12a", "size": 67725 }, { "path": "events.rs", - "sha256": "e9633d74784d26e520d787cd53ca0fa2878e4b2ccbdeb6757829351118d2e4b3", - "size": 43641 + "sha256": "7602ec0b0548acc25089d185cf32a85b310c4a0444601e62483406d892508d1e", + "size": 43874 }, { "path": "metadata.rs", - "sha256": "2b319aa8ac87337e1dd54db714662e7e86cbce8a56e1e467e6a37d26bb7ebed9", + "sha256": "dcbeac300dd1bfc49e36a606d9cf53406a0331d0fd5391e7ee253297f5b7faec", "size": 40516 }, { "path": "mod.rs", - "sha256": "c8167c5934c48b0898b8e1f1f57b61e7c829d8d20b101ad4834376662f44722e", + "sha256": "cb931d810fdd1a14c66f166f5b1d28f06aeb955cfc21626009379c31648a9272", "size": 365 }, { "path": "types.rs", - "sha256": "30dcef90c3683275f892a12b9784ce25503796331968e0a427c87310ed51b547", - "size": 45308 + "sha256": "19e2cfc30a77a6cd6d923ff6afaccdc4e229b15da4e76752f93b66f27749e9e1", + "size": 45670 } ], "format": 1, - "ir_sha256": "7042c629f34d3606581d07b2d2c03b65116c2467810724163c54674865825cc0", + "ir_sha256": "133bac0154f8f94aa30e40c11ff7ed38b10dd4d82974aec87c02d404fcd12619", "language": "rust", "mux_protocol": 12, "schema_version": 2 diff --git a/cmux-tui/bindings/rust/src/generated/commands.rs b/cmux-tui/bindings/rust/src/generated/commands.rs index 97428e15ce45..74fe45c97ff1 100644 --- a/cmux-tui/bindings/rust/src/generated/commands.rs +++ b/cmux-tui/bindings/rust/src/generated/commands.rs @@ -1,5 +1,5 @@ // This file is generated. Do not edit by hand. -// cmux-tui mux protocol 12, IR 7042c629f34d3606581d07b2d2c03b65116c2467810724163c54674865825cc0. +// cmux-tui mux protocol 12, IR 133bac0154f8f94aa30e40c11ff7ed38b10dd4d82974aec87c02d404fcd12619. // The emitter owns this layout so generation is independent of the installed rustfmt. use super::metadata::*; diff --git a/cmux-tui/bindings/rust/src/generated/events.rs b/cmux-tui/bindings/rust/src/generated/events.rs index 085adf39abf2..1bc8f826d05f 100644 --- a/cmux-tui/bindings/rust/src/generated/events.rs +++ b/cmux-tui/bindings/rust/src/generated/events.rs @@ -1,5 +1,5 @@ // This file is generated. Do not edit by hand. -// cmux-tui mux protocol 12, IR 7042c629f34d3606581d07b2d2c03b65116c2467810724163c54674865825cc0. +// cmux-tui mux protocol 12, IR 133bac0154f8f94aa30e40c11ff7ed38b10dd4d82974aec87c02d404fcd12619. // The emitter owns this layout so generation is independent of the installed rustfmt. use super::metadata::*; @@ -12,6 +12,9 @@ use std::collections::BTreeMap; #[rustfmt::skip] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct AgentChangedEvent { + /// Adapter identity when the producer knows it; absent from protocol-11 event senders and null when no adapter was identified. + #[serde(default, skip_serializing_if = "Optional::is_missing")] + pub agent: Optional, pub session: Nullable, pub source: T::AgentSource, pub state: T::AgentState, diff --git a/cmux-tui/bindings/rust/src/generated/metadata.rs b/cmux-tui/bindings/rust/src/generated/metadata.rs index fd05b172e829..623fe4d94061 100644 --- a/cmux-tui/bindings/rust/src/generated/metadata.rs +++ b/cmux-tui/bindings/rust/src/generated/metadata.rs @@ -1,12 +1,12 @@ // This file is generated. Do not edit by hand. -// cmux-tui mux protocol 12, IR 7042c629f34d3606581d07b2d2c03b65116c2467810724163c54674865825cc0. +// cmux-tui mux protocol 12, IR 133bac0154f8f94aa30e40c11ff7ed38b10dd4d82974aec87c02d404fcd12619. // The emitter owns this layout so generation is independent of the installed rustfmt. use crate::{CommandMetadata, EventMetadata, ProfileMetadata, StreamMetadata}; pub const SDK_SCHEMA_VERSION: u32 = 2; pub const MUX_PROTOCOL_VERSION: u32 = 12; -pub const SDK_IR_SHA256: &str = "7042c629f34d3606581d07b2d2c03b65116c2467810724163c54674865825cc0"; +pub const SDK_IR_SHA256: &str = "133bac0154f8f94aa30e40c11ff7ed38b10dd4d82974aec87c02d404fcd12619"; #[rustfmt::skip] pub const CONTROL_PROFILE: ProfileMetadata = ProfileMetadata { diff --git a/cmux-tui/bindings/rust/src/generated/mod.rs b/cmux-tui/bindings/rust/src/generated/mod.rs index 159ffcb60e85..1d8c244493f5 100644 --- a/cmux-tui/bindings/rust/src/generated/mod.rs +++ b/cmux-tui/bindings/rust/src/generated/mod.rs @@ -1,5 +1,5 @@ // This file is generated. Do not edit by hand. -// cmux-tui mux protocol 12, IR 7042c629f34d3606581d07b2d2c03b65116c2467810724163c54674865825cc0. +// cmux-tui mux protocol 12, IR 133bac0154f8f94aa30e40c11ff7ed38b10dd4d82974aec87c02d404fcd12619. // The emitter owns this layout so generation is independent of the installed rustfmt. mod commands; diff --git a/cmux-tui/bindings/rust/src/generated/types.rs b/cmux-tui/bindings/rust/src/generated/types.rs index f836062dfb1d..47b9cf239deb 100644 --- a/cmux-tui/bindings/rust/src/generated/types.rs +++ b/cmux-tui/bindings/rust/src/generated/types.rs @@ -1,5 +1,5 @@ // This file is generated. Do not edit by hand. -// cmux-tui mux protocol 12, IR 7042c629f34d3606581d07b2d2c03b65116c2467810724163c54674865825cc0. +// cmux-tui mux protocol 12, IR 133bac0154f8f94aa30e40c11ff7ed38b10dd4d82974aec87c02d404fcd12619. // The emitter owns this layout so generation is independent of the installed rustfmt. use crate::{Nullable, Optional}; @@ -37,6 +37,8 @@ pub enum AgentReportSource { #[rustfmt::skip] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum AgentSource { + #[serde(rename = "plugin")] + Plugin, #[serde(rename = "detected")] Detected, #[serde(rename = "socket")] @@ -671,6 +673,9 @@ pub struct ProcessInfoResult { /// Working directory of the process group that owns the PTY, read at request time. Null when the lookup fails; absent from daemons that predate the field. Clients treat absence as null. #[serde(default, skip_serializing_if = "Optional::is_missing")] pub foreground_cwd: Optional, + /// Executable path or name of the PTY foreground process-group leader, read at request time. Null when the lookup fails; absent from daemons that predate the field. Clients treat absence as null. + #[serde(default, skip_serializing_if = "Optional::is_missing")] + pub foreground_executable: Optional, pub pid: Nullable, } diff --git a/cmux-tui/bindings/rust/src/resource/client.rs b/cmux-tui/bindings/rust/src/resource/client.rs index 81c767199b78..b2318117a435 100644 --- a/cmux-tui/bindings/rust/src/resource/client.rs +++ b/cmux-tui/bindings/rust/src/resource/client.rs @@ -774,6 +774,7 @@ fn operation_class(operation: &str) -> OperationClass { | ops::SESSION_GET | ops::SESSION_CREATION_RESOLVE | ops::SESSION_SNAPSHOT + | ops::SESSION_JOURNAL_PRODUCER_LIST | ops::SESSION_PING | ops::CLIENT_LIST | ops::CLIENT_GET @@ -1146,6 +1147,7 @@ mod tests { #[test] fn classification_matches_connection_control_exceptions() { assert_eq!(operation_class(ops::TERMINAL_COPY), OperationClass::Read); + assert_eq!(operation_class(ops::SESSION_JOURNAL_PRODUCER_LIST), OperationClass::Read); assert_eq!(operation_class(ops::REQUEST_CANCEL), OperationClass::ConnectionControl); assert_eq!(operation_class(ops::TERMINAL_VIEWER_RESIZE), OperationClass::ConnectionControl); assert_eq!(operation_class(ops::TAB_CREATE_TERMINAL), OperationClass::Mutation); diff --git a/cmux-tui/bindings/rust/src/resource/handles.rs b/cmux-tui/bindings/rust/src/resource/handles.rs index 22851e7c2d75..003fcfc30423 100644 --- a/cmux-tui/bindings/rust/src/resource/handles.rs +++ b/cmux-tui/bindings/rust/src/resource/handles.rs @@ -427,6 +427,108 @@ impl Session { self.client.stream(ops::SESSION_JOURNAL_SUBSCRIBE, params).map(SessionJournalStream::new) } + /// Lists the generic journal producers installed for this session. + /// Plugins can use this read to diagnose a rejected or superseded + /// registration without knowing any daemon-internal storage details. + pub fn journal_producers(&self) -> Result { + let result = wire::decode_exact::( + &self.client.read(ops::SESSION_JOURNAL_PRODUCER_LIST, self.params())?, + "journal producer list", + )?; + result.validate()?; + Ok(result) + } + + /// Installs or updates a userland journal producer manifest for this + /// session. The manifest is validated by the daemon before any event is + /// accepted. + pub fn put_journal_producer( + &self, + manifest: &super::journal_plugin::JournalProducerManifest, + mutation: MutationOptions, + ) -> Result> { + manifest.validate()?; + let value = self.client.mutate( + ops::SESSION_JOURNAL_PRODUCER_PUT, + self.params().value( + "manifest", + serde_json::to_value(manifest).map_err(|error| { + Error::InvalidArgument(format!( + "journal producer manifest is not encodable: {error}" + )) + })?, + ), + mutation, + )?; + mutation_result(value, |value| { + let result: super::journal_plugin::JournalProducerPutResult = + wire::decode_exact(value, "journal producer result")?; + result.validate()?; + Ok(result) + }) + } + + /// Compatibility alias for the first generic journal SDK spelling. + pub fn put_journal_producer_manifest( + &self, + manifest: &super::journal_plugin::JournalProducerManifest, + mutation: MutationOptions, + ) -> Result> { + self.put_journal_producer(manifest, mutation) + } + + /// Compatibility alias for the first agent-plugin SDK preview. + pub fn put_agent_plugin_manifest( + &self, + manifest: &super::journal_plugin::AgentPluginManifest, + mutation: MutationOptions, + ) -> Result> { + self.put_journal_producer(manifest, mutation) + } + + /// Appends one event to the session journal from a userland producer. + pub fn append_journal( + &self, + event: &super::journal_plugin::JournalIngress, + mutation: MutationOptions, + ) -> Result> { + event.validate()?; + let value = self.client.mutate( + ops::SESSION_JOURNAL_APPEND, + self.params().value( + "event", + serde_json::to_value(event).map_err(|error| { + Error::InvalidArgument(format!("journal event is not encodable: {error}")) + })?, + ), + mutation, + )?; + mutation_result(value, |value| { + let result: super::journal_plugin::JournalAppendResult = + wire::decode_exact(value, "journal append result")?; + result.validate()?; + Ok(result) + }) + } + + /// Compatibility alias for the first generic journal SDK spelling. + pub fn append_journal_event( + &self, + event: &super::journal_plugin::JournalIngress, + mutation: MutationOptions, + ) -> Result> { + self.append_journal(event, mutation) + } + + /// Compatibility alias for the first agent-plugin SDK preview. + pub fn append_agent_plugin_event( + &self, + event: &super::journal_plugin::AgentPluginIngress, + mutation: MutationOptions, + ) -> Result> { + self.append_journal(event, mutation) + } + pub fn ping(&self) -> Result { wire::decode_exact( &self.client.read(ops::SESSION_PING, self.params())?, @@ -1452,25 +1554,33 @@ impl Tab { } impl Session { - pub fn terminals(&self) -> Result> { + /// Returns the terminal catalog values from one list request. + /// + /// Use this when a caller needs to inspect every terminal. Calling + /// `Terminal::refresh` after `terminals` would turn one catalog read into + /// an N+1 request pattern and discard the snapshots the list operation + /// already returned. + pub fn terminal_snapshots(&self) -> Result> { wire::list::( &self.client.read(ops::TERMINAL_LIST, self.params())?, "terminals", "terminal", ) - .map(|snapshots| snapshots.into_iter().map(|snapshot| self.terminal(snapshot.id)).collect()) + } + + pub fn terminals(&self) -> Result> { + self.terminal_snapshots().map(|snapshots| { + snapshots.into_iter().map(|snapshot| self.terminal(snapshot.id)).collect() + }) } pub fn find_terminals_by_name(&self, name: &str) -> Result> { - Ok(wire::list::( - &self.client.read(ops::TERMINAL_LIST, self.params())?, - "terminals", - "terminal", - )? - .into_iter() - .filter(|snapshot| snapshot.title == name) - .map(|snapshot| self.terminal(snapshot.id)) - .collect()) + Ok(self + .terminal_snapshots()? + .into_iter() + .filter(|snapshot| snapshot.title == name) + .map(|snapshot| self.terminal(snapshot.id)) + .collect()) } pub fn browsers(&self) -> Result> { diff --git a/cmux-tui/bindings/rust/src/resource/journal_plugin.rs b/cmux-tui/bindings/rust/src/resource/journal_plugin.rs new file mode 100644 index 000000000000..eb92a1cf771e --- /dev/null +++ b/cmux-tui/bindings/rust/src/resource/journal_plugin.rs @@ -0,0 +1,407 @@ +//! Typed request models for userland journal producers. +//! +//! These are generic journal contracts. The `AgentPlugin*` names below are +//! compatibility aliases for early SDK users. A plugin is not a special +//! journal writer, and adding a new plugin must not require a core type. + +use super::typed_stream::{JournalClass, JournalReplayPolicy, JournalSensitivity, JournalSubject}; +use crate::{Error, Result}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::BTreeSet; + +const MAX_COMPONENT_BYTES: usize = 64; +const MAX_KIND_BYTES: usize = 128; +const MAX_PERMISSION_COUNT: usize = 32; +const MAX_PERMISSION_BYTES: usize = 128; +const MAX_EVENTS: usize = 64; +const MAX_SUBJECT_COUNT: usize = 64; +const MAX_SUBJECT_ID_BYTES: usize = 512; +const MAX_CAUSAL_ID_BYTES: usize = 128; +const MAX_MANIFEST_BYTES: usize = 1024 * 1024; +const MAX_EVENT_ID_BYTES: usize = 128; + +fn valid_component(value: &str) -> bool { + !value.is_empty() + && value.len() <= MAX_COMPONENT_BYTES + && value.as_bytes().first().is_some_and(|byte| byte.is_ascii_alphanumeric()) + && value.bytes().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_' || byte == b'-' + }) +} + +fn valid_kind(value: &str) -> bool { + !value.is_empty() && value.len() <= MAX_KIND_BYTES && value.split('.').all(valid_component) +} + +fn valid_decimal(value: &str) -> bool { + if value.is_empty() || value.starts_with('+') || (value.starts_with('0') && value.len() > 1) { + return false; + } + value.bytes().all(|byte| byte.is_ascii_digit()) && value.parse::().is_ok() +} + +fn valid_event_id(value: &str) -> bool { + !value.is_empty() && value.len() <= MAX_EVENT_ID_BYTES +} + +fn sensitivity_rank(value: JournalSensitivity) -> u8 { + match value { + JournalSensitivity::Public => 0, + JournalSensitivity::Metadata => 1, + JournalSensitivity::Sensitive => 2, + JournalSensitivity::Secret => 3, + } +} + +fn serialize_optional_decimal( + value: &Option, + serializer: S, +) -> std::result::Result +where + S: serde::Serializer, +{ + match value { + Some(value) => serializer.serialize_some(&value.to_string()), + None => serializer.serialize_none(), + } +} + +/// One event schema declared by a journal producer. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct JournalEventSchema { + pub kind: String, + pub schema_version: u32, + pub class: JournalClass, + pub replay: JournalReplayPolicy, + pub sensitivity: JournalSensitivity, + pub payload_schema: Value, +} + +/// Manifest installed by a userland journal producer. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct JournalProducerManifest { + pub producer_id: String, + pub namespace: String, + pub manifest_version: u32, + pub max_sensitivity: JournalSensitivity, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub permissions: Vec, + pub events: Vec, +} + +impl JournalProducerManifest { + /// Validate the same structural and authority rules checked by the + /// daemon. The daemon remains authoritative because it also compiles the + /// JSON schemas inside its transaction. + pub fn validate(&self) -> Result<()> { + if !valid_component(&self.producer_id) { + return Err(Error::InvalidArgument( + "producer_id must match [a-z0-9][a-z0-9_-]* and contain at most 64 bytes".into(), + )); + } + if self.namespace != format!("plugin.{}", self.producer_id) { + return Err(Error::InvalidArgument( + "journal producer namespace must be plugin.".into(), + )); + } + if self.manifest_version == 0 || self.events.is_empty() || self.events.len() > MAX_EVENTS { + return Err(Error::InvalidArgument(format!( + "manifest_version must be positive and events must contain 1 to {MAX_EVENTS} entries" + ))); + } + if self.max_sensitivity == JournalSensitivity::Secret { + return Err(Error::InvalidArgument( + "secret journal payload storage is unavailable".into(), + )); + } + if self.permissions.is_empty() || self.permissions.len() > MAX_PERMISSION_COUNT { + return Err(Error::InvalidArgument(format!( + "permissions must contain 1 to {MAX_PERMISSION_COUNT} entries" + ))); + } + if self + .permissions + .iter() + .any(|permission| permission.is_empty() || permission.len() > MAX_PERMISSION_BYTES) + { + return Err(Error::InvalidArgument(format!( + "permissions must contain 1 to {MAX_PERMISSION_BYTES} bytes" + ))); + } + if !self + .permissions + .iter() + .any(|permission| permission == &format!("journal.append.{}", self.namespace)) + { + return Err(Error::InvalidArgument( + "journal producer manifest requires its journal append permission".into(), + )); + } + let encoded = serde_json::to_vec(self) + .map_err(|error| Error::Decode(format!("serialize journal manifest: {error}")))?; + if encoded.len() > MAX_MANIFEST_BYTES { + return Err(Error::InvalidArgument(format!( + "journal producer manifest exceeds {MAX_MANIFEST_BYTES} bytes" + ))); + } + let namespace_prefix = format!("{}.", self.namespace); + let mut identities = BTreeSet::new(); + for event in &self.events { + if !valid_kind(&event.kind) || !event.kind.starts_with(&namespace_prefix) { + return Err(Error::InvalidArgument( + "journal event kind must be a dotted lowercase name inside the producer namespace" + .into(), + )); + } + if event.schema_version == 0 { + return Err(Error::InvalidArgument( + "journal event schema_version must be positive".into(), + )); + } + if event.sensitivity == JournalSensitivity::Secret + || sensitivity_rank(event.sensitivity) > sensitivity_rank(self.max_sensitivity) + { + return Err(Error::InvalidArgument( + "journal event sensitivity exceeds producer authority".into(), + )); + } + if !identities.insert((&event.kind, event.schema_version)) { + return Err(Error::InvalidArgument( + "journal producer declares a duplicate event schema".into(), + )); + } + } + Ok(()) + } +} + +/// Generic journal ingress envelope for a userland producer. +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct JournalIngress { + pub producer_id: String, + pub manifest_version: u32, + pub kind: String, + pub schema_version: u32, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(serialize_with = "serialize_optional_decimal")] + pub occurred_at_ms: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub subjects: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub sensitivity: Option, + pub payload: Value, + #[serde(skip_serializing_if = "Option::is_none")] + pub causation_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub correlation_id: Option, +} + +impl JournalIngress { + /// Validate the envelope before it crosses the socket. The selected + /// producer manifest remains the authority for schema and sensitivity. + pub fn validate(&self) -> Result<()> { + let namespace_prefix = format!("plugin.{}.", self.producer_id); + if !valid_component(&self.producer_id) + || self.manifest_version == 0 + || self.schema_version == 0 + || !valid_kind(&self.kind) + || !self.kind.starts_with(&namespace_prefix) + || self.subjects.len() > MAX_SUBJECT_COUNT + || self.subjects.iter().any(|subject| { + !valid_component(&subject.kind) + || subject.id.is_empty() + || subject.id.len() > MAX_SUBJECT_ID_BYTES + }) + { + return Err(Error::InvalidArgument("journal event envelope is invalid".into())); + } + if self.sensitivity == Some(JournalSensitivity::Secret) { + return Err(Error::InvalidArgument( + "secret journal payload storage is unavailable".into(), + )); + } + if self + .causation_id + .as_ref() + .is_some_and(|value| value.is_empty() || value.len() > MAX_CAUSAL_ID_BYTES) + || self + .correlation_id + .as_ref() + .is_some_and(|value| value.is_empty() || value.len() > MAX_CAUSAL_ID_BYTES) + { + return Err(Error::InvalidArgument(format!( + "causation_id and correlation_id must contain 1 to {MAX_CAUSAL_ID_BYTES} bytes" + ))); + } + Ok(()) + } +} + +/// Receipt returned by `session.journal.producer.put`. +#[derive(Clone, Debug, PartialEq, serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub struct JournalProducerPutResult { + pub producer_id: String, + pub manifest_version: u32, + pub namespace: String, + pub sequence: String, + pub event_id: String, +} + +impl JournalProducerPutResult { + /// Validate a mutation receipt before exposing server data to a plugin. + pub fn validate(&self) -> Result<()> { + if !valid_component(&self.producer_id) + || self.manifest_version == 0 + || self.namespace != format!("plugin.{}", self.producer_id) + || !valid_decimal(&self.sequence) + || !valid_event_id(&self.event_id) + { + return Err(Error::Decode("invalid journal producer mutation result".into())); + } + Ok(()) + } +} + +/// Result returned by `session.journal.producer.list`. +#[derive(Clone, Debug, PartialEq, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct JournalProducerListResult { + pub producers: Vec, +} + +impl JournalProducerListResult { + /// Validate a server response before exposing it to a plugin. + pub fn validate(&self) -> Result<()> { + const MAX_PRODUCERS: usize = 1024; + if self.producers.len() > MAX_PRODUCERS { + return Err(Error::Decode(format!( + "journal producer list contains more than {MAX_PRODUCERS} entries" + ))); + } + for producer in &self.producers { + producer.validate().map_err(|error| { + Error::Decode(format!("invalid journal producer manifest: {error}")) + })?; + } + Ok(()) + } +} + +/// Receipt returned by `session.journal.append`. +#[derive(Clone, Debug, PartialEq, serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub struct JournalAppendResult { + pub producer_id: String, + pub sequence: String, + pub event_id: String, +} + +impl JournalAppendResult { + /// Validate a mutation receipt before exposing server data to a plugin. + pub fn validate(&self) -> Result<()> { + if !valid_component(&self.producer_id) + || !valid_decimal(&self.sequence) + || !valid_event_id(&self.event_id) + { + return Err(Error::Decode("invalid journal append result".into())); + } + Ok(()) + } +} + +// Compatibility names from the first agent-plugin preview. Keep them as +// aliases so plugins do not need a coordinated SDK upgrade. +pub type AgentPluginEventSchema = JournalEventSchema; +pub type AgentPluginManifest = JournalProducerManifest; +pub type AgentPluginSubject = JournalSubject; +pub type AgentPluginIngress = JournalIngress; +pub type AgentPluginListResult = JournalProducerListResult; +pub type JournalEventSubject = JournalSubject; + +#[cfg(test)] +mod tests { + use super::*; + + fn manifest(producer_id: &str) -> JournalProducerManifest { + JournalProducerManifest { + producer_id: producer_id.into(), + namespace: format!("plugin.{producer_id}"), + manifest_version: 1, + max_sensitivity: JournalSensitivity::Sensitive, + permissions: vec![format!("journal.append.plugin.{producer_id}")], + events: vec![JournalEventSchema { + kind: format!("plugin.{producer_id}.state.changed"), + schema_version: 1, + class: JournalClass::State, + replay: JournalReplayPolicy::Required, + sensitivity: JournalSensitivity::Sensitive, + payload_schema: serde_json::json!({"type":"object"}), + }], + } + } + + #[test] + fn producer_component_grammar_is_shared_with_core() { + assert!(manifest("screen-detector").validate().is_ok()); + assert!(manifest("screen_detector").validate().is_ok()); + assert!(manifest("_screen-detector").validate().is_err()); + assert!(manifest("Screen-detector").validate().is_err()); + } + + #[test] + fn producer_and_ingress_limits_are_checked_before_socket_io() { + let mut producer = manifest("screen-detector"); + producer.permissions = + vec!["journal.append.plugin.screen-detector".into(); MAX_PERMISSION_COUNT + 1]; + assert!(producer.validate().is_err()); + + let mut event = JournalIngress { + producer_id: "screen-detector".into(), + manifest_version: 1, + kind: "plugin.screen-detector.agent.state.changed".into(), + schema_version: 1, + occurred_at_ms: None, + subjects: vec![JournalSubject { + kind: "terminal".into(), + id: "x".repeat(MAX_SUBJECT_ID_BYTES + 1), + }], + sensitivity: None, + payload: serde_json::json!({}), + causation_id: Some("c".repeat(MAX_CAUSAL_ID_BYTES + 1)), + correlation_id: None, + }; + assert!(event.validate().is_err()); + event.subjects[0].id = "x".repeat(MAX_SUBJECT_ID_BYTES); + event.causation_id = Some("c".repeat(MAX_CAUSAL_ID_BYTES)); + assert!(event.validate().is_ok()); + + event.kind = "agent.state.changed".into(); + assert!(event.validate().is_err()); + } + + #[test] + fn mutation_receipts_reject_invalid_identity_and_sequence() { + let mut put = JournalProducerPutResult { + producer_id: "screen-detector".into(), + manifest_version: 1, + namespace: "plugin.screen-detector".into(), + sequence: "1".into(), + event_id: "event-1".into(), + }; + assert!(put.validate().is_ok()); + put.namespace = "agent".into(); + assert!(put.validate().is_err()); + + let append = JournalAppendResult { + producer_id: "screen-detector".into(), + sequence: "01".into(), + event_id: "event-1".into(), + }; + assert!(append.validate().is_err()); + } +} diff --git a/cmux-tui/bindings/rust/src/resource/mod.rs b/cmux-tui/bindings/rust/src/resource/mod.rs index 7dad248f52ac..a58be97521ab 100644 --- a/cmux-tui/bindings/rust/src/resource/mod.rs +++ b/cmux-tui/bindings/rust/src/resource/mod.rs @@ -1,6 +1,7 @@ mod client; mod handles; mod id; +mod journal_plugin; mod model; mod ops; mod options; @@ -18,6 +19,11 @@ pub use id::{ OpaqueId, PairingRequestId, PaneId, ScreenId, Selector, SessionId, SidebarViewId, SplitId, StreamId, TabId, TerminalId, WorkspaceId, }; +pub use journal_plugin::{ + AgentPluginEventSchema, AgentPluginIngress, AgentPluginListResult, AgentPluginManifest, + AgentPluginSubject, JournalAppendResult, JournalEventSchema, JournalEventSubject, + JournalIngress, JournalProducerListResult, JournalProducerManifest, JournalProducerPutResult, +}; pub use model::{ AgentSnapshot, AgentSnapshotSource, BrowserSnapshot, BrowserSource, BrowserStatus, BrowserViewerResizeResult, CellPixelsResult, ClientSnapshot, ClientTerminalSize, diff --git a/cmux-tui/bindings/rust/src/resource/model.rs b/cmux-tui/bindings/rust/src/resource/model.rs index ed9e693e2505..fda3aab55748 100644 --- a/cmux-tui/bindings/rust/src/resource/model.rs +++ b/cmux-tui/bindings/rust/src/resource/model.rs @@ -486,6 +486,7 @@ pub struct TerminalSnapshot { pub rows: u16, pub running: bool, pub lifecycle: TerminalLifecycle, + pub stream_revision: Option, pub exit: Option, pub extra: BTreeMap, } @@ -512,6 +513,8 @@ impl<'de> Deserialize<'de> for TerminalSnapshot { rows: u16, running: bool, lifecycle: TerminalLifecycle, + #[serde(default, deserialize_with = "deserialize_optional_decimal")] + stream_revision: Option, #[serde(default, deserialize_with = "deserialize_optional_non_null")] exit: Option, #[serde(default)] @@ -559,6 +562,7 @@ impl<'de> Deserialize<'de> for TerminalSnapshot { rows: wire.rows, running: wire.running, lifecycle: wire.lifecycle, + stream_revision: wire.stream_revision, exit: wire.exit, extra: wire.extra, }) @@ -700,6 +704,7 @@ pub enum AgentSnapshotSource { Hook, Socket, Detected, + Plugin, } /// Catalog snapshot for one detected agent. @@ -1134,6 +1139,15 @@ pub struct TerminalDefaultsSnapshot { #[serde(deny_unknown_fields)] pub struct TerminalScreenResult { pub text: String, + /// Monotonic terminal output revision. Null means that the server cannot + /// provide it. Plugins can use it to avoid parsing an unchanged viewport. + #[serde(default, deserialize_with = "deserialize_nullable_decimal")] + pub revision: Option, + /// Latest bounded OSC 9 progress payload from the terminal output stream. + /// Null means that the server cannot provide it. Plugins may interpret + /// this value; the daemon does not attach agent meaning to it. + #[serde(default, deserialize_with = "deserialize_nullable")] + pub osc_progress: Option, #[serde(deserialize_with = "deserialize_positive_u16")] pub cols: u16, #[serde(deserialize_with = "deserialize_positive_u16")] @@ -1238,6 +1252,11 @@ pub struct ProcessInfoResult { /// omits the field. #[serde(default)] pub foreground_cwd: Option, + /// Executable path or name of the PTY foreground process group leader. + /// This lets userland plugins identify nested agents without putting + /// vendor logic in the daemon. + #[serde(default)] + pub foreground_executable: Option, pub children: Vec, } @@ -1516,6 +1535,24 @@ where deserialize_decimal(deserializer).map(Some) } +fn deserialize_nullable_decimal<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + Option::::deserialize(deserializer)? + .map(|value| { + if value.is_empty() + || value.starts_with('+') + || (value.starts_with('0') && value.len() > 1) + || !value.bytes().all(|byte| byte.is_ascii_digit()) + { + return Err(serde::de::Error::custom("decimal must be a canonical uint64 string")); + } + value.parse::().map_err(serde::de::Error::custom) + }) + .transpose() +} + fn deserialize_positive_i32<'de, D>(deserializer: D) -> Result where D: Deserializer<'de>, @@ -1616,3 +1653,53 @@ where let value = String::deserialize(deserializer)?; base64::engine::general_purpose::STANDARD.decode(value).map_err(serde::de::Error::custom) } + +#[cfg(test)] +mod tests { + use super::TerminalScreenResult; + + #[test] + fn terminal_screen_metadata_accepts_omitted_and_explicit_null() { + let base = r#"{ + "text":"ready", + "cols":80, + "rows":24, + "cursor_row":0, + "cursor_col":0, + "cursor_visible":true + }"#; + let omitted: TerminalScreenResult = serde_json::from_str(base).unwrap(); + assert_eq!(omitted.revision, None); + assert_eq!(omitted.osc_progress, None); + + let explicit: TerminalScreenResult = serde_json::from_str( + r#"{ + "text":"ready", + "revision":null, + "osc_progress":null, + "cols":80, + "rows":24, + "cursor_row":0, + "cursor_col":0, + "cursor_visible":true + }"#, + ) + .unwrap(); + assert_eq!(explicit.revision, None); + assert_eq!(explicit.osc_progress, None); + } + + #[test] + fn terminal_screen_revision_stays_canonical_decimal() { + let json = r#"{ + "text":"ready", + "revision":"01", + "cols":80, + "rows":24, + "cursor_row":0, + "cursor_col":0, + "cursor_visible":true + }"#; + assert!(serde_json::from_str::(json).is_err()); + } +} diff --git a/cmux-tui/bindings/rust/src/resource/ops.rs b/cmux-tui/bindings/rust/src/resource/ops.rs index 2202a26f6dfe..efe5aee2878b 100644 --- a/cmux-tui/bindings/rust/src/resource/ops.rs +++ b/cmux-tui/bindings/rust/src/resource/ops.rs @@ -9,6 +9,9 @@ pub(crate) const SESSION_CREATION_RESOLVE: &str = "session.creation.resolve"; pub(crate) const SESSION_SNAPSHOT: &str = "session.snapshot"; pub(crate) const SESSION_EVENTS: &str = "session.events"; pub(crate) const SESSION_JOURNAL_SUBSCRIBE: &str = "session.journal.subscribe"; +pub(crate) const SESSION_JOURNAL_PRODUCER_LIST: &str = "session.journal.producer.list"; +pub(crate) const SESSION_JOURNAL_PRODUCER_PUT: &str = "session.journal.producer.put"; +pub(crate) const SESSION_JOURNAL_APPEND: &str = "session.journal.append"; pub(crate) const SESSION_PING: &str = "session.ping"; pub(crate) const SESSION_SHUTDOWN: &str = "session.shutdown"; pub(crate) const SESSION_RELOAD_CONFIG: &str = "session.reload_config"; diff --git a/cmux-tui/bindings/rust/src/resource/typed_stream.rs b/cmux-tui/bindings/rust/src/resource/typed_stream.rs index 5e29471aa69d..024097e22a2e 100644 --- a/cmux-tui/bindings/rust/src/resource/typed_stream.rs +++ b/cmux-tui/bindings/rust/src/resource/typed_stream.rs @@ -12,6 +12,7 @@ use super::stream::{ResourceStream, StreamCancellation, StreamItemValidator}; use super::wire::{self, Params, field}; use crate::{Error, Result}; use base64::Engine; +use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; use std::time::Duration; @@ -181,7 +182,8 @@ pub enum SessionEvent { Unknown { kind: String, raw: Document }, } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] pub enum JournalClass { State, Observation, @@ -189,14 +191,16 @@ pub enum JournalClass { Checkpoint, } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] pub enum JournalReplayPolicy { Required, Advisory, Never, } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] pub enum JournalSensitivity { Public, Metadata, @@ -218,7 +222,8 @@ pub struct JournalAuthority { pub role: String, } -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(deny_unknown_fields)] pub struct JournalSubject { pub kind: String, pub id: String, diff --git a/cmux-tui/bindings/rust/tests/operation_reachability.rs b/cmux-tui/bindings/rust/tests/operation_reachability.rs index c8b7ddec368e..675ea846b762 100644 --- a/cmux-tui/bindings/rust/tests/operation_reachability.rs +++ b/cmux-tui/bindings/rust/tests/operation_reachability.rs @@ -16,7 +16,7 @@ fn every_high_level_operation_constant_has_a_facade_call_site() { Some(rest.split(':').next().unwrap()) }) .collect::>(); - assert_eq!(names.len(), 114, "update this count only with the accepted inventory"); + assert_eq!(names.len(), 117, "update this count only with the accepted inventory"); for name in names { assert!( call_sites.contains(&format!("ops::{name}")), diff --git a/cmux-tui/bindings/typescript/.cmux-resource-api.json b/cmux-tui/bindings/typescript/.cmux-resource-api.json index 4c4481343150..0667d105f967 100644 --- a/cmux-tui/bindings/typescript/.cmux-resource-api.json +++ b/cmux-tui/bindings/typescript/.cmux-resource-api.json @@ -1,5 +1,5 @@ { - "catalog_sha256": "beef8293ded489648261ccddfd31b3f796f9d7d10506f13f6c5d1577a3f4fbac", + "catalog_sha256": "08a8190787e1b38d0592b85856d791fb0fe098d58eddce85ee98ecf88cc5a1a7", "operations": { "agent.list": { "class": "read" diff --git a/cmux-tui/bindings/typescript/src/internal/operations.ts b/cmux-tui/bindings/typescript/src/internal/operations.ts index 2e342a8bc535..6c1c3599eb0d 100644 --- a/cmux-tui/bindings/typescript/src/internal/operations.ts +++ b/cmux-tui/bindings/typescript/src/internal/operations.ts @@ -24,6 +24,9 @@ export const operations = Object.freeze({ sessionCreationResolve: op("session.creation.resolve", "read"), sessionEvents: op("session.events", "stream_open"), sessionJournalSubscribe: op("session.journal.subscribe", "stream_open"), + sessionJournalProducerList: op("session.journal.producer.list", "read"), + sessionJournalProducerPut: op("session.journal.producer.put", "mutation"), + sessionJournalAppend: op("session.journal.append", "mutation"), sessionPing: op("session.ping", "read"), sessionShutdown: op("session.shutdown", "mutation"), sessionReloadConfig: op("session.reload_config", "mutation"), diff --git a/cmux-tui/bindings/typescript/src/models.ts b/cmux-tui/bindings/typescript/src/models.ts index 25bee8b24003..bfba88a6c69a 100644 --- a/cmux-tui/bindings/typescript/src/models.ts +++ b/cmux-tui/bindings/typescript/src/models.ts @@ -158,7 +158,7 @@ export interface AgentSnapshot extends Snapshot { readonly sessionId: SessionId; readonly terminalId: TerminalId; readonly state: "working" | "blocked" | "idle" | "done" | "unknown"; - readonly source: "hook" | "socket" | "detected"; + readonly source: "hook" | "socket" | "detected" | "plugin"; readonly updatedAtMs: DecimalString; readonly sourceSession: string | null; } @@ -255,6 +255,10 @@ export interface PaneNeighborResult { export interface TerminalScreenResult { readonly text: string; + /** Coalesced PTY output revision, when supplied; null means unavailable. */ + readonly revision?: DecimalString | null; + /** Bounded OSC 9 progress text, when supplied; null means unavailable. */ + readonly oscProgress?: string | null; readonly cols: number; readonly rows: number; readonly cursorRow: number; @@ -343,6 +347,8 @@ export interface ProcessInfoResult { * omits the field. */ readonly foregroundCwd: string | null; + /** Executable path or name of the PTY foreground process-group leader. */ + readonly foregroundExecutable: string | null; readonly children: readonly number[]; } @@ -672,6 +678,55 @@ export interface JournalSubject { readonly id: string; } +export interface JournalEventSchema { + readonly kind: string; + readonly schemaVersion: number; + readonly class: JournalClass; + readonly replay: JournalReplayPolicy; + readonly sensitivity: JournalSensitivity; + readonly payloadSchema: JsonValue; +} + +export interface JournalProducerManifest { + readonly producerId: string; + readonly namespace: string; + readonly manifestVersion: number; + readonly maxSensitivity: JournalSensitivity; + readonly permissions: readonly string[]; + readonly events: readonly JournalEventSchema[]; +} + +export interface JournalIngress { + readonly producerId: string; + readonly manifestVersion: number; + readonly kind: string; + readonly schemaVersion: number; + readonly occurredAtMs?: DecimalString; + readonly subjects?: readonly JournalSubject[]; + readonly sensitivity?: JournalSensitivity; + readonly payload: JsonValue; + readonly causationId?: string; + readonly correlationId?: string; +} + +export interface JournalProducerPutResult { + readonly producerId: string; + readonly manifestVersion: number; + readonly namespace: string; + readonly sequence: DecimalString; + readonly eventId: string; +} + +export interface JournalProducerListResult { + readonly producers: readonly JournalProducerManifest[]; +} + +export interface JournalAppendResult { + readonly producerId: string; + readonly sequence: DecimalString; + readonly eventId: string; +} + export interface SessionJournalRecord { readonly sequence: DecimalString; readonly eventId: string; diff --git a/cmux-tui/bindings/typescript/src/raw/generated/.cmux-sdk-manifest.json b/cmux-tui/bindings/typescript/src/raw/generated/.cmux-sdk-manifest.json index 368707b5b562..1364d551afa5 100644 --- a/cmux-tui/bindings/typescript/src/raw/generated/.cmux-sdk-manifest.json +++ b/cmux-tui/bindings/typescript/src/raw/generated/.cmux-sdk-manifest.json @@ -2,32 +2,32 @@ "files": [ { "path": "commands.ts", - "sha256": "46f84a403c02da6198690053d6e77db510fbedd2f754f33778bb5046466ad720", + "sha256": "5cf2bd5feade9046dec8505a8a020e78ff5e30aa7c1fbb4ee5a99b0c1bb6d31b", "size": 53720 }, { "path": "events.ts", - "sha256": "abf987cf75676f2e73cfbce857fbd87eb88befcc3bf9b560eb56d8cd20503cad", - "size": 15778 + "sha256": "cc0b87a44df3d755ac9c61c8574a793de1b21bc9ccd49d73c4c5df1775822848", + "size": 15940 }, { "path": "index.ts", - "sha256": "202edf7b57d3df2eb9338b83d92697ec608f34402436f75e42f5553e31119937", + "sha256": "1b242cb5a3de58027c9ac5368f38cd1eecc6a0c2de1f497d00c22b18457f1dc9", "size": 272 }, { "path": "metadata.ts", - "sha256": "41f94523768a8a2c2ed372750cd0f6a7274b12d9d6fff67b49fca574f339b5c9", - "size": 305982 + "sha256": "b985e3132ff0240730c4a750151b4657dc6b3c423b581cd21e58bde2cee681e8", + "size": 306759 }, { "path": "types.ts", - "sha256": "f78deb674e98b10470a5c3e57743fc0b4eeb8c16a84f2cc2259708e181373a9d", - "size": 20439 + "sha256": "81d5c410bbfe4920ef67b45086cb8b8f7cd2583c8a72831cb6384d1c38a42835", + "size": 20697 } ], "format": 1, - "ir_sha256": "7042c629f34d3606581d07b2d2c03b65116c2467810724163c54674865825cc0", + "ir_sha256": "133bac0154f8f94aa30e40c11ff7ed38b10dd4d82974aec87c02d404fcd12619", "language": "typescript", "mux_protocol": 12, "schema_version": 2 diff --git a/cmux-tui/bindings/typescript/src/raw/generated/commands.ts b/cmux-tui/bindings/typescript/src/raw/generated/commands.ts index 6f0173847a1e..bcedda44d905 100644 --- a/cmux-tui/bindings/typescript/src/raw/generated/commands.ts +++ b/cmux-tui/bindings/typescript/src/raw/generated/commands.ts @@ -1,5 +1,5 @@ /* This file is generated. Do not edit by hand. */ -/* cmux-tui mux protocol 12, IR 7042c629f34d3606581d07b2d2c03b65116c2467810724163c54674865825cc0. */ +/* cmux-tui mux protocol 12, IR 133bac0154f8f94aa30e40c11ff7ed38b10dd4d82974aec87c02d404fcd12619. */ import type * as T from "./types.js"; diff --git a/cmux-tui/bindings/typescript/src/raw/generated/events.ts b/cmux-tui/bindings/typescript/src/raw/generated/events.ts index 593b4eeab2ca..5bed9acdc0cd 100644 --- a/cmux-tui/bindings/typescript/src/raw/generated/events.ts +++ b/cmux-tui/bindings/typescript/src/raw/generated/events.ts @@ -1,11 +1,13 @@ /* This file is generated. Do not edit by hand. */ -/* cmux-tui mux protocol 12, IR 7042c629f34d3606581d07b2d2c03b65116c2467810724163c54674865825cc0. */ +/* cmux-tui mux protocol 12, IR 133bac0154f8f94aa30e40c11ff7ed38b10dd4d82974aec87c02d404fcd12619. */ import type * as T from "./types.js"; /** Protocol v11; emission: emitted; streams: subscribe. */ export type AgentChangedEvent = { event: "agent-changed" } & { + /** Adapter identity when the producer knows it; absent from protocol-11 event senders and null when no adapter was identified. */ + "agent"?: (string) | null; "session": (string) | null; "source": T.AgentSource; "state": T.AgentState; diff --git a/cmux-tui/bindings/typescript/src/raw/generated/index.ts b/cmux-tui/bindings/typescript/src/raw/generated/index.ts index 4a31da912432..6bd59033cecf 100644 --- a/cmux-tui/bindings/typescript/src/raw/generated/index.ts +++ b/cmux-tui/bindings/typescript/src/raw/generated/index.ts @@ -1,5 +1,5 @@ /* This file is generated. Do not edit by hand. */ -/* cmux-tui mux protocol 12, IR 7042c629f34d3606581d07b2d2c03b65116c2467810724163c54674865825cc0. */ +/* cmux-tui mux protocol 12, IR 133bac0154f8f94aa30e40c11ff7ed38b10dd4d82974aec87c02d404fcd12619. */ export * from "./types.js"; export * from "./commands.js"; diff --git a/cmux-tui/bindings/typescript/src/raw/generated/metadata.ts b/cmux-tui/bindings/typescript/src/raw/generated/metadata.ts index 9f58a4c10b42..e5e1be169a2b 100644 --- a/cmux-tui/bindings/typescript/src/raw/generated/metadata.ts +++ b/cmux-tui/bindings/typescript/src/raw/generated/metadata.ts @@ -1,10 +1,10 @@ /* This file is generated. Do not edit by hand. */ -/* cmux-tui mux protocol 12, IR 7042c629f34d3606581d07b2d2c03b65116c2467810724163c54674865825cc0. */ +/* cmux-tui mux protocol 12, IR 133bac0154f8f94aa30e40c11ff7ed38b10dd4d82974aec87c02d404fcd12619. */ export const SDK_SCHEMA_VERSION = 2 as const; export const MUX_PROTOCOL_VERSION = 12 as const; -export const SDK_IR_SHA256 = "7042c629f34d3606581d07b2d2c03b65116c2467810724163c54674865825cc0" as const; +export const SDK_IR_SHA256 = "133bac0154f8f94aa30e40c11ff7ed38b10dd4d82974aec87c02d404fcd12619" as const; export const PROTOCOL = { "id_type": "uint64", "javascript_id_policy": "All protocol identifiers are uint64 JSON numbers. JavaScript and TypeScript SDKs must decode them losslessly as bigint (or validated decimal strings at their public boundary), and must not expose IEEE-754 number ids. Pairing request ids, revisions, timestamps, frame sequences, and reservation ids follow the same rule.", @@ -1829,6 +1829,7 @@ export const TYPE_SCHEMAS: Readonly> = { "AgentSource": { "kind": "enum", "values": [ + "plugin", "detected", "socket", "hook" @@ -4111,6 +4112,16 @@ export const TYPE_SCHEMAS: Readonly> = { "name": "string" } }, + "foreground_executable": { + "description": "Executable path or name of the PTY foreground process-group leader, read at request time. Null when the lookup fails; absent from daemons that predate the field. Clients treat absence as null.", + "nullable": true, + "presence": "optional", + "since": 12, + "type": { + "kind": "scalar", + "name": "string" + } + }, "pid": { "nullable": true, "presence": "required", @@ -11629,6 +11640,16 @@ export const EVENT_SCHEMAS: Readonly> = { "agent-changed": { "additional_properties": false, "fields": { + "agent": { + "description": "Adapter identity when the producer knows it; absent from protocol-11 event senders and null when no adapter was identified.", + "nullable": true, + "presence": "optional", + "since": 12, + "type": { + "kind": "scalar", + "name": "string" + } + }, "event": { "nullable": false, "presence": "required", diff --git a/cmux-tui/bindings/typescript/src/raw/generated/types.ts b/cmux-tui/bindings/typescript/src/raw/generated/types.ts index 685871fb9fad..c8604182c3a6 100644 --- a/cmux-tui/bindings/typescript/src/raw/generated/types.ts +++ b/cmux-tui/bindings/typescript/src/raw/generated/types.ts @@ -1,5 +1,5 @@ /* This file is generated. Do not edit by hand. */ -/* cmux-tui mux protocol 12, IR 7042c629f34d3606581d07b2d2c03b65116c2467810724163c54674865825cc0. */ +/* cmux-tui mux protocol 12, IR 133bac0154f8f94aa30e40c11ff7ed38b10dd4d82974aec87c02d404fcd12619. */ /** JSON accepted by the wire codec. bigint is serialized as an exact JSON integer. */ @@ -16,7 +16,7 @@ export type AgentRecord = { export type AgentReportSource = "socket" | "hook"; -export type AgentSource = "detected" | "socket" | "hook"; +export type AgentSource = "plugin" | "detected" | "socket" | "hook"; export type AgentState = "working" | "blocked" | "idle" | "done" | "unknown"; @@ -406,6 +406,8 @@ export type ProcessInfoResult = { "cwd": (string) | null; /** Working directory of the process group that owns the PTY, read at request time. Null when the lookup fails; absent from daemons that predate the field. Clients treat absence as null. */ "foreground_cwd"?: (string) | null; + /** Executable path or name of the PTY foreground process-group leader, read at request time. Null when the lookup fails; absent from daemons that predate the field. Clients treat absence as null. */ + "foreground_executable"?: (string) | null; "pid": (number) | null; }; diff --git a/cmux-tui/bindings/typescript/src/resources.ts b/cmux-tui/bindings/typescript/src/resources.ts index ebf15f180e6e..c57de3379f62 100644 --- a/cmux-tui/bindings/typescript/src/resources.ts +++ b/cmux-tui/bindings/typescript/src/resources.ts @@ -119,6 +119,16 @@ import { type ViewerResizeResult, type ViewerReleaseResult, type JsonValue, + type JournalAppendResult, + type JournalClass, + type JournalEventSchema, + type JournalIngress, + type JournalProducerListResult, + type JournalProducerManifest, + type JournalProducerPutResult, + type JournalReplayPolicy, + type JournalSensitivity, + type JournalSubject, type WorkspaceSnapshot, } from "./models.js"; import type { @@ -929,13 +939,318 @@ function agentSnapshot(value: unknown): AgentSnapshot { source: requiredEnum( payload, "source", - ["hook", "socket", "detected"] as const, + ["hook", "socket", "detected", "plugin"] as const, ), updatedAtMs: requiredDecimal(payload, "updated_at_ms"), sourceSession: requiredNullableString(payload, "source_session"), }); } +const JOURNAL_CLASSES = ["state", "observation", "effect", "checkpoint"] as const; +const JOURNAL_REPLAY_POLICIES = ["required", "advisory", "never"] as const; +const JOURNAL_SENSITIVITIES = ["public", "metadata", "sensitive", "secret"] as const; +const MAX_JOURNAL_MANIFEST_BYTES = 1_048_576; + +function isPositiveUint32(value: unknown): value is number { + return typeof value === "number" + && Number.isSafeInteger(value) + && value >= 1 + && value <= 0xffff_ffff; +} + +function isJournalComponent(value: string, maximumBytes = 64): boolean { + return hasUtf8ByteLength(value, 1, maximumBytes) + && /^[a-z0-9][a-z0-9_-]*$/.test(value); +} + +function isJournalKind(value: string): boolean { + return hasUtf8ByteLength(value, 1, 128) + && value.split(".").every((part) => isJournalComponent(part)); +} + +function journalSubject(value: unknown, label = "journal subject"): JournalSubject { + const payload = record(value, label); + strictObject(payload, ["kind", "id"], label); + const kind = requiredString(payload, "kind"); + const id = requiredString(payload, "id"); + if (!isJournalComponent(kind)) { + throw new CmuxProtocolError(`${label} kind must be a lowercase component`); + } + if (!hasUtf8ByteLength(id, 1, 512)) { + throw new CmuxProtocolError(`${label} id must contain 1 to 512 UTF-8 bytes`); + } + return Object.freeze({ + kind, + id, + }); +} + +function journalEventSchema(value: unknown): JournalEventSchema { + const payload = record(value, "journal event schema"); + strictObject( + payload, + ["kind", "schema_version", "class", "replay", "sensitivity", "payload_schema"], + "journal event schema", + ); + const kind = requiredString(payload, "kind"); + if (!isJournalKind(kind)) { + throw new CmuxProtocolError( + "journal event kind must be a dotted lowercase name", + ); + } + return Object.freeze({ + kind, + schemaVersion: requiredPositiveUint32(payload, "schema_version"), + class: requiredEnum(payload, "class", JOURNAL_CLASSES), + replay: requiredEnum(payload, "replay", JOURNAL_REPLAY_POLICIES), + sensitivity: requiredEnum(payload, "sensitivity", JOURNAL_SENSITIVITIES), + payloadSchema: jsonValue(payload.payload_schema, "journal payload schema"), + }); +} + +function journalProducerManifest(value: unknown): JournalProducerManifest { + const payload = record(value, "journal producer manifest"); + strictObject( + payload, + [ + "producer_id", "namespace", "manifest_version", "max_sensitivity", + "permissions", "events", + ], + "journal producer manifest", + ); + if (!Array.isArray(payload.permissions)) { + throw new CmuxProtocolError("journal producer permissions must be an array"); + } + if (!Array.isArray(payload.events)) { + throw new CmuxProtocolError("journal producer events must be an array"); + } + const result = Object.freeze({ + producerId: requiredString(payload, "producer_id"), + namespace: requiredString(payload, "namespace"), + manifestVersion: requiredPositiveUint32(payload, "manifest_version"), + maxSensitivity: requiredEnum(payload, "max_sensitivity", JOURNAL_SENSITIVITIES), + permissions: Object.freeze(payload.permissions.map((permission, index) => { + if (typeof permission !== "string") { + throw new CmuxProtocolError(`journal permission ${index} must be a string`); + } + return permission; + })), + events: Object.freeze(payload.events.map(journalEventSchema)), + }); + try { + journalManifestFields(result); + } catch (error) { + if (error instanceof CmuxProtocolError) throw error; + throw new CmuxProtocolError( + error instanceof Error ? error.message : String(error), + ); + } + return result; +} + +function journalProducerListResult(value: unknown): JournalProducerListResult { + const payload = record(value, "journal producer list result"); + strictObject(payload, ["producers"], "journal producer list result"); + if (!Array.isArray(payload.producers)) { + throw new CmuxProtocolError("journal producer list must be an array"); + } + if (payload.producers.length > 1024) { + throw new CmuxProtocolError("journal producer list contains too many entries"); + } + return Object.freeze({ + producers: Object.freeze(payload.producers.map(journalProducerManifest)), + }); +} + +function journalProducerPutResult(value: unknown): JournalProducerPutResult { + const payload = record(value, "journal producer result"); + strictObject( + payload, + ["producer_id", "manifest_version", "namespace", "sequence", "event_id"], + "journal producer result", + ); + const producerId = decodedJournalString(payload, "producer_id", 64); + if (!isJournalComponent(producerId)) { + throw new CmuxProtocolError("journal producer result has an invalid producer_id"); + } + const namespace = decodedJournalString(payload, "namespace", 128); + if (namespace !== `plugin.${producerId}`) { + throw new CmuxProtocolError( + "journal producer result namespace must equal plugin.", + ); + } + const eventId = decodedJournalString(payload, "event_id", 128); + return Object.freeze({ + producerId, + manifestVersion: requiredPositiveUint32(payload, "manifest_version"), + namespace, + sequence: requiredDecimal(payload, "sequence"), + eventId, + }); +} + +function journalAppendResult(value: unknown): JournalAppendResult { + const payload = record(value, "journal append result"); + strictObject(payload, ["producer_id", "sequence", "event_id"], "journal append result"); + const producerId = decodedJournalString(payload, "producer_id", 64); + if (!isJournalComponent(producerId)) { + throw new CmuxProtocolError("journal append result has an invalid producer_id"); + } + const eventId = decodedJournalString(payload, "event_id", 128); + return Object.freeze({ + producerId, + sequence: requiredDecimal(payload, "sequence"), + eventId, + }); +} + +function journalSensitivityRank(value: JournalSensitivity): number { + return JOURNAL_SENSITIVITIES.indexOf(value); +} + +function journalString(value: unknown, label: string, maxBytes: number): string { + if (typeof value !== "string" || !hasUtf8ByteLength(value, 1, maxBytes)) { + throw new TypeError(`${label} must contain 1 to ${maxBytes} UTF-8 bytes`); + } + return value; +} + +function decodedJournalString( + payload: Record, + key: string, + maxBytes: number, +): string { + const value = payload[key]; + if (typeof value !== "string" || !hasUtf8ByteLength(value, 1, maxBytes)) { + throw new CmuxProtocolError( + `resource field ${key} must contain 1 to ${maxBytes} UTF-8 bytes`, + ); + } + return value; +} + +function journalManifestFields(value: JournalProducerManifest): Record { + const producerId = journalString(value.producerId, "producerId", 64); + if (!isJournalComponent(producerId)) { + throw new TypeError("producerId must be a lowercase component"); + } + const namespace = journalString(value.namespace, "namespace", 72); + if (namespace !== `plugin.${producerId}`) { + throw new TypeError("namespace must equal plugin."); + } + if (!isPositiveUint32(value.manifestVersion)) { + throw new TypeError("manifestVersion must be a positive uint32"); + } + if (!JOURNAL_SENSITIVITIES.includes(value.maxSensitivity)) { + throw new TypeError("maxSensitivity is invalid"); + } + if (value.maxSensitivity === "secret") { + throw new TypeError("secret journal payload storage is unavailable"); + } + if (!Array.isArray(value.permissions) || value.permissions.length < 1 || value.permissions.length > 32) { + throw new TypeError("permissions must contain 1 to 32 strings"); + } + const permission = `journal.append.${namespace}`; + if (!value.permissions.includes(permission)) { + throw new TypeError(`permissions must include ${permission}`); + } + if (!Array.isArray(value.events) || value.events.length < 1 || value.events.length > 64) { + throw new TypeError("events must contain 1 to 64 entries"); + } + const seen = new Set(); + const events = value.events.map((event) => { + const kind = journalString(event.kind, "event.kind", 128); + if (!kind.startsWith(`${namespace}.`) || !isJournalKind(kind)) { + throw new TypeError("event.kind must be a dotted lowercase name inside namespace"); + } + if (!isPositiveUint32(event.schemaVersion)) { + throw new TypeError("event.schemaVersion must be a positive uint32"); + } + if (!JOURNAL_CLASSES.includes(event.class) || !JOURNAL_REPLAY_POLICIES.includes(event.replay) || !JOURNAL_SENSITIVITIES.includes(event.sensitivity)) { + throw new TypeError("event class, replay, or sensitivity is invalid"); + } + if (event.sensitivity === "secret" || journalSensitivityRank(event.sensitivity) > journalSensitivityRank(value.maxSensitivity)) { + throw new TypeError("event sensitivity exceeds producer authority"); + } + const identity = `${kind}:${event.schemaVersion}`; + if (seen.has(identity)) throw new TypeError("events must not declare duplicates"); + seen.add(identity); + return { + kind, + schema_version: event.schemaVersion, + class: event.class, + replay: event.replay, + sensitivity: event.sensitivity, + payload_schema: jsonValue(event.payloadSchema, "event.payloadSchema"), + }; + }); + const fields = { + producer_id: producerId, + namespace, + manifest_version: value.manifestVersion, + max_sensitivity: value.maxSensitivity, + permissions: value.permissions.map((item) => journalString(item, "permission", 128)), + events, + }; + const encoded = JSON.stringify(fields); + if (!hasUtf8ByteLength(encoded, 1, MAX_JOURNAL_MANIFEST_BYTES)) { + throw new TypeError("journal producer manifest exceeds 1 MiB"); + } + return fields; +} + +function journalIngressFields(value: JournalIngress): Record { + const producerId = journalString(value.producerId, "producerId", 64); + if (!isJournalComponent(producerId)) { + throw new TypeError("producerId must be a lowercase component"); + } + if (!isPositiveUint32(value.manifestVersion)) { + throw new TypeError("manifestVersion must be a positive uint32"); + } + const kind = journalString(value.kind, "kind", 128); + if ( + !isPositiveUint32(value.schemaVersion) + || !isJournalKind(kind) + || !kind.startsWith(`plugin.${producerId}.`) + ) { + throw new TypeError( + "schemaVersion must be positive and kind must be inside the producer namespace", + ); + } + const fields: Record = { + producer_id: producerId, + manifest_version: value.manifestVersion, + kind, + schema_version: value.schemaVersion, + payload: jsonValue(value.payload, "journal payload"), + }; + if (value.occurredAtMs !== undefined) fields.occurred_at_ms = decimalString(value.occurredAtMs); + if (value.subjects !== undefined) { + if (value.subjects.length > 64) throw new TypeError("subjects must contain at most 64 entries"); + if (value.subjects.length > 0) { + fields.subjects = value.subjects.map((subject) => { + const subjectKind = journalString(subject.kind, "subject.kind", 64); + if (!isJournalComponent(subjectKind)) { + throw new TypeError("subject.kind must be a lowercase component"); + } + return { + kind: subjectKind, + id: journalString(subject.id, "subject.id", 512), + }; + }); + } + } + if (value.sensitivity !== undefined) { + if (!JOURNAL_SENSITIVITIES.includes(value.sensitivity) || value.sensitivity === "secret") { + throw new TypeError("sensitivity is invalid or unavailable"); + } + fields.sensitivity = value.sensitivity; + } + if (value.causationId !== undefined) fields.causation_id = journalString(value.causationId, "causationId", 128); + if (value.correlationId !== undefined) fields.correlation_id = journalString(value.correlationId, "correlationId", 128); + return fields; +} + function sidebarViewSnapshot(value: unknown): SidebarViewSnapshot { const payload = unwrap(value, ["sidebar_view"]); return Object.freeze({ @@ -1368,14 +1683,11 @@ function sessionJournalRecord(value: unknown): SessionJournalRecord { if (!Array.isArray(payload.subjects)) { throw new CmuxProtocolError("journal subjects must be an array"); } - const subjects = payload.subjects.map((subjectValue, index) => { - const subjectPayload = record(subjectValue, `journal subject ${index}`); - strictObject(subjectPayload, ["kind", "id"], "journal subject"); - return Object.freeze({ - kind: requiredString(subjectPayload, "kind"), - id: requiredString(subjectPayload, "id"), - }); - }); + if (payload.subjects.length > 64) { + throw new CmuxProtocolError("journal subjects must contain at most 64 entries"); + } + const subjects = payload.subjects.map((subjectValue, index) => + journalSubject(subjectValue, `journal subject ${index}`)); return Object.freeze({ sequence: requiredDecimal(payload, "sequence"), eventId: requiredString(payload, "event_id"), @@ -1880,13 +2192,27 @@ function terminalScreenResult(value: unknown): TerminalScreenResult { strictObject( payload, [ - "text", "cols", "rows", "cursor_row", "cursor_col", "cursor_visible", - "extra", + "text", "revision", "osc_progress", "cols", "rows", "cursor_row", + "cursor_col", "cursor_visible", "extra", ], "terminal screen result", ); + let revision: DecimalString | null | undefined; + if (payload.revision !== undefined) { + revision = payload.revision === null + ? null + : requiredDecimal(payload, "revision"); + } + let oscProgress: string | null | undefined; + if (payload.osc_progress !== undefined) { + oscProgress = payload.osc_progress === null + ? null + : requiredString(payload, "osc_progress"); + } return Object.freeze({ text: requiredString(payload, "text"), + ...optionalProperty("revision", revision), + ...optionalProperty("oscProgress", oscProgress), cols: requiredPositiveUint16(payload, "cols"), rows: requiredPositiveUint16(payload, "rows"), cursorRow: requiredUint16(payload, "cursor_row"), @@ -2057,7 +2383,15 @@ function processInfoResult(value: unknown): ProcessInfoResult { const payload = record(value, "process info result"); strictObject( payload, - ["pid", "executable", "argv", "cwd", "foreground_cwd", "children"], + [ + "pid", + "executable", + "argv", + "cwd", + "foreground_cwd", + "foreground_executable", + "children", + ], "process info result", ); if ( @@ -2077,6 +2411,9 @@ function processInfoResult(value: unknown): ProcessInfoResult { foregroundCwd: Object.hasOwn(payload, "foreground_cwd") ? requiredNullableString(payload, "foreground_cwd") : null, + foregroundExecutable: Object.hasOwn(payload, "foreground_executable") + ? requiredNullableString(payload, "foreground_executable") + : null, children: Object.freeze( payload.children.map((item) => requiredUnsignedInteger({ child: item }, "child")), @@ -2846,6 +3183,48 @@ export class Session extends Handle { ); } + /** Lists generic journal producers installed in this session. */ + async listJournalProducers( + options: RequestOptions = {}, + ): Promise { + const result = journalProducerListResult( + await this.client[readOperation]( + operations.sessionJournalProducerList, + this.params(), + options, + ), + ); + return result.producers; + } + + /** Installs or updates a userland journal producer manifest. */ + putJournalProducer( + manifest: JournalProducerManifest, + options: MutationOptions = {}, + ): Promise> { + return this.client[mutateOperation]( + operations.sessionJournalProducerPut, + { ...this.params(), manifest: journalManifestFields(manifest) }, + options, + journalProducerPutResult, + (result) => result, + ); + } + + /** Appends one generic journal event from a userland producer. */ + appendJournal( + event: JournalIngress, + options: MutationOptions = {}, + ): Promise> { + return this.client[mutateOperation]( + operations.sessionJournalAppend, + { ...this.params(), event: journalIngressFields(event) }, + options, + journalAppendResult, + (result) => result, + ); + } + shutdown( force = false, options: MutationOptions = {}, diff --git a/cmux-tui/bindings/typescript/test/resource-api.test.ts b/cmux-tui/bindings/typescript/test/resource-api.test.ts index 2e7daa5b4da4..c2e5cad40b57 100644 --- a/cmux-tui/bindings/typescript/test/resource-api.test.ts +++ b/cmux-tui/bindings/typescript/test/resource-api.test.ts @@ -406,6 +406,47 @@ test("journal records must match their envelope cursor", async () => { client.close(); }); +test("journal records validate subject grammar at the decode boundary", async () => { + let streamId = ""; + const transport = new FakeTransport((request, current) => { + if (request.operation !== "session.journal.subscribe") return; + streamId = (request.params as Envelope).stream_id as string; + current.ok(request, { stream_id: streamId }); + }); + const client = new Client({ transport }); + const stream = await client.session(SESSION).journal(); + const next = stream.next(); + transport.emit({ + protocol: "cmux.protocol/2", + type: "stream_item", + stream_id: streamId, + sequence: "1", + cursor: { generation: String(SESSION), revision: "1" }, + item: { + sequence: "1", + event_id: "event_invalid_subject", + schema_version: 1, + kind: "plugin.screen-detector.agent.state.changed", + class: "state", + replay: "required", + occurred_at_ms: "1", + committed_at_ms: "2", + producer: { kind: "plugin", id: "screen-detector" }, + authority: null, + causation_id: null, + correlation_id: null, + causation_depth: 0, + subjects: [{ kind: "Agent", id: "agent-1" }], + sensitivity: "metadata", + payload: {}, + resource_revision: null, + previous_resource_revision: null, + }, + }); + await assert.rejects(() => next, /journal subject.*lowercase component/); + client.close(); +}); + test("resource protocol releases cancellation handles at dispatch", async () => { for (const synchronous of [true, false]) { const transport = new DispatchHandleTransport(synchronous); @@ -1028,6 +1069,292 @@ test("optional fields and expected revisions reach the wire", async () => { client.close(); }); +test("userland agent plugins expose generic journal data and terminal metadata", async () => { + const manifest = { + producerId: "screen-detector", + namespace: "plugin.screen-detector", + manifestVersion: 1, + maxSensitivity: "metadata", + permissions: ["journal.append.plugin.screen-detector"], + events: [{ + kind: "plugin.screen-detector.agent.state.changed", + schemaVersion: 1, + class: "state", + replay: "required", + sensitivity: "metadata", + payloadSchema: { type: "object" }, + }], + } as const; + const manifestWire = { + producer_id: "screen-detector", + namespace: "plugin.screen-detector", + manifest_version: 1, + max_sensitivity: "metadata", + permissions: ["journal.append.plugin.screen-detector"], + events: [{ + kind: "plugin.screen-detector.agent.state.changed", + schema_version: 1, + class: "state", + replay: "required", + sensitivity: "metadata", + payload_schema: { type: "object" }, + }], + }; + const transport = new FakeTransport((request, current) => { + switch (request.operation) { + case "agent.list": + current.ok(request, [{ + id: AGENT, + session_id: SESSION, + terminal_id: TERMINAL, + state: "working", + source: "plugin", + updated_at_ms: "10", + source_session: "pid:42", + }]); + return; + case "terminal.screen.read": + current.ok(request, { + text: "working", + revision: "42", + osc_progress: "4;1;50", + cols: 80, + rows: 24, + cursor_row: 0, + cursor_col: 7, + cursor_visible: true, + }); + return; + case "session.journal.producer.list": + current.ok(request, { producers: [manifestWire] }); + return; + case "session.journal.producer.put": + current.ok(request, { + value: { + producer_id: "screen-detector", + manifest_version: 1, + namespace: "plugin.screen-detector", + sequence: "11", + event_id: "event-11", + }, + generation: "generation-a", + revision: "12", + replayed: false, + }); + return; + case "session.journal.append": + current.ok(request, { + value: { + producer_id: "screen-detector", + sequence: "13", + event_id: "event-13", + }, + generation: "generation-a", + revision: "14", + replayed: false, + }); + return; + default: + throw new Error(`unexpected operation ${request.operation}`); + } + }); + const client = new Client({ transport, randomHex128: () => HEX_A }); + const session = client.session(SESSION); + const terminal = session.terminal(TERMINAL); + + const agents = await session.listAgents(); + assert.equal(agents[0]?.snapshot?.source, "plugin"); + const screen = await terminal.readScreen(); + assert.equal(screen.revision, "42"); + assert.equal(screen.oscProgress, "4;1;50"); + + const producers = await session.listJournalProducers(); + assert.equal(producers[0]?.producerId, "screen-detector"); + const installed = await session.putJournalProducer(manifest, { + idempotencyKey: "manifest-1", + }); + assert.equal(installed.value.eventId, "event-11"); + const appended = await session.appendJournal({ + producerId: "screen-detector", + manifestVersion: 1, + kind: "plugin.screen-detector.agent.state.changed", + schemaVersion: 1, + payload: { state: "working" }, + }, { idempotencyKey: "event-1" }); + assert.equal(appended.value.sequence, "13"); + + const put = transport.requests.find( + (request) => request.operation === "session.journal.producer.put", + ); + assert.deepEqual((put?.params as Envelope).manifest, manifestWire); + const append = transport.requests.find( + (request) => request.operation === "session.journal.append", + ); + assert.deepEqual((append?.params as Envelope).event, { + producer_id: "screen-detector", + manifest_version: 1, + kind: "plugin.screen-detector.agent.state.changed", + schema_version: 1, + payload: { state: "working" }, + }); + client.close(); +}); + +test("journal ingress rejects kinds outside the producer namespace before transport", () => { + const transport = new FakeTransport(() => { + throw new Error("invalid ingress reached the transport"); + }); + const client = new Client({ transport }); + const session = client.session(SESSION); + assert.throws( + () => session.appendJournal({ + producerId: "screen-detector", + manifestVersion: 1, + kind: "agent.state.changed", + schemaVersion: 1, + payload: { state: "working" }, + }), + TypeError, + ); + assert.equal(transport.requests.length, 0); + client.close(); +}); + +test("terminal screen metadata accepts omitted and nullable legacy forms", async () => { + let reads = 0; + const transport = new FakeTransport((request, current) => { + if (request.operation !== "terminal.screen.read") { + throw new Error(`unexpected operation ${request.operation}`); + } + reads += 1; + current.ok(request, reads === 1 + ? { + text: "legacy", + cols: 80, + rows: 24, + cursor_row: 0, + cursor_col: 0, + cursor_visible: true, + } + : { + text: "nullable", + revision: null, + osc_progress: null, + cols: 80, + rows: 24, + cursor_row: 0, + cursor_col: 0, + cursor_visible: true, + }); + }); + const client = new Client({ transport }); + const terminal = client.session(SESSION).terminal(TERMINAL); + const omitted = await terminal.readScreen(); + assert.equal(omitted.revision, undefined); + assert.equal(omitted.oscProgress, undefined); + const nullable = await terminal.readScreen(); + assert.equal(nullable.revision, null); + assert.equal(nullable.oscProgress, null); + client.close(); +}); + +test("journal producer responses reject malformed manifests at the SDK boundary", async () => { + const transport = new FakeTransport((request, current) => { + if (request.operation !== "session.journal.producer.list") { + throw new Error(`unexpected operation ${request.operation}`); + } + current.ok(request, { + producers: [{ + producer_id: "screen!detector", + namespace: "plugin.screen!detector", + manifest_version: 1, + max_sensitivity: "metadata", + permissions: ["journal.append.plugin.screen!detector"], + events: [{ + kind: "plugin.screen!detector.state.changed", + schema_version: 1, + class: "state", + replay: "required", + sensitivity: "metadata", + payload_schema: { type: "object" }, + }], + }], + }); + }); + const client = new Client({ transport }); + await assert.rejects( + () => client.session(SESSION).listJournalProducers(), + CmuxProtocolError, + ); + client.close(); +}); + +test("journal mutation responses reject invalid producer identity", async () => { + const manifest = { + producerId: "screen-detector", + namespace: "plugin.screen-detector", + manifestVersion: 1, + maxSensitivity: "metadata", + permissions: ["journal.append.plugin.screen-detector"], + events: [{ + kind: "plugin.screen-detector.state.changed", + schemaVersion: 1, + class: "state", + replay: "required", + sensitivity: "metadata", + payloadSchema: { type: "object" }, + }], + } as const; + const transport = new FakeTransport((request, current) => { + if (request.operation === "session.journal.producer.put") { + current.ok(request, { + value: { + producer_id: "screen!detector", + manifest_version: 1, + namespace: "plugin.screen!detector", + sequence: "1", + event_id: "event-1", + }, + generation: "generation-a", + revision: "1", + replayed: false, + }); + return; + } + if (request.operation === "session.journal.append") { + current.ok(request, { + value: { + producer_id: "screen!detector", + sequence: "1", + event_id: "event-1", + }, + generation: "generation-a", + revision: "1", + replayed: false, + }); + return; + } + throw new Error(`unexpected operation ${request.operation}`); + }); + const client = new Client({ transport }); + const session = client.session(SESSION); + await assert.rejects( + () => session.putJournalProducer(manifest), + CmuxProtocolError, + ); + await assert.rejects( + () => session.appendJournal({ + producerId: "screen-detector", + manifestVersion: 1, + kind: "plugin.screen-detector.state.changed", + schemaVersion: 1, + payload: { state: "working" }, + }), + CmuxProtocolError, + ); + client.close(); +}); + test("indeterminate mutations are typed and never retried", async () => { const transport = new FakeTransport((request, current) => { current.emit({ diff --git a/cmux-tui/bindings/zig/.cmux-resource-api.json b/cmux-tui/bindings/zig/.cmux-resource-api.json index 4c4481343150..0667d105f967 100644 --- a/cmux-tui/bindings/zig/.cmux-resource-api.json +++ b/cmux-tui/bindings/zig/.cmux-resource-api.json @@ -1,5 +1,5 @@ { - "catalog_sha256": "beef8293ded489648261ccddfd31b3f796f9d7d10506f13f6c5d1577a3f4fbac", + "catalog_sha256": "08a8190787e1b38d0592b85856d791fb0fe098d58eddce85ee98ecf88cc5a1a7", "operations": { "agent.list": { "class": "read" diff --git a/cmux-tui/bindings/zig/src/cmux.zig b/cmux-tui/bindings/zig/src/cmux.zig index 4159a4b78641..7291792e11a7 100644 --- a/cmux-tui/bindings/zig/src/cmux.zig +++ b/cmux-tui/bindings/zig/src/cmux.zig @@ -21,6 +21,22 @@ pub const UndoLayoutOptions = resource.UndoLayoutOptions; pub const ClientMetadataUpdate = resource.ClientMetadataUpdate; pub const OptionalStringUpdate = resource.OptionalStringUpdate; pub const Cursor = resource.Cursor; +pub const JournalClass = resource.JournalClass; +pub const JournalReplayPolicy = resource.JournalReplayPolicy; +pub const JournalSensitivity = resource.JournalSensitivity; +pub const JournalSubject = resource.JournalSubject; +pub const JournalEventSchema = resource.JournalEventSchema; +pub const JournalProducerManifest = resource.JournalProducerManifest; +pub const JournalIngress = resource.JournalIngress; +pub const JournalProducerPutResult = resource.JournalProducerPutResult; +pub const JournalProducerListResult = resource.JournalProducerListResult; +pub const JournalAppendResult = resource.JournalAppendResult; +pub const AgentPluginEventSchema = resource.AgentPluginEventSchema; +pub const AgentPluginManifest = resource.AgentPluginManifest; +pub const AgentPluginSubject = resource.AgentPluginSubject; +pub const AgentPluginIngress = resource.AgentPluginIngress; +pub const AgentPluginListResult = resource.AgentPluginListResult; +pub const JournalEventSubject = resource.JournalEventSubject; pub const CreatedPath = resource.CreatedPath; pub const CreatedWorkspaceOnly = resource.CreatedWorkspaceOnly; pub const CreatedTerminalPath = resource.CreatedTerminalPath; @@ -141,6 +157,8 @@ pub const OwnedPingResult = resource.OwnedPingResult; pub const OwnedEmptyResult = resource.OwnedEmptyResult; pub const OwnedTerminalScreenResult = resource.OwnedTerminalScreenResult; +pub const OwnedJournalProducerListResult = + resource.OwnedJournalProducerListResult; pub const OwnedTerminalStateResult = resource.OwnedTerminalStateResult; pub const OwnedTerminalHistoryResult = @@ -243,6 +261,7 @@ pub const NotificationLevel = resource.NotificationLevel; pub const NotificationSnapshot = resource.NotificationSnapshot; pub const AgentState = resource.AgentState; pub const AgentSource = resource.AgentSource; +pub const AgentReportSource = resource.AgentReportSource; pub const AgentSnapshot = resource.AgentSnapshot; pub const PairingStatus = resource.PairingStatus; pub const PairingDecision = resource.PairingDecision; @@ -289,6 +308,10 @@ pub const TerminalMutationResult = resource.TerminalMutationResult; pub const NotificationMutationResult = resource.NotificationMutationResult; pub const AgentMutationResult = resource.AgentMutationResult; +pub const JournalProducerPutMutationResult = + resource.JournalProducerPutMutationResult; +pub const JournalAppendMutationResult = + resource.JournalAppendMutationResult; pub const PairingResolutionMutationResult = resource.PairingResolutionMutationResult; pub const FrontendProjectionMutationResult = diff --git a/cmux-tui/bindings/zig/src/raw/generated/.cmux-sdk-manifest.json b/cmux-tui/bindings/zig/src/raw/generated/.cmux-sdk-manifest.json index aa2cb08e1f13..6adeaf6369d1 100644 --- a/cmux-tui/bindings/zig/src/raw/generated/.cmux-sdk-manifest.json +++ b/cmux-tui/bindings/zig/src/raw/generated/.cmux-sdk-manifest.json @@ -7,12 +7,12 @@ }, { "path": "protocol.zig", - "sha256": "9d7aaeeeae553ff7b82c3188c6bdb6e50a2834961dbb0cb515aabbdab576da71", - "size": 170081 + "sha256": "a6b5acf5249dd088e7bdb77bf9806f15125f8898fc4c533531d380c7c8122d54", + "size": 170627 } ], "format": 1, - "ir_sha256": "7042c629f34d3606581d07b2d2c03b65116c2467810724163c54674865825cc0", + "ir_sha256": "133bac0154f8f94aa30e40c11ff7ed38b10dd4d82974aec87c02d404fcd12619", "language": "zig", "mux_protocol": 12, "schema_version": 2 diff --git a/cmux-tui/bindings/zig/src/raw/generated/protocol.zig b/cmux-tui/bindings/zig/src/raw/generated/protocol.zig index 11d09bd8afab..a6220117663d 100644 --- a/cmux-tui/bindings/zig/src/raw/generated/protocol.zig +++ b/cmux-tui/bindings/zig/src/raw/generated/protocol.zig @@ -7,7 +7,7 @@ const client_runtime = @import("../client.zig"); pub const schema_version: u16 = 2; pub const mux_protocol: u16 = 12; -pub const ir_sha256 = "7042c629f34d3606581d07b2d2c03b65116c2467810724163c54674865825cc0"; +pub const ir_sha256 = "133bac0154f8f94aa30e40c11ff7ed38b10dd4d82974aec87c02d404fcd12619"; pub const AgentRecord = struct { session: wire.Nullable([]const u8), @@ -36,11 +36,13 @@ pub const AgentReportSource = enum { }; pub const AgentSource = enum { + plugin, detected, socket, hook, pub fn fromWire(value: []const u8) !@This() { + if (std.mem.eql(u8, value, "plugin")) return .plugin; if (std.mem.eql(u8, value, "detected")) return .detected; if (std.mem.eql(u8, value, "socket")) return .socket; if (std.mem.eql(u8, value, "hook")) return .hook; @@ -49,6 +51,7 @@ pub const AgentSource = enum { pub fn toWire(self: @This()) []const u8 { return switch (self) { + .plugin => "plugin", .detected => "detected", .socket => "socket", .hook => "hook", @@ -795,6 +798,8 @@ pub const ProcessInfoResult = struct { cwd: wire.Nullable([]const u8), /// Working directory of the process group that owns the PTY, read at request time. Null when the lookup fails; absent from daemons that predate the field. Clients treat absence as null. foreground_cwd: wire.Field([]const u8) = .absent, + /// Executable path or name of the PTY foreground process-group leader, read at request time. Null when the lookup fails; absent from daemons that predate the field. Clients treat absence as null. + foreground_executable: wire.Field([]const u8) = .absent, pid: wire.Nullable(u32), }; @@ -4423,6 +4428,8 @@ pub fn zoomPane(client: anytype, request: ZoomPaneRequest) !wire.Decoded(ZoomPan } pub const AgentChangedEvent = struct { + /// Adapter identity when the producer knows it; absent from protocol-11 event senders and null when no adapter was identified. + agent: wire.Field([]const u8) = .absent, event: []const u8, session: wire.Nullable([]const u8), source: AgentSource, diff --git a/cmux-tui/bindings/zig/src/resource.zig b/cmux-tui/bindings/zig/src/resource.zig index 6ef69f7ca704..bb7296a3786b 100644 --- a/cmux-tui/bindings/zig/src/resource.zig +++ b/cmux-tui/bindings/zig/src/resource.zig @@ -43,6 +43,9 @@ pub const Operation = enum { session_creation_resolve, session_events, session_journal_subscribe, + session_journal_producer_list, + session_journal_producer_put, + session_journal_append, session_ping, session_shutdown, session_reload_config, @@ -160,6 +163,9 @@ pub const Operation = enum { .session_creation_resolve => "session.creation.resolve", .session_events => "session.events", .session_journal_subscribe => "session.journal.subscribe", + .session_journal_producer_list => "session.journal.producer.list", + .session_journal_producer_put => "session.journal.producer.put", + .session_journal_append => "session.journal.append", .session_ping => "session.ping", .session_shutdown => "session.shutdown", .session_reload_config => "session.reload_config", @@ -298,6 +304,7 @@ pub const Operation = enum { .session_snapshot, .session_creation_resolve, .session_ping, + .session_journal_producer_list, .client_list, .client_get, .pairing_request_list, @@ -367,6 +374,9 @@ pub const Operation = enum { .session_creation_resolve => .{ .owner = .session, .method = "resolveCreation" }, .session_events => .{ .owner = .session, .method = "eventsFrom" }, .session_journal_subscribe => .{ .owner = .session, .method = "journal" }, + .session_journal_producer_list => .{ .owner = .session, .method = "journalProducers" }, + .session_journal_producer_put => .{ .owner = .session, .method = "putJournalProducer" }, + .session_journal_append => .{ .owner = .session, .method = "appendJournal" }, .session_ping => .{ .owner = .session, .method = "ping" }, .session_shutdown => .{ .owner = .session, .method = "shutdown" }, .session_reload_config => .{ .owner = .session, .method = "reloadConfig" }, @@ -836,6 +846,10 @@ pub const JournalReplayPolicy = enum { required, advisory, never, + + pub fn wireName(self: JournalReplayPolicy) []const u8 { + return @tagName(self); + } }; pub const JournalSensitivity = enum { @@ -900,6 +914,378 @@ pub const JournalSubject = struct { id: []const u8, }; +const max_journal_component_bytes: usize = 64; +const max_journal_kind_bytes: usize = 128; +const max_journal_manifest_bytes: usize = 1024 * 1024; + +fn validJournalComponent(value: []const u8) bool { + if (value.len == 0 or value.len > max_journal_component_bytes) { + return false; + } + for (value, 0..) |byte, index| { + if (index == 0) { + if (!((byte >= 'a' and byte <= 'z') or + (byte >= '0' and byte <= '9'))) + { + return false; + } + } else if (!((byte >= 'a' and byte <= 'z') or + (byte >= '0' and byte <= '9') or byte == '_' or byte == '-')) + { + return false; + } + } + return true; +} + +fn validJournalKind(value: []const u8) bool { + if (value.len == 0 or value.len > max_journal_kind_bytes) { + return false; + } + var parts = std.mem.splitScalar(u8, value, '.'); + var count: usize = 0; + while (parts.next()) |part| { + if (!validJournalComponent(part)) return false; + count += 1; + } + return count > 0; +} + +fn journalSensitivityRank(value: JournalSensitivity) u8 { + return switch (value) { + .public => 0, + .metadata => 1, + .sensitive => 2, + .secret => 3, + }; +} + +/// One event schema declared by a generic userland journal producer. +pub const JournalEventSchema = struct { + kind: []const u8, + schema_version: u32, + /// `class` is reserved by Zig, so the public field is `journal_class`. + journal_class: JournalClass, + replay: JournalReplayPolicy, + sensitivity: JournalSensitivity, + payload_schema: raw.wire.Value, + + /// Encodes the stable wire names, including the reserved `class` key. + pub fn toValue( + self: JournalEventSchema, + allocator: std.mem.Allocator, + ) !raw.wire.Value { + var object = raw.wire.Object.init(allocator); + try object.put("kind", .{ .string = try allocator.dupe(u8, self.kind) }); + try object.put( + "schema_version", + .{ .integer = self.schema_version }, + ); + try object.put( + "class", + .{ .string = self.journal_class.wireName() }, + ); + try object.put( + "replay", + .{ .string = self.replay.wireName() }, + ); + try object.put( + "sensitivity", + .{ .string = self.sensitivity.wireName() }, + ); + try object.put( + "payload_schema", + try raw.wire.cloneValue(allocator, self.payload_schema), + ); + return .{ .object = object }; + } +}; + +/// Manifest installed by a generic userland journal producer. +pub const JournalProducerManifest = struct { + producer_id: []const u8, + /// The wire namespace. It must equal `plugin.`. + namespace_: []const u8, + manifest_version: u32, + max_sensitivity: JournalSensitivity, + permissions: []const []const u8 = &.{}, + events: []const JournalEventSchema, + + /// Checks local shape and authority constraints. JSON Schema compilation + /// remains a daemon responsibility because the daemon is authoritative. + pub fn validate(self: JournalProducerManifest) !void { + if (!validJournalComponent(self.producer_id)) { + return error.InvalidJournalProducerId; + } + const prefix = "plugin."; + if (self.namespace_.len != prefix.len + self.producer_id.len or + !std.mem.startsWith(u8, self.namespace_, prefix) or + !std.mem.eql(u8, self.namespace_[prefix.len..], self.producer_id)) + { + return error.InvalidJournalProducerNamespace; + } + if (self.manifest_version == 0) { + return error.InvalidJournalManifestVersion; + } + if (self.max_sensitivity == .secret) { + return error.InvalidJournalSensitivity; + } + if (self.permissions.len == 0 or self.permissions.len > 32) { + return error.InvalidJournalPermissions; + } + const required_permission_prefix = "journal.append."; + var has_required_permission = false; + for (self.permissions) |permission| { + if (permission.len == 0 or permission.len > 128 or + !std.unicode.utf8ValidateSlice(permission)) + { + return error.InvalidJournalPermission; + } + if (std.mem.startsWith(u8, permission, required_permission_prefix) and + std.mem.eql( + u8, + permission[required_permission_prefix.len..], + self.namespace_, + )) + { + has_required_permission = true; + } + } + if (!has_required_permission) { + return error.MissingJournalAppendPermission; + } + if (self.events.len == 0 or self.events.len > 64) { + return error.InvalidJournalEvents; + } + for (self.events, 0..) |event, index| { + if (!validJournalKind(event.kind) or + event.kind.len <= self.namespace_.len or + !std.mem.startsWith(u8, event.kind, self.namespace_) or + event.kind[self.namespace_.len] != '.') + { + return error.InvalidJournalEventKind; + } + if (event.schema_version == 0) { + return error.InvalidJournalSchemaVersion; + } + if (event.sensitivity == .secret or + journalSensitivityRank(event.sensitivity) > + journalSensitivityRank(self.max_sensitivity)) + { + return error.InvalidJournalEventSensitivity; + } + for (self.events[0..index]) |previous| { + if (previous.schema_version == event.schema_version and + std.mem.eql(u8, previous.kind, event.kind)) + { + return error.DuplicateJournalEventSchema; + } + } + } + } + + /// Encodes a validated manifest into a caller-owned JSON value. + pub fn toValue( + self: JournalProducerManifest, + allocator: std.mem.Allocator, + ) !raw.wire.Value { + try self.validate(); + // Build and size-check in an arena first. This matters for callers + // that use a general-purpose allocator: an oversized manifest must + // not leave all of its partially-built strings and arrays behind. + var build_arena = std.heap.ArenaAllocator.init(allocator); + defer build_arena.deinit(); + const build_allocator = build_arena.allocator(); + var object = raw.wire.Object.init(build_allocator); + try object.put( + "producer_id", + .{ .string = try build_allocator.dupe(u8, self.producer_id) }, + ); + try object.put( + "namespace", + .{ .string = try build_allocator.dupe(u8, self.namespace_) }, + ); + try object.put( + "manifest_version", + .{ .integer = self.manifest_version }, + ); + try object.put( + "max_sensitivity", + .{ .string = self.max_sensitivity.wireName() }, + ); + var permissions = std.json.Array.init(build_allocator); + for (self.permissions) |permission| { + try permissions.append(.{ + .string = try build_allocator.dupe(u8, permission), + }); + } + try object.put("permissions", .{ .array = permissions }); + var events = std.json.Array.init(build_allocator); + for (self.events) |event| { + try events.append(try event.toValue(build_allocator)); + } + try object.put("events", .{ .array = events }); + const value = raw.wire.Value{ .object = object }; + const encoded = try raw.wire.stringifyAlloc(build_allocator, value); + if (encoded.len > max_journal_manifest_bytes) { + return error.InvalidJournalManifestSize; + } + // The result is independent of the short-lived build arena. The + // caller owns the returned tree and must release it with its normal + // allocator or arena policy. + return raw.wire.cloneValue(allocator, value); + } +}; + +/// Generic journal ingress envelope emitted by a userland producer. +pub const JournalIngress = struct { + producer_id: []const u8, + manifest_version: u32, + kind: []const u8, + schema_version: u32, + occurred_at_ms: ?u64 = null, + subjects: []const JournalSubject = &.{}, + sensitivity: ?JournalSensitivity = null, + payload: raw.wire.Value, + causation_id: ?[]const u8 = null, + correlation_id: ?[]const u8 = null, + + pub fn validate(self: JournalIngress) !void { + if (!validJournalComponent(self.producer_id) or + self.manifest_version == 0 or self.schema_version == 0 or + !validJournalKind(self.kind)) + { + return error.InvalidJournalIngress; + } + const namespace_prefix = "plugin."; + const producer_start = namespace_prefix.len; + const producer_end = producer_start + self.producer_id.len; + if (!std.mem.startsWith(u8, self.kind, namespace_prefix) or + self.kind.len <= producer_end or self.kind[producer_end] != '.' or + !std.mem.eql(u8, self.kind[producer_start..producer_end], self.producer_id)) + { + return error.InvalidJournalIngress; + } + if (self.subjects.len > 64) return error.TooManyJournalSubjects; + for (self.subjects) |subject| { + if (!validJournalComponent(subject.kind) or subject.id.len == 0 or + subject.id.len > 512 or !std.unicode.utf8ValidateSlice(subject.id)) + { + return error.InvalidJournalSubject; + } + } + if (self.sensitivity) |sensitivity| { + if (sensitivity == .secret) return error.InvalidJournalSensitivity; + } + for ([_]?[]const u8{ self.causation_id, self.correlation_id }) |id| { + if (id) |value| { + if (value.len == 0 or value.len > 128 or + !std.unicode.utf8ValidateSlice(value)) + { + return error.InvalidJournalCorrelationId; + } + } + } + } + + pub fn toValue( + self: JournalIngress, + allocator: std.mem.Allocator, + ) !raw.wire.Value { + try self.validate(); + var object = raw.wire.Object.init(allocator); + try object.put( + "producer_id", + .{ .string = try allocator.dupe(u8, self.producer_id) }, + ); + try object.put( + "manifest_version", + .{ .integer = self.manifest_version }, + ); + try object.put("kind", .{ .string = try allocator.dupe(u8, self.kind) }); + try object.put( + "schema_version", + .{ .integer = self.schema_version }, + ); + if (self.occurred_at_ms) |occurred_at_ms| { + try object.put( + "occurred_at_ms", + .{ .string = try std.fmt.allocPrint( + allocator, + "{d}", + .{occurred_at_ms}, + ) }, + ); + } + if (self.subjects.len > 0) { + var subjects = std.json.Array.init(allocator); + for (self.subjects) |subject| { + var encoded = raw.wire.Object.init(allocator); + try encoded.put( + "kind", + .{ .string = try allocator.dupe(u8, subject.kind) }, + ); + try encoded.put( + "id", + .{ .string = try allocator.dupe(u8, subject.id) }, + ); + try subjects.append(.{ .object = encoded }); + } + try object.put("subjects", .{ .array = subjects }); + } + if (self.sensitivity) |sensitivity| { + try object.put( + "sensitivity", + .{ .string = sensitivity.wireName() }, + ); + } + try object.put( + "payload", + try raw.wire.cloneValue(allocator, self.payload), + ); + if (self.causation_id) |id| { + try object.put( + "causation_id", + .{ .string = try allocator.dupe(u8, id) }, + ); + } + if (self.correlation_id) |id| { + try object.put( + "correlation_id", + .{ .string = try allocator.dupe(u8, id) }, + ); + } + return .{ .object = object }; + } +}; + +pub const JournalProducerPutResult = struct { + producer_id: []const u8, + manifest_version: u32, + namespace_: []const u8, + sequence: []const u8, + event_id: []const u8, +}; + +pub const JournalProducerListResult = struct { + producers: []const JournalProducerManifest, +}; + +pub const JournalAppendResult = struct { + producer_id: []const u8, + sequence: []const u8, + event_id: []const u8, +}; + +// Compatibility names from the first agent-plugin preview. The wire remains +// generic, so adding a detector does not require a core type. +pub const AgentPluginEventSchema = JournalEventSchema; +pub const AgentPluginManifest = JournalProducerManifest; +pub const AgentPluginSubject = JournalSubject; +pub const AgentPluginIngress = JournalIngress; +pub const AgentPluginListResult = JournalProducerListResult; +pub const JournalEventSubject = JournalSubject; + pub const SessionJournalRecord = struct { sequence: u64, event_id: []const u8, @@ -5721,10 +6107,22 @@ pub const AgentListOptions = struct { state: ?AgentState = null, }; +/// Sources accepted by the legacy `agent.report` operation. This is kept +/// separate from `AgentSource`, which also describes plugin and detected +/// observations in returned snapshots. +pub const AgentReportSource = enum { + hook, + socket, + + pub fn wireName(self: AgentReportSource) []const u8 { + return @tagName(self); + } +}; + pub const AgentReportOptions = struct { terminal_id: TerminalId, state: AgentState, - source: AgentSource, + source: AgentReportSource, source_session: ?[]const u8 = null, }; @@ -6589,11 +6987,12 @@ pub const AgentSource = union(enum) { hook, socket, detected, + plugin, unknown: []const u8, pub fn wireName(self: AgentSource) []const u8 { return switch (self) { - inline .hook, .socket, .detected => |_, tag| @tagName(tag), + inline .hook, .socket, .detected, .plugin => |_, tag| @tagName(tag), .unknown => |value| value, }; } @@ -6722,7 +7121,7 @@ pub const CreationResolution = struct { idempotency_key: ?[]const u8, created_path: ?CreatedPath, generation: ?[]const u8, - revision: ?u64, + revision: ?u64 = null, }; pub const ClientTransport = union(enum) { @@ -7004,6 +7403,10 @@ pub const TerminalScreenResult = struct { cursor_row: u16, cursor_col: u16, cursor_visible: bool, + /// Coalesced PTY output revision. Null means unavailable. + revision: ?u64 = null, + /// Bounded OSC 9 progress payload. Null means unavailable. + osc_progress: ?[]const u8 = null, /// Catalog-defined forward-compatible fields. extra: ?raw.wire.Object, }; @@ -7252,6 +7655,8 @@ pub const OwnedTabSnapshot = OwnedValue(TabSnapshot); pub const OwnedPingResult = OwnedValue(PingResult); pub const OwnedEmptyResult = OwnedValue(EmptyResult); pub const OwnedTerminalScreenResult = OwnedValue(TerminalScreenResult); +pub const OwnedJournalProducerListResult = + OwnedDecodedValue(JournalProducerListResult); pub const OwnedTerminalStateResult = OwnedDecodedValue(TerminalStateResult); pub const OwnedTerminalHistoryResult = @@ -7300,6 +7705,10 @@ pub const TabMutationResult = TypedMutationResult(TabSnapshot); pub const NotificationMutationResult = TypedMutationResult(NotificationSnapshot); pub const AgentMutationResult = TypedMutationResult(AgentSnapshot); +pub const JournalProducerPutMutationResult = + TypedMutationResult(JournalProducerPutResult); +pub const JournalAppendMutationResult = + TypedMutationResult(JournalAppendResult); pub const PairingResolutionMutationResult = TypedMutationResult(PairingResolutionResult); pub const FrontendProjectionMutationResult = @@ -7464,6 +7873,17 @@ fn requiredNullableDecimalU64( }; } +fn optionalNullableDecimalU64( + object: raw.wire.Object, + name: []const u8, +) !?u64 { + const value = object.get(name) orelse return null; + return switch (value) { + .null => null, + else => try decimalU64(value), + }; +} + fn strictOptionalId( comptime Id: type, object: raw.wire.Object, @@ -7561,6 +7981,7 @@ fn parseAgentSource(value: []const u8) AgentSource { if (std.mem.eql(u8, value, "hook")) return .hook; if (std.mem.eql(u8, value, "socket")) return .socket; if (std.mem.eql(u8, value, "detected")) return .detected; + if (std.mem.eql(u8, value, "plugin")) return .plugin; return .{ .unknown = value }; } @@ -8231,6 +8652,8 @@ fn decodeTerminalScreenResult( "cursor_row", "cursor_col", "cursor_visible", + "revision", + "osc_progress", "extra", }, ); @@ -8251,6 +8674,8 @@ fn decodeTerminalScreenResult( 0, ), .cursor_visible = try objectBool(object, "cursor_visible"), + .revision = try optionalNullableDecimalU64(object, "revision"), + .osc_progress = try optionalNullableString(object, "osc_progress"), .extra = try optionalExtra(object), }; } @@ -8922,6 +9347,181 @@ fn decodeAgentSnapshot(value: raw.wire.Value) !AgentSnapshot { }; } +fn decodeJournalEventSchema(value: raw.wire.Value) !JournalEventSchema { + const object = try detailObject(value); + try ensureOnlyFields( + object, + &.{ + "kind", + "schema_version", + "class", + "replay", + "sensitivity", + "payload_schema", + }, + ); + return .{ + .kind = try journalBoundedString(object, "kind", max_journal_kind_bytes), + .schema_version = try objectUnsigned(u32, object, "schema_version", 1), + .journal_class = try decodeJournalClass( + try objectString(object, "class"), + ), + .replay = try decodeJournalReplayPolicy( + try objectString(object, "replay"), + ), + .sensitivity = try decodeJournalSensitivity( + try objectString(object, "sensitivity"), + ), + .payload_schema = object.get("payload_schema") orelse + return error.MissingField, + }; +} + +fn decodeJournalProducerManifest( + allocator: std.mem.Allocator, + value: raw.wire.Value, +) !JournalProducerManifest { + const object = try detailObject(value); + try ensureOnlyFields( + object, + &.{ + "producer_id", + "namespace", + "manifest_version", + "max_sensitivity", + "permissions", + "events", + }, + ); + const raw_permissions = switch (object.get("permissions") orelse + return error.MissingField) { + .array => |items| items.items, + else => return error.ExpectedArray, + }; + if (raw_permissions.len == 0 or raw_permissions.len > 32) { + return error.InvalidJournalPermissions; + } + const permissions = try allocator.alloc([]const u8, raw_permissions.len); + for (raw_permissions, 0..) |permission, index| { + const text = switch (permission) { + .string => |item| item, + else => return error.ExpectedString, + }; + if (text.len == 0 or text.len > 128 or + !std.unicode.utf8ValidateSlice(text)) + { + return error.InvalidJournalPermission; + } + permissions[index] = text; + } + const raw_events = switch (object.get("events") orelse + return error.MissingField) { + .array => |items| items.items, + else => return error.ExpectedArray, + }; + if (raw_events.len == 0 or raw_events.len > 64) { + return error.InvalidJournalEvents; + } + const events = try allocator.alloc(JournalEventSchema, raw_events.len); + for (raw_events, 0..) |event, index| { + events[index] = try decodeJournalEventSchema(event); + } + const manifest = JournalProducerManifest{ + .producer_id = try journalBoundedString(object, "producer_id", max_journal_component_bytes), + .namespace_ = try journalBoundedString(object, "namespace", 72), + .manifest_version = try objectUnsigned(u32, object, "manifest_version", 1), + .max_sensitivity = try decodeJournalSensitivity( + try objectString(object, "max_sensitivity"), + ), + .permissions = permissions, + .events = events, + }; + try manifest.validate(); + return manifest; +} + +fn journalDecimalString( + object: raw.wire.Object, + name: []const u8, +) ![]const u8 { + const text = try journalBoundedString(object, name, 128); + _ = try decimalU64(.{ .string = text }); + return text; +} + +fn decodeJournalProducerPutResult( + value: raw.wire.Value, +) !JournalProducerPutResult { + const object = try detailObject(value); + try ensureOnlyFields( + object, + &.{ + "producer_id", + "manifest_version", + "namespace", + "sequence", + "event_id", + }, + ); + const producer_id = try journalBoundedString( + object, + "producer_id", + max_journal_component_bytes, + ); + const namespace_ = try journalBoundedString(object, "namespace", 128); + const namespace_prefix = "plugin."; + if (namespace_.len != namespace_prefix.len + producer_id.len or + !std.mem.startsWith(u8, namespace_, namespace_prefix) or + !std.mem.eql(u8, namespace_[namespace_prefix.len..], producer_id)) + { + return error.InvalidJournalProducerNamespace; + } + return .{ + .producer_id = producer_id, + .manifest_version = try objectUnsigned(u32, object, "manifest_version", 1), + .namespace_ = namespace_, + .sequence = try journalDecimalString(object, "sequence"), + .event_id = try journalBoundedString(object, "event_id", 128), + }; +} + +fn decodeJournalProducerListResult( + allocator: std.mem.Allocator, + value: raw.wire.Value, +) !JournalProducerListResult { + const object = try detailObject(value); + try ensureOnlyFields(object, &.{"producers"}); + const raw_producers = switch (object.get("producers") orelse + return error.MissingField) { + .array => |items| items.items, + else => return error.ExpectedArray, + }; + if (raw_producers.len > 1024) return error.TooManyJournalProducers; + const producers = try allocator.alloc( + JournalProducerManifest, + raw_producers.len, + ); + for (raw_producers, 0..) |producer, index| { + producers[index] = try decodeJournalProducerManifest( + allocator, + producer, + ); + } + return .{ .producers = producers }; +} + +fn decodeJournalAppendResult( + value: raw.wire.Value, +) !JournalAppendResult { + const object = try detailObject(value); + try ensureOnlyFields(object, &.{ "producer_id", "sequence", "event_id" }); + return .{ + .producer_id = try journalBoundedString(object, "producer_id", max_journal_component_bytes), + .sequence = try journalDecimalString(object, "sequence"), + .event_id = try journalBoundedString(object, "event_id", 128), + }; +} + fn decodePairingRequestSnapshot( value: raw.wire.Value, ) !PairingRequestSnapshot { @@ -9497,6 +10097,11 @@ fn decodeOwnedAllocatedResult( decoded_arena.allocator(), owned_result.value, ) + else if (comptime Result == JournalProducerListResult) + try decodeJournalProducerListResult( + decoded_arena.allocator(), + owned_result.value, + ) else @compileError("unsupported allocated result"); const decoded = OwnedDecodedValue(Result){ @@ -9537,6 +10142,10 @@ fn decodeTypedMutation( try decodeTerminalDefaultsSnapshot(raw_result.value) else if (comptime Value == PairingResolutionResult) try decodePairingResolutionResult(raw_result.value) + else if (comptime Value == JournalProducerPutResult) + try decodeJournalProducerPutResult(raw_result.value) + else if (comptime Value == JournalAppendResult) + try decodeJournalAppendResult(raw_result.value) else try decodeTypedSnapshot(Value, raw_result.value); const typed = TypedMutationResult(Value){ @@ -10188,6 +10797,117 @@ fn HandleImpl( ); } + /// Lists generic journal producer manifests installed in this + /// session. The returned value owns its decoded arrays and wire + /// storage until `deinit`. + pub fn journalProducers( + self: Self, + ) !OwnedJournalProducerListResult { + if (comptime !std.mem.eql(u8, scope, "session")) { + return error.UnsupportedHandleOperation; + } + var params = try Params(Id).init( + self.client.allocator, + scope, + &self.target, + null, + ); + defer params.deinit(); + return decodeOwnedAllocatedResult( + JournalProducerListResult, + self.client.allocator, + try self.client.read( + .session_journal_producer_list, + params.asValue(), + ), + ); + } + + /// Slice-oriented alias for `journalProducers`. + pub fn listJournalProducers( + self: Self, + ) !OwnedJournalProducerListResult { + return self.journalProducers(); + } + + /// Installs or replaces a generic userland journal producer. + pub fn putJournalProducer( + self: Self, + manifest: JournalProducerManifest, + mutation: MutationOptions, + ) !JournalProducerPutMutationResult { + if (comptime !std.mem.eql(u8, scope, "session")) { + return error.UnsupportedHandleOperation; + } + var params = try Params(Id).init( + self.client.allocator, + scope, + &self.target, + null, + ); + defer params.deinit(); + try params.putValue( + "manifest", + try manifest.toValue(params.arena.allocator()), + ); + return decodeTypedMutation( + JournalProducerPutResult, + try self.client.mutate( + .session_journal_producer_put, + params.asValue(), + mutation, + ), + ); + } + + /// Compatibility alias for the first agent-plugin SDK preview. + pub fn putJournalProducerManifest( + self: Self, + manifest: JournalProducerManifest, + mutation: MutationOptions, + ) !JournalProducerPutMutationResult { + return self.putJournalProducer(manifest, mutation); + } + + /// Appends one event from a registered userland producer. + pub fn appendJournal( + self: Self, + event: JournalIngress, + mutation: MutationOptions, + ) !JournalAppendMutationResult { + if (comptime !std.mem.eql(u8, scope, "session")) { + return error.UnsupportedHandleOperation; + } + var params = try Params(Id).init( + self.client.allocator, + scope, + &self.target, + null, + ); + defer params.deinit(); + try params.putValue( + "event", + try event.toValue(params.arena.allocator()), + ); + return decodeTypedMutation( + JournalAppendResult, + try self.client.mutate( + .session_journal_append, + params.asValue(), + mutation, + ), + ); + } + + /// Compatibility alias for the first agent-plugin SDK preview. + pub fn appendJournalEvent( + self: Self, + event: JournalIngress, + mutation: MutationOptions, + ) !JournalAppendMutationResult { + return self.appendJournal(event, mutation); + } + pub fn createNotification( self: Self, notification_options: NotificationCreateOptions, @@ -10233,12 +10953,6 @@ fn HandleImpl( if (comptime !std.mem.eql(u8, scope, "session")) { return error.UnsupportedHandleOperation; } - switch (report.source) { - .hook, .socket => {}, - .detected, .unknown => { - return error.InvalidReportAgentSource; - }, - } var params = try Params(Id).init( self.client.allocator, scope, @@ -12665,6 +13379,50 @@ pub const Session = struct { return self.impl().listAgents(options); } + pub fn journalProducers( + self: Self, + ) !OwnedJournalProducerListResult { + return self.impl().journalProducers(); + } + + pub fn listJournalProducers( + self: Self, + ) !OwnedJournalProducerListResult { + return self.impl().listJournalProducers(); + } + + pub fn putJournalProducer( + self: Self, + manifest: JournalProducerManifest, + mutation: MutationOptions, + ) !JournalProducerPutMutationResult { + return self.impl().putJournalProducer(manifest, mutation); + } + + pub fn putJournalProducerManifest( + self: Self, + manifest: JournalProducerManifest, + mutation: MutationOptions, + ) !JournalProducerPutMutationResult { + return self.impl().putJournalProducerManifest(manifest, mutation); + } + + pub fn appendJournal( + self: Self, + event: JournalIngress, + mutation: MutationOptions, + ) !JournalAppendMutationResult { + return self.impl().appendJournal(event, mutation); + } + + pub fn appendJournalEvent( + self: Self, + event: JournalIngress, + mutation: MutationOptions, + ) !JournalAppendMutationResult { + return self.impl().appendJournalEvent(event, mutation); + } + pub fn createNotification( self: Self, options: NotificationCreateOptions, @@ -15891,7 +16649,7 @@ test "layout undo requires and forwards confirmation capability" { test "every catalog operation reaches a typed public facade" { @setEvalBranchQuota(20_000); const operation_fields = std.meta.fields(Operation); - try std.testing.expectEqual(@as(usize, 114), operation_fields.len); + try std.testing.expectEqual(@as(usize, 117), operation_fields.len); inline for (operation_fields, 0..) |field, index| { const operation: Operation = @enumFromInt(field.value); const binding = comptime operation.facadeBinding(); @@ -15936,6 +16694,9 @@ test "public facades expose only valid resource and stream capabilities" { "listPairingRequests", "listNotifications", "listAgents", + "journalProducers", + "putJournalProducer", + "appendJournal", "createNotification", "reportAgent", "ensureSidebarView", @@ -17702,6 +18463,139 @@ test "typed terminal decoders reject malformed and retain future enums" { } } +test "generic journal producer values preserve the userland wire contract" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + var schema = try raw.wire.parse( + std.testing.allocator, + "{\"type\":\"object\"}", + .{}, + ); + defer schema.deinit(); + const manifest = JournalProducerManifest{ + .producer_id = "screen-detector", + .namespace_ = "plugin.screen-detector", + .manifest_version = 1, + .max_sensitivity = .sensitive, + .permissions = &.{"journal.append.plugin.screen-detector"}, + .events = &.{.{ + .kind = "plugin.screen-detector.state.changed", + .schema_version = 1, + .journal_class = .state, + .replay = .required, + .sensitivity = .sensitive, + .payload_schema = schema.value, + }}, + }; + try manifest.validate(); + const invalid_ingress = JournalIngress{ + .producer_id = "screen-detector", + .manifest_version = 1, + .kind = "agent.state.changed", + .schema_version = 1, + .payload = schema.value, + }; + try std.testing.expectError( + error.InvalidJournalIngress, + invalid_ingress.validate(), + ); + const encoded_manifest = try manifest.toValue(arena.allocator()); + const manifest_object = try detailObject(encoded_manifest); + try std.testing.expectEqualStrings( + "plugin.screen-detector", + try objectString(manifest_object, "namespace"), + ); + try std.testing.expect(manifest_object.get("namespace_") == null); + const encoded_event = switch (manifest_object.get("events") orelse return error.MissingField) { + .array => |items| items.items[0], + else => return error.ExpectedArray, + }; + try std.testing.expectEqualStrings( + "state", + try objectString( + try detailObject(encoded_event), + "class", + ), + ); + + var list = try raw.wire.parse( + std.testing.allocator, + "{\"producers\":[{\"producer_id\":\"screen-detector\",\"namespace\":\"plugin.screen-detector\",\"manifest_version\":1,\"max_sensitivity\":\"sensitive\",\"permissions\":[\"journal.append.plugin.screen-detector\"],\"events\":[{\"kind\":\"plugin.screen-detector.state.changed\",\"schema_version\":1,\"class\":\"state\",\"replay\":\"required\",\"sensitivity\":\"sensitive\",\"payload_schema\":{\"type\":\"object\"}}]}]}", + .{}, + ); + defer list.deinit(); + const decoded_list = try decodeJournalProducerListResult( + arena.allocator(), + list.value, + ); + try std.testing.expectEqual(@as(usize, 1), decoded_list.producers.len); + try std.testing.expectEqualStrings( + "plugin.screen-detector.state.changed", + decoded_list.producers[0].events[0].kind, + ); + + var malformed_put = try raw.wire.parse( + std.testing.allocator, + "{\"producer_id\":\"screen-detector\",\"manifest_version\":1," ++ + "\"namespace\":\"plugin.other\",\"sequence\":\"1\",\"event_id\":\"event-1\"}", + .{}, + ); + defer malformed_put.deinit(); + try std.testing.expectError( + error.InvalidJournalProducerNamespace, + decodeJournalProducerPutResult(malformed_put.value), + ); + + var agent = try raw.wire.parse( + std.testing.allocator, + "{\"id\":\"agent_11111111111111111111111111111111\",\"session_id\":\"session_22222222222222222222222222222222\",\"terminal_id\":\"term_33333333333333333333333333333333\",\"state\":\"working\",\"source\":\"plugin\",\"updated_at_ms\":\"9\",\"source_session\":null}", + .{}, + ); + defer agent.deinit(); + try std.testing.expectEqual( + AgentSource.plugin, + (try decodeAgentSnapshot(agent.value)).source, + ); + + var screen = try raw.wire.parse( + std.testing.allocator, + "{\"text\":\"ready\",\"revision\":\"12\",\"osc_progress\":\"4;1;50\",\"cols\":80,\"rows\":24,\"cursor_row\":1,\"cursor_col\":2,\"cursor_visible\":true}", + .{}, + ); + defer screen.deinit(); + const decoded_screen = try decodeTerminalScreenResult(screen.value); + try std.testing.expectEqual(@as(?u64, 12), decoded_screen.revision); + try std.testing.expectEqualStrings( + "4;1;50", + decoded_screen.osc_progress orelse return error.MissingField, + ); + + var invalid = manifest; + invalid.events = &.{.{ + .kind = "plugin.screen-detector.state.changed", + .schema_version = 1, + .journal_class = .state, + .replay = .required, + .sensitivity = .secret, + .payload_schema = schema.value, + }}; + try std.testing.expectError( + error.InvalidJournalEventSensitivity, + invalid.validate(), + ); + + const legacy_screen = TerminalScreenResult{ + .text = "legacy", + .cols = 80, + .rows = 24, + .cursor_row = 0, + .cursor_col = 0, + .cursor_visible = true, + .extra = null, + }; + try std.testing.expect(legacy_screen.revision == null); +} + test "terminal lifecycle and durable exit constraints are strict" { var decoded_arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer decoded_arena.deinit(); diff --git a/cmux-tui/crates/cmux-terminal-client/src/lib.rs b/cmux-tui/crates/cmux-terminal-client/src/lib.rs index 65a4bed74582..d6b84bbaff47 100644 --- a/cmux-tui/crates/cmux-terminal-client/src/lib.rs +++ b/cmux-tui/crates/cmux-terminal-client/src/lib.rs @@ -2083,6 +2083,7 @@ mod tests { pid: None, command: Vec::new(), cwd: None, + osc_progress: String::new(), }, ) .unwrap() diff --git a/cmux-tui/crates/cmux-tui-core/src/agent_hooks.rs b/cmux-tui/crates/cmux-tui-core/src/agent_hooks.rs index 75842b6046f6..bb8375b370b2 100644 --- a/cmux-tui/crates/cmux-tui-core/src/agent_hooks.rs +++ b/cmux-tui/crates/cmux-tui-core/src/agent_hooks.rs @@ -9,13 +9,20 @@ use crate::{ pub const AGENT_HOOK_PRODUCER_ID: &str = "cmux_agent"; pub const AGENT_HOOK_MANIFEST_VERSION: u32 = 1; -const AGENT_HOOK_FORMAT: &str = "cmux.agent-hook.v1"; +pub(crate) const AGENT_HOOK_FORMAT: &str = "cmux.agent-hook.v1"; +/// Internal lifecycle event emitted when the generic core supervisor observes +/// a userland journal-plugin exit. This is a core projection adapter, not an +/// agent implementation. It lets the roster remain a journal-only projection +/// even when a crashed plugin cannot emit per-terminal `session.ended` events. +// Keep the wire value from the preview so journals already on disk replay +// after this boundary rename. +pub(crate) const JOURNAL_PLUGIN_EXIT_NATIVE_EVENT: &str = "AgentPluginExited"; const MAX_AGENT_SOURCE_BYTES: usize = 64; const MAX_NATIVE_EVENT_BYTES: usize = 128; const NORMALIZED_TEXT_BYTES: usize = 8 * 1024; const REDACTED_AGENT_VALUE: &str = "[redacted]"; -const AGENT_EVENT_KINDS: [&str; 12] = [ +const AGENT_EVENT_KINDS: [&str; 13] = [ "agent.session.started", "agent.turn.started", "agent.turn.completed", @@ -28,6 +35,7 @@ const AGENT_EVENT_KINDS: [&str; 12] = [ "agent.error.reported", "agent.state.changed", "agent.session.ended", + "agent.plugin.exited", ]; pub fn agent_hook_journal_ingress( @@ -76,6 +84,38 @@ pub fn agent_hook_journal_ingress( }) } +/// Build the core-owned lifecycle event for an unexpectedly exited userland +/// plugin. The reducer uses the plugin subject to retire all entries owned by +/// that producer in one deterministic fold. +pub(crate) fn journal_plugin_exit_journal_ingress( + plugin_id: &str, + generation: u64, +) -> anyhow::Result { + validate_agent_source(plugin_id)?; + Ok(JournalIngress { + producer_id: AGENT_HOOK_PRODUCER_ID.into(), + manifest_version: AGENT_HOOK_MANIFEST_VERSION, + kind: "agent.plugin.exited".into(), + schema_version: 1, + occurred_at_ms: None, + subjects: vec![JournalSubject { kind: "plugin".into(), id: plugin_id.into() }], + sensitivity: Some(JournalSensitivity::Sensitive), + payload: json!({ + "format": AGENT_HOOK_FORMAT, + "adapter": {"id": "cmux", "version": 1}, + "native_event": JOURNAL_PLUGIN_EXIT_NATIVE_EVENT, + "normalized": { + "plugin_id": plugin_id, + "plugin_generation": generation.to_string(), + "observed_at_ms": crate::workspace_registry::unix_epoch_ms()?.to_string(), + }, + "native": {}, + }), + causation_id: None, + correlation_id: None, + }) +} + fn redact_agent_native(native_event: &str, mut native: Value) -> Value { if semantic_key(native_event) == "input" { return json!({"redacted":true,"reason":"raw_input"}); @@ -179,7 +219,7 @@ pub(crate) fn built_in_agent_producer_manifest() -> JournalProducerManifest { "type":"string", "minLength":1, "maxLength":MAX_AGENT_SOURCE_BYTES, - "pattern":"^[a-z0-9_-]+$" + "pattern":"^[a-z0-9][a-z0-9_-]*$" }, "version":{"const":1} }, @@ -215,10 +255,11 @@ fn validate_agent_source(source: &str) -> anyhow::Result<()> { anyhow::ensure!( !source.is_empty() && source.len() <= MAX_AGENT_SOURCE_BYTES + && source.as_bytes().first().is_some_and(|byte| byte.is_ascii_alphanumeric()) && source.bytes().all(|byte| { byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'_' | b'-') }), - "agent source must contain 1 to {MAX_AGENT_SOURCE_BYTES} lowercase ASCII letters, digits, hyphens, or underscores" + "agent source must match [a-z0-9][a-z0-9_-]* and contain at most {MAX_AGENT_SOURCE_BYTES} bytes" ); Ok(()) } @@ -782,6 +823,13 @@ fn semantic_key(value: &str) -> String { mod tests { use super::*; + #[test] + fn agent_source_uses_the_shared_component_grammar() { + assert!(agent_hook_journal_ingress("codex-agent", "Stop", None, json!({})).is_ok()); + assert!(agent_hook_journal_ingress("-codex", "Stop", None, json!({})).is_err()); + assert!(agent_hook_journal_ingress("codex.agent", "Stop", None, json!({})).is_err()); + } + #[test] fn completion_hooks_share_one_semantic_kind_and_keep_native_payload() { for (source, event) in [ diff --git a/cmux-tui/crates/cmux-tui-core/src/event_bus.rs b/cmux-tui/crates/cmux-tui-core/src/event_bus.rs index 9086fb6a407a..f49b1ea5ad9f 100644 --- a/cmux-tui/crates/cmux-tui-core/src/event_bus.rs +++ b/cmux-tui/crates/cmux-tui-core/src/event_bus.rs @@ -467,6 +467,7 @@ mod tests { state: format!("one-{index}").into(), source: "hook".into(), session: None, + agent: None, updated_at_ms: index, }); broadcaster.emit(MuxEvent::AgentChanged { @@ -474,6 +475,7 @@ mod tests { state: format!("two-{index}").into(), source: "socket".into(), session: Some("agent-session".into()), + agent: None, updated_at_ms: index, }); } @@ -494,6 +496,7 @@ mod tests { state, source, session: Some(session), + agent: None, updated_at_ms: 9_999, } if state.as_ref() == "two-9999" && source.as_ref() == "socket" @@ -559,6 +562,7 @@ mod tests { state: "working".into(), source: "hook".into(), session: None, + agent: None, updated_at_ms: 1, }); broadcaster.emit(MuxEvent::SurfaceExited(4)); diff --git a/cmux-tui/crates/cmux-tui-core/src/journal_plugin.rs b/cmux-tui/crates/cmux-tui-core/src/journal_plugin.rs new file mode 100644 index 000000000000..164894a7ded1 --- /dev/null +++ b/cmux-tui/crates/cmux-tui-core/src/journal_plugin.rs @@ -0,0 +1,777 @@ +//! Supervision for userland journal plugins. +//! +//! The core owns only the lifecycle of a plugin process. Agent identity, +//! screen parsing, process discovery, and vendor rules stay outside this +//! module. A plugin communicates through the local resource socket and writes +//! normal journal events, so every frontend observes the same reducer state. + +#[cfg(windows)] +use std::mem::size_of; +#[cfg(unix)] +use std::os::unix::process::CommandExt; +#[cfg(windows)] +use std::os::windows::process::CommandExt; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, ExitStatus, Stdio}; +use std::sync::{Arc, Condvar, Mutex}; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant}; + +#[cfg(windows)] +use windows_sys::Win32::Foundation::{CloseHandle, HANDLE, INVALID_HANDLE_VALUE}; +#[cfg(windows)] +use windows_sys::Win32::System::Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, TH32CS_SNAPTHREAD, THREADENTRY32, Thread32First, Thread32Next, +}; +#[cfg(windows)] +use windows_sys::Win32::System::JobObjects::{ + AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, + JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation, + SetInformationJobObject, TerminateJobObject, +}; +#[cfg(windows)] +use windows_sys::Win32::System::Threading::{ + CREATE_SUSPENDED, OpenProcess, OpenThread, PROCESS_SET_QUOTA, PROCESS_TERMINATE, ResumeThread, + THREAD_SUSPEND_RESUME, +}; + +const SUPERVISOR_WAIT: Duration = Duration::from_millis(500); +const MAX_RESTART_DELAY: Duration = Duration::from_secs(30); +const MAX_PLUGIN_COMMAND_ARGS: usize = 256; +const MAX_PLUGIN_COMMAND_ARG_BYTES: usize = 4096; +/// A child must stay healthy for this long before a later crash earns a fresh +/// backoff budget. Without a stability window, a crash loop can reset the +/// counter on every short-lived spawn and retry forever at one second. +const STABLE_RUNTIME: Duration = Duration::from_secs(30); + +pub(crate) type JournalPluginExitHandler = Arc; + +/// A plugin child plus the OS-owned boundary used to stop its descendants. +/// The supervisor must own the whole process tree: a plugin helper that keeps +/// running after the leader exits can otherwise continue to append events +/// after its generation has been fenced. +struct JournalPluginChild { + child: Child, + #[cfg(unix)] + process_group_id: libc::pid_t, + #[cfg(windows)] + job: WindowsJournalPluginJob, +} + +impl JournalPluginChild { + fn try_wait(&mut self) -> std::io::Result> { + self.child.try_wait() + } + + /// Terminate the owned process tree and reap the leader. The group/job + /// operation runs before `wait`, so descendants are not left behind when + /// the leader exits first. + fn terminate(&mut self) { + self.terminate_descendants(); + let _ = self.child.kill(); + let _ = self.child.wait(); + } + + fn terminate_descendants(&self) { + #[cfg(unix)] + { + if self.process_group_id > 0 { + // The child is placed in a fresh process group before exec. + // A negative pid addresses that group, including helpers that + // inherited it from the plugin. + unsafe { + libc::kill(-self.process_group_id, libc::SIGKILL); + } + } + } + #[cfg(windows)] + { + self.job.terminate_descendants(); + } + } +} + +#[cfg(windows)] +struct WindowsJournalPluginJob { + // Keep the kernel handle as an integer in the shared supervisor state. + // `windows_sys::HANDLE` is a raw pointer and therefore is not `Send`, + // even though Windows kernel handles are process-wide, thread-safe + // values. Converting at the FFI boundary preserves the ownership model + // without making an unsafe `Send` promise for the containing state. + handle: usize, +} + +#[cfg(windows)] +impl WindowsJournalPluginJob { + fn raw_handle(&self) -> HANDLE { + self.handle as HANDLE + } + + fn assign(child: &Child) -> std::io::Result { + let handle = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) }; + if handle.is_null() { + return Err(std::io::Error::last_os_error()); + } + let job = Self { handle: handle as usize }; + let mut information = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); + information.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + let information_size = u32::try_from(size_of::()) + .expect("Windows job information fits in u32"); + if unsafe { + SetInformationJobObject( + job.raw_handle(), + JobObjectExtendedLimitInformation, + std::ptr::from_ref(&information).cast(), + information_size, + ) + } == 0 + { + return Err(std::io::Error::last_os_error()); + } + + let process = unsafe { OpenProcess(PROCESS_SET_QUOTA | PROCESS_TERMINATE, 0, child.id()) }; + if process.is_null() { + return Err(std::io::Error::last_os_error()); + } + let assigned = unsafe { AssignProcessToJobObject(job.raw_handle(), process) }; + let assign_error = (assigned == 0).then(std::io::Error::last_os_error); + unsafe { + CloseHandle(process); + } + if let Some(error) = assign_error { + return Err(error); + } + Ok(job) + } + + fn terminate_descendants(&self) { + unsafe { + TerminateJobObject(self.raw_handle(), 1); + } + } +} + +#[cfg(windows)] +impl Drop for WindowsJournalPluginJob { + fn drop(&mut self) { + unsafe { + CloseHandle(self.raw_handle()); + } + } +} + +#[cfg(windows)] +fn resume_suspended_journal_plugin(child: &Child) -> std::io::Result<()> { + let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0) }; + if snapshot == INVALID_HANDLE_VALUE { + return Err(std::io::Error::last_os_error()); + } + let result = (|| { + let mut entry = THREADENTRY32 { + dwSize: u32::try_from(size_of::()) + .expect("Windows thread entry size fits in u32"), + ..THREADENTRY32::default() + }; + if unsafe { Thread32First(snapshot, &mut entry) } == 0 { + return Err(std::io::Error::last_os_error()); + } + loop { + if entry.th32OwnerProcessID == child.id() { + let thread = unsafe { OpenThread(THREAD_SUSPEND_RESUME, 0, entry.th32ThreadID) }; + if thread.is_null() { + return Err(std::io::Error::last_os_error()); + } + let resumed = unsafe { ResumeThread(thread) }; + let error = (resumed == u32::MAX).then(std::io::Error::last_os_error); + unsafe { + CloseHandle(thread); + } + return error.map_or(Ok(()), Err); + } + if unsafe { Thread32Next(snapshot, &mut entry) } == 0 { + return Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "suspended journal plugin has no thread to resume", + )); + } + } + })(); + unsafe { + CloseHandle(snapshot); + } + result +} + +/// A configured userland journal plugin. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct JournalPluginOptions { + /// Stable plugin id. It is passed to the plugin and used for diagnostics. + pub id: String, + /// Executable plus arguments. The executable must be an absolute path + /// after config resolution. + pub command: Vec, + /// Optional working directory for the plugin process. + pub cwd: Option, + /// Artifact revision. A changed revision restarts a running child even + /// when its command and working directory stay the same. + pub revision: Option, +} + +impl JournalPluginOptions { + pub fn validate(&self) -> anyhow::Result<()> { + anyhow::ensure!(!self.id.is_empty(), "journal plugin id must not be empty"); + anyhow::ensure!(self.id.len() <= 64, "journal plugin id is too long"); + anyhow::ensure!( + self.id.as_bytes().first().is_some_and(|byte| byte.is_ascii_alphanumeric()) + && self.id.bytes().all(|byte| { + byte.is_ascii_lowercase() + || byte.is_ascii_digit() + || byte == b'_' + || byte == b'-' + }), + "journal plugin id must match [a-z0-9][a-z0-9_-]*" + ); + anyhow::ensure!( + self.id != crate::AGENT_HOOK_PRODUCER_ID, + "journal plugin id is reserved for the built-in agent hook producer" + ); + let Some(executable) = self.command.first() else { + anyhow::bail!("journal plugin command must not be empty"); + }; + anyhow::ensure!(!executable.trim().is_empty(), "journal plugin command must not be empty"); + anyhow::ensure!( + Path::new(executable).is_absolute(), + "journal plugin command[0] must be an absolute executable path" + ); + anyhow::ensure!( + self.command.len() <= MAX_PLUGIN_COMMAND_ARGS, + "journal plugin command must contain at most {MAX_PLUGIN_COMMAND_ARGS} arguments" + ); + anyhow::ensure!( + self.command.iter().all(|argument| { + argument.len() <= MAX_PLUGIN_COMMAND_ARG_BYTES && !argument.contains('\0') + }), + "journal plugin command arguments must be at most {MAX_PLUGIN_COMMAND_ARG_BYTES} bytes and contain no NUL" + ); + if let Some(cwd) = &self.cwd { + anyhow::ensure!( + !cwd.is_empty() && Path::new(cwd).is_absolute() && !cwd.contains('\0'), + "journal plugin cwd must be an absolute path" + ); + } + if let Some(revision) = &self.revision { + anyhow::ensure!(!revision.is_empty(), "journal plugin revision must not be empty"); + anyhow::ensure!( + !revision.bytes().any(|byte| byte.is_ascii_control()), + "journal plugin revision must not contain control characters" + ); + anyhow::ensure!(revision.len() <= 128, "journal plugin revision is too long"); + } + Ok(()) + } +} + +#[derive(Default)] +struct SupervisorState { + options: Option, + socket: Option, + session: Option, + child: Option, + restart_at: Option, + failures: u32, + child_started_at: Option, + started: bool, + stopping: bool, + generation: u64, + child_generation: Option, + exit_handler: Option, +} + +/// Owns one plugin process and restarts failed children until shutdown. The +/// runtime is deliberately small and has no knowledge of any agent vendor or +/// event schema. +pub struct JournalPluginRuntime { + state: Arc<(Mutex, Condvar)>, + thread: Mutex>>, +} + +impl Default for JournalPluginRuntime { + fn default() -> Self { + Self { + state: Arc::new((Mutex::new(SupervisorState::default()), Condvar::new())), + thread: Mutex::new(None), + } + } +} + +impl JournalPluginRuntime { + /// Replace the configured plugin. A running child is stopped before the + /// new command is started, which prevents two plugins writing the same + /// producer namespace during a config reload. + pub fn configure(&self, options: Option) { + // An invalid replacement disables the old process. Keeping stale + // configuration alive after a failed reload would report data that + // the user just removed or rejected. + let options = options.and_then(|options| { + if let Err(error) = options.validate() { + eprintln!("cmux-tui: disabling invalid journal plugin configuration: {error}"); + None + } else { + Some(options) + } + }); + let retired = { + let (lock, changed) = &*self.state; + let mut state = lock.lock().unwrap_or_else(|error| error.into_inner()); + if state.options == options { + return; + } + let retired = state.child_generation.and_then(|generation| { + state.options.as_ref().map(|options| (options.id.clone(), generation)) + }); + stop_child(&mut state); + state.options = options; + state.generation = next_generation(state.generation); + state.child_generation = None; + state.failures = 0; + state.restart_at = None; + changed.notify_all(); + (state.exit_handler.clone(), retired) + }; + // A configuration replacement is also a producer retirement. Notify + // outside the lock so the callback can append its cleanup event + // without blocking the supervisor. + if let (Some(handler), Some((plugin_id, generation))) = retired { + handler(&plugin_id, generation); + } + } + + /// Install the callback used to turn an unexpected child exit into a + /// generic journal lifecycle event. The callback runs without the + /// supervisor mutex held. + pub(crate) fn set_exit_handler(&self, handler: Option) { + let (lock, _) = &*self.state; + lock.lock().unwrap_or_else(|error| error.into_inner()).exit_handler = handler; + } + + /// Starts supervision after the local socket has been bound. This ordering + /// gives the plugin an authoritative socket path and avoids a startup race. + pub fn start(&self, socket: PathBuf, session: String) { + self.start_inner(socket, session, None); + } + + /// Starts supervision with a generation reserved by the durable session + /// registry. The seed is applied only before the first supervisor start; + /// later child restarts continue to use the in-memory increment. + pub(crate) fn start_with_generation_seed( + &self, + socket: PathBuf, + session: String, + generation_seed: u64, + ) { + self.start_inner(socket, session, Some(generation_seed)); + } + + fn start_inner(&self, socket: PathBuf, session: String, generation_seed: Option) { + let (lock, changed) = &*self.state; + let mut state = lock.lock().unwrap_or_else(|error| error.into_inner()); + state.socket = Some(socket); + state.session = Some(session); + if !state.started { + if let Some(seed) = generation_seed { + // The registry reserves the exact generation for this + // daemon start. Configuration may have advanced the local + // counter before the socket was bound, but it must not + // replace the durable reservation with `seed - 1`. + state.generation = seed; + } + state.started = true; + let shared = Arc::clone(&self.state); + let handle = thread::Builder::new() + .name("cmux-journal-plugin".into()) + .spawn(move || supervise(shared)); + match handle { + Ok(handle) => *self.thread.lock().unwrap() = Some(handle), + Err(error) => { + state.started = false; + eprintln!("cmux-tui: journal plugin supervisor did not start: {error}"); + } + } + } + changed.notify_all(); + } + + /// Stops the child and joins the supervisor. Shutdown is terminal for this + /// runtime; construct a new runtime to start supervision again. It is safe + /// to call more than once, including from `Drop` after an earlier explicit + /// shutdown. + pub fn shutdown(&self) { + let (lock, changed) = &*self.state; + let retired = { + let mut state = lock.lock().unwrap_or_else(|error| error.into_inner()); + state.stopping = true; + let retired = state.child_generation.and_then(|generation| { + state.options.as_ref().map(|options| (options.id.clone(), generation)) + }); + stop_child(&mut state); + changed.notify_all(); + (state.exit_handler.clone(), retired) + }; + // Treat an intentional daemon shutdown as a producer retirement too. + // Otherwise a restored roster could retain rows from a child that was + // stopped cleanly and never had a chance to emit session.ended. + if let (Some(handler), Some((plugin_id, generation))) = retired { + handler(&plugin_id, generation); + } + if let Some(handle) = self.thread.lock().unwrap().take() + && handle.join().is_err() + { + eprintln!("cmux-tui: journal plugin supervisor panicked during shutdown"); + } + } +} + +impl Drop for JournalPluginRuntime { + fn drop(&mut self) { + self.shutdown(); + } +} + +fn supervise(shared: Arc<(Mutex, Condvar)>) { + loop { + let (lock, changed) = &*shared; + let mut state = lock.lock().unwrap_or_else(|error| error.into_inner()); + if state.stopping { + stop_child(&mut state); + return; + } + + let child_status = state.child.as_mut().map(JournalPluginChild::try_wait); + if let Some(child_status) = child_status { + match child_status { + Ok(Some(status)) => { + eprintln!("cmux-tui: journal plugin exited with {status}"); + let (exited_generation, child_started_at) = mark_child_exit(&mut state, false); + note_child_failure(&mut state, Instant::now(), child_started_at); + let exit_handler = state.exit_handler.clone(); + let plugin_id = state.options.as_ref().map(|options| options.id.clone()); + drop(state); + if let (Some(handler), Some(plugin_id), Some(generation)) = + (exit_handler, plugin_id, exited_generation) + { + handler(&plugin_id, generation); + } + continue; + } + Ok(None) => { + let _ = changed.wait_timeout(state, SUPERVISOR_WAIT); + continue; + } + Err(error) => { + eprintln!("cmux-tui: cannot inspect journal plugin: {error}"); + let (exited_generation, child_started_at) = mark_child_exit(&mut state, true); + note_child_failure(&mut state, Instant::now(), child_started_at); + let exit_handler = state.exit_handler.clone(); + let plugin_id = state.options.as_ref().map(|options| options.id.clone()); + drop(state); + if let (Some(handler), Some(plugin_id), Some(generation)) = + (exit_handler, plugin_id, exited_generation) + { + handler(&plugin_id, generation); + } + continue; + } + } + } + + let Some(options) = state.options.clone() else { + let _ = changed.wait_timeout(state, SUPERVISOR_WAIT); + continue; + }; + let Some(socket) = state.socket.clone() else { + let _ = changed.wait_timeout(state, SUPERVISOR_WAIT); + continue; + }; + if state.restart_at.is_some_and(|at| at > Instant::now()) { + let wait = state + .restart_at + .and_then(|at| at.checked_duration_since(Instant::now())) + .unwrap_or(SUPERVISOR_WAIT) + .min(SUPERVISOR_WAIT); + let _ = changed.wait_timeout(state, wait); + continue; + } + let session = state.session.clone().unwrap_or_else(|| "main".into()); + let generation = state.generation; + match spawn_plugin(&options, &socket, &session, generation) { + Ok(child) => { + state.child = Some(child); + state.child_generation = Some(generation); + state.child_started_at = Some(Instant::now()); + state.restart_at = None; + } + Err(error) => { + eprintln!("cmux-tui: journal plugin failed to start: {error}"); + note_child_failure(&mut state, Instant::now(), None); + } + } + } +} + +fn spawn_plugin( + options: &JournalPluginOptions, + socket: &PathBuf, + session: &str, + generation: u64, +) -> anyhow::Result { + let mut command = Command::new(&options.command[0]); + command + .args(&options.command[1..]) + .env("CMUX_TUI_SOCKET", socket) + .env("CMUX_MUX_SOCKET", socket) + .env("CMUX_TUI_SESSION_ID", session) + .env("CMUX_PLUGIN_ID", &options.id) + .env("CMUX_PLUGIN_GENERATION", generation.to_string()) + .env("CMUX_PLUGIN_PROTOCOL_VERSION", "1") + .env("CMUX_PLUGIN_KIND", "journal") + .env("CMUX_JOURNAL_PLUGIN", "1") + // Keep the preview name for plugins that shipped before the generic + // contract was named. It carries no authority and is only a hint. + .env("CMUX_AGENT_PLUGIN", "1") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::inherit()); + #[cfg(unix)] + command.process_group(0); + #[cfg(windows)] + command.creation_flags(CREATE_SUSPENDED); + if let Some(cwd) = &options.cwd { + command.current_dir(cwd); + } + if let Some(revision) = &options.revision { + command.env("CMUX_PLUGIN_REVISION", revision); + } + let child = command.spawn()?; + #[cfg(unix)] + { + let process_group_id = libc::pid_t::try_from(child.id()).map_err(|_| { + anyhow::anyhow!("journal plugin pid is outside the process-group range") + })?; + Ok(JournalPluginChild { process_group_id, child }) + } + #[cfg(windows)] + { + let mut child = child; + let job = match WindowsJournalPluginJob::assign(&child) { + Ok(job) => job, + Err(error) => { + let _ = child.kill(); + let _ = child.wait(); + return Err(anyhow::anyhow!("isolate journal plugin process tree: {error}")); + } + }; + if let Err(error) = resume_suspended_journal_plugin(&child) { + job.terminate_descendants(); + let _ = child.kill(); + let _ = child.wait(); + return Err(anyhow::anyhow!("resume isolated journal plugin: {error}")); + } + Ok(JournalPluginChild { child, job }) + } + #[cfg(not(any(unix, windows)))] + { + Ok(JournalPluginChild { child }) + } +} + +fn stop_child(state: &mut SupervisorState) { + if let Some(mut child) = state.child.take() { + child.terminate(); + } + state.child_generation = None; + state.child_started_at = None; +} + +/// Retire the observed child and advance the restart fence. The old +/// generation is returned for the cleanup event; the new generation is used +/// only by the next child. The start time is returned separately so the +/// backoff policy can decide whether this was a stable or crash-loop exit. +fn mark_child_exit( + state: &mut SupervisorState, + terminate_child: bool, +) -> (Option, Option) { + if terminate_child { + if let Some(mut child) = state.child.take() { + child.terminate(); + } + } else { + if let Some(child) = state.child.take() { + // `try_wait` already reaped the leader. The group/job can still + // contain helpers, so close that ownership boundary before the + // wrapper is dropped. + child.terminate_descendants(); + } + } + let exited_generation = state.child_generation.take(); + let child_started_at = state.child_started_at.take(); + if exited_generation.is_some() { + // A restarted child must receive a new fence even if the old process + // did not emit a final event. Without this increment, late records + // from the dead child can be accepted as current after the restart. + state.generation = next_generation(state.generation); + } + (exited_generation, child_started_at) +} + +/// Account for a failed launch or child exit. A child that lived through the +/// stability window resets the accumulated crash-loop budget; a short-lived +/// child advances it. Keeping this policy in the generic supervisor avoids +/// embedding agent-specific assumptions in the host. +fn note_child_failure( + state: &mut SupervisorState, + now: Instant, + child_started_at: Option, +) { + if child_started_at.is_some_and(|started_at| now.duration_since(started_at) >= STABLE_RUNTIME) { + state.failures = 0; + } + state.failures = state.failures.saturating_add(1); + state.restart_at = Some(now + restart_delay(state.failures)); +} + +fn restart_delay(failures: u32) -> Duration { + let shift = failures.saturating_sub(1).min(5); + Duration::from_secs(1_u64 << shift).min(MAX_RESTART_DELAY) +} + +fn next_generation(current: u64) -> u64 { + current.wrapping_add(1).max(1) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn options_reject_empty_or_unsafe_commands() { + let invalid = JournalPluginOptions { + id: "valid_id".into(), + command: vec![], + cwd: None, + revision: None, + }; + assert!(invalid.validate().is_err()); + let too_many = JournalPluginOptions { + id: "valid_id".into(), + command: (0..=MAX_PLUGIN_COMMAND_ARGS) + .map(|index| format!("/tmp/plugin-{index}")) + .collect(), + cwd: None, + revision: None, + }; + assert!(too_many.validate().is_err()); + let invalid_cwd = JournalPluginOptions { + id: "valid_id".into(), + command: vec!["/tmp/detector".into()], + cwd: Some("/tmp/with\0nul".into()), + revision: None, + }; + assert!(invalid_cwd.validate().is_err()); + let reserved = JournalPluginOptions { + id: crate::AGENT_HOOK_PRODUCER_ID.into(), + command: vec!["/tmp/detector".into()], + cwd: None, + revision: None, + }; + assert!(reserved.validate().is_err()); + let valid = JournalPluginOptions { + id: "screen_detector".into(), + command: vec!["/tmp/detector".into()], + cwd: None, + revision: Some("abc".into()), + }; + assert!(valid.validate().is_ok()); + } + + #[test] + fn restart_delay_is_bounded() { + assert_eq!(restart_delay(1), Duration::from_secs(1)); + assert_eq!(restart_delay(6), Duration::from_secs(32).min(MAX_RESTART_DELAY)); + } + + #[test] + fn unexpected_restart_advances_the_generation_fence() { + assert_eq!(next_generation(0), 1); + assert_eq!(next_generation(41), 42); + assert_eq!(next_generation(u64::MAX), 1); + + let mut state = SupervisorState { + generation: 1, + child_generation: Some(1), + ..SupervisorState::default() + }; + assert_eq!(mark_child_exit(&mut state, false), (Some(1), None)); + assert_eq!(state.generation, 2); + assert!(state.child_generation.is_none()); + } + + #[test] + fn crash_backoff_accumulates_for_short_lived_children() { + let started = Instant::now(); + let mut state = SupervisorState { + failures: 2, + child_started_at: Some(started), + ..SupervisorState::default() + }; + + let child_started_at = state.child_started_at.take(); + note_child_failure(&mut state, started + Duration::from_secs(5), child_started_at); + + assert_eq!(state.failures, 3); + assert_eq!(state.restart_at, Some(started + Duration::from_secs(9))); + } + + #[test] + fn stable_children_reset_the_crash_backoff_budget() { + let started = Instant::now(); + let mut state = SupervisorState { + failures: 5, + child_started_at: Some(started), + ..SupervisorState::default() + }; + + let child_started_at = state.child_started_at.take(); + note_child_failure(&mut state, started + STABLE_RUNTIME, child_started_at); + + assert_eq!(state.failures, 1); + assert_eq!(state.restart_at, Some(started + STABLE_RUNTIME + Duration::from_secs(1))); + } + + #[test] + fn persisted_start_generation_is_not_reduced_after_configuration() { + let runtime = JournalPluginRuntime::default(); + runtime.configure(Some(JournalPluginOptions { + id: "screen_detector".into(), + command: vec!["/tmp/screen-detector".into()], + cwd: None, + revision: None, + })); + + runtime.start_with_generation_seed( + PathBuf::from("/tmp/cmux-tui-test.sock"), + "main".into(), + 17, + ); + let generation = runtime.state.0.lock().unwrap().generation; + assert_eq!(generation, 17); + runtime.shutdown(); + } + + #[cfg(windows)] + #[test] + fn windows_runtime_can_cross_thread_boundaries() { + fn assert_send_sync() {} + + assert_send_sync::(); + } +} diff --git a/cmux-tui/crates/cmux-tui-core/src/journal_reducers.rs b/cmux-tui/crates/cmux-tui-core/src/journal_reducers.rs new file mode 100644 index 000000000000..a5930dc44410 --- /dev/null +++ b/cmux-tui/crates/cmux-tui-core/src/journal_reducers.rs @@ -0,0 +1,1562 @@ +//! Durable materialized views folded from the session journal. +//! +//! A journal reducer is a pure fold: committed `agent.*` records go in, a +//! deterministic state comes out. The daemon persists each reducer's cursor +//! (the last journal sequence folded) and a snapshot of its state in the +//! registry's `meta` table, so a restart restores the snapshot and re-folds +//! only the journal tail. Bumping a reducer's version discards the snapshot +//! and re-folds from the journal head; state older than the earliest +//! retained journal record is rebuilt lazily by the next events, which is +//! acceptable for live rosters and wrong for ledgers - keep ledger-shaped +//! reducers versioned conservatively. +//! +//! The first reducer is the agent roster: the set of live agents per +//! terminal that agents views render. Every write path is a journal event +//! (hook helpers append `agent.*` events; socket `agent report` appends an +//! echo event after its direct projection commit), so the roster never has +//! a second writer to diverge from. + +use std::cmp::Ordering; +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::agent_hooks::AGENT_HOOK_PRODUCER_ID; +use crate::workspace_registry::SessionJournalRecord; +use crate::{AgentSource, AgentState, JournalSubject}; + +pub(crate) const AGENT_ROSTER_REDUCER_ID: &str = "agent_roster"; +/// Bump to discard persisted snapshots and re-fold from the journal head. +/// Version 2 added the agent adapter id to roster entries. Version 3 +/// added screen-detected events and hook/screen/socket arbitration. Version 5 +/// adds durable plugin-exit fences so late observations cannot resurrect rows. +pub(crate) const AGENT_ROSTER_REDUCER_VERSION: u32 = 5; +/// Stable envelope used by userland agent plugins. The producer id is the +/// plugin identity; the payload id must match it before the event is folded. +pub(crate) const AGENT_PLUGIN_FORMAT: &str = "cmux.agent-plugin.v1"; +/// Legacy native event retained so journals written by the old in-core +/// detector can still be replayed after the detector moves to userland. New +/// detector processes must use the generic plugin event envelope instead. +pub(crate) const LEGACY_SCREEN_DETECT_NATIVE_EVENT: &str = "ScreenDetect"; + +/// A hook-owned roster entry younger than this cannot be overwritten by a +/// screen-detected state: live hooks are stronger evidence than screen +/// scraping. An agent whose hooks stopped reporting for this long (dead +/// helper, uninstalled hooks) falls back to screen detection. +pub(crate) const STALE_HOOK_MS: u64 = 30_000; + +/// The adapter id and native event the socket report path uses for its echo +/// journal events. The echo carries the explicit state in `normalized`, so +/// the fold never has to guess a semantic mapping for it. +pub(crate) const SOCKET_REPORT_ADAPTER: &str = "socket"; +pub(crate) const SOCKET_REPORT_NATIVE_EVENT: &str = "StateReport"; + +fn agent_state_from_str(value: &str) -> Option { + Some(match value { + "working" => AgentState::Working, + "blocked" => AgentState::Blocked, + "idle" => AgentState::Idle, + "done" => AgentState::Done, + "unknown" => AgentState::Unknown, + _ => return None, + }) +} + +fn agent_source_from_str(value: &str) -> Option { + Some(match value { + "plugin" => AgentSource::Plugin, + "detected" => AgentSource::Detected, + "socket" => AgentSource::Socket, + "hook" => AgentSource::Hook, + _ => return None, + }) +} + +/// The lifecycle state a hook journal kind implies, or `None` for kinds +/// that carry no top-level transition (child agents, unclassified changes). +fn state_for_hook_kind(kind: &str) -> Option { + Some(match kind { + // A freshly started session sits at its prompt; a completed turn + // returns to it. + "agent.session.started" | "agent.turn.completed" => AgentState::Idle, + "agent.turn.started" => AgentState::Working, + "agent.approval.requested" + | "agent.question.requested" + | "agent.plan_review.requested" + | "agent.error.reported" => AgentState::Blocked, + "agent.session.ended" => AgentState::Done, + _ => return None, + }) +} + +/// One journal record reduced to the fields the roster fold reads. Built +/// from a live `JournalIngress` at commit time and from stored +/// `SessionJournalRecord`s during tail replay, with identical semantics so +/// both paths fold to the same state. +pub(crate) struct RosterEvent<'a> { + pub(crate) producer_id: &'a str, + pub(crate) kind: &'a str, + pub(crate) subjects: &'a [JournalSubject], + pub(crate) payload: &'a Value, + pub(crate) committed_at_ms: u64, +} + +impl<'a> RosterEvent<'a> { + pub(crate) fn from_record(record: &'a SessionJournalRecord) -> Self { + Self { + producer_id: &record.producer.id, + kind: &record.kind, + subjects: &record.subjects, + payload: &record.payload, + committed_at_ms: record.committed_at_ms, + } + } + + fn terminal_id(&self) -> Option<&str> { + self.subjects + .iter() + .find(|subject| subject.kind == "terminal") + .map(|subject| subject.id.as_str()) + } + + fn adapter_id(&self) -> Option<&str> { + self.payload.get("adapter")?.get("id")?.as_str() + } + + fn native_event(&self) -> Option<&str> { + self.payload.get("native_event")?.as_str() + } + + fn normalized(&self, field: &str) -> Option<&str> { + self.payload.get("normalized")?.get(field)?.as_str() + } + + fn normalized_u64(&self, field: &str) -> Option { + let value = self.payload.get("normalized")?.get(field)?; + value.as_str().and_then(|value| value.parse::().ok()).or_else(|| value.as_u64()) + } + + /// A plugin can report when it observed a terminal, but it cannot make + /// that evidence newer than the host commit that admitted it. Keeping + /// older timestamps preserves the delayed-append fence, while clamping a + /// future timestamp prevents a buggy or hostile plugin from outranking a + /// live hook indefinitely. + fn plugin_observed_at_ms(&self) -> Option { + self.normalized_u64("observed_at_ms").map(|observed| observed.min(self.committed_at_ms)) + } + + fn plugin_event(&self) -> bool { + if self.producer_id == AGENT_HOOK_PRODUCER_ID + || !valid_component(self.producer_id) + || self.payload.get("format").and_then(Value::as_str) != Some(AGENT_PLUGIN_FORMAT) + { + return false; + } + let Some(plugin) = self.payload.get("plugin") else { return false }; + let Some(plugin_id) = plugin.get("id").and_then(Value::as_str) else { return false }; + let Some(plugin_version) = plugin.get("version").and_then(Value::as_u64) else { + return false; + }; + if plugin_id != self.producer_id + || plugin_version == 0 + || plugin_version > u64::from(u32::MAX) + { + return false; + } + let Some(adapter) = self.payload.get("adapter") else { return false }; + let Some(adapter_id) = adapter.get("id").and_then(Value::as_str) else { return false }; + let Some(adapter_version) = adapter.get("version").and_then(Value::as_u64) else { + return false; + }; + if !valid_component(adapter_id) || adapter_version == 0 { + return false; + } + let Some(event_name) = self.payload.get("event").and_then(Value::as_str) else { + return false; + }; + if event_name != "state.changed" && event_name != "session.ended" { + return false; + } + let expected_kind = format!("plugin.{}.agent.{}", self.producer_id, event_name); + if self.kind != expected_kind { + return false; + } + let Some(normalized) = self.payload.get("normalized") else { return false }; + let Some(state) = normalized.get("state").and_then(Value::as_str) else { return false }; + if agent_state_from_str(state).is_none() { + return false; + } + let Some(source_session) = normalized.get("source_session").and_then(Value::as_str) else { + return false; + }; + if source_session.is_empty() || source_session.len() > 256 || source_session.contains('\0') + { + return false; + } + let Some(observed_at_ms) = normalized.get("observed_at_ms") else { return false }; + if decimal_u64(observed_at_ms).is_none() { + return false; + } + if let Some(generation) = normalized.get("plugin_generation") + && decimal_string_u64(generation).is_none() + { + return false; + } + true + } + + fn plugin_event_name(&self) -> Option<&str> { + if !self.plugin_event() { + return None; + } + self.payload.get("event")?.as_str() + } +} + +fn valid_component(value: &str) -> bool { + !value.is_empty() + && value.len() <= 64 + && value.bytes().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_' || byte == b'-' + }) + && value.as_bytes().first().is_some_and(|byte| byte.is_ascii_alphanumeric()) +} + +fn decimal_u64(value: &Value) -> Option { + value + .as_str() + .and_then(|text| { + (!text.is_empty() && text.bytes().all(|byte| byte.is_ascii_digit())).then_some(text) + }) + .and_then(|text| text.parse::().ok()) + .or_else(|| value.as_u64()) +} + +/// Generation tags are part of the public plugin envelope and must stay +/// strings. Keeping one wire type avoids a number/string fork in replay and +/// prevents a producer from changing the identity representation between +/// emissions. +fn decimal_string_u64(value: &Value) -> Option { + let text = value.as_str()?; + (!text.is_empty() && text.bytes().all(|byte| byte.is_ascii_digit())) + .then_some(text) + .and_then(|text| text.parse::().ok()) +} + +/// Compare evidence owned by the same userland producer. A supervisor +/// generation is a restart fence, so a newer generation wins even when its +/// wall-clock observation is older than the previous process' last report. +fn plugin_event_order( + existing: &RosterEntry, + producer: &str, + generation: Option<&str>, + updated_at_ms: u64, +) -> Ordering { + if existing.producer.as_deref() != Some(producer) { + return updated_at_ms.cmp(&existing.updated_at_ms); + } + match ( + generation.and_then(|value| value.parse::().ok()), + existing.producer_generation.as_deref().and_then(|value| value.parse::().ok()), + ) { + (Some(incoming), Some(current)) => match incoming.cmp(¤t) { + Ordering::Equal => updated_at_ms.cmp(&existing.updated_at_ms), + ordering => ordering, + }, + (Some(_), None) => Ordering::Greater, + (None, Some(_)) => Ordering::Less, + (None, None) => updated_at_ms.cmp(&existing.updated_at_ms), + } +} + +fn source_rank(source: AgentSource) -> u8 { + match source { + AgentSource::Socket => 0, + AgentSource::Detected => 1, + AgentSource::Plugin => 2, + AgentSource::Hook => 3, + } +} + +/// Return true only when an incoming timestamp is current or newer. Using a +/// signed comparison avoids `saturating_sub`, which treats an older event as +/// fresh after clock skew or journal replay. +fn timestamp_is_current(existing: u64, incoming: u64) -> bool { + incoming >= existing +} + +fn fresh_hook(existing: u64, incoming: u64) -> bool { + timestamp_is_current(existing, incoming) && incoming - existing < STALE_HOOK_MS +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct RosterEntry { + pub(crate) state: String, + pub(crate) source: String, + /// Producer identity for plugin-owned entries. This prevents one + /// plugin's exit event from removing another plugin's observation. + #[serde(default)] + pub(crate) producer: Option, + /// Supervisor child generation, when the plugin supplied it. This is a + /// stronger restart fence than wall-clock timestamps. + #[serde(default)] + pub(crate) producer_generation: Option, + pub(crate) session: Option, + /// The reporting adapter id (`claude`, `codex`, ...). Direct socket + /// reports do not know the agent behind the terminal, so it is absent + /// there until a hook event claims the terminal. + #[serde(default)] + pub(crate) agent: Option, + pub(crate) updated_at_ms: u64, +} + +/// Restart fences retained after a plugin child exits. A tagged observation +/// proves its process generation. An untagged observation cannot prove that a +/// replacement exists, so it stays fenced after the first untagged exit. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +struct PluginExitFence { + #[serde(default)] + highest_tagged_generation: Option, + #[serde(default)] + untagged_exit_at_ms: Option, +} + +impl RosterEntry { + pub(crate) fn agent_state(&self) -> AgentState { + agent_state_from_str(&self.state).unwrap_or(AgentState::Unknown) + } + + pub(crate) fn agent_source(&self) -> AgentSource { + agent_source_from_str(&self.source).unwrap_or(AgentSource::Hook) + } +} + +/// A roster change produced by folding one record. The host applies these +/// as side effects (projection commits, change broadcasts); the fold itself +/// only mutates roster state. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum RosterDelta { + Upsert { terminal_id: String, entry: RosterEntry }, + Remove { terminal_id: String, source: AgentSource }, +} + +/// Live-agent roster: terminal public id to the agent's last reported +/// lifecycle state. An ended session leaves the roster (history stays in +/// the journal and the durable agent projection); a hook-owned entry +/// ignores socket reports so a slow poller cannot overwrite live hook +/// state. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub(crate) struct AgentRoster { + pub(crate) entries: HashMap, + /// Durable producer fences. The map is keyed by validated plugin id and + /// grows only when a configured producer is observed by the journal. + #[serde(default)] + plugin_exit_fences: HashMap, +} + +impl AgentRoster { + /// Fold one committed record. Deterministic: identical event sequences + /// produce identical rosters, so a snapshot plus the journal tail always + /// reproduces the live state. + pub(crate) fn apply(&mut self, event: &RosterEvent<'_>) -> Vec { + if event.producer_id != AGENT_HOOK_PRODUCER_ID && !event.plugin_event() { + return Vec::new(); + } + if event.producer_id == AGENT_HOOK_PRODUCER_ID + && event.native_event() == Some(crate::agent_hooks::JOURNAL_PLUGIN_EXIT_NATIVE_EVENT) + { + let Some(plugin_id) = event.normalized("plugin_id") else { return Vec::new() }; + if !valid_component(plugin_id) { + return Vec::new(); + } + let cutoff = event + .normalized_u64("observed_at_ms") + .unwrap_or(event.committed_at_ms) + .min(event.committed_at_ms); + let generation = event.normalized("plugin_generation"); + let generation_number = generation.and_then(|value| value.parse::().ok()); + if generation.is_some() && generation_number.is_none() { + return Vec::new(); + } + self.record_plugin_exit_fence(plugin_id, generation_number, cutoff); + let retired = self + .entries + .iter() + .filter(|(_, entry)| { + entry.agent_source() == AgentSource::Plugin + && entry.producer.as_deref() == Some(plugin_id) + && match generation_number { + // A tagged generation is a unique child identity, + // so remove every row from it even if its wall + // clock is ahead of the supervisor's cutoff. + Some(generation) => { + entry + .producer_generation + .as_deref() + .and_then(|value| value.parse::().ok()) + == Some(generation) + } + // An untagged exit can only retire an untagged + // row. It must never clear a replacement child + // whose generation is known. + None => entry.producer_generation.is_none(), + } + && (generation_number.is_some() || entry.updated_at_ms <= cutoff) + }) + .map(|(terminal_id, _)| terminal_id.clone()) + .collect::>(); + return retired + .into_iter() + .filter_map(|terminal_id| { + self.entries.remove(&terminal_id)?; + Some(RosterDelta::Remove { terminal_id, source: AgentSource::Plugin }) + }) + .collect(); + } + if event.plugin_event() + && self.plugin_observation_is_fenced( + event.producer_id, + event.normalized("plugin_generation"), + ) + { + return Vec::new(); + } + let Some(terminal_id) = event.terminal_id() else { return Vec::new() }; + let (state, source, producer, producer_generation, session, agent, updated_at_ms) = + if event.plugin_event() { + let Some(event_name) = event.plugin_event_name() else { return Vec::new() }; + let Some(state) = event.normalized("state").and_then(agent_state_from_str) else { + return Vec::new(); + }; + if event_name != "state.changed" && event_name != "session.ended" { + return Vec::new(); + } + let updated_at_ms = event.plugin_observed_at_ms().unwrap_or(event.committed_at_ms); + let producer_generation = event + .normalized("plugin_generation") + .and_then(|value| value.parse::().ok()) + .map(|value| value.to_string()); + let session = event.normalized("source_session").map(str::to_string); + let agent = event.adapter_id().map(str::to_string); + ( + if event_name == "session.ended" { AgentState::Done } else { state }, + AgentSource::Plugin, + Some(event.producer_id.to_string()), + producer_generation, + session, + agent, + updated_at_ms, + ) + } else if event.adapter_id() == Some(SOCKET_REPORT_ADAPTER) { + // Socket echo: explicit state and timestamp carried in the + // payload, so the roster mirrors the direct projection + // commit exactly. The reporter does not know the agent type. + let Some(state) = event.normalized("state").and_then(agent_state_from_str) else { + return Vec::new(); + }; + let source = event + .normalized("source") + .and_then(agent_source_from_str) + .unwrap_or(AgentSource::Socket); + let updated_at_ms = + event.normalized_u64("updated_at_ms").unwrap_or(event.committed_at_ms); + let session = event.normalized("source_session").map(str::to_string); + (state, source, None, None, session, None, updated_at_ms) + } else if event.native_event() == Some(LEGACY_SCREEN_DETECT_NATIVE_EVENT) { + // Screen detection: the daemon parsed the terminal tail. + // Explicit state like the socket echo, but the adapter is + // the detected agent and the source is `detected`. + let Some(state) = event.normalized("state").and_then(agent_state_from_str) else { + return Vec::new(); + }; + let agent = event.adapter_id().map(str::to_string); + (state, AgentSource::Detected, None, None, None, agent, event.committed_at_ms) + } else { + let Some(state) = state_for_hook_kind(event.kind) else { return Vec::new() }; + let agent = event.adapter_id().map(str::to_string); + (state, AgentSource::Hook, None, None, None, agent, event.committed_at_ms) + }; + // Source arbitration: hook > screen > socket per terminal. Hook + // events always win. Screen detection may not overwrite an entry a + // live hook owns (fresher than STALE_HOOK_MS), and its exit removal + // only applies to entries screen detection itself established. + // Socket reports lose to both stronger sources. + match source { + AgentSource::Hook => { + if let Some(existing) = self.entries.get(terminal_id) + && existing.agent_source() == AgentSource::Hook + && !timestamp_is_current(existing.updated_at_ms, updated_at_ms) + { + return Vec::new(); + } + } + AgentSource::Plugin => { + if let Some(existing) = self.entries.get(terminal_id) { + let existing_source = existing.agent_source(); + if existing_source == AgentSource::Hook { + // The producer observation is the evidence clock. A + // delayed append must not turn an old screen read + // into fresh evidence just because the journal + // accepted it later. The journal commit time orders + // transport, while `observed_at_ms` orders what the + // plugin actually saw. + if fresh_hook(existing.updated_at_ms, updated_at_ms) { + return Vec::new(); + } + // An older plugin observation cannot reclaim a hook + // row merely because the hook is stale. The next + // current observation can do so. + if updated_at_ms < existing.updated_at_ms { + return Vec::new(); + } + } + if source_rank(existing_source) == source_rank(source) + && plugin_event_order( + existing, + producer.as_deref().unwrap_or_default(), + producer_generation.as_deref(), + updated_at_ms, + ) == Ordering::Less + { + return Vec::new(); + } + } + } + AgentSource::Detected => { + if let Some(existing) = self.entries.get(terminal_id) { + let existing_source = existing.agent_source(); + // A stale hook may be reclaimed by screen evidence. Keep + // the generic precedence fence for plugin observations, + // which are stronger than this legacy detected source, + // while preserving the documented hook staleness rule. + if existing_source != AgentSource::Hook + && source_rank(existing_source) > source_rank(source) + { + return Vec::new(); + } + if existing_source == AgentSource::Hook + && fresh_hook(existing.updated_at_ms, updated_at_ms) + { + return Vec::new(); + } + if existing_source == AgentSource::Hook + && updated_at_ms < existing.updated_at_ms + { + return Vec::new(); + } + if existing_source == AgentSource::Detected + && !timestamp_is_current(existing.updated_at_ms, updated_at_ms) + { + return Vec::new(); + } + if state == AgentState::Done && existing_source != AgentSource::Detected { + return Vec::new(); + } + } + } + AgentSource::Socket => { + if let Some(existing) = self.entries.get(terminal_id) + && (existing.agent_source() != AgentSource::Socket + || !timestamp_is_current(existing.updated_at_ms, updated_at_ms)) + { + return Vec::new(); + } + } + } + if state == AgentState::Done { + // An ended agent leaves the roster entirely; the done state is + // still committed to the durable projection by the host so + // history and remote caches converge. + let owned_by_event = self.entries.get(terminal_id).is_some_and(|entry| { + entry.agent_source() == source + && (source != AgentSource::Plugin + || (entry.producer.as_deref() == producer.as_deref() + && match ( + entry.producer_generation.as_deref(), + producer_generation.as_deref(), + ) { + (Some(existing), Some(incoming)) => { + if existing == incoming { + true + } else { + incoming + .parse::() + .ok() + .zip(existing.parse::().ok()) + .is_some_and(|(incoming, existing)| incoming > existing) + } + } + (None, None) => true, + _ => false, + })) + }); + return if owned_by_event { + self.entries.remove(terminal_id); + vec![RosterDelta::Remove { terminal_id: terminal_id.to_string(), source }] + } else { + Vec::new() + }; + } + let entry = RosterEntry { + state: state.as_str().to_string(), + source: source.as_str().to_string(), + producer, + producer_generation, + session, + // A socket entry keeps any agent identity a hook already + // established for this terminal. + agent: agent + .or_else(|| self.entries.get(terminal_id).and_then(|entry| entry.agent.clone())), + updated_at_ms, + }; + if self.entries.get(terminal_id) == Some(&entry) { + return Vec::new(); + } + self.entries.insert(terminal_id.to_string(), entry.clone()); + vec![RosterDelta::Upsert { terminal_id: terminal_id.to_string(), entry }] + } + + /// Drop a terminal that left the session (tab closed, terminal + /// tombstoned). Terminal lifecycle does not flow through `agent.*` + /// events yet, so the host retires entries explicitly. + pub(crate) fn retire_terminal(&mut self, terminal_id: &str) -> bool { + self.entries.remove(terminal_id).is_some() + } + + fn record_plugin_exit_fence( + &mut self, + plugin_id: &str, + generation: Option, + cutoff_ms: u64, + ) { + let fence = self.plugin_exit_fences.entry(plugin_id.to_string()).or_default(); + match generation { + Some(generation) => { + if fence.highest_tagged_generation.is_none_or(|current| generation > current) { + fence.highest_tagged_generation = Some(generation); + } + } + None => { + if fence.highest_tagged_generation.is_none() { + fence.untagged_exit_at_ms = + Some(fence.untagged_exit_at_ms.unwrap_or_default().max(cutoff_ms)); + } + } + } + } + + fn plugin_observation_is_fenced(&self, plugin_id: &str, generation: Option<&str>) -> bool { + let Some(fence) = self.plugin_exit_fences.get(plugin_id) else { return false }; + match generation.and_then(|value| value.parse::().ok()) { + Some(generation) => { + fence.highest_tagged_generation.is_some_and(|highest| generation <= highest) + } + // An untagged event cannot prove that it belongs to a child that + // started after any observed exit. Once a tagged child has also + // exited, reject it for the same reason. + None => { + fence.highest_tagged_generation.is_some() || fence.untagged_exit_at_ms.is_some() + } + } + } + + pub(crate) fn snapshot(&self) -> Value { + serde_json::to_value(self).unwrap_or(Value::Null) + } + + pub(crate) fn restore(snapshot: &str) -> Option { + let roster = serde_json::from_str::(snapshot).ok()?; + if !roster.entries.values().all(valid_restored_entry) { + return None; + } + if !roster.plugin_exit_fences.iter().all(|(plugin_id, fence)| { + plugin_id != AGENT_HOOK_PRODUCER_ID + && valid_component(plugin_id) + && fence.highest_tagged_generation.is_none_or(|generation| generation > 0) + }) { + return None; + } + Some(roster) + } +} + +fn valid_restored_entry(entry: &RosterEntry) -> bool { + let Some(state) = agent_state_from_str(&entry.state) else { return false }; + let Some(source) = agent_source_from_str(&entry.source) else { return false }; + if state == AgentState::Done { + return false; + } + if entry + .session + .as_deref() + .is_some_and(|session| session.is_empty() || session.len() > 256 || session.contains('\0')) + { + return false; + } + if entry.agent.as_deref().is_some_and(|agent| !valid_component(agent)) { + return false; + } + match source { + AgentSource::Plugin => { + let Some(producer) = entry.producer.as_deref() else { return false }; + if producer == AGENT_HOOK_PRODUCER_ID || !valid_component(producer) { + return false; + } + entry.producer_generation.as_deref().is_none_or(valid_decimal_generation) + } + AgentSource::Detected | AgentSource::Socket | AgentSource::Hook => { + entry.producer.is_none() && entry.producer_generation.is_none() + } + } +} + +fn valid_decimal_generation(value: &str) -> bool { + !value.is_empty() + && value.bytes().all(|byte| byte.is_ascii_digit()) + && value.parse::().is_ok() +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn hook_event<'a>( + sequence: u64, + kind: &'a str, + subjects: &'a [JournalSubject], + payload: &'a Value, + ) -> RosterEvent<'a> { + RosterEvent { + producer_id: AGENT_HOOK_PRODUCER_ID, + kind, + subjects, + payload, + committed_at_ms: 1_000 + sequence, + } + } + + fn terminal_subject(id: &str) -> Vec { + vec![JournalSubject { kind: "terminal".into(), id: id.into() }] + } + + #[test] + fn lifecycle_kinds_fold_into_roster_states() { + let subjects = terminal_subject("term_a"); + let payload = json!({}); + let mut roster = AgentRoster::default(); + + roster.apply(&hook_event(1, "agent.session.started", &subjects, &payload)); + assert_eq!(roster.entries["term_a"].state, "idle"); + assert_eq!(roster.entries["term_a"].agent, None, "payload without adapter has no agent"); + + roster.apply(&hook_event(2, "agent.turn.started", &subjects, &payload)); + assert_eq!(roster.entries["term_a"].state, "working"); + + roster.apply(&hook_event(3, "agent.approval.requested", &subjects, &payload)); + assert_eq!(roster.entries["term_a"].state, "blocked"); + + // Child events carry no top-level transition. + let deltas = roster.apply(&hook_event(4, "agent.child.spawned", &subjects, &payload)); + assert!(deltas.is_empty()); + assert_eq!(roster.entries["term_a"].state, "blocked"); + + let deltas = roster.apply(&hook_event(5, "agent.session.ended", &subjects, &payload)); + assert_eq!( + deltas, + vec![RosterDelta::Remove { terminal_id: "term_a".into(), source: AgentSource::Hook }] + ); + assert!(roster.entries.is_empty()); + } + + #[test] + fn socket_echo_carries_explicit_state_and_loses_to_hook_entries() { + let subjects = terminal_subject("term_a"); + let socket_payload = json!({ + "adapter": {"id": SOCKET_REPORT_ADAPTER, "version": 1}, + "normalized": {"state": "working", "source": "socket", "source_session": "probe"}, + }); + let mut roster = AgentRoster::default(); + + roster.apply(&hook_event(1, "agent.state.changed", &subjects, &socket_payload)); + let entry = &roster.entries["term_a"]; + assert_eq!(entry.state, "working"); + assert_eq!(entry.source, "socket"); + assert_eq!(entry.session.as_deref(), Some("probe")); + assert_eq!(entry.agent, None); + + // A hook event takes the terminal over and names the agent... + let hook_payload = json!({"adapter": {"id": "claude", "version": 1}}); + roster.apply(&hook_event(2, "agent.turn.started", &subjects, &hook_payload)); + assert_eq!(roster.entries["term_a"].source, "hook"); + assert_eq!(roster.entries["term_a"].agent.as_deref(), Some("claude")); + + // ...and later socket reports cannot downgrade it. + let deltas = + roster.apply(&hook_event(3, "agent.state.changed", &subjects, &socket_payload)); + assert!(deltas.is_empty()); + assert_eq!(roster.entries["term_a"].source, "hook"); + } + + #[test] + fn folds_are_idempotent_per_state_and_deterministic_across_replays() { + let subjects = terminal_subject("term_a"); + let payload = json!({}); + let events = [ + "agent.session.started", + "agent.turn.started", + "agent.turn.started", + "agent.turn.completed", + ]; + + let mut live = AgentRoster::default(); + let mut delta_count = 0usize; + for (index, kind) in events.iter().enumerate() { + delta_count += + live.apply(&hook_event(index as u64 + 1, kind, &subjects, &payload)).len(); + } + // A same-state re-report still refreshes recency (chronological + // views sort on it), so every event here produces a delta. + assert_eq!(delta_count, 4); + + let mut replayed = AgentRoster::default(); + for (index, kind) in events.iter().enumerate() { + replayed.apply(&hook_event(index as u64 + 1, kind, &subjects, &payload)); + } + assert_eq!(serde_json::to_value(&live).unwrap(), serde_json::to_value(&replayed).unwrap()); + } + + fn stamped_event<'a>( + committed_at_ms: u64, + kind: &'a str, + subjects: &'a [JournalSubject], + payload: &'a Value, + ) -> RosterEvent<'a> { + RosterEvent { + producer_id: AGENT_HOOK_PRODUCER_ID, + kind, + subjects, + payload, + committed_at_ms, + } + } + + fn screen_payload(agent: &str, state: &str) -> Value { + json!({ + "format": "cmux.agent-hook.v1", + "adapter": {"id": agent, "version": 1}, + "native_event": "ScreenDetect", + "normalized": {"state": state}, + "native": {}, + }) + } + + #[test] + fn screen_detect_events_fold_with_detected_source_and_adapter_agent() { + let subjects = terminal_subject("term_a"); + let payload = screen_payload("codex", "working"); + let mut roster = AgentRoster::default(); + + let deltas = + roster.apply(&stamped_event(5_000, "agent.state.changed", &subjects, &payload)); + assert_eq!(deltas.len(), 1); + let entry = &roster.entries["term_a"]; + assert_eq!(entry.state, "working"); + assert_eq!(entry.source, "detected"); + assert_eq!(entry.agent.as_deref(), Some("codex")); + assert_eq!(entry.updated_at_ms, 5_000); + } + + #[test] + fn screen_detect_loses_to_fresh_hooks_and_claims_stale_ones() { + let subjects = terminal_subject("term_a"); + let hook_payload = json!({"adapter": {"id": "claude", "version": 1}}); + let screen = screen_payload("claude", "blocked"); + let mut roster = AgentRoster::default(); + + roster.apply(&stamped_event(10_000, "agent.turn.started", &subjects, &hook_payload)); + // A fresh hook entry (29s old) is live agent truth. + let deltas = + roster.apply(&stamped_event(39_000, "agent.state.changed", &subjects, &screen)); + assert!(deltas.is_empty()); + assert_eq!(roster.entries["term_a"].source, "hook"); + + // At 30s the hook is stale and screen detection takes over. + let deltas = + roster.apply(&stamped_event(40_000, "agent.state.changed", &subjects, &screen)); + assert_eq!(deltas.len(), 1); + assert_eq!(roster.entries["term_a"].source, "detected"); + assert_eq!(roster.entries["term_a"].state, "blocked"); + + // A hook event always reclaims the terminal. + let deltas = + roster.apply(&stamped_event(41_000, "agent.turn.started", &subjects, &hook_payload)); + assert_eq!(deltas.len(), 1); + assert_eq!(roster.entries["term_a"].source, "hook"); + } + + #[test] + fn legacy_detected_source_reclaims_a_stale_hook_at_the_boundary() { + let subjects = terminal_subject("term_a"); + let hook_payload = json!({"adapter": {"id": "claude", "version": 1}}); + let screen = screen_payload("claude", "idle"); + let mut roster = AgentRoster::default(); + + roster.apply(&stamped_event(10_000, "agent.turn.started", &subjects, &hook_payload)); + let deltas = roster.apply(&stamped_event( + 10_000 + STALE_HOOK_MS, + "agent.state.changed", + &subjects, + &screen, + )); + + assert_eq!(deltas.len(), 1); + assert_eq!(roster.entries["term_a"].source, "detected"); + assert_eq!(roster.entries["term_a"].state, "idle"); + } + + #[test] + fn screen_detect_beats_socket_reports_in_both_directions() { + let subjects = terminal_subject("term_a"); + let socket_payload = json!({ + "adapter": {"id": SOCKET_REPORT_ADAPTER, "version": 1}, + "normalized": {"state": "idle", "source": "socket"}, + }); + let screen = screen_payload("codex", "working"); + let mut roster = AgentRoster::default(); + + // Screen detection overwrites a socket-owned entry... + roster.apply(&stamped_event(1_000, "agent.state.changed", &subjects, &socket_payload)); + let deltas = roster.apply(&stamped_event(2_000, "agent.state.changed", &subjects, &screen)); + assert_eq!(deltas.len(), 1); + assert_eq!(roster.entries["term_a"].source, "detected"); + + // ...and a later socket report cannot downgrade it. + let deltas = + roster.apply(&stamped_event(3_000, "agent.state.changed", &subjects, &socket_payload)); + assert!(deltas.is_empty()); + assert_eq!(roster.entries["term_a"].source, "detected"); + assert_eq!(roster.entries["term_a"].state, "working"); + } + + #[test] + fn screen_detect_exit_removes_only_detected_entries() { + let subjects = terminal_subject("term_a"); + let done = screen_payload("codex", "done"); + let mut roster = AgentRoster::default(); + + // Detected entry: the agent process left the pane -> removal. + roster.apply(&stamped_event( + 1_000, + "agent.state.changed", + &subjects, + &screen_payload("codex", "working"), + )); + let deltas = roster.apply(&stamped_event(2_000, "agent.session.ended", &subjects, &done)); + assert_eq!( + deltas, + vec![RosterDelta::Remove { + terminal_id: "term_a".into(), + source: AgentSource::Detected, + }] + ); + assert!(roster.entries.is_empty()); + + // A fresh hook entry is never removed by a screen exit. + let hook_payload = json!({"adapter": {"id": "claude", "version": 1}}); + roster.apply(&stamped_event(10_000, "agent.turn.started", &subjects, &hook_payload)); + let deltas = roster.apply(&stamped_event(11_000, "agent.session.ended", &subjects, &done)); + assert!(deltas.is_empty()); + assert_eq!(roster.entries["term_a"].source, "hook"); + + // A socket entry is not removed by a screen exit either. + let mut socket_roster = AgentRoster::default(); + let socket_payload = json!({ + "adapter": {"id": SOCKET_REPORT_ADAPTER, "version": 1}, + "normalized": {"state": "idle", "source": "socket"}, + }); + socket_roster.apply(&stamped_event( + 1_000, + "agent.state.changed", + &subjects, + &socket_payload, + )); + let deltas = + socket_roster.apply(&stamped_event(2_000, "agent.session.ended", &subjects, &done)); + assert!(deltas.is_empty()); + assert_eq!(socket_roster.entries["term_a"].source, "socket"); + } + + #[test] + fn userland_plugin_events_fold_without_core_vendor_knowledge() { + let subjects = terminal_subject("term_a"); + let working = json!({ + "format": AGENT_PLUGIN_FORMAT, + "plugin": {"id":"screen_detector","version":1}, + "adapter": {"id":"codex","version":1}, + "event":"state.changed", + "normalized": { + "state":"working", + "source_session":"pid:42", + "observed_at_ms":"1000" + } + }); + let ended = json!({ + "format": AGENT_PLUGIN_FORMAT, + "plugin": {"id":"screen_detector","version":1}, + "adapter": {"id":"codex","version":1}, + "event":"session.ended", + "normalized": { + "state":"done", + "source_session":"pid:42", + "observed_at_ms":"2000" + } + }); + let mut roster = AgentRoster::default(); + let event = RosterEvent { + producer_id: "screen_detector", + kind: "plugin.screen_detector.agent.state.changed", + subjects: &subjects, + payload: &working, + committed_at_ms: 1000, + }; + let deltas = roster.apply(&event); + assert_eq!(deltas.len(), 1); + assert_eq!(roster.entries["term_a"].source, "plugin"); + assert_eq!(roster.entries["term_a"].agent.as_deref(), Some("codex")); + + let event = RosterEvent { + producer_id: "screen_detector", + kind: "plugin.screen_detector.agent.session.ended", + subjects: &subjects, + payload: &ended, + committed_at_ms: 2000, + }; + assert_eq!( + roster.apply(&event), + vec![RosterDelta::Remove { terminal_id: "term_a".into(), source: AgentSource::Plugin }] + ); + assert!(roster.entries.is_empty()); + } + + #[test] + fn fresh_hook_wins_over_plugin_and_stale_hook_can_be_replaced() { + let subjects = terminal_subject("term_a"); + let hook_payload = json!({"adapter":{"id":"claude","version":1}}); + let plugin_payload = |observed_at_ms: u64| { + json!({ + "format": AGENT_PLUGIN_FORMAT, + "plugin": {"id":"screen_detector","version":1}, + "adapter": {"id":"claude","version":1}, + "event":"state.changed", + "normalized":{ + "state":"blocked", + "source_session":"pid:42", + "observed_at_ms":observed_at_ms.to_string() + } + }) + }; + let mut roster = AgentRoster::default(); + roster.apply(&stamped_event(10_000, "agent.turn.started", &subjects, &hook_payload)); + let first_plugin_payload = plugin_payload(39_000); + let plugin = RosterEvent { + producer_id: "screen_detector", + kind: "plugin.screen_detector.agent.state.changed", + subjects: &subjects, + payload: &first_plugin_payload, + committed_at_ms: 39_000, + }; + assert!(roster.apply(&plugin).is_empty()); + assert_eq!(roster.entries["term_a"].source, "hook"); + let plugin_payload = plugin_payload(40_000); + let plugin = RosterEvent { payload: &plugin_payload, committed_at_ms: 40_000, ..plugin }; + assert_eq!(roster.apply(&plugin).len(), 1); + assert_eq!(roster.entries["term_a"].source, "plugin"); + } + + #[test] + fn an_older_plugin_observation_cannot_reclaim_a_hook_row() { + let subjects = terminal_subject("term_a"); + let hook_payload = json!({"adapter":{"id":"claude","version":1}}); + let plugin_payload = json!({ + "format": AGENT_PLUGIN_FORMAT, + "plugin": {"id":"screen_detector","version":1}, + "adapter": {"id":"claude","version":1}, + "event":"state.changed", + "normalized":{"state":"blocked","source_session":"pid:42","observed_at_ms":"9000"} + }); + let mut roster = AgentRoster::default(); + roster.apply(&stamped_event(10_000, "agent.turn.started", &subjects, &hook_payload)); + let plugin = RosterEvent { + producer_id: "screen_detector", + kind: "plugin.screen_detector.agent.state.changed", + subjects: &subjects, + payload: &plugin_payload, + committed_at_ms: 50_000, + }; + assert!(roster.apply(&plugin).is_empty()); + assert_eq!(roster.entries["term_a"].source, "hook"); + } + + #[test] + fn future_plugin_observation_cannot_outdate_a_live_hook() { + let subjects = terminal_subject("term_a"); + let hook_payload = json!({"adapter":{"id":"claude","version":1}}); + let plugin_payload = json!({ + "format": AGENT_PLUGIN_FORMAT, + "plugin": {"id":"screen_detector","version":1}, + "adapter": {"id":"claude","version":1}, + "event":"state.changed", + "normalized": { + "state":"blocked", + "source_session":"pid:42", + "observed_at_ms":u64::MAX.to_string() + } + }); + let mut roster = AgentRoster::default(); + roster.apply(&stamped_event(10_000, "agent.turn.started", &subjects, &hook_payload)); + let plugin = RosterEvent { + producer_id: "screen_detector", + kind: "plugin.screen_detector.agent.state.changed", + subjects: &subjects, + payload: &plugin_payload, + committed_at_ms: 20_000, + }; + assert!(roster.apply(&plugin).is_empty()); + assert_eq!(roster.entries["term_a"].source, "hook"); + } + + #[test] + fn supervisor_exit_retires_only_old_entries_for_that_plugin() { + let subjects_a = terminal_subject("term_a"); + let subjects_b = terminal_subject("term_b"); + let payload = |session: &str, timestamp: u64| { + json!({ + "format": AGENT_PLUGIN_FORMAT, + "plugin": {"id":"screen_detector","version":1}, + "adapter": {"id":"codex","version":1}, + "event":"state.changed", + "normalized": { + "state":"working", + "source_session":session, + "observed_at_ms":timestamp.to_string() + } + }) + }; + let mut roster = AgentRoster::default(); + let first = payload("pid:1", 100); + let second = payload("pid:2", 300); + roster.apply(&RosterEvent { + producer_id: "screen_detector", + kind: "plugin.screen_detector.agent.state.changed", + subjects: &subjects_a, + payload: &first, + committed_at_ms: 100, + }); + roster.apply(&RosterEvent { + producer_id: "screen_detector", + kind: "plugin.screen_detector.agent.state.changed", + subjects: &subjects_b, + payload: &second, + committed_at_ms: 300, + }); + let exit = json!({ + "format": crate::agent_hooks::AGENT_HOOK_FORMAT, + "adapter":{"id":"cmux","version":1}, + "native_event": crate::agent_hooks::JOURNAL_PLUGIN_EXIT_NATIVE_EVENT, + "normalized":{"plugin_id":"screen_detector","observed_at_ms":"200"}, + "native":{} + }); + let deltas = roster.apply(&RosterEvent { + producer_id: AGENT_HOOK_PRODUCER_ID, + kind: "agent.plugin.exited", + subjects: &[], + payload: &exit, + committed_at_ms: 200, + }); + assert_eq!( + deltas, + vec![RosterDelta::Remove { terminal_id: "term_a".into(), source: AgentSource::Plugin }] + ); + assert!(!roster.entries.contains_key("term_a")); + assert!(roster.entries.contains_key("term_b")); + } + + #[test] + fn late_exit_from_an_old_plugin_generation_cannot_remove_replacement_rows() { + let subjects = terminal_subject("term_a"); + let event = |generation: &str, timestamp: u64| { + let payload = json!({ + "format": AGENT_PLUGIN_FORMAT, + "plugin": {"id":"screen_detector","version":1}, + "adapter": {"id":"codex","version":1}, + "event":"state.changed", + "normalized": { + "state":"working", + "source_session":"pid:42", + "plugin_generation":generation, + "observed_at_ms":timestamp.to_string() + } + }); + (payload, timestamp) + }; + let (old_payload, old_time) = event("1", 100); + let (new_payload, new_time) = event("2", 200); + let mut roster = AgentRoster::default(); + roster.apply(&RosterEvent { + producer_id: "screen_detector", + kind: "plugin.screen_detector.agent.state.changed", + subjects: &subjects, + payload: &old_payload, + committed_at_ms: old_time, + }); + roster.apply(&RosterEvent { + producer_id: "screen_detector", + kind: "plugin.screen_detector.agent.state.changed", + subjects: &subjects, + payload: &new_payload, + committed_at_ms: new_time, + }); + let exit = json!({ + "format": crate::agent_hooks::AGENT_HOOK_FORMAT, + "adapter":{"id":"cmux","version":1}, + "native_event": crate::agent_hooks::JOURNAL_PLUGIN_EXIT_NATIVE_EVENT, + "normalized": { + "plugin_id":"screen_detector", + "plugin_generation":"1", + "observed_at_ms":"300" + }, + "native":{} + }); + assert!( + roster + .apply(&RosterEvent { + producer_id: AGENT_HOOK_PRODUCER_ID, + kind: "agent.plugin.exited", + subjects: &[], + payload: &exit, + committed_at_ms: 300, + }) + .is_empty() + ); + assert_eq!(roster.entries["term_a"].producer_generation.as_deref(), Some("2")); + } + + #[test] + fn late_observation_from_an_exited_plugin_generation_cannot_recreate_a_row() { + let subjects = terminal_subject("term_a"); + let payload = |observed_at_ms: u64| { + json!({ + "format": AGENT_PLUGIN_FORMAT, + "plugin": {"id":"screen_detector","version":1}, + "adapter": {"id":"codex","version":1}, + "event":"state.changed", + "normalized": { + "state":"working", + "source_session":"pid:42", + "plugin_generation":"1", + "observed_at_ms": observed_at_ms.to_string() + } + }) + }; + let mut roster = AgentRoster::default(); + let first = payload(100); + roster.apply(&RosterEvent { + producer_id: "screen_detector", + kind: "plugin.screen_detector.agent.state.changed", + subjects: &subjects, + payload: &first, + committed_at_ms: 100, + }); + let exit = json!({ + "format": crate::agent_hooks::AGENT_HOOK_FORMAT, + "adapter":{"id":"cmux","version":1}, + "native_event": crate::agent_hooks::JOURNAL_PLUGIN_EXIT_NATIVE_EVENT, + "normalized": { + "plugin_id":"screen_detector", + "plugin_generation":"1", + "observed_at_ms":"200" + }, + "native":{} + }); + assert_eq!( + roster.apply(&RosterEvent { + producer_id: AGENT_HOOK_PRODUCER_ID, + kind: "agent.plugin.exited", + subjects: &[], + payload: &exit, + committed_at_ms: 200, + }), + vec![RosterDelta::Remove { terminal_id: "term_a".into(), source: AgentSource::Plugin }] + ); + let late = payload(300); + assert!( + roster + .apply(&RosterEvent { + producer_id: "screen_detector", + kind: "plugin.screen_detector.agent.state.changed", + subjects: &subjects, + payload: &late, + committed_at_ms: 300, + }) + .is_empty() + ); + assert!(!roster.entries.contains_key("term_a")); + } + + #[test] + fn newer_plugin_generation_can_claim_after_an_exit_fence() { + let subjects = terminal_subject("term_a"); + let payload = |generation: &str, observed_at_ms: u64| { + json!({ + "format": AGENT_PLUGIN_FORMAT, + "plugin": {"id":"screen_detector","version":1}, + "adapter": {"id":"codex","version":1}, + "event":"state.changed", + "normalized": { + "state":"working", + "source_session":"pid:42", + "plugin_generation":generation, + "observed_at_ms": observed_at_ms.to_string() + } + }) + }; + let exit = |generation: &str, observed_at_ms: u64| { + json!({ + "format": crate::agent_hooks::AGENT_HOOK_FORMAT, + "adapter":{"id":"cmux","version":1}, + "native_event": crate::agent_hooks::JOURNAL_PLUGIN_EXIT_NATIVE_EVENT, + "normalized": { + "plugin_id":"screen_detector", + "plugin_generation":generation, + "observed_at_ms": observed_at_ms.to_string() + }, + "native":{} + }) + }; + let mut roster = AgentRoster::default(); + let old = payload("1", 100); + roster.apply(&RosterEvent { + producer_id: "screen_detector", + kind: "plugin.screen_detector.agent.state.changed", + subjects: &subjects, + payload: &old, + committed_at_ms: 100, + }); + let old_exit = exit("1", 200); + roster.apply(&RosterEvent { + producer_id: AGENT_HOOK_PRODUCER_ID, + kind: "agent.plugin.exited", + subjects: &[], + payload: &old_exit, + committed_at_ms: 200, + }); + let replacement = payload("2", 150); + assert_eq!( + roster + .apply(&RosterEvent { + producer_id: "screen_detector", + kind: "plugin.screen_detector.agent.state.changed", + subjects: &subjects, + payload: &replacement, + committed_at_ms: 300, + }) + .len(), + 1 + ); + assert_eq!(roster.entries["term_a"].producer_generation.as_deref(), Some("2")); + } + + #[test] + fn untagged_plugin_exit_cannot_remove_a_tagged_child() { + let subjects = terminal_subject("term_a"); + let payload = json!({ + "format": AGENT_PLUGIN_FORMAT, + "plugin": {"id":"screen_detector","version":1}, + "adapter": {"id":"codex","version":1}, + "event":"state.changed", + "normalized": { + "state":"working", + "source_session":"pid:42", + "plugin_generation":"7", + "observed_at_ms":"100" + } + }); + let mut roster = AgentRoster::default(); + assert_eq!( + roster + .apply(&RosterEvent { + producer_id: "screen_detector", + kind: "plugin.screen_detector.agent.state.changed", + subjects: &subjects, + payload: &payload, + committed_at_ms: 100, + }) + .len(), + 1 + ); + + let exit = json!({ + "format": crate::agent_hooks::AGENT_HOOK_FORMAT, + "adapter":{"id":"cmux","version":1}, + "native_event": crate::agent_hooks::JOURNAL_PLUGIN_EXIT_NATIVE_EVENT, + "normalized":{"plugin_id":"screen_detector","observed_at_ms":"200"}, + "native":{} + }); + assert!( + roster + .apply(&RosterEvent { + producer_id: AGENT_HOOK_PRODUCER_ID, + kind: "agent.plugin.exited", + subjects: &[], + payload: &exit, + committed_at_ms: 200, + }) + .is_empty() + ); + assert!(roster.entries.contains_key("term_a")); + } + + #[test] + fn stale_plugin_observations_cannot_regress_a_generation() { + let subjects = terminal_subject("term_a"); + let payload = |generation: &str, state: &str, observed_at_ms: &str| { + json!({ + "format": AGENT_PLUGIN_FORMAT, + "plugin": {"id":"screen_detector","version":1}, + "adapter": {"id":"codex","version":1}, + "event":"state.changed", + "normalized": { + "state":state, + "source_session":"pid:42", + "plugin_generation":generation, + "observed_at_ms":observed_at_ms + } + }) + }; + let mut roster = AgentRoster::default(); + let current = payload("2", "working", "200"); + roster.apply(&RosterEvent { + producer_id: "screen_detector", + kind: "plugin.screen_detector.agent.state.changed", + subjects: &subjects, + payload: ¤t, + committed_at_ms: 200, + }); + let old_timestamp = payload("2", "blocked", "100"); + assert!( + roster + .apply(&RosterEvent { + producer_id: "screen_detector", + kind: "plugin.screen_detector.agent.state.changed", + subjects: &subjects, + payload: &old_timestamp, + committed_at_ms: 300, + }) + .is_empty() + ); + assert_eq!(roster.entries["term_a"].state, "working"); + + let old_generation = payload("1", "blocked", "999"); + assert!( + roster + .apply(&RosterEvent { + producer_id: "screen_detector", + kind: "plugin.screen_detector.agent.state.changed", + subjects: &subjects, + payload: &old_generation, + committed_at_ms: 999, + }) + .is_empty() + ); + assert_eq!(roster.entries["term_a"].producer_generation.as_deref(), Some("2")); + } + + #[test] + fn malformed_plugin_envelopes_never_enter_the_roster() { + let subjects = terminal_subject("term_a"); + let valid = json!({ + "format": AGENT_PLUGIN_FORMAT, + "plugin": {"id":"screen_detector","version":1}, + "adapter": {"id":"codex","version":1}, + "event":"state.changed", + "normalized": { + "state":"working", + "source_session":"pid:42", + "observed_at_ms":"100" + } + }); + let malformed = [ + json!({"format":"wrong","plugin":{"id":"screen_detector","version":1},"adapter":{"id":"codex","version":1},"event":"state.changed","normalized":{"state":"working","source_session":"pid:42","observed_at_ms":"100"}}), + json!({"format":AGENT_PLUGIN_FORMAT,"plugin":{"id":"other","version":1},"adapter":{"id":"codex","version":1},"event":"state.changed","normalized":{"state":"working","source_session":"pid:42","observed_at_ms":"100"}}), + json!({"format":AGENT_PLUGIN_FORMAT,"plugin":{"id":"screen_detector","version":1},"adapter":{"id":"codex","version":1},"event":"state.changed","normalized":{"state":"working","source_session":"pid:42"}}), + json!({"format":AGENT_PLUGIN_FORMAT,"plugin":{"id":"screen_detector","version":1},"adapter":{"id":"codex","version":1},"event":"state.changed","normalized":{"state":"working","source_session":"pid:42","plugin_generation":7,"observed_at_ms":"100"}}), + ]; + for payload in malformed { + let event = RosterEvent { + producer_id: "screen_detector", + kind: "plugin.screen_detector.agent.state.changed", + subjects: &subjects, + payload: &payload, + committed_at_ms: 100, + }; + let mut roster = AgentRoster::default(); + assert!(roster.apply(&event).is_empty()); + assert!(roster.entries.is_empty()); + } + let mut roster = AgentRoster::default(); + assert_eq!( + roster + .apply(&RosterEvent { + producer_id: "screen_detector", + kind: "plugin.screen_detector.agent.state.changed", + subjects: &subjects, + payload: &valid, + committed_at_ms: 100, + }) + .len(), + 1 + ); + } + + #[test] + fn snapshot_round_trips_and_foreign_producers_are_ignored() { + let subjects = terminal_subject("term_a"); + let payload = json!({}); + let mut roster = AgentRoster::default(); + roster.apply(&hook_event(1, "agent.turn.started", &subjects, &payload)); + + let snapshot = roster.snapshot().to_string(); + let restored = AgentRoster::restore(&snapshot).unwrap(); + assert_eq!(restored.entries, roster.entries); + + let foreign = RosterEvent { + producer_id: "someone_else", + ..hook_event(2, "agent.session.ended", &subjects, &payload) + }; + assert!(roster.apply(&foreign).is_empty()); + assert!(!roster.entries.is_empty()); + } + + #[test] + fn restore_rejects_unknown_entry_semantics() { + let snapshot = json!({ + "entries": { + "term_a": { + "state": "working", + "source": "stronger-than-hook", + "producer": null, + "producer_generation": null, + "session": null, + "agent": null, + "updated_at_ms": 1 + } + }, + "plugin_exit_fences": {} + }) + .to_string(); + + assert!(AgentRoster::restore(&snapshot).is_none()); + } +} diff --git a/cmux-tui/crates/cmux-tui-core/src/lib.rs b/cmux-tui/crates/cmux-tui-core/src/lib.rs index aade7f08b183..6d7a447f1c46 100644 --- a/cmux-tui/crates/cmux-tui-core/src/lib.rs +++ b/cmux-tui/crates/cmux-tui-core/src/lib.rs @@ -27,6 +27,8 @@ mod journal_checkpoint; mod journal_hooks; mod journal_ingress; mod journal_kernel; +mod journal_plugin; +mod journal_reducers; mod model; mod mux; mod pairing; @@ -43,6 +45,7 @@ mod resource_tab; mod short_id; mod sidebar_resource; mod surface; +mod terminal_metadata; mod workspace_registry; pub mod layout; @@ -60,6 +63,7 @@ pub use agent_hooks::{ pub use browser::{BrowserFailure, TRANSPORT_SAFE_CAPTURE_MEGAPIXELS, normalize_url}; pub use event_bus::{MuxEventBroadcaster, MuxEventReceiver}; pub use journal_ingress::{FrontendFocusTarget, FrontendJournalEvent}; +pub use journal_plugin::{JournalPluginOptions, JournalPluginRuntime}; pub use layout::{ DEFAULT_VIEWPORT_PANE_WIDTH, ExactSplitResize, ExactViewportSplitResize, LayoutResult, MAX_VIEWPORT_PANE_WIDTH, MIN_VIEWPORT_PANE_WIDTH, Rect, SplitEdge, SplitResize, diff --git a/cmux-tui/crates/cmux-tui-core/src/mux.rs b/cmux-tui/crates/cmux-tui-core/src/mux.rs index 7c3ee8a20fce..e4f79d7b4764 100644 --- a/cmux-tui/crates/cmux-tui-core/src/mux.rs +++ b/cmux-tui/crates/cmux-tui-core/src/mux.rs @@ -908,6 +908,7 @@ pub enum MuxEvent { state: Arc, source: Arc, session: Option>, + agent: Option>, updated_at_ms: u64, }, Bell(SurfaceId), @@ -1155,6 +1156,11 @@ impl Direction { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AgentSource { + /// An installed userland agent plugin wrote the observation. + Plugin, + /// Legacy source value emitted by pre-userland screen detection. Current + /// core code never emits it; the reducer keeps it so old journals replay + /// after screen detection moves to a userland plugin. Detected, Socket, Hook, @@ -1163,6 +1169,7 @@ pub enum AgentSource { impl AgentSource { pub fn as_str(self) -> &'static str { match self { + AgentSource::Plugin => "plugin", AgentSource::Detected => "detected", AgentSource::Socket => "socket", AgentSource::Hook => "hook", @@ -1231,6 +1238,103 @@ fn agent_hook_notification( Some((format!("{agent} {verb}"), body, level)) } +/// A stored projection state string as its typed form; unknown spellings +/// degrade to `Unknown`, which every agents view hides. +fn parse_projection_agent_state(value: &str) -> AgentState { + match value { + "working" => AgentState::Working, + "blocked" => AgentState::Blocked, + "idle" => AgentState::Idle, + "done" => AgentState::Done, + _ => AgentState::Unknown, + } +} + +/// The agent roster host: reducer state plus its journal fold cursor. +/// Lock ordering rule: never acquire another `Mux` lock while holding this +/// one - fold paths release it before persisting, and commit paths only +/// take a read after their registry/state locks, so `registry -> roster` +/// is the single global order. +#[derive(Debug, Default)] +struct AgentRosterHost { + roster: crate::journal_reducers::AgentRoster, + cursor: u64, +} + +/// Restore the roster from its persisted snapshot and fold the journal tail +/// committed after the cursor. A reducer-version mismatch discards the +/// snapshot and re-folds from the journal head. Deltas produced here are +/// dropped deliberately: their projection commits and change broadcasts +/// already happened when the events first committed, and the durable +/// projection restores itself independently. +fn restore_agent_roster(registry: &WorkspaceRegistry) -> anyhow::Result { + use crate::journal_reducers::{ + AGENT_ROSTER_REDUCER_ID, AGENT_ROSTER_REDUCER_VERSION, AgentRoster, RosterEvent, + }; + let (mut host, mut needs_repair) = + match registry.journal_reducer_state(AGENT_ROSTER_REDUCER_ID)? { + Some((version, cursor, snapshot)) if version == AGENT_ROSTER_REDUCER_VERSION => { + match AgentRoster::restore(&snapshot) { + Some(roster) => (AgentRosterHost { roster, cursor }, false), + // The cursor is meaningful only with the snapshot that was + // captured at the same fold boundary. Replaying from zero + // is the safe recovery path for malformed persisted state. + None => (AgentRosterHost::default(), true), + } + } + Some(_) => (AgentRosterHost::default(), true), + None => (AgentRosterHost::default(), false), + }; + // A cursor beyond the current journal head cannot describe a retained + // snapshot boundary. Treat it like any other rejected checkpoint so a + // metadata write or journal repair cannot make startup fail permanently. + if host.cursor > 0 { + let journal_head = registry.session_journal_head()?; + if host.cursor > journal_head { + host = AgentRosterHost::default(); + needs_repair = true; + } + } + let started_at = host.cursor; + loop { + let page = registry.session_journal_after(host.cursor, 512)?; + if page.records.is_empty() { + break; + } + for record in &page.records { + host.roster.apply(&RosterEvent::from_record(record)); + host.cursor = host.cursor.max(record.sequence); + } + } + if needs_repair || host.cursor != started_at { + registry.put_journal_reducer_state( + AGENT_ROSTER_REDUCER_ID, + AGENT_ROSTER_REDUCER_VERSION, + host.cursor, + &host.roster.snapshot().to_string(), + )?; + } + Ok(host) +} + +fn agent_provider_identity(ingress: &crate::JournalIngress) -> Option { + ingress + .payload + .get("normalized") + .and_then(|normalized| normalized.get("agent_type")) + .and_then(Value::as_str) + .or_else(|| { + ingress + .payload + .get("adapter") + .and_then(|adapter| adapter.get("id")) + .and_then(Value::as_str) + }) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_ascii_lowercase) +} + #[derive(Debug, Clone)] pub struct AgentRecord { pub surface: SurfaceId, @@ -1238,6 +1342,9 @@ pub struct AgentRecord { pub state: AgentState, pub source: AgentSource, pub session: Option, + /// The reporting adapter id (`claude`, `codex`, ...) when a hook has + /// claimed the terminal; absent for socket-only reports. + pub agent: Option, pub updated_at_ms: u64, } @@ -1316,14 +1423,6 @@ impl HookFence { } } -/// Durable hook projection carried by a hook-sourced agent report. Socket -/// reports carry none; hook reports carry the fence state and the journal -/// sequence that produced it. -struct DurableHookReport { - state: crate::workspace_registry::AgentHookProjectionState, - journal_sequence: u64, -} - /// Session-less adapters get a local generation token. The journal sequence /// is durable and strictly increasing, so a new legacy lifecycle cannot reuse /// the previous fence identity after restart. @@ -1386,9 +1485,20 @@ struct TerminalAgentRecord { state: AgentState, source: AgentSource, session: Option, + agent: Option, updated_at_ms: u64, } +/// Who initiated an agent projection commit: a direct socket/SDK report +/// (which must echo its intent into the journal so the roster fold sees +/// it), or the roster fold itself applying a journal-derived delta (which +/// must not echo, or every hook event would append a second record). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum AgentReportOrigin { + Direct, + RosterFold, +} + enum AgentReportTarget<'a> { Surface(SurfaceId), Resource { selectors: &'a crate::ResourceSelectors, terminal_id: &'a TerminalPublicId }, @@ -2300,9 +2410,12 @@ pub struct Mux { default_colors: Mutex, durable_terminal_defaults: AtomicBool, sidebar_plugin: Mutex, + journal_plugin: crate::journal_plugin::JournalPluginRuntime, machine_usage: Mutex>, agent_records: Mutex>, agent_hook_fences: Mutex>, + agent_roster: Mutex, + agent_roster_fold: Mutex<()>, /// Nonterminal notifications remain placement-local. Terminal unread /// state is keyed separately by stable content identity so every view of /// one terminal shares the same attention marker. @@ -2608,6 +2721,7 @@ impl Mux { notification_ledger, notification_reads, } = restore_public_projections(&state, registry.public_projections()?)?; + let agent_roster = restore_agent_roster(®istry)?; let journal_producers = registry.journal_producer_manifests()?; let session_public_id = registry.session_id().clone(); let machine_public_id = registry.machine_id().clone(); @@ -2719,9 +2833,12 @@ impl Mux { default_colors: Mutex::new(default_colors), durable_terminal_defaults: AtomicBool::new(has_terminal_defaults), sidebar_plugin: Mutex::new(SidebarPluginRuntime::default()), + journal_plugin: crate::journal_plugin::JournalPluginRuntime::default(), machine_usage: Mutex::new(None), agent_records: Mutex::new(agent_records), agent_hook_fences: Mutex::new(agent_hook_fences), + agent_roster: Mutex::new(agent_roster), + agent_roster_fold: Mutex::new(()), placement_notifications: Mutex::new(HashMap::new()), terminal_notifications: Mutex::new(terminal_notifications), notification_ledger: Mutex::new(notification_ledger), @@ -2767,6 +2884,13 @@ impl Mux { test_surface_runtime, session, }); + let weak_mux = Arc::downgrade(&mux); + mux.journal_plugin.set_exit_handler(Some(Arc::new(move |plugin_id, generation| { + let Some(mux) = weak_mux.upgrade() else { return }; + // Do not drop a late exit callback here. The reducer uses the + // child generation to fence a replacement process. + mux.record_journal_plugin_exit(plugin_id, generation); + }))); crate::journal_ingress::start(&mux, journal_ingress_receiver)?; mux.materialize_interrupted_resource_workspaces()?; mux.materialize_restored_browsers(&contents)?; @@ -2781,6 +2905,12 @@ impl Mux { } } } + // The roster reducer and the public projection are durable in + // separate transactions. A crash can therefore leave a plugin row in + // the roster while dropping the projection side effect. Reconcile + // after restored surfaces exist, and repeat at the end of asynchronous + // terminal adoption for hosts that were not available yet. + mux.reconcile_agent_roster_projections(); let recovery_deadline = Instant::now() + Duration::from_secs(15); while mux.reconcile_interrupted_resource_creations()? { if Instant::now() >= recovery_deadline { @@ -3483,6 +3613,7 @@ impl Mux { // only hooks scoped to this terminal, not the entire pending table. if let Ok(terminal_id) = TerminalPublicId::parse(terminal_id) { let _ = self.retry_pending_agent_hooks_for_terminal(&terminal_id); + self.reconcile_agent_roster_projections_for_terminal(&terminal_id); } Ok(()) } @@ -5674,6 +5805,12 @@ impl Mux { self.workspace_registry.lock().unwrap().journal_producer_manifests() } + pub(crate) fn userland_journal_producer_manifests( + &self, + ) -> anyhow::Result> { + self.workspace_registry.lock().unwrap().userland_journal_producer_manifests() + } + pub(crate) fn put_journal_producer( &self, manifest: &crate::JournalProducerManifest, @@ -5701,7 +5838,22 @@ impl Mux { origin: &str, idempotency_key: &str, ) -> anyhow::Result { - let validated = self.journal_kernel.validate_ingress(ingress)?; + let validated = match self.journal_kernel.validate_ingress(ingress) { + Ok(validated) => validated, + Err(validation_error) => { + // A receipt is authoritative for an exact retry. Its ingress + // may name a superseded manifest after a producer upgrade, + // while a new ingress must still pass current validation. + let replay = { + let registry = self.workspace_registry.lock().unwrap(); + registry.replay_journal_ingress(ingress, origin, idempotency_key)? + }; + if let Some(commit) = replay { + return self.finish_journal_ingress(ingress, origin, idempotency_key, commit); + } + return Err(validation_error); + } + }; let commit = if self.journal_ingress.enabled() { self.journal_ingress.send_producer( ingress.clone(), @@ -5721,6 +5873,16 @@ impl Mux { } commit }; + self.finish_journal_ingress(ingress, origin, idempotency_key, commit) + } + + fn finish_journal_ingress( + &self, + ingress: &crate::JournalIngress, + origin: &str, + idempotency_key: &str, + commit: crate::JournalAppendCommit, + ) -> anyhow::Result { // Replayed journal commits still need projection reconciliation. A // process can crash after the durable journal commit and before the // in-memory/resource projection update. The sequence guard makes this @@ -5764,6 +5926,23 @@ impl Mux { "agent hook projection applied; retry bookkeeping cleanup deferred", ); } + // A replayed plugin event can repair a projection after a process + // crash between the durable journal commit and the in-memory fold. + // Hook replay remains owned by its durable retry projector, while the + // generic plugin envelope is safe to fold repeatedly because the + // reducer fences by journal sequence and observed timestamp. + let is_plugin_event = ingress.payload.get("format").and_then(Value::as_str) + == Some(crate::journal_reducers::AGENT_PLUGIN_FORMAT); + // A replay can follow a crash before either projection or reducer + // side effects. The reducer cursor makes folding an already-applied + // sequence a no-op, so replay every agent event and repair a missing + // roster fold without duplicating live deltas. + if !commit.replayed + || is_plugin_event + || ingress.producer_id == crate::agent_hooks::AGENT_HOOK_PRODUCER_ID + { + self.fold_agent_roster(ingress, &commit); + } Ok(commit) } @@ -5782,6 +5961,16 @@ impl Mux { if ingress.producer_id != crate::agent_hooks::AGENT_HOOK_PRODUCER_ID { return Ok(()); } + // Screen-detection events reuse the agent-hook envelope and the + // `agent.session.ended` kind for process exits. They are owned by + // the roster reducer, not the hook fence projector; otherwise a + // detected process exit would create a Hook `Done` fence and could + // suppress a later real hook lifecycle. + if ingress.payload.get("native_event").and_then(Value::as_str) + == Some(crate::journal_reducers::LEGACY_SCREEN_DETECT_NATIVE_EVENT) + { + return Ok(()); + } let Some(state) = agent_state_for_hook_kind(&ingress.kind) else { return Ok(()) }; let Some(terminal_subject) = ingress.subjects.iter().find(|subject| subject.kind == "terminal") @@ -5863,7 +6052,10 @@ impl Mux { AgentSource::Hook, Some(marker), true, - Some(DurableHookReport { state: hook_state, journal_sequence: sequence }), + Some(hook_state), + Some(sequence), + AgentReportOrigin::RosterFold, + agent_provider_identity(ingress), )?; fences.insert( terminal_id.clone(), @@ -5887,6 +6079,260 @@ impl Mux { Ok(()) } + /// Fold one fresh `agent.*` journal commit into the roster reducer and + /// apply the resulting deltas (projection commits, change broadcasts). + /// The roster is derived state: this fold plus the startup tail replay + /// are its only writers, so the journal fully determines it. Best + /// effort by design: a hook may outlive its terminal, and a journal + /// append must never start failing because a view cannot update. + fn fold_agent_roster( + &self, + ingress: &crate::JournalIngress, + commit: &crate::JournalAppendCommit, + ) { + use crate::journal_reducers::{ + AGENT_ROSTER_REDUCER_ID, AGENT_ROSTER_REDUCER_VERSION, RosterEvent, + }; + if ingress.producer_id != crate::agent_hooks::AGENT_HOOK_PRODUCER_ID + && ingress.payload.get("format").and_then(Value::as_str) + != Some(crate::journal_reducers::AGENT_PLUGIN_FORMAT) + { + return; + } + let _fold = self.agent_roster_fold.lock().unwrap(); + // Consume every intervening committed record under registry -> roster + // lock order. Concurrent appends and delayed hook retries cannot jump + // the cursor over a record that startup replay would have consumed. + let (deltas, cursor, snapshot) = { + let registry = self.workspace_registry.lock().unwrap(); + let mut host = self.agent_roster.lock().unwrap(); + if commit.sequence <= host.cursor { + return; + } + let mut deltas = Vec::new(); + while host.cursor < commit.sequence { + let page = match registry.session_journal_after(host.cursor, 512) { + Ok(page) => page, + Err(error) => { + eprintln!("cmux-tui: reading agent journal tail failed: {error}"); + return; + } + }; + if page.records.is_empty() { + break; + } + for record in + page.records.iter().take_while(|record| record.sequence <= commit.sequence) + { + let changes = host.roster.apply(&RosterEvent::from_record(record)); + // Hooks already use the durable, session-fenced projector. + // Applying their reducer delta again would bypass its + // stale-session checks and create duplicate mutations. + if record.payload.get("format").and_then(Value::as_str) + == Some(crate::journal_reducers::AGENT_PLUGIN_FORMAT) + { + deltas.extend(changes); + } + host.cursor = record.sequence; + } + } + (deltas, host.cursor, host.roster.snapshot().to_string()) + }; + if let Err(error) = self.workspace_registry.lock().unwrap().put_journal_reducer_state( + AGENT_ROSTER_REDUCER_ID, + AGENT_ROSTER_REDUCER_VERSION, + cursor, + &snapshot, + ) { + eprintln!("cmux-tui: persisting the agent roster snapshot failed: {error}"); + } + for delta in deltas { + self.apply_roster_delta(delta, &ingress.kind); + } + } + + /// Repair public projections whose plugin roster event was folded before + /// the daemon stopped. The roster is the canonical live view; the + /// projection is a separately committed compatibility view for clients. + /// Compare durable values first so a healthy restart emits no mutations. + fn reconcile_agent_roster_projections(&self) { + let entries = self + .agent_roster + .lock() + .unwrap() + .roster + .entries + .iter() + .filter(|(_, entry)| entry.agent_source() == AgentSource::Plugin) + .map(|(terminal_id, entry)| (terminal_id.clone(), entry.clone())) + .collect::>(); + for (terminal_id, entry) in entries { + let Ok(terminal_id) = TerminalPublicId::parse(&terminal_id) else { continue }; + self.reconcile_agent_roster_projection_for_entry(&terminal_id, entry); + } + } + + fn reconcile_agent_roster_projections_for_terminal(&self, terminal_id: &TerminalPublicId) { + let entry = self + .agent_roster + .lock() + .unwrap() + .roster + .entries + .get(terminal_id.as_str()) + .filter(|entry| entry.agent_source() == AgentSource::Plugin) + .cloned(); + if let Some(entry) = entry { + self.reconcile_agent_roster_projection_for_entry(terminal_id, entry); + } + } + + fn reconcile_agent_roster_projection_for_entry( + &self, + terminal_id: &TerminalPublicId, + entry: crate::journal_reducers::RosterEntry, + ) { + let registry = match self.workspace_registry.lock() { + Ok(registry) => registry, + Err(_) => { + eprintln!( + "cmux-tui: could not inspect agent projection for {terminal_id} during startup reconciliation: workspace registry mutex is poisoned" + ); + return; + } + }; + let projection = match registry.public_agent_projections(Some(terminal_id), None) { + Ok(projections) => projections.into_iter().next(), + Err(error) => { + eprintln!( + "cmux-tui: could not inspect agent projection for {terminal_id} during startup reconciliation: {error}" + ); + return; + } + }; + drop(registry); + let matches = projection.as_ref().is_some_and(|projection| { + projection.state == entry.state + && projection.source == entry.source + && projection.source_session == entry.session + && projection.agent == entry.agent + }); + if matches { + return; + } + self.apply_roster_delta( + crate::journal_reducers::RosterDelta::Upsert { + terminal_id: terminal_id.to_string(), + entry, + }, + "startup-reconcile", + ); + } + + /// Apply one roster delta's side effects: the durable agent projection + /// commit and the agent-changed broadcast remote frontends converge on. + /// A removal commits the done state (history keeps the exit; the roster + /// already dropped the live entry). + fn apply_roster_delta(&self, delta: crate::journal_reducers::RosterDelta, kind: &str) { + use crate::journal_reducers::RosterDelta; + let (terminal_id, state, source, session, agent_adapter) = match delta { + RosterDelta::Upsert { terminal_id, entry } => ( + terminal_id, + entry.agent_state(), + entry.agent_source(), + entry.session.clone(), + entry.agent, + ), + RosterDelta::Remove { terminal_id, source } => { + (terminal_id, AgentState::Done, source, None, None) + } + }; + let Ok(terminal_id) = TerminalPublicId::parse(&terminal_id) else { return }; + let Some(surface) = self.resource_surface_for_terminal(&terminal_id) else { return }; + let mutation = match WorkspaceMutation::new( + format!("roster-{}", crate::workspace_registry::new_uuid_v4()), + "journal-reducer", + ) { + Ok(mutation) => mutation, + Err(_) => return, + }; + let fingerprint = serde_json::json!({ + "operation":"agent.report", + "surface":surface, + "state":state.as_str(), + "source":source.as_str(), + "source_session":session, + }); + if let Err(error) = self.commit_agent_report( + AgentReportTarget::Surface(surface), + state, + source, + session, + None, + &mutation, + &fingerprint, + false, + None, + None, + AgentReportOrigin::RosterFold, + agent_adapter, + ) { + eprintln!( + "cmux-tui: agent projection update for {terminal_id} ({kind}) failed: {error}" + ); + } + } + + /// Record a direct socket/SDK agent report in the journal so the roster + /// reducer (and any future reducer) sees every agent intent in one log. + /// The event wears the agent-hook payload shape with a dedicated + /// adapter, and the fold recognizes that adapter as an echo whose + /// projection commit already happened. + fn append_agent_report_echo( + &self, + terminal_id: &TerminalPublicId, + state: AgentState, + source: AgentSource, + session: Option<&str>, + updated_at_ms: u64, + ) { + use crate::journal_reducers::{SOCKET_REPORT_ADAPTER, SOCKET_REPORT_NATIVE_EVENT}; + let ingress = crate::JournalIngress { + producer_id: crate::agent_hooks::AGENT_HOOK_PRODUCER_ID.into(), + manifest_version: crate::agent_hooks::AGENT_HOOK_MANIFEST_VERSION, + kind: "agent.state.changed".into(), + schema_version: 1, + occurred_at_ms: None, + subjects: vec![crate::JournalSubject { + kind: "terminal".into(), + id: terminal_id.to_string(), + }], + sensitivity: Some(crate::JournalSensitivity::Sensitive), + payload: serde_json::json!({ + "format": crate::agent_hooks::AGENT_HOOK_FORMAT, + "adapter": {"id": SOCKET_REPORT_ADAPTER, "version": 1}, + "native_event": SOCKET_REPORT_NATIVE_EVENT, + "normalized": { + "state": state.as_str(), + "source": source.as_str(), + "source_session": session, + // The direct commit's timestamp, so the roster mirrors + // the projection exactly instead of stamping fold time. + "updated_at_ms": updated_at_ms.to_string(), + }, + "native": {}, + }), + causation_id: None, + correlation_id: None, + }; + let idempotency_key = + format!("agent-report-echo-{}", crate::workspace_registry::new_uuid_v4()); + if let Err(error) = self.append_journal_ingress(&ingress, "agent-report", &idempotency_key) + { + eprintln!("cmux-tui: journaling an agent report for {terminal_id} failed: {error}"); + } + } + pub(crate) fn journal_hook_states( &self, ) -> anyhow::Result> { @@ -9701,9 +10147,23 @@ impl Mux { source: AgentSource, session: Option, ) -> anyhow::Result { - self.report_agent_with_sequence_lock(surface, state, source, session, false, None) + self.report_agent_with_sequence_lock( + surface, + state, + source, + session, + false, + None, + None, + AgentReportOrigin::Direct, + None, + ) } + // Keep the sequence lock, hook fence, and origin explicit at this + // internal transaction boundary. Grouping them into a bag would hide the + // lock-order contract that protects journal replay. + #[allow(clippy::too_many_arguments)] fn report_agent_with_sequence_lock( &self, surface: SurfaceId, @@ -9711,7 +10171,10 @@ impl Mux { source: AgentSource, session: Option, sequence_lock_held: bool, - hook: Option, + hook_state: Option, + journal_sequence: Option, + origin: AgentReportOrigin, + agent_adapter: Option, ) -> anyhow::Result { let mutation = WorkspaceMutation::new( format!("raw-agent-{}", crate::workspace_registry::new_uuid_v4()), @@ -9733,8 +10196,10 @@ impl Mux { &mutation, &fingerprint, sequence_lock_held, - hook.as_ref().map(|hook| &hook.state), - hook.as_ref().map(|hook| hook.journal_sequence), + hook_state.as_ref(), + journal_sequence, + origin, + agent_adapter, )?; let record = record.context("fresh raw agent report unexpectedly replayed")?; if source != AgentSource::Hook { @@ -9775,6 +10240,8 @@ impl Mux { false, None, None, + AgentReportOrigin::Direct, + None, ); if result.is_ok() && source != AgentSource::Hook { let _ = self.retry_pending_agent_hooks_for_terminal(terminal_id); @@ -9795,6 +10262,8 @@ impl Mux { sequence_lock_held: bool, hook_state: Option<&crate::workspace_registry::AgentHookProjectionState>, journal_sequence: Option, + origin: AgentReportOrigin, + agent_adapter: Option, ) -> anyhow::Result<(ResourcePatchCommit, Option)> { // Hook replay already owns this guard to serialize sequence checks and // projection commits. Other report sources acquire it before the @@ -9894,17 +10363,57 @@ impl Mux { }); let now = now_ms(); let mut records = self.agent_records.lock().unwrap(); - let socket_report_ignored = records.get(&terminal_id).is_some_and(|existing| { - existing.source == AgentSource::Hook - && source == AgentSource::Socket - && !effective_hook_state.is_some_and(|state| state.ended) - }); + // Hook and plugin observations are stronger agent truth than a direct + // socket report. Check the durable projection as well as the + // in-memory cache so arbitration survives a restart. + let durable_stronger = + registry.public_agent_projections(Some(&terminal_id), None)?.into_iter().next().filter( + |projection| { + (projection.source == AgentSource::Hook.as_str() + || projection.source == AgentSource::Plugin.as_str() + || projection.source == AgentSource::Detected.as_str()) + && projection.state != AgentState::Done.as_str() + && source == AgentSource::Socket + }, + ); + let socket_report_ignored = source == AgentSource::Socket + && !effective_hook_state.is_some_and(|state| state.ended) + && (records.get(&terminal_id).is_some_and(|existing| { + existing.source == AgentSource::Hook + || existing.source == AgentSource::Detected + || existing.source == AgentSource::Plugin + }) || durable_stronger.is_some()); + let agent_adapter = agent_adapter + .or_else(|| records.get(&terminal_id).and_then(|record| record.agent.clone())); let record = match records.get(&terminal_id) { Some(existing) if socket_report_ignored => existing.clone(), + None if socket_report_ignored => match durable_stronger { + Some(existing) => TerminalAgentRecord { + state: parse_projection_agent_state(&existing.state), + source: if existing.source == AgentSource::Plugin.as_str() { + AgentSource::Plugin + } else if existing.source == AgentSource::Detected.as_str() { + AgentSource::Detected + } else { + AgentSource::Hook + }, + session: existing.source_session, + agent: existing.agent, + updated_at_ms: existing.updated_at_ms, + }, + None => TerminalAgentRecord { + state: agent_state, + source, + session: source_session, + agent: agent_adapter, + updated_at_ms: now, + }, + }, _ => TerminalAgentRecord { state: agent_state, source, session: source_session, + agent: agent_adapter, updated_at_ms: now, }, }; @@ -9925,10 +10434,11 @@ impl Mux { "state":record.state.as_str(), "source":record.source.as_str(), "updated_at_ms":record.updated_at_ms.to_string(), - "source_session":persisted_source_session.or(record.session.clone()), + "source_session":persisted_source_session.as_deref().or(record.session.as_deref()), + "extra":{"agent":record.agent}, }); let mut public_value = value.clone(); - public_value["source_session"] = serde_json::json!(record.session); + public_value["source_session"] = serde_json::json!(record.session.as_deref()); let deltas = if effective_hook_state.is_some_and(|state| state.ended) { serde_json::json!([{ "kind":"delete", @@ -9968,19 +10478,19 @@ impl Mux { }, ); } - state.resource_revision = commit.revision; - if !commit.replayed { - records.insert(terminal_id.clone(), record.clone()); - } + records.insert(terminal_id.clone(), record.clone()); drop(records); + state.resource_revision = commit.revision; drop(state); drop(registry); + drop(sequence_guard); let agent = AgentRecord { surface, terminal_id, state: record.state, source: record.source, session: record.session, + agent: record.agent, updated_at_ms: record.updated_at_ms, }; if !commit.replayed { @@ -9990,8 +10500,21 @@ impl Mux { state: Arc::from(agent.state.as_str()), source: Arc::from(agent.source.as_str()), session: agent.session.as_deref().map(Arc::from), + agent: agent.agent.as_deref().map(Arc::from), updated_at_ms: agent.updated_at_ms, }); + if origin == AgentReportOrigin::Direct { + // The roster only folds journal events, so a direct report + // records its intent in the log; the fold recognizes the + // echo adapter and applies it roster-only. + self.append_agent_report_echo( + &agent.terminal_id, + agent.state, + agent.source, + agent.session.as_deref(), + agent.updated_at_ms, + ); + } } Ok((commit, Some(agent))) } @@ -10023,6 +10546,26 @@ impl Mux { // The registry guard is dropped before acquiring the fence guard. self.agent_hook_fences.lock().unwrap().remove(terminal_id); self.agent_records.lock().unwrap().remove(terminal_id); + // Terminal lifecycle does not flow through `agent.*` journal events + // yet, so a closed terminal retires its roster entry explicitly. + // The snapshot persists so a restart does not resurrect the entry; + // the roster lock is released before the registry lock per the + // host's lock-ordering rule. + let retired = { + let mut host = self.agent_roster.lock().unwrap(); + let retired = host.roster.retire_terminal(terminal_id.as_str()); + retired.then(|| (host.cursor, host.roster.snapshot().to_string())) + }; + if let Some((cursor, snapshot)) = retired + && let Err(error) = self.workspace_registry.lock().unwrap().put_journal_reducer_state( + crate::journal_reducers::AGENT_ROSTER_REDUCER_ID, + crate::journal_reducers::AGENT_ROSTER_REDUCER_VERSION, + cursor, + &snapshot, + ) + { + eprintln!("cmux-tui: persisting the agent roster snapshot failed: {error}"); + } self.terminal_notifications.lock().unwrap().remove(terminal_id); } @@ -10052,7 +10595,7 @@ impl Mux { surface: Option, state: Option, ) -> Vec { - let records = self.agent_records.lock().unwrap().clone(); + let entries = self.agent_roster.lock().unwrap().roster.entries.clone(); let state_snapshot = self.state.lock().unwrap(); let requested_terminal = surface.and_then(|surface| { state_snapshot @@ -10061,9 +10604,10 @@ impl Mux { .or_else(|| state_snapshot.terminal_runtime_by_id(surface)) .and_then(|surface| surface.terminal_public_id().cloned()) }); - let mut records = records + let mut records = entries .into_iter() - .filter_map(|(terminal_id, record)| { + .filter_map(|(terminal_id, entry)| { + let terminal_id = TerminalPublicId::parse(terminal_id).ok()?; let representative = state_snapshot .placements_of_content(&ContentPublicId::Terminal(terminal_id.clone())) .first() @@ -10074,10 +10618,11 @@ impl Mux { Some(AgentRecord { surface: representative, terminal_id, - state: record.state, - source: record.source, - session: record.session, - updated_at_ms: record.updated_at_ms, + state: entry.agent_state(), + source: entry.agent_source(), + session: entry.session, + agent: entry.agent, + updated_at_ms: entry.updated_at_ms, }) }) .collect::>(); @@ -10096,6 +10641,7 @@ impl Mux { pub fn shutdown(&self) { self.shutting_down.store(true, Ordering::Release); self.config_reload_changed.notify_all(); + self.journal_plugin.shutdown(); self.journal_kernel.wake_waiters(); let hook_deadline = Instant::now() + crate::journal_hooks::SHUTDOWN_WAIT; if !self.journal_hook_runtime.shutdown_until(hook_deadline) { @@ -10272,6 +10818,50 @@ impl Mux { } } + /// Configure the optional userland agent plugin. The process starts only + /// after the local resource socket has been bound. + pub fn configure_journal_plugin(&self, options: Option) { + self.journal_plugin.configure(options); + } + + /// Start the configured journal plugin against the bound local socket. + pub fn start_journal_plugin(&self, socket: std::path::PathBuf) { + let generation = match self.workspace_registry.lock() { + Ok(registry) => registry.reserve_journal_plugin_generation(), + Err(_) => Err(anyhow::anyhow!("workspace registry mutex is poisoned")), + }; + match generation { + Ok(generation) => self.journal_plugin.start_with_generation_seed( + socket, + self.session.clone(), + generation, + ), + Err(error) => eprintln!( + "cmux-tui: journal plugin not started because its generation could not be reserved: {error}" + ), + } + } + + /// Journal a supervisor-observed plugin exit. The roster reducer removes + /// only entries owned by this producer, so a crash cannot leave stale + /// rows until the next terminal scan and the cleanup remains replayable. + fn record_journal_plugin_exit(&self, plugin_id: &str, generation: u64) { + let ingress = + match crate::agent_hooks::journal_plugin_exit_journal_ingress(plugin_id, generation) { + Ok(ingress) => ingress, + Err(error) => { + eprintln!("cmux-tui: invalid journal plugin exit id {plugin_id:?}: {error}"); + return; + } + }; + let key = + format!("journal-plugin-exit-{plugin_id}-{}", crate::workspace_registry::new_uuid_v4()); + if let Err(error) = self.append_journal_ingress(&ingress, "journal-plugin-supervisor", &key) + { + eprintln!("cmux-tui: journal plugin exit cleanup could not be journaled: {error}"); + } + } + pub fn ensure_sidebar_plugin( self: &Arc, cols: u16, @@ -17123,6 +17713,7 @@ fn sidebar_retry_delay(failures: u32) -> Duration { impl Drop for Mux { fn drop(&mut self) { + self.journal_plugin.shutdown(); self.finalize_terminal_journal("mux drop"); self.journal_kernel.shutdown(); if let Ok(runtime) = self.browser_runtime.get_mut() @@ -22922,17 +23513,20 @@ mod tests { assert_eq!(filtered.len(), 1); assert_eq!(filtered[0].session.as_deref(), Some("hook-session")); assert!(mux.list_agents(Some(surface.id), Some(AgentState::Done)).is_empty()); - assert_eq!(mux.with_state(|state| state.resource_revision), initial_revision + 3); - assert_eq!(mux.resource_event_epoch(), initial_epoch + 3); + // The late socket report is a replay-equivalent no-op because the + // hook projection already owns this terminal. + assert_eq!(mux.with_state(|state| state.resource_revision), initial_revision + 2); + // Each fresh direct report publishes twice on the shared change + // epoch: its resource commit and its journal echo. + assert_eq!(mux.resource_event_epoch(), initial_epoch + 4); assert_eq!(mux.resource_agent_projection_count_for_test().unwrap(), 1); let resource_events = mux.resource_events_after(initial_revision).unwrap(); - assert_eq!(resource_events.batches.len(), 3); + assert_eq!(resource_events.batches.len(), 2); assert_eq!(resource_events.batches[0].changes[0]["value"]["source"], "socket"); assert_eq!(resource_events.batches[1].changes[0]["value"]["source"], "hook"); - assert_eq!(resource_events.batches[2].changes[0]["value"]["source"], "hook"); - assert_eq!(resource_events.batches[2].changes[0]["value"]["state"], "blocked"); + assert_eq!(resource_events.batches[1].changes[0]["value"]["state"], "blocked"); assert_eq!( - resource_events.batches[2].changes[0]["value"]["source_session"], + resource_events.batches[1].changes[0]["value"]["source_session"], "hook-session" ); assert!(matches!( @@ -23026,19 +23620,22 @@ mod tests { assert_eq!(ignored.state, AgentState::Blocked); assert_eq!(ignored.source, AgentSource::Hook); assert_eq!(ignored.session.as_deref(), Some("hook-session")); - assert_eq!(mux.with_state(|state| state.resource_revision), created_revision + 3); - assert_eq!(mux.resource_event_epoch(), initial_epoch + 3); + // The late socket report is a replay-equivalent no-op because the + // hook projection already owns this terminal. + assert_eq!(mux.with_state(|state| state.resource_revision), created_revision + 2); + // Each fresh direct report publishes twice on the shared change + // epoch: its resource commit and its journal echo. + assert_eq!(mux.resource_event_epoch(), initial_epoch + 4); assert_eq!(mux.resource_agent_projection_count_for_test().unwrap(), 1); let batches = mux.resource_events_after(created_revision).unwrap().batches; assert_eq!( batches.iter().map(|batch| batch.revision).collect::>(), - vec![created_revision + 1, created_revision + 2, created_revision + 3] + vec![created_revision + 1, created_revision + 2] ); assert_eq!(batches[0].changes[0]["value"]["source"], "socket"); assert_eq!(batches[0].changes[0]["value"]["source_session"], "raw-session"); assert_eq!(batches[1].changes[0]["value"], hook["result"]["value"]); - assert_eq!(batches[2].changes[0]["value"], hook["result"]["value"]); assert_eq!( crate::resource_api::public_session_snapshot(&mux).unwrap()["agents"], serde_json::json!([hook["result"]["value"].clone()]) @@ -23260,7 +23857,7 @@ mod tests { "claude", "UserPromptSubmit", Some(&terminal_id.to_string()), - serde_json::json!({}), + serde_json::json!({"agent_type":"claude"}), ) .unwrap(); @@ -23270,6 +23867,9 @@ mod tests { mux.apply_agent_hook_record(&hook, 1).unwrap(); assert_eq!(mux.list_agents(Some(surface.id), None)[0].state, AgentState::Working); + assert_eq!(mux.list_agents(Some(surface.id), None)[0].agent.as_deref(), Some("claude")); + let snapshot = crate::resource_api::public_session_snapshot(&mux).unwrap(); + assert_eq!(snapshot["agents"][0]["extra"]["agent"], serde_json::json!("claude")); assert_eq!( mux.agent_hook_fences.lock().unwrap().get(&terminal_id).map(|fence| fence.sequence), Some(1) @@ -23740,6 +24340,147 @@ mod tests { assert_eq!(record.updated_at_ms, working_at); } + #[test] + fn replay_of_an_old_plugin_receipt_survives_manifest_upgrade() { + let mux = test_mux(); + let manifest = |manifest_version| crate::JournalProducerManifest { + producer_id: "screen_test".into(), + namespace: "plugin.screen_test".into(), + manifest_version, + max_sensitivity: crate::JournalSensitivity::Metadata, + permissions: vec!["journal.append.plugin.screen_test".into()], + events: vec![crate::JournalEventSchema { + kind: "plugin.screen_test.observation".into(), + schema_version: 1, + class: crate::JournalClass::Observation, + replay: crate::JournalReplayPolicy::Advisory, + sensitivity: crate::JournalSensitivity::Metadata, + payload_schema: serde_json::json!({"type":"object"}), + }], + }; + mux.put_journal_producer(&manifest(1), "test", "producer-v1").unwrap(); + let ingress = crate::JournalIngress { + producer_id: "screen_test".into(), + manifest_version: 1, + kind: "plugin.screen_test.observation".into(), + schema_version: 1, + occurred_at_ms: None, + subjects: Vec::new(), + sensitivity: None, + payload: serde_json::json!({"state":"idle"}), + causation_id: None, + correlation_id: None, + }; + let first = mux.append_journal_ingress(&ingress, "screen", "observation-1").unwrap(); + assert!(!first.replayed); + + mux.put_journal_producer(&manifest(2), "test", "producer-v2").unwrap(); + + let replay = mux.append_journal_ingress(&ingress, "screen", "observation-1"); + assert!(replay.is_ok(), "an old receipt must remain replayable: {replay:?}"); + assert!(replay.unwrap().replayed); + } + + #[test] + fn startup_repairs_a_plugin_projection_lost_after_journal_commit() { + let root = std::env::temp_dir().join(format!( + "cmux-agent-plugin-reconcile-{}", + crate::workspace_registry::new_uuid_v4() + )); + let session = "plugin-reconcile"; + let terminal_id; + { + let registry = WorkspaceRegistry::open(&root, session).unwrap(); + let mux = Mux::from_workspace_registry( + session.into(), + SurfaceOptions::default(), + registry, + ProviderWorkspaceState::default(), + true, + ) + .unwrap(); + let surface = mux.new_workspace(None, None).unwrap(); + terminal_id = surface.terminal_public_id().cloned().expect("workspace terminal"); + let manifest = crate::JournalProducerManifest { + producer_id: "screen_test".into(), + namespace: "plugin.screen_test".into(), + manifest_version: 1, + max_sensitivity: crate::JournalSensitivity::Metadata, + permissions: vec!["journal.append.plugin.screen_test".into()], + events: vec![crate::JournalEventSchema { + kind: "plugin.screen_test.agent.state.changed".into(), + schema_version: 1, + class: crate::JournalClass::State, + replay: crate::JournalReplayPolicy::Advisory, + sensitivity: crate::JournalSensitivity::Metadata, + payload_schema: serde_json::json!({"type":"object"}), + }], + }; + mux.put_journal_producer(&manifest, "test", "plugin-reconcile-manifest").unwrap(); + let ingress = crate::JournalIngress { + producer_id: "screen_test".into(), + manifest_version: 1, + kind: "plugin.screen_test.agent.state.changed".into(), + schema_version: 1, + occurred_at_ms: None, + subjects: vec![crate::JournalSubject { + kind: "terminal".into(), + id: terminal_id.to_string(), + }], + sensitivity: None, + payload: serde_json::json!({ + "format": crate::journal_reducers::AGENT_PLUGIN_FORMAT, + "plugin": {"id":"screen_test", "version":1}, + "adapter": {"id":"codex", "version":1}, + "event": "state.changed", + "normalized": { + "state":"working", + "source_session":"pid:42", + "observed_at_ms":"100" + } + }), + causation_id: None, + correlation_id: None, + }; + let validated = mux.journal_kernel.validate_ingress(&ingress).unwrap(); + let commit = mux + .workspace_registry + .lock() + .unwrap() + .append_journal_ingress(&ingress, &validated, "test", "plugin-reconcile-event") + .unwrap(); + + // The journal transaction has committed. Fail only the following + // projection transaction to model a daemon crash in that window. + mux.workspace_registry.lock().unwrap().set_resource_patch_failure(true).unwrap(); + mux.fold_agent_roster(&ingress, &commit); + assert_eq!(mux.list_agents(Some(surface.id), None).len(), 1); + assert_eq!(mux.resource_agent_projection_count_for_test().unwrap(), 0); + mux.workspace_registry.lock().unwrap().set_resource_patch_failure(false).unwrap(); + mux.shutdown(); + } + + let registry = WorkspaceRegistry::open(&root, session).unwrap(); + let reopened = Mux::from_workspace_registry( + session.into(), + SurfaceOptions::default(), + registry, + ProviderWorkspaceState::default(), + true, + ) + .unwrap(); + let repaired = reopened.list_agents(None, None); + assert_eq!(repaired.len(), 1); + assert_eq!(repaired[0].state, AgentState::Working); + assert_eq!(repaired[0].source, AgentSource::Plugin); + assert_eq!(repaired[0].agent.as_deref(), Some("codex")); + assert_eq!(repaired[0].session.as_deref(), Some("pid:42")); + assert_eq!(reopened.resource_agent_projection_count_for_test().unwrap(), 1); + reopened.shutdown(); + drop(reopened); + std::fs::remove_dir_all(root).unwrap(); + } + #[test] fn failed_agent_hook_projection_does_not_consume_sequence() { let mux = test_mux(); @@ -23970,6 +24711,10 @@ mod tests { |surface: &Surface| surface.terminal_public_id().cloned().expect("workspace terminal"); let first_terminal = terminal_id(&first); let second_terminal = terminal_id(&second); + // Drive these rows through the Mux ingress path. A direct registry + // append bypasses the roster fold and cannot model a real pending + // hook, because the roster is derived only from committed ingress. + mux.workspace_registry.lock().unwrap().set_resource_patch_failure(true).unwrap(); let hook = |terminal_id: &TerminalPublicId, key: &str| { let ingress = crate::agent_hooks::agent_hook_journal_ingress( "claude", @@ -23978,16 +24723,12 @@ mod tests { serde_json::json!({}), ) .unwrap(); - let validated = mux.journal_kernel.validate_ingress(&ingress).unwrap(); - mux.workspace_registry - .lock() - .unwrap() - .append_journal_ingress(&ingress, &validated, "test", key) - .unwrap(); + mux.append_journal_ingress(&ingress, "test", key).unwrap(); }; hook(&first_terminal, "first-pending"); hook(&second_terminal, "second-pending"); + mux.workspace_registry.lock().unwrap().set_resource_patch_failure(false).unwrap(); mux.report_agent(first.id, AgentState::Working, AgentSource::Socket, None).unwrap(); let pending = @@ -24012,7 +24753,10 @@ mod tests { 1 ); assert_eq!(mux.list_agents(Some(first.id), None)[0].source, AgentSource::Hook); - assert!(mux.list_agents(Some(second.id), None).is_empty()); + // The roster is journal-derived and folds the second hook even while + // its public resource projection waits in the retry queue. The + // terminal-scoped retry must still leave that second hook pending. + assert_eq!(mux.list_agents(Some(second.id), None)[0].source, AgentSource::Hook); mux.report_agent(second.id, AgentState::Working, AgentSource::Socket, None).unwrap(); assert!( @@ -24688,6 +25432,335 @@ mod tests { ); } + #[test] + fn raw_socket_report_reaches_the_roster_through_its_journal_echo() { + let mux = test_mux(); + let surface = mux.new_workspace(None, None).unwrap(); + mux.report_agent( + surface.id, + AgentState::Working, + AgentSource::Socket, + Some("probe".into()), + ) + .unwrap(); + let records = mux.list_agents(Some(surface.id), None); + assert_eq!(records.len(), 1); + assert_eq!(records[0].state, AgentState::Working); + assert_eq!(records[0].source, AgentSource::Socket); + assert_eq!(records[0].session.as_deref(), Some("probe")); + // The roster only folds journal events, so the record's presence + // proves the direct report echoed its intent into the journal. + let echoes = mux + .workspace_registry + .lock() + .unwrap() + .session_journal_after(0, 512) + .unwrap() + .records + .into_iter() + .filter(|record| record.kind == "agent.state.changed") + .count(); + assert_eq!(echoes, 1); + } + + #[test] + fn agent_roster_rederives_from_the_journal_head_without_its_snapshot() { + let root = std::env::temp_dir() + .join(format!("cmux-roster-rederive-{}", crate::workspace_registry::new_uuid_v4())); + let session = "roster-rederive"; + let (terminal_id, live_entries) = { + let registry = WorkspaceRegistry::open(&root, session).unwrap(); + let mux = Mux::from_workspace_registry( + session.into(), + SurfaceOptions::default(), + registry, + ProviderWorkspaceState::default(), + true, + ) + .unwrap(); + let surface = mux.new_workspace(None, None).unwrap(); + let terminal_id = mux.with_state(|state| { + match state.resource_indexes.content_ids.get(&surface.id).unwrap() { + ContentPublicId::Terminal(terminal_id) => terminal_id.clone(), + ContentPublicId::Browser(_) => panic!("workspace opened a browser"), + } + }); + let append = |event: &str, key: &str| { + let ingress = crate::agent_hooks::agent_hook_journal_ingress( + "claude", + event, + Some(&terminal_id.to_string()), + serde_json::json!({"session_id":"native-1"}), + ) + .unwrap(); + mux.append_journal_ingress(&ingress, "test", key).unwrap(); + }; + append("SessionStart", "rederive-1"); + append("UserPromptSubmit", "rederive-2"); + let entries = mux.agent_roster.lock().unwrap().roster.entries.clone(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[terminal_id.as_str()].state, "working"); + mux.shutdown(); + (terminal_id, entries) + }; + + // Wipe the persisted reducer state so the reopen cannot lean on the + // snapshot: an identical roster proves it derives from the journal. + let registry = WorkspaceRegistry::open(&root, session).unwrap(); + registry + .put_journal_reducer_state(crate::journal_reducers::AGENT_ROSTER_REDUCER_ID, 0, 0, "") + .unwrap(); + let reopened = Mux::from_workspace_registry( + session.into(), + SurfaceOptions::default(), + registry, + ProviderWorkspaceState::default(), + true, + ) + .unwrap(); + let rederived = reopened.agent_roster.lock().unwrap().roster.entries.clone(); + assert_eq!(rederived, live_entries); + + // Folding the tail after an ended session removes the entry, and + // that removal survives the next reopen through the snapshot. + let ingress = crate::agent_hooks::agent_hook_journal_ingress( + "claude", + "SessionEnd", + Some(&terminal_id.to_string()), + serde_json::json!({"session_id":"native-1"}), + ) + .unwrap(); + reopened.append_journal_ingress(&ingress, "test", "rederive-3").unwrap(); + assert!(reopened.agent_roster.lock().unwrap().roster.entries.is_empty()); + reopened.shutdown(); + drop(reopened); + + let registry = WorkspaceRegistry::open(&root, session).unwrap(); + let final_mux = Mux::from_workspace_registry( + session.into(), + SurfaceOptions::default(), + registry, + ProviderWorkspaceState::default(), + true, + ) + .unwrap(); + assert!(final_mux.agent_roster.lock().unwrap().roster.entries.is_empty()); + final_mux.shutdown(); + drop(final_mux); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn invalid_agent_roster_snapshot_replays_from_the_journal_head() { + let root = std::env::temp_dir().join(format!( + "cmux-roster-invalid-snapshot-{}", + crate::workspace_registry::new_uuid_v4() + )); + let session = "roster-invalid-snapshot"; + let (terminal_id, cursor) = { + let registry = WorkspaceRegistry::open(&root, session).unwrap(); + let mux = Mux::from_workspace_registry( + session.into(), + SurfaceOptions::default(), + registry, + ProviderWorkspaceState::default(), + true, + ) + .unwrap(); + let surface = mux.new_workspace(None, None).unwrap(); + let terminal_id = mux.with_state(|state| { + match state.resource_indexes.content_ids.get(&surface.id).unwrap() { + ContentPublicId::Terminal(terminal_id) => terminal_id.clone(), + ContentPublicId::Browser(_) => panic!("workspace opened a browser"), + } + }); + let ingress = crate::agent_hooks::agent_hook_journal_ingress( + "claude", + "UserPromptSubmit", + Some(terminal_id.as_str()), + serde_json::json!({"session_id":"native-1"}), + ) + .unwrap(); + mux.append_journal_ingress(&ingress, "test", "invalid-snapshot-1").unwrap(); + let cursor = mux.agent_roster.lock().unwrap().cursor; + assert_eq!( + mux.agent_roster.lock().unwrap().roster.entries[terminal_id.as_str()].state, + "working" + ); + mux.shutdown(); + drop(mux); + (terminal_id, cursor) + }; + + let registry = WorkspaceRegistry::open(&root, session).unwrap(); + registry + .put_journal_reducer_state( + crate::journal_reducers::AGENT_ROSTER_REDUCER_ID, + crate::journal_reducers::AGENT_ROSTER_REDUCER_VERSION, + cursor, + "not-json", + ) + .unwrap(); + drop(registry); + + let registry = WorkspaceRegistry::open(&root, session).unwrap(); + let reopened = Mux::from_workspace_registry( + session.into(), + SurfaceOptions::default(), + registry, + ProviderWorkspaceState::default(), + true, + ) + .unwrap(); + let entry = reopened + .agent_roster + .lock() + .unwrap() + .roster + .entries + .get(terminal_id.as_str()) + .cloned() + .expect("invalid snapshots must replay the retained journal"); + assert_eq!(entry.state, "working"); + assert_eq!(entry.source, "hook"); + reopened.shutdown(); + drop(reopened); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn invalid_empty_agent_roster_snapshot_is_repaired_on_restart() { + let root = std::env::temp_dir().join(format!( + "cmux-roster-invalid-empty-snapshot-{}", + crate::workspace_registry::new_uuid_v4() + )); + let session = "roster-invalid-empty-snapshot"; + let registry = WorkspaceRegistry::open(&root, session).unwrap(); + registry + .put_journal_reducer_state( + crate::journal_reducers::AGENT_ROSTER_REDUCER_ID, + crate::journal_reducers::AGENT_ROSTER_REDUCER_VERSION, + 0, + "not-json", + ) + .unwrap(); + drop(registry); + + let registry = WorkspaceRegistry::open(&root, session).unwrap(); + let mux = Mux::from_workspace_registry( + session.into(), + SurfaceOptions::default(), + registry, + ProviderWorkspaceState::default(), + true, + ) + .unwrap(); + assert!(mux.agent_roster.lock().unwrap().roster.entries.is_empty()); + mux.shutdown(); + drop(mux); + + let registry = WorkspaceRegistry::open(&root, session).unwrap(); + let (version, cursor, snapshot) = registry + .journal_reducer_state(crate::journal_reducers::AGENT_ROSTER_REDUCER_ID) + .unwrap() + .expect("startup must replace a rejected empty snapshot"); + assert_eq!(version, crate::journal_reducers::AGENT_ROSTER_REDUCER_VERSION); + assert_eq!(cursor, 0); + assert!(crate::journal_reducers::AgentRoster::restore(&snapshot).is_some()); + drop(registry); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn agent_roster_replays_when_persisted_cursor_is_ahead_of_journal() { + let root = std::env::temp_dir() + .join(format!("cmux-roster-ahead-cursor-{}", crate::workspace_registry::new_uuid_v4())); + let session = "roster-ahead-cursor"; + let terminal_id = { + let registry = WorkspaceRegistry::open(&root, session).unwrap(); + let mux = Mux::from_workspace_registry( + session.into(), + SurfaceOptions::default(), + registry, + ProviderWorkspaceState::default(), + true, + ) + .unwrap(); + let surface = mux.new_workspace(None, None).unwrap(); + let terminal_id = mux.with_state(|state| { + match state.resource_indexes.content_ids.get(&surface.id).unwrap() { + ContentPublicId::Terminal(terminal_id) => terminal_id.clone(), + ContentPublicId::Browser(_) => panic!("workspace opened a browser"), + } + }); + let ingress = crate::agent_hooks::agent_hook_journal_ingress( + "claude", + "UserPromptSubmit", + Some(terminal_id.as_str()), + serde_json::json!({"session_id":"native-1"}), + ) + .unwrap(); + mux.append_journal_ingress(&ingress, "test", "ahead-cursor-1").unwrap(); + mux.shutdown(); + drop(mux); + terminal_id + }; + + let registry = WorkspaceRegistry::open(&root, session).unwrap(); + let journal_head = registry.session_journal_head().unwrap(); + assert!(journal_head > 0); + // Keep a valid snapshot, but move its cursor beyond the retained + // journal. Startup must reject that checkpoint and replay the journal + // instead of returning a cursor.invalid error. + registry + .put_journal_reducer_state( + crate::journal_reducers::AGENT_ROSTER_REDUCER_ID, + crate::journal_reducers::AGENT_ROSTER_REDUCER_VERSION, + journal_head.checked_add(1).expect("test journal head must not overflow"), + &crate::journal_reducers::AgentRoster::default().snapshot().to_string(), + ) + .unwrap(); + drop(registry); + + let registry = WorkspaceRegistry::open(&root, session).unwrap(); + let reopened = Mux::from_workspace_registry( + session.into(), + SurfaceOptions::default(), + registry, + ProviderWorkspaceState::default(), + true, + ) + .unwrap(); + let entry = reopened + .agent_roster + .lock() + .unwrap() + .roster + .entries + .get(terminal_id.as_str()) + .cloned() + .expect("an ahead cursor must replay the retained journal"); + assert_eq!(entry.state, "working"); + assert_eq!(entry.source, "hook"); + let (live_head, cursor, snapshot) = { + let registry = reopened.workspace_registry.lock().unwrap(); + let (_, cursor, snapshot) = registry + .journal_reducer_state(crate::journal_reducers::AGENT_ROSTER_REDUCER_ID) + .unwrap() + .expect("startup must repair the rejected cursor"); + (registry.session_journal_head().unwrap(), cursor, snapshot) + }; + // Mux startup can append unrelated lifecycle records after the roster + // checkpoint is repaired. The durable cursor must reach the journal + // head that existed at reopen, while the live head may have advanced. + assert_eq!(cursor, journal_head); + assert!(live_head >= cursor); + assert!(crate::journal_reducers::AgentRoster::restore(&snapshot).is_some()); + reopened.shutdown(); + drop(reopened); + std::fs::remove_dir_all(root).unwrap(); + } + #[test] fn failed_raw_agent_report_rolls_back_projection_memory_revision_and_event() { let mux = test_mux(); diff --git a/cmux-tui/crates/cmux-tui-core/src/mux/public_projections.rs b/cmux-tui/crates/cmux-tui-core/src/mux/public_projections.rs index 35c99ce8d59a..a41fa8b369ce 100644 --- a/cmux-tui/crates/cmux-tui-core/src/mux/public_projections.rs +++ b/cmux-tui/crates/cmux-tui-core/src/mux/public_projections.rs @@ -119,6 +119,7 @@ pub(super) fn restore_public_projections( state, source: agent_source(&agent.source)?, session: (!internal_marker).then_some(agent.source_session).flatten(), + agent: agent.agent, updated_at_ms: agent.updated_at_ms, }, ); @@ -163,6 +164,7 @@ fn agent_state(value: &str) -> anyhow::Result { fn agent_source(value: &str) -> anyhow::Result { match value { + "plugin" => Ok(AgentSource::Plugin), "detected" => Ok(AgentSource::Detected), "socket" => Ok(AgentSource::Socket), "hook" => Ok(AgentSource::Hook), @@ -237,6 +239,7 @@ mod tests { source: "hook".into(), updated_at_ms: 1, source_session: None, + agent: None, }], agent_hook_states: Vec::new(), terminal_defaults: None, @@ -319,6 +322,7 @@ mod tests { source: "hook".into(), updated_at_ms: 1, source_session: None, + agent: None, }], agent_hook_states: Vec::new(), terminal_defaults: None, @@ -341,6 +345,7 @@ mod tests { source: "hook".into(), updated_at_ms: 1, source_session: Some("cmux-hook-sequence:12".into()), + agent: None, }], agent_hook_states: Vec::new(), terminal_defaults: None, @@ -363,6 +368,7 @@ mod tests { source: "socket".into(), updated_at_ms: 3, source_session: Some("socket-session".into()), + agent: None, }], agent_hook_states: Vec::new(), terminal_defaults: None, diff --git a/cmux-tui/crates/cmux-tui-core/src/platform.rs b/cmux-tui/crates/cmux-tui-core/src/platform.rs index eac05801d847..f5e460423dcb 100644 --- a/cmux-tui/crates/cmux-tui-core/src/platform.rs +++ b/cmux-tui/crates/cmux-tui-core/src/platform.rs @@ -988,6 +988,51 @@ pub fn foreground_cwd(pid: u32) -> Option { process_cwd(foreground_process_group(pid)?) } +/// Executable path or name of a terminal's live foreground process-group +/// leader (see [`foreground_cwd`] for the leader resolution contract). Exposed +/// as generic terminal metadata so userland plugins can identify their own +/// foreground applications. +/// Returns `None` when the leader is gone, the child has no controlling +/// terminal, or the platform denies the lookup. +pub fn foreground_process_name(pid: u32) -> Option { + process_name(foreground_process_group(pid)?) +} + +#[cfg(target_os = "linux")] +fn process_name(pid: u32) -> Option { + // argv[0]'s basename beats /proc//comm: comm truncates to 15 + // bytes and wrapper launchers exec with a meaningful argv[0]. + let argv0 = std::fs::read(format!("/proc/{pid}/cmdline")).ok().and_then(|cmdline| { + let argv0 = cmdline.split(|byte| *byte == 0).next()?; + let argv0 = std::str::from_utf8(argv0).ok()?.trim(); + (!argv0.is_empty()).then(|| argv0.to_string()) + }); + argv0.or_else(|| { + let comm = std::fs::read_to_string(format!("/proc/{pid}/comm")).ok()?; + let comm = comm.trim(); + (!comm.is_empty()).then(|| comm.to_string()) + }) +} + +#[cfg(target_os = "macos")] +fn process_name(pid: u32) -> Option { + let pid = libc::c_int::try_from(pid).ok()?; + let mut path = [0u8; libc::PROC_PIDPATHINFO_MAXSIZE as usize]; + // SAFETY: proc_pidpath writes at most `path.len()` bytes and returns + // the written byte count (0 on failure). + let written = unsafe { libc::proc_pidpath(pid, path.as_mut_ptr().cast(), path.len() as u32) }; + if written <= 0 { + return None; + } + let path = std::str::from_utf8(&path[..written as usize]).ok()?; + (!path.is_empty()).then(|| path.to_string()) +} + +#[cfg(not(any(target_os = "linux", target_os = "macos")))] +fn process_name(_pid: u32) -> Option { + None +} + #[cfg(target_os = "linux")] fn foreground_process_group(pid: u32) -> Option { let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?; diff --git a/cmux-tui/crates/cmux-tui-core/src/resource_api.rs b/cmux-tui/crates/cmux-tui-core/src/resource_api.rs index c0fc78aed05d..1e05e415cc7a 100644 --- a/cmux-tui/crates/cmux-tui-core/src/resource_api.rs +++ b/cmux-tui/crates/cmux-tui-core/src/resource_api.rs @@ -437,6 +437,14 @@ pub(crate) fn public_terminal_snapshot( "running": durable.lifecycle == TerminalLifecycle::Running, "lifecycle": lifecycle, }); + if let Some(surface) = surface + && let Ok(revision) = surface.terminal_stream_revision() + { + // This is a coalesced output revision, not the resource revision. It + // lets external observers skip a full screen read when the PTY did + // not change. + terminal["stream_revision"] = json!(revision.to_string()); + } if let Some(cwd) = surface.and_then(crate::Surface::presented_directory) { terminal["cwd"] = json!(cwd); } diff --git a/cmux-tui/crates/cmux-tui-core/src/resource_router/auxiliary.rs b/cmux-tui/crates/cmux-tui-core/src/resource_router/auxiliary.rs index e90aa832d1df..513c7be8e30c 100644 --- a/cmux-tui/crates/cmux-tui-core/src/resource_router/auxiliary.rs +++ b/cmux-tui/crates/cmux-tui-core/src/resource_router/auxiliary.rs @@ -718,9 +718,18 @@ mod tests { let replay = dispatch(&mux, report_request("agent-report-once")).unwrap(); assert_eq!(replay["replayed"], true); assert_eq!(replay["value"], first["value"]); + let revision_after_first = first["revision"].as_str().unwrap().parse::().unwrap(); + let epoch_after_first = mux.resource_event_epoch(); let repeated = dispatch(&mux, report_request("agent-report-twice")).unwrap(); - assert_eq!(repeated["replayed"], false); - assert_eq!(repeated["value"]["id"], first["value"]["id"]); + assert_eq!(repeated["replayed"], true); + assert_eq!(repeated["revision"], revision_after_first.to_string()); + assert_eq!(repeated["value"], first["value"]); + assert_eq!(mux.resource_event_epoch(), epoch_after_first); + assert_eq!( + mux.with_state(|state| state.resource_revision), + revision_after_first, + "a same-state socket report must not advance the shared revision" + ); assert!( !first["value"]["id"] .as_str() @@ -743,6 +752,18 @@ mod tests { assert_eq!(listed[0]["id"], first["value"]["id"]); } + #[test] + fn public_agent_report_rejects_internal_plugin_and_detected_sources() { + for source in ["plugin", "detected"] { + assert!( + parse_agent_source(source).is_err(), + "{source} is an internal projection source" + ); + } + assert!(parse_agent_source("hook").is_ok()); + assert!(parse_agent_source("socket").is_ok()); + } + #[test] fn filtered_agent_list_does_not_decode_unrelated_projections() { let mux = Mux::new_for_test("filtered-agent-list", SurfaceOptions::default()); diff --git a/cmux-tui/crates/cmux-tui-core/src/resource_router/content.rs b/cmux-tui/crates/cmux-tui-core/src/resource_router/content.rs index 7e2bb288ec0d..5d135e90e509 100644 --- a/cmux-tui/crates/cmux-tui-core/src/resource_router/content.rs +++ b/cmux-tui/crates/cmux-tui-core/src/resource_router/content.rs @@ -111,28 +111,16 @@ fn terminal_screen_read( request: &ParsedResourceRequest, ) -> Result { let (_, surface) = resolve_terminal_surface(mux, &request.selectors)?; - let (text, cols, rows, cursor_col, cursor_row, cursor_visible) = surface - .try_with_terminal(|terminal| { - let text = terminal.viewport_text()?; - let (cursor_col, cursor_row) = terminal.cursor_position().unwrap_or((0, 0)); - Ok::<_, ghostty_vt::Error>(( - text, - terminal.cols(), - terminal.rows(), - cursor_col, - cursor_row, - terminal.mode(25, false), - )) - }) - .map_err(resource_operation_error)? - .map_err(|error| resource_operation_error(error.into()))?; + let snapshot = surface.terminal_screen_snapshot().map_err(resource_operation_error)?; Ok(json!({ - "text":text, - "cols":cols, - "rows":rows, - "cursor_row":cursor_row, - "cursor_col":cursor_col, - "cursor_visible":cursor_visible, + "text":snapshot.text, + "cols":snapshot.cols, + "rows":snapshot.rows, + "cursor_row":snapshot.cursor_row, + "cursor_col":snapshot.cursor_col, + "cursor_visible":snapshot.cursor_visible, + "revision":snapshot.revision.to_string(), + "osc_progress":snapshot.osc_progress, })) } @@ -331,6 +319,7 @@ fn terminal_process_get( "argv":argv, "children":children, "foreground_cwd":crate::platform::foreground_cwd(pid), + "foreground_executable":crate::platform::foreground_process_name(pid), }); if let Some(executable) = executable { value["executable"] = json!(executable); diff --git a/cmux-tui/crates/cmux-tui-core/src/server.rs b/cmux-tui/crates/cmux-tui-core/src/server.rs index d19682b9f996..321f36c1a8db 100644 --- a/cmux-tui/crates/cmux-tui-core/src/server.rs +++ b/cmux-tui/crates/cmux-tui-core/src/server.rs @@ -8400,7 +8400,7 @@ fn handle_journal_extension_request( let origin = LOCAL_JOURNAL_PRINCIPAL; match request.envelope.operation { ResourceOperation::SessionJournalProducerList => mux - .journal_producer_manifests() + .userland_journal_producer_manifests() .map(|producers| json!({"producers":producers})) .map_err(|error| journal_extension_error("session.journal.producer.list", error)), ResourceOperation::SessionJournalProducerPut => { @@ -10313,7 +10313,7 @@ fn parse_agent_source(source: &str) -> anyhow::Result { match source { "socket" => Ok(AgentSource::Socket), "hook" => Ok(AgentSource::Hook), - other => anyhow::bail!("bad source {other}"), + other => anyhow::bail!("bad source {other}; raw report-agent accepts only socket or hook"), } } @@ -10323,6 +10323,7 @@ fn agent_json(record: &AgentRecord) -> Value { "state": record.state.as_str(), "source": record.source.as_str(), "session": record.session, + "agent": record.agent, "updated_at_ms": record.updated_at_ms, }) } @@ -12326,6 +12327,9 @@ fn handle_command_with_cancellation( "command": surface.spawn_command(), "cwd": surface.local_cwd(), "foreground_cwd": surface.process_id().and_then(platform::foreground_cwd), + "foreground_executable": surface + .process_id() + .and_then(platform::foreground_process_name), })) } Command::MoveTerminal { terminal_id, workspace_key, terminal_incarnation, mutation } => { @@ -13360,12 +13364,13 @@ fn subscribed_event_json(event: &MuxEvent) -> Value { MuxEvent::TitleChanged { surface, title } => { json!({"event": "title-changed", "surface": surface, "title": title.as_ref()}) } - MuxEvent::AgentChanged { surface, state, source, session, updated_at_ms } => json!({ + MuxEvent::AgentChanged { surface, state, source, session, agent, updated_at_ms } => json!({ "event": "agent-changed", "surface": surface, "state": state.as_ref(), "source": source.as_ref(), "session": session.as_deref(), + "agent": agent.as_deref(), "updated_at_ms": updated_at_ms, }), MuxEvent::Bell(id) => json!({"event": "bell", "surface": id}), @@ -19430,7 +19435,9 @@ mod tests { assert_eq!(result["source"], "socket"); assert_eq!(result["session"], "raw-command"); assert_eq!(mux.with_state(|state| state.resource_revision), revision + 1); - assert_eq!(mux.resource_event_epoch(), epoch + 1); + // A fresh direct report publishes twice on the shared change epoch: + // its resource commit and its journal echo. + assert_eq!(mux.resource_event_epoch(), epoch + 2); assert_eq!(mux.resource_agent_projection_count_for_test().unwrap(), 1); let events = mux.resource_events_after(revision).unwrap(); assert_eq!(events.batches.len(), 1); @@ -19438,6 +19445,30 @@ mod tests { assert_eq!(events.batches[0].changes[0]["value"]["source_session"], "raw-command"); } + #[test] + fn raw_report_agent_command_rejects_internal_projection_sources() { + let mux = test_mux(); + let surface = mux.new_workspace(None, None).unwrap(); + + for source in ["plugin", "detected"] { + let error = handle_command( + &mux, + 0, + Command::ReportAgent { + surface: surface.id, + state: "working".into(), + source: source.into(), + session: Some("raw-command".into()), + }, + &test_writer(), + ) + .unwrap_err(); + assert!(error.to_string().contains("bad source"), "{source}: {error}"); + } + + assert_eq!(mux.resource_agent_projection_count_for_test().unwrap(), 0); + } + #[test] fn guarded_browser_pointer_commands_require_a_numeric_frame_guard() { for cmd in ["browser-mouse-guarded", "browser-wheel-guarded"] { @@ -23184,6 +23215,7 @@ mod tests { state: Arc::::from("working"), source: Arc::::from("hook"), session: Some(Arc::::from("review")), + agent: Some(Arc::::from("claude")), updated_at_ms: 41, }), json!({ @@ -23192,6 +23224,7 @@ mod tests { "state": "working", "source": "hook", "session": "review", + "agent": "claude", "updated_at_ms": 41, }) ); diff --git a/cmux-tui/crates/cmux-tui-core/src/surface.rs b/cmux-tui/crates/cmux-tui-core/src/surface.rs index f962c7a1a554..300dc5df8fd3 100644 --- a/cmux-tui/crates/cmux-tui-core/src/surface.rs +++ b/cmux-tui/crates/cmux-tui-core/src/surface.rs @@ -81,6 +81,21 @@ pub(crate) enum ConfirmedInputFailure { Indeterminate(std::io::Error), } +/// A terminal viewport and its output watermark captured at one parser +/// boundary. The stream writers advance their revision while holding the +/// terminal lock, so a reader cannot pair text from one boundary with a +/// revision from another. +pub(crate) struct TerminalScreenSnapshot { + pub(crate) text: String, + pub(crate) cols: u16, + pub(crate) rows: u16, + pub(crate) cursor_col: u16, + pub(crate) cursor_row: u16, + pub(crate) cursor_visible: bool, + pub(crate) revision: u64, + pub(crate) osc_progress: String, +} + /// Nonblocking probe for the terminal mouse protocol and reporting mode. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum PointerSemanticProbe { @@ -1405,6 +1420,17 @@ impl Drop for ReaderCompletionGuard { } impl PtyTerminalRuntime { + /// Feed raw child output to the generic terminal metadata parser. The + /// parser has no knowledge of agents or plugins and keeps only bounded + /// terminal protocol state. + fn observe_terminal_output(&self, bytes: &[u8]) { + self.terminal_metadata.lock().unwrap().observe_output(bytes); + } + + fn terminal_osc_progress(&self) -> String { + self.terminal_metadata.lock().unwrap().osc_progress().to_string() + } + fn begin_terminal_journal_update(&self) -> Option> { let _gate = self.journal_capture_gate.lock().unwrap(); if !self.journal_capture_open.load(Ordering::Acquire) { @@ -1490,6 +1516,10 @@ pub struct PtyTerminalRuntime { reaper_completion: Arc, term: Mutex>, stream_progress: Box, + /// Generic metadata parsed from raw PTY output. This field has no agent + /// or roster knowledge, so userland plugins can consume it through the + /// resource API without moving detection policy into core. + terminal_metadata: Mutex, mouse_encoders: Mutex>, runtime: Mutex, /// Explicit lifecycle authority for this process. Session content may @@ -1770,6 +1800,8 @@ pub(crate) struct TerminalStreamProgress { next_resource_waiter_id: AtomicU64, state: Mutex, changed: Condvar, + #[cfg(test)] + test_before_notify: Mutex>>, } #[derive(Default)] @@ -1828,6 +1860,8 @@ impl Default for TerminalStreamProgress { next_resource_waiter_id: AtomicU64::new(1), state: Mutex::new(TerminalStreamProgressState::default()), changed: Condvar::new(), + #[cfg(test)] + test_before_notify: Mutex::new(None), } } } @@ -1838,6 +1872,10 @@ impl TerminalStreamProgress { } pub(crate) fn notify(&self) { + #[cfg(test)] + if let Some(hook) = self.test_before_notify.lock().unwrap().clone() { + hook(); + } let mut state = self.state.lock().unwrap(); state.revision = state.revision.wrapping_add(1); // An expired budget is retained only while the stream is unchanged. @@ -1853,6 +1891,11 @@ impl TerminalStreamProgress { } } + #[cfg(test)] + fn set_before_notify_hook(&self, hook: Option>) { + *self.test_before_notify.lock().unwrap() = hook; + } + fn notify_reconnect(&self) { self.notify(); } @@ -2403,6 +2446,7 @@ impl Surface { reaper_completion: Arc::new(ReaderCompletion::default()), term: Mutex::new(Box::new(term)), stream_progress: Box::new(TerminalStreamProgress::default()), + terminal_metadata: Mutex::new(Default::default()), mouse_encoders: Mutex::new(Box::new(mouse_encoders)), runtime: Mutex::new(PtyRuntime::Local { writer, master: Some(master), killer }), lifetime, @@ -2524,6 +2568,7 @@ impl Surface { .cursor_activity() .expect("valid local terminals expose cursor activity"); let normalized = term.vt_write_with_normalized(&buf[..n]); + pty.observe_terminal_output(&buf[..n]); let cursor_changed = term .cursor_activity() .expect("valid local terminals expose cursor activity") @@ -2558,10 +2603,14 @@ impl Surface { // for the reader loop, so any journal allocation can happen after // releasing the lock. let journal_output = journal_enabled.then_some(normalized); - ( - pty.render_generation.fetch_add(1, Ordering::AcqRel) + 1, - journal_output, - ) + let generation = + pty.render_generation.fetch_add(1, Ordering::AcqRel) + 1; + // Advance the output watermark before releasing + // the parser lock. Screen snapshots take the same + // lock, so they cannot observe this frame with the + // previous revision. + pty.stream_progress.notify(); + (generation, journal_output) }; let (generation, journal_output) = generation; if let (Some(journal_target), Some(journal_output)) = @@ -2774,9 +2823,9 @@ impl Surface { scroll_changed = Some(after); broadcast_render_scroll_locked(pty, after); } + pty.stream_progress.notify(); pty.render_generation.fetch_add(1, Ordering::AcqRel) + 1 }; - pty.stream_progress.notify(); pty.request_frame(generation); if let Some((offset, at_bottom)) = scroll_changed && let Some(mux) = mux.upgrade() @@ -2831,6 +2880,11 @@ impl Surface { let title_changed = Arc::new(AtomicBool::new(false)); let callbacks = hosted_terminal_callbacks(id, mux.clone(), title_changed.clone()); let mut term = Terminal::new(snapshot.cols, snapshot.rows, opts.scrollback, callbacks)?; + let mut terminal_metadata = crate::terminal_metadata::TerminalMetadata::default(); + anyhow::ensure!( + terminal_metadata.set_osc_progress(&snapshot.osc_progress), + "terminal host returned invalid OSC progress metadata" + ); term.resize( snapshot.cols, snapshot.rows, @@ -2905,6 +2959,7 @@ impl Surface { reaper_completion: Arc::new(ReaderCompletion::default()), term: Mutex::new(Box::new(term)), stream_progress: Box::new(TerminalStreamProgress::default()), + terminal_metadata: Mutex::new(terminal_metadata), mouse_encoders: Mutex::new(Box::new(mouse_encoders)), runtime: Mutex::new(PtyRuntime::Hosted(Box::new(attachment))), lifetime, @@ -3081,6 +3136,7 @@ impl Surface { let journal_enabled = journal_update.is_some(); let before = terminal_scroll_position(&term); let normalized = term.vt_write_with_normalized(&output); + pty.observe_terminal_output(&output); let output = match normalized { Cow::Borrowed(_) => output, Cow::Owned(normalized) => normalized, @@ -3143,6 +3199,11 @@ impl Surface { scroll_changed = Some(after); broadcast_render_scroll_locked(pty, after); } + // Advance the output watermark while the + // parser lock is held. A screen snapshot + // cannot then pair this text with an old + // revision. + pty.stream_progress.notify(); ( pty.render_generation.fetch_add(1, Ordering::AcqRel) + 1, journal_output, @@ -3267,11 +3328,10 @@ impl Surface { let title = replacement.title().unwrap_or_default(); let pwd = replacement.pwd(); let mut scroll_changed = None; - let generation = { - let mut term = pty.term.lock().unwrap(); - let before = terminal_scroll_position(&term); - **term = replacement; - pty.mouse_encoders.lock().unwrap().sync_from_terminal(&term); + let generation = pty.with_terminal_stream_update(|term| { + let before = terminal_scroll_position(term); + *term = replacement; + pty.mouse_encoders.lock().unwrap().sync_from_terminal(term); *geometry = next_geometry; pty.journal_geometry(next_geometry); *pty.title.lock().unwrap() = title.clone(); @@ -3280,7 +3340,7 @@ impl Surface { applied_color_overrides = colors; applied_color_revision = term.color_revision(); applied_cursor_activity = term.cursor_activity().ok(); - let after = terminal_scroll_position(&term); + let after = terminal_scroll_position(term); if before != after { scroll_changed = Some(after); broadcast_render_scroll_locked(pty, after); @@ -3295,11 +3355,11 @@ impl Surface { kitty_image_aliases, kitty_state, colors: Box::new( - pty.terminal_colors_locked(&term, defaults), + pty.terminal_colors_locked(term, defaults), ), }); pty.render_generation.fetch_add(1, Ordering::AcqRel) + 1 - }; + }); drop(geometry); surface.publish_pending_directory(); pty.stream_progress.notify(); @@ -3567,12 +3627,23 @@ impl Surface { if !color_delta.is_empty() { replacement_term.vt_write(&color_delta); } + let mut replacement_metadata = + crate::terminal_metadata::TerminalMetadata::default(); + if !replacement_metadata + .set_osc_progress(&replacement_snapshot.osc_progress) + { + if !retry.wait_or_fail(pty) { + return; + } + continue; + } title_changed.store(false, Ordering::Relaxed); let title = replacement_term.title().unwrap_or_default(); let pwd = replacement_term.pwd(); let generation = { let mut term = pty.term.lock().unwrap(); **term = replacement_term; + *pty.terminal_metadata.lock().unwrap() = replacement_metadata; pty.mouse_encoders.lock().unwrap().sync_from_terminal(&term); *geometry = next_geometry; *pty.title.lock().unwrap() = title.clone(); @@ -3590,10 +3661,10 @@ impl Surface { kitty_state: replacement_snapshot.kitty_state, colors: Box::new(pty.terminal_colors_locked(&term, defaults)), }); + pty.stream_progress.notify_reconnect(); pty.render_generation.fetch_add(1, Ordering::AcqRel) + 1 }; drop(geometry); - pty.stream_progress.notify_reconnect(); pty.request_frame(generation); if !reconnect_mux.terminal_host_reconnected( surface.id, @@ -3949,6 +4020,7 @@ impl Surface { reaper_completion: Arc::new(ReaderCompletion::default()), term: Mutex::new(Box::new(term)), stream_progress: Box::new(TerminalStreamProgress::default()), + terminal_metadata: Mutex::new(Default::default()), mouse_encoders: Mutex::new(Box::new(mouse_encoders)), runtime: Mutex::new(PtyRuntime::ExitedHosted), lifetime: PtyLifetime::SessionOwned, @@ -4180,6 +4252,7 @@ impl Surface { reaper_completion: Arc::new(ReaderCompletion::default()), term: Mutex::new(Box::new(term)), stream_progress: Box::new(TerminalStreamProgress::default()), + terminal_metadata: Mutex::new(Default::default()), mouse_encoders: Mutex::new(Box::new(mouse_encoders)), runtime: Mutex::new(PtyRuntime::Local { writer: Box::new(std::io::sink()), @@ -4623,6 +4696,33 @@ impl Surface { Ok(pty.stream_progress.revision()) } + /// Capture the viewport, generic terminal metadata, and stream revision + /// while the parser lock is held. This is the only screen-read path that + /// can safely use the revision as a scheduling watermark. + pub(crate) fn terminal_screen_snapshot(&self) -> anyhow::Result { + let Some(pty) = self.as_pty() else { + anyhow::bail!("browser surface does not have a VT terminal"); + }; + let mut term = pty.term.lock().unwrap(); + let text = term.viewport_text()?; + let (cursor_col, cursor_row) = term.cursor_position().unwrap_or((0, 0)); + let cursor_visible = term.mode(25, false); + // Match the parser's lock order, term -> metadata -> stream progress. + // The revision is advanced before the terminal lock is released. + let osc_progress = pty.terminal_osc_progress(); + let revision = pty.stream_progress.revision(); + Ok(TerminalScreenSnapshot { + text, + cols: term.cols(), + rows: term.rows(), + cursor_col, + cursor_row, + cursor_visible, + revision, + osc_progress, + }) + } + pub(crate) fn subscribe_terminal_stream_change( &self, ) -> ghostty_vt::Result> { @@ -4650,8 +4750,8 @@ impl Surface { let pty = self.as_pty()?; let mut term = pty.term.lock().unwrap(); term.vt_write(bytes); + pty.observe_terminal_output(bytes); pty.mouse_encoders.lock().unwrap().sync_from_terminal(&term); - drop(term); pty.stream_progress.notify(); Some(()) } @@ -6628,6 +6728,16 @@ impl PtySurface { } } + /// Apply one replacement to the terminal stream and publish its revision. + /// The revision must be published before another screen reader can acquire + /// the terminal lock. + fn with_terminal_stream_update(&self, update: impl FnOnce(&mut Terminal) -> R) -> R { + let mut term = self.term.lock().unwrap(); + let result = update(&mut term); + self.stream_progress.notify(); + result + } + /// Publish the last PTY generation before the mux drops this surface. /// /// A normal frame request may still be waiting for the cadence deadline, @@ -6973,6 +7083,9 @@ impl PtySurface { colors, }); } + // Geometry changes are terminal-stream transitions too. Publish the + // revision before releasing the parser lock so screen snapshots have + // one consistent boundary for text and dimensions. self.stream_progress.notify(); Ok(true) } @@ -9452,6 +9565,135 @@ mod tests { assert_eq!(progress.revision(), revision_before); } + #[test] + fn terminal_snapshot_cannot_pair_new_text_with_an_old_revision() { + let mux = Mux::new_for_test("terminal-snapshot-boundary", SurfaceOptions::default()); + let surface = + Surface::spawn_for_test(1, SurfaceOptions::default(), Arc::downgrade(&mux)).unwrap(); + let progress = &surface.as_pty().unwrap().stream_progress; + let revision_before = progress.revision(); + let (notify_started_tx, notify_started_rx) = sync_channel(1); + let (release_notify_tx, release_notify_rx) = sync_channel(1); + let release_notify = Arc::new(Mutex::new(release_notify_rx)); + progress.set_before_notify_hook(Some(Arc::new(move || { + notify_started_tx.send(()).unwrap(); + release_notify + .lock() + .unwrap() + .recv_timeout(Duration::from_secs(2)) + .expect("snapshot test did not release the notification boundary"); + }))); + + let update_surface = surface.clone(); + let update = std::thread::spawn(move || { + update_surface.apply_stream_output_for_test(b"new-output").unwrap(); + }); + notify_started_rx + .recv_timeout(Duration::from_secs(1)) + .expect("output did not reach the notification boundary"); + + let (snapshot_entered_tx, snapshot_entered_rx) = sync_channel(1); + let (snapshot_tx, snapshot_rx) = sync_channel(1); + let snapshot_surface = surface.clone(); + let snapshot_revision_surface = snapshot_surface.clone(); + std::thread::spawn(move || { + let snapshot = snapshot_surface + .try_with_terminal(|terminal| { + snapshot_entered_tx.send(()).unwrap(); + let text = terminal.viewport_text().unwrap(); + let revision = snapshot_revision_surface.terminal_stream_revision().unwrap(); + (text, revision) + }) + .unwrap(); + snapshot_tx.send(snapshot).unwrap(); + }); + + assert!( + snapshot_entered_rx.recv_timeout(Duration::from_millis(250)).is_err(), + "snapshot entered while output revision notification was still pending" + ); + assert!( + snapshot_rx.try_recv().is_err(), + "snapshot returned while output revision notification was still pending" + ); + + release_notify_tx.send(()).unwrap(); + update.join().unwrap(); + snapshot_entered_rx + .recv_timeout(Duration::from_secs(1)) + .expect("snapshot did not run after the output boundary"); + let (text, revision) = snapshot_rx + .recv_timeout(Duration::from_secs(1)) + .expect("snapshot result was not delivered"); + assert!(text.contains("new-output"), "snapshot omitted applied output: {text:?}"); + assert!(revision > revision_before, "snapshot returned stale revision {revision}"); + progress.set_before_notify_hook(None); + } + + #[cfg(unix)] + #[test] + fn hosted_replacement_publishes_revision_before_unlocking_terminal() { + let mux = + Mux::new_for_test("hosted-replacement-snapshot-boundary", SurfaceOptions::default()); + let surface = + Surface::spawn_for_test(1, SurfaceOptions::default(), Arc::downgrade(&mux)).unwrap(); + let progress = &surface.as_pty().unwrap().stream_progress; + let revision_before = progress.revision(); + let (notify_started_tx, notify_started_rx) = sync_channel(1); + let (release_notify_tx, release_notify_rx) = sync_channel(1); + let release_notify_hook = Arc::new(Mutex::new(release_notify_rx)); + progress.set_before_notify_hook(Some(Arc::new(move || { + notify_started_tx.send(()).unwrap(); + release_notify_hook + .lock() + .unwrap() + .recv_timeout(Duration::from_secs(2)) + .expect("hosted replacement test did not release the notification boundary"); + }))); + + let mut replacement = Terminal::new(81, 24, 10_000, Callbacks::default()).unwrap(); + replacement.resize(81, 24, 8, 16).unwrap(); + let update_surface = surface.clone(); + let update = std::thread::spawn(move || { + let pty = update_surface.as_pty().unwrap(); + let mut geometry = pty.geometry.lock().unwrap(); + let next_geometry = PtyGeometry { cols: 81, ..*geometry }; + pty.with_terminal_stream_update(|term| { + *term = replacement; + *geometry = next_geometry; + term.vt_write(b"host-replacement"); + }); + }); + notify_started_rx + .recv_timeout(Duration::from_secs(1)) + .expect("hosted replacement did not reach the notification boundary"); + + let (snapshot_entered_tx, snapshot_entered_rx) = sync_channel(1); + let (snapshot_tx, snapshot_rx) = sync_channel(1); + let snapshot_surface = surface.clone(); + let snapshot_thread = std::thread::spawn(move || { + let snapshot = snapshot_surface.terminal_screen_snapshot().unwrap(); + snapshot_entered_tx.send(()).unwrap(); + snapshot_tx.send((snapshot.text, snapshot.revision)).unwrap(); + }); + let entered_during_notify = + snapshot_entered_rx.recv_timeout(Duration::from_millis(250)).is_ok(); + + release_notify_tx.send(()).unwrap(); + update.join().unwrap(); + snapshot_thread.join().unwrap(); + assert!( + !entered_during_notify, + "hosted replacement unlocked terminal before revision publication" + ); + let (text, revision) = snapshot_rx + .recv_timeout(Duration::from_secs(1)) + .expect("hosted replacement snapshot was not delivered"); + assert!(text.contains("host-replacement"), "snapshot omitted replacement text: {text:?}"); + assert!(revision > revision_before, "snapshot returned stale revision {revision}"); + progress.set_before_notify_hook(None); + } + #[test] fn resource_wait_subscription_wakes_for_output_resize_reconnect_and_clear() { let mux = Mux::new_for_test("terminal-resource-progress", SurfaceOptions::default()); diff --git a/cmux-tui/crates/cmux-tui-core/src/terminal_host_protocol.rs b/cmux-tui/crates/cmux-tui-core/src/terminal_host_protocol.rs index b1758d9154dd..1d6c719062c7 100644 --- a/cmux-tui/crates/cmux-tui-core/src/terminal_host_protocol.rs +++ b/cmux-tui/crates/cmux-tui-core/src/terminal_host_protocol.rs @@ -48,6 +48,11 @@ pub const FLAG_SMART_RENDERER: u32 = 1 << 2; /// Protocol-v4 HostHello flag. The authenticated launch-owner connection must /// send `Activate` after its daemon has durably committed public topology. pub const FLAG_LAUNCH_ACTIVATION_REQUIRED: u32 = 1 << 3; +/// ClientHello opt-in and HostHello acknowledgement for the optional +/// generic terminal metadata tail in a Snapshot payload. The bit is separate +/// from the protocol version so older persistent hosts and renderers can keep +/// using the exact v4 snapshot layout. +pub const FLAG_TERMINAL_METADATA: u32 = 1 << 4; /// ResizeAck payload flag: this request changed the canonical grid and its /// sequenced Resized+Colors transition was enqueued immediately before the /// targeted acknowledgement. diff --git a/cmux-tui/crates/cmux-tui-core/src/terminal_host_runtime.rs b/cmux-tui/crates/cmux-tui-core/src/terminal_host_runtime.rs index 0ac50535fc34..b047d7c7685e 100644 --- a/cmux-tui/crates/cmux-tui-core/src/terminal_host_runtime.rs +++ b/cmux-tui/crates/cmux-tui-core/src/terminal_host_runtime.rs @@ -30,7 +30,7 @@ use crate::terminal_host_protocol::{ CLEAR_HISTORY_ACK_FALLBACK_WRITE_TIMEOUT, CLEAR_HISTORY_ACK_KNOWN_NOT_DELIVERED, CLEAR_HISTORY_ACK_OK, CLEAR_HISTORY_ACK_PRESERVATION_FAILED, CLEAR_HISTORY_ACK_STREAM_TIMEOUT, FLAG_COLORS_FOLLOW, FLAG_LAUNCH_ACTIVATION_REQUIRED, FLAG_SMART_RENDERER, - FLAG_VIEWER_SIZE_ACKS, Frame, HostLaunchFailure, HostLaunchFailureKind, + FLAG_TERMINAL_METADATA, FLAG_VIEWER_SIZE_ACKS, Frame, HostLaunchFailure, HostLaunchFailureKind, KITTY_IMAGE_ALIAS_COUNT_LEN, KITTY_IMAGE_ALIAS_ENCODED_LEN, LAUNCH_ACTIVATION_PROTOCOL_VERSION, MAX_FRAME_PAYLOAD, MAX_KITTY_IMAGE_ALIASES, MessageKind, PROTOCOL_VERSION, RESIZE_ACK_CANONICAL_CHANGED, TerminalExit, decode_host_launch_failure, decode_terminal_exit, @@ -158,6 +158,10 @@ pub struct TerminalHostRecord { /// accept fire-and-forget input but cannot confirm PTY delivery. #[serde(default)] pub supports_input_ack: bool, + /// Additive snapshot capability. Missing/false records use the v4 + /// snapshot layout without the optional generic terminal metadata tail. + #[serde(default)] + pub supports_terminal_metadata: bool, } impl std::fmt::Debug for TerminalHostRecord { @@ -175,6 +179,7 @@ impl std::fmt::Debug for TerminalHostRecord { .field("supports_clear_history", &self.supports_clear_history) .field("supports_terminate_ack", &self.supports_terminate_ack) .field("supports_input_ack", &self.supports_input_ack) + .field("supports_terminal_metadata", &self.supports_terminal_metadata) .finish() } } @@ -230,6 +235,10 @@ pub struct HostSnapshot { pub pid: Option, pub command: Vec, pub cwd: Option, + /// Optional generic terminal metadata restored at the same snapshot + /// boundary. It is sent only when the client and host negotiate the + /// `FLAG_TERMINAL_METADATA` capability. + pub osc_progress: String, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -2285,10 +2294,16 @@ mod unix { || record.supports_clear_history || record.supports_terminate_ack || record.supports_input_ack + || record.supports_terminal_metadata { anyhow::bail!("legacy terminal-host record has unexpected liveness fields"); } } else { + if record.record_version < HOST_RECORD_VERSION && record.supports_terminal_metadata { + anyhow::bail!( + "legacy terminal-host record advertises terminal metadata without support" + ); + } if record.record_version == 2 && record.supports_terminate_ack { anyhow::bail!("version 2 terminal-host record advertises terminate receipts"); } @@ -2759,19 +2774,28 @@ mod unix { }; let mut hello_frame = hello.into_frame(1); hello_frame.version = protocol_version; + let terminal_metadata_requested = + protocol_version == PROTOCOL_VERSION && record.supports_terminal_metadata; if smart_renderer { hello_frame.flags = FLAG_SMART_RENDERER | FLAG_VIEWER_SIZE_ACKS; } + if terminal_metadata_requested { + hello_frame.flags |= FLAG_TERMINAL_METADATA; + } write_frame(&mut stream, &hello_frame)?; let hello_frame = read_required_frame(&mut stream, "host hello")?; if hello_frame.kind != MessageKind::HostHello || hello_frame.version != protocol_version || hello_frame.flags - & !(FLAG_VIEWER_SIZE_ACKS | FLAG_SMART_RENDERER | FLAG_LAUNCH_ACTIVATION_REQUIRED) + & !(FLAG_VIEWER_SIZE_ACKS + | FLAG_SMART_RENDERER + | FLAG_LAUNCH_ACTIVATION_REQUIRED + | FLAG_TERMINAL_METADATA) != 0 || hello_frame.request_id != 1 || hello_frame.sequence != 0 || (smart_renderer && hello_frame.flags & FLAG_SMART_RENDERER == 0) + || (!terminal_metadata_requested && hello_frame.flags & FLAG_TERMINAL_METADATA != 0) { anyhow::bail!("terminal host rejected owner handshake"); } @@ -2787,6 +2811,7 @@ mod unix { { anyhow::bail!("terminal-host record identity does not match live host"); } + let terminal_metadata_negotiated = hello_frame.flags & FLAG_TERMINAL_METADATA != 0; let snapshot_frame = read_required_frame(&mut stream, "terminal snapshot")?; if snapshot_frame.kind != MessageKind::Snapshot || snapshot_frame.version != protocol_version @@ -2795,7 +2820,11 @@ mod unix { { anyhow::bail!("terminal host did not send an initial snapshot"); } - let mut snapshot = decode_snapshot_for_version(&snapshot_frame.payload, protocol_version)?; + let mut snapshot = decode_snapshot_for_version( + &snapshot_frame.payload, + protocol_version, + terminal_metadata_negotiated, + )?; let colors_frame = read_required_frame(&mut stream, "terminal color state")?; if colors_frame.kind != MessageKind::Colors || colors_frame.version != protocol_version @@ -3472,6 +3501,10 @@ mod unix { owner_token: CapabilityToken, capabilities: CapabilityStore, term: Mutex, + /// Generic metadata parsed from the same ordered PTY bytes as the + /// authoritative terminal. Snapshot code takes this after `term`, + /// preserving one metadata boundary for reconnecting mirrors. + terminal_metadata: Mutex, default_colors: Mutex, stream_progress: TerminalStreamProgress, writer: Mutex>, @@ -3946,6 +3979,7 @@ mod unix { // command submitter publishes the smart resync marker // while it still owns source order. self.broadcast(MessageKind::Output, clear.clone()); + self.stream_progress.notify(); ParserClearHistoryResult::Cleared(clear) } ClearHistoryTransition::Blocked => ParserClearHistoryResult::Blocked, @@ -5110,6 +5144,7 @@ mod unix { supports_clear_history: true, supports_terminate_ack: true, supports_input_ack: true, + supports_terminal_metadata: true, }; let record_root = Path::new(&launch.record_path) .parent() @@ -5290,6 +5325,7 @@ mod unix { owner_token: bootstrapped.owner_token(), capabilities: CapabilityStore::new(64), term: Mutex::new(term), + terminal_metadata: Mutex::new(crate::terminal_metadata::TerminalMetadata::default()), default_colors: Mutex::new(launch.default_colors), stream_progress: TerminalStreamProgress::default(), writer: Mutex::new(pty_writer), @@ -5358,6 +5394,7 @@ mod unix { .cursor_activity() .expect("valid host terminals expose cursor activity"); let normalized = term.vt_write_with_normalized(&bytes).into_owned(); + parser_host.terminal_metadata.lock().unwrap().observe_output(&bytes); let title = title_changed .swap(false, Ordering::AcqRel) .then(|| term.title().unwrap_or_default()); @@ -5383,10 +5420,12 @@ mod unix { // snapshot cannot include output that its boundary // still describes as unapplied. parser_host.smart.mark_applied(source_cursor); + // Keep the host stream watermark on the same side + // of the terminal lock as the applied bytes. + parser_host.stream_progress.notify(); title }; parser_host.note_parser_progress(); - parser_host.stream_progress.notify(); parser_host.parser_budget.release(accounted_bytes); if let Some(title) = title { parser_host.broadcast(MessageKind::Title, title.into_bytes()); @@ -5428,7 +5467,6 @@ mod unix { .map_err(|error| error.to_string()); if matches!(result, Ok(ParserClearHistoryResult::Cleared(_))) { parser_host.note_parser_progress(); - parser_host.stream_progress.notify(); } flush_pending_responses(); let _ = response.send(result); @@ -5571,7 +5609,11 @@ mod unix { let hello_frame = read_required_frame(&mut stream, "client hello")?; if hello_frame.kind != MessageKind::ClientHello || hello_frame.sequence != 0 - || hello_frame.flags & !(FLAG_VIEWER_SIZE_ACKS | FLAG_SMART_RENDERER) != 0 + || hello_frame.flags + & !(FLAG_VIEWER_SIZE_ACKS | FLAG_SMART_RENDERER | FLAG_TERMINAL_METADATA) + != 0 + || (hello_frame.flags & FLAG_TERMINAL_METADATA != 0 + && hello_frame.version != PROTOCOL_VERSION) { anyhow::bail!("terminal-host client did not send ClientHello"); } @@ -5594,6 +5636,8 @@ mod unix { let smart_renderer = selected_version >= SMART_RENDERER_PROTOCOL_VERSION && hello_frame.flags & FLAG_SMART_RENDERER != 0 && matches!(hello.role, ClientRole::Renderer | ClientRole::Admin); + let terminal_metadata = + selected_version == PROTOCOL_VERSION && hello_frame.flags & FLAG_TERMINAL_METADATA != 0; let mut hello_response = Frame::new(MessageKind::HostHello, response.encode()); if viewer_size_acks { hello_response.flags |= FLAG_VIEWER_SIZE_ACKS; @@ -5604,6 +5648,9 @@ mod unix { if smart_renderer { hello_response.flags |= FLAG_SMART_RENDERER; } + if terminal_metadata { + hello_response.flags |= FLAG_TERMINAL_METADATA; + } hello_response.request_id = hello_frame.request_id; write_frame(&mut stream, &hello_response)?; @@ -5684,6 +5731,7 @@ mod unix { crate::surface::VT_REPLAY_MAX_BYTES, )?; let colors = term.color_overrides(); + let osc_progress = host.terminal_metadata.lock().unwrap().osc_progress().to_owned(); let (cols, rows) = *size; let cell_pixels = *cell_pixels; debug_assert_eq!((term.cols(), term.rows()), (cols, rows)); @@ -5728,6 +5776,7 @@ mod unix { &host.owner_token, selected_version, ), + osc_progress, }, colors, snapshot_sequence, @@ -5748,7 +5797,11 @@ mod unix { if !activation_required { launch_owner.stream_ready(); } - let mut snapshot_frame = Frame::new(MessageKind::Snapshot, encode_snapshot(&snapshot)?); + let include_terminal_metadata = hello_response.flags & FLAG_TERMINAL_METADATA != 0; + let mut snapshot_frame = Frame::new( + MessageKind::Snapshot, + encode_snapshot_for_version(&snapshot, selected_version, include_terminal_metadata)?, + ); snapshot_frame.sequence = snapshot_sequence; write_frame(&mut stream, &snapshot_frame)?; let mut colors_frame = @@ -6063,6 +6116,25 @@ mod unix { } fn encode_snapshot(snapshot: &HostSnapshot) -> anyhow::Result> { + encode_snapshot_for_version(snapshot, PROTOCOL_VERSION, false) + } + + fn encode_snapshot_for_version( + snapshot: &HostSnapshot, + protocol_version: u16, + include_terminal_metadata: bool, + ) -> anyhow::Result> { + if !(LEGACY_PROTOCOL_VERSION..=PROTOCOL_VERSION).contains(&protocol_version) { + anyhow::bail!("unsupported terminal-host snapshot protocol {protocol_version}"); + } + if include_terminal_metadata && protocol_version < PROTOCOL_VERSION { + anyhow::bail!("terminal metadata requires the current snapshot protocol"); + } + if snapshot.osc_progress.chars().count() > crate::terminal_metadata::MAX_PROGRESS_CHARS + || snapshot.osc_progress.chars().any(char::is_control) + { + anyhow::bail!("terminal-host OSC progress is out of range"); + } let (cols, rows) = normalize_terminal_geometry(snapshot.cols, snapshot.rows)?; snapshot .kitty_state @@ -6085,6 +6157,9 @@ mod unix { output.extend_from_slice(&snapshot.cell_pixels.0.max(1).to_le_bytes()); output.extend_from_slice(&snapshot.cell_pixels.1.max(1).to_le_bytes()); encode_kitty_replay_state(&mut output, snapshot.kitty_state)?; + if include_terminal_metadata { + put_string(&mut output, &snapshot.osc_progress)?; + } if output.len() > MAX_FRAME_PAYLOAD { anyhow::bail!("terminal-host snapshot payload is too large"); } @@ -6100,20 +6175,24 @@ mod unix { #[cfg(test)] fn decode_snapshot(payload: &[u8]) -> anyhow::Result { - decode_snapshot_for_version(payload, PROTOCOL_VERSION) + decode_snapshot_for_version(payload, PROTOCOL_VERSION, false) } pub fn decode_host_snapshot_payload(payload: &[u8]) -> anyhow::Result { - decode_snapshot_for_version(payload, PROTOCOL_VERSION) + decode_snapshot_for_version(payload, PROTOCOL_VERSION, false) } fn decode_snapshot_for_version( payload: &[u8], protocol_version: u16, + include_terminal_metadata: bool, ) -> anyhow::Result { if !(LEGACY_PROTOCOL_VERSION..=PROTOCOL_VERSION).contains(&protocol_version) { anyhow::bail!("unsupported terminal-host snapshot protocol {protocol_version}"); } + if include_terminal_metadata && protocol_version < PROTOCOL_VERSION { + anyhow::bail!("terminal metadata requires the current snapshot protocol"); + } let mut decoder = PayloadDecoder::new(payload); let (cols, rows) = normalize_terminal_geometry(decoder.u16()?, decoder.u16()?)?; let pid = match decoder.u32()? { @@ -6147,6 +6226,20 @@ mod unix { } else { KittyReplayState::disabled() }; + let osc_progress = if include_terminal_metadata { + if !decoder.has_remaining() { + anyhow::bail!("terminal-host snapshot omitted negotiated metadata"); + } + let value = decoder.string()?; + if value.chars().count() > crate::terminal_metadata::MAX_PROGRESS_CHARS + || value.chars().any(char::is_control) + { + anyhow::bail!("terminal-host OSC progress is out of range"); + } + value + } else { + String::new() + }; pty_size(cols, rows, cell_pixels)?; decoder.finish()?; Ok(HostSnapshot { @@ -6161,6 +6254,7 @@ mod unix { pid, command, cwd, + osc_progress, }) } @@ -6497,6 +6591,10 @@ mod unix { } Ok(()) } + + fn has_remaining(&self) -> bool { + self.offset < self.payload.len() + } } fn put_bytes(output: &mut Vec, bytes: &[u8]) -> anyhow::Result<()> { @@ -6691,6 +6789,7 @@ mod unix { owner_token: CapabilityToken::random().unwrap(), capabilities: CapabilityStore::new(64), term: Mutex::new(term), + terminal_metadata: Mutex::new(crate::terminal_metadata::TerminalMetadata::default()), default_colors: Mutex::new(DefaultColors::default()), stream_progress: TerminalStreamProgress::default(), writer: Mutex::new(Box::new(std::io::sink())), @@ -6781,6 +6880,7 @@ mod unix { owner_token: CapabilityToken::random().unwrap(), capabilities: CapabilityStore::new(64), term: Mutex::new(term), + terminal_metadata: Mutex::new(crate::terminal_metadata::TerminalMetadata::default()), default_colors: Mutex::new(DefaultColors::default()), stream_progress: TerminalStreamProgress::default(), writer: Mutex::new(Box::new(std::io::sink())), @@ -6856,6 +6956,7 @@ mod unix { supports_clear_history: true, supports_terminate_ack: true, supports_input_ack: true, + supports_terminal_metadata: true, }; let record_path = record.record_path(&root); let lease = HostLivenessLease::acquire(liveness_path(&record_path, &record)).unwrap(); @@ -6881,6 +6982,7 @@ mod unix { supports_clear_history: false, supports_terminate_ack: false, supports_input_ack: true, + supports_terminal_metadata: false, }; let record_path = std::env::temp_dir().join(format!( "cmux-input-ack-surface-{}-{}.json", @@ -6904,6 +7006,7 @@ mod unix { pid: None, command: Vec::new(), cwd: None, + osc_progress: String::new(), }, protocol_version: PROTOCOL_VERSION, smart_renderer: false, @@ -7120,6 +7223,7 @@ mod unix { pid: Some(42), command: vec!["/bin/cat".into()], cwd: Some("/tmp".into()), + osc_progress: String::new(), }; let payload = encode_snapshot(&snapshot).unwrap(); @@ -7135,6 +7239,60 @@ mod unix { ); } + #[test] + fn snapshot_payload_round_trip_preserves_negotiated_terminal_metadata() { + let snapshot = HostSnapshot { + cols: 80, + rows: 24, + cell_pixels: (9, 18), + replay: b"replay".to_vec(), + kitty_image_aliases: Vec::new(), + kitty_state: KittyReplayState::disabled(), + sequence_boundary: 0, + colors: TerminalColorOverrides::default(), + pid: None, + command: Vec::new(), + cwd: None, + osc_progress: "4;1;50".into(), + }; + let payload = encode_snapshot_for_version(&snapshot, PROTOCOL_VERSION, true).unwrap(); + let decoded = decode_snapshot_for_version(&payload, PROTOCOL_VERSION, true).unwrap(); + assert_eq!(decoded.osc_progress, snapshot.osc_progress); + assert!( + decode_snapshot(&payload).is_err(), + "a metadata tail must not be accepted without negotiation" + ); + } + + #[test] + fn host_snapshot_negotiates_terminal_metadata_at_the_stream_boundary() { + let host = exited_host_fixture(); + assert!(host.terminal_metadata.lock().unwrap().set_osc_progress("4;1;50")); + let (server_stream, mut client_stream) = UnixStream::pair().unwrap(); + client_stream.set_read_timeout(Some(Duration::from_secs(1))).unwrap(); + let server = thread::spawn({ + let host = host.clone(); + move || serve_client(host, server_stream) + }); + + let mut hello = snapshot_boundary_client_hello(&host, false).unwrap(); + hello.flags |= FLAG_TERMINAL_METADATA; + write_frame(&mut client_stream, &hello).unwrap(); + let host_hello = read_required_frame(&mut client_stream, "host hello").unwrap(); + assert_eq!(host_hello.flags & FLAG_TERMINAL_METADATA, FLAG_TERMINAL_METADATA); + let snapshot_frame = read_required_frame(&mut client_stream, "snapshot").unwrap(); + let snapshot = + decode_snapshot_for_version(&snapshot_frame.payload, PROTOCOL_VERSION, true) + .unwrap(); + assert_eq!(snapshot.osc_progress, "4;1;50"); + assert_eq!( + read_required_frame(&mut client_stream, "colors").unwrap().kind, + MessageKind::Colors + ); + drop(client_stream); + assert!(server.join().unwrap().is_ok()); + } + #[test] fn snapshot_payload_matches_the_cross_language_current_golden_bytes() { let snapshot = HostSnapshot { @@ -7149,6 +7307,7 @@ mod unix { pid: None, command: Vec::new(), cwd: None, + osc_progress: String::new(), }; assert_eq!( @@ -7175,11 +7334,13 @@ mod unix { pid: Some(42), command: vec!["/bin/cat".into()], cwd: Some("/tmp".into()), + osc_progress: String::new(), }; let snapshot_payload = encode_snapshot(&snapshot).unwrap(); let v2_snapshot_len = snapshot_payload.len() - KITTY_REPLAY_STATE_ENCODED_LEN; - let decoded = decode_snapshot_for_version(&snapshot_payload[..v2_snapshot_len], 2) - .expect("protocol-v2 snapshots end after cell metrics"); + let decoded = + decode_snapshot_for_version(&snapshot_payload[..v2_snapshot_len], 2, false) + .expect("protocol-v2 snapshots end after cell metrics"); assert_eq!(decoded.replay, snapshot.replay); assert_eq!(decoded.kitty_image_aliases, snapshot.kitty_image_aliases); assert_eq!(decoded.cell_pixels, snapshot.cell_pixels); @@ -7193,6 +7354,7 @@ mod unix { let decoded = decode_snapshot_for_version( &snapshot_payload[..v1_snapshot_len], LEGACY_PROTOCOL_VERSION, + false, ) .expect("protocol-v1 snapshots end before Kitty aliases"); assert_eq!(decoded.replay, snapshot.replay); @@ -7334,6 +7496,7 @@ mod unix { pid: None, command: Vec::new(), cwd: None, + osc_progress: String::new(), }, protocol_version: PROTOCOL_VERSION, smart_renderer: false, @@ -7388,6 +7551,7 @@ mod unix { pid: None, command: Vec::new(), cwd: None, + osc_progress: String::new(), }, protocol_version: PROTOCOL_VERSION, smart_renderer: true, @@ -7655,6 +7819,7 @@ mod unix { pid: None, command: Vec::new(), cwd: None, + osc_progress: String::new(), }, protocol_version: PROTOCOL_VERSION, smart_renderer: true, @@ -7704,6 +7869,7 @@ mod unix { pid: None, command: Vec::new(), cwd: None, + osc_progress: String::new(), }, protocol_version: PROTOCOL_VERSION, smart_renderer: false, @@ -8003,6 +8169,7 @@ mod unix { legacy.record_version = version; legacy.supports_terminate_ack = version >= 3; legacy.supports_input_ack = false; + legacy.supports_terminal_metadata = false; validate_terminal_host_record(&record_path, &legacy).unwrap(); legacy.supports_input_ack = true; assert!( @@ -8031,6 +8198,7 @@ mod unix { legacy.supports_clear_history = false; legacy.supports_terminate_ack = false; legacy.supports_input_ack = false; + legacy.supports_terminal_metadata = false; let legacy_path = legacy.record_path(root); write_record(&legacy_path, &legacy).unwrap(); @@ -8149,7 +8317,10 @@ mod unix { let hellos = server.join().unwrap(); assert_eq!( hellos, - vec![(PROTOCOL_VERSION, FLAG_SMART_RENDERER | FLAG_VIEWER_SIZE_ACKS)] + vec![( + PROTOCOL_VERSION, + FLAG_SMART_RENDERER | FLAG_VIEWER_SIZE_ACKS | FLAG_TERMINAL_METADATA + )] ); let _ = fs::remove_file(endpoint); @@ -8184,6 +8355,7 @@ mod unix { pid: None, command: vec!["/bin/cat".into()], cwd: None, + osc_progress: String::new(), }, protocol_version: PROTOCOL_VERSION, smart_renderer: false, @@ -8300,6 +8472,7 @@ mod unix { pid: None, command: Vec::new(), cwd: None, + osc_progress: String::new(), }, protocol_version: PROTOCOL_VERSION, smart_renderer: true, @@ -8459,6 +8632,7 @@ mod unix { pid: None, command: Vec::new(), cwd: None, + osc_progress: String::new(), }, protocol_version: PROTOCOL_VERSION, smart_renderer: false, @@ -8577,6 +8751,7 @@ mod unix { pid: Some(42), command: vec!["/bin/cat".into()], cwd: Some("/tmp".into()), + osc_progress: String::new(), }; let mut payload = encode_snapshot(&snapshot).unwrap(); payload.truncate( @@ -8625,6 +8800,10 @@ mod unix { #[test] fn smart_owner_negotiation_falls_back_to_a_live_legacy_host() { let (record_path, record, lease) = record_fixture("legacy-fallback"); + let mut record = record; + // This fixture models a current-protocol host from before the + // optional metadata extension. It must not receive the new tail. + record.supports_terminal_metadata = false; let endpoint = PathBuf::from(&record.endpoint); prepare_private_dir(endpoint.parent().unwrap()).unwrap(); let _ = fs::remove_file(&endpoint); @@ -8695,6 +8874,7 @@ mod unix { pid: None, command: vec!["/bin/sh".into()], cwd: None, + osc_progress: String::new(), }; let mut snapshot_frame = Frame::new(MessageKind::Snapshot, encode_snapshot(&snapshot)?); diff --git a/cmux-tui/crates/cmux-tui-core/src/terminal_metadata.rs b/cmux-tui/crates/cmux-tui-core/src/terminal_metadata.rs new file mode 100644 index 000000000000..5c36770381c3 --- /dev/null +++ b/cmux-tui/crates/cmux-tui-core/src/terminal_metadata.rs @@ -0,0 +1,364 @@ +//! Bounded, generic terminal metadata collected from PTY output. +//! +//! The OSC string framing state machine is adapted from herdrdev/herdr's +//! `src/pane/osc.rs`, Apache-2.0, commit +//! `7b675f42af35508eab66ac42fe1598628597a893`. The cmux implementation is +//! modified by manaflow: it retains only OSC 9 progress text, applies strict +//! byte and character bounds, accepts C1 ST, and exposes the result as a +//! terminal primitive. It has no agent names, manifests, or roster policy. + +const MAX_OSC_BODY_BYTES: usize = 4096; +pub(crate) const MAX_PROGRESS_CHARS: usize = 256; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +enum OscState { + #[default] + Ground, + Escape, + Body, + BodyEscape, + IgnoringString, + IgnoringStringEscape, + Discarding, + DiscardingEscape, +} + +#[derive(Debug, Default)] +struct OscCollector { + state: OscState, + body: Vec, + /// Number of UTF-8 continuation bytes still expected after a lead byte. + /// Raw C1 values share this byte range, so framing is recognized only at + /// code-point boundaries. The first continuation also has a lead-specific + /// range, which rejects overlong encodings and surrogate encodings before + /// they can hide a C1 framing byte. + utf8_continuations: u8, + utf8_min: u8, + utf8_max: u8, +} + +impl OscCollector { + fn observe(&mut self, bytes: &[u8], mut receive: impl FnMut(&[u8])) { + for &byte in bytes { + if self.utf8_continuations > 0 { + if (self.utf8_min..=self.utf8_max).contains(&byte) { + self.utf8_continuations -= 1; + if self.state == OscState::Body { + self.push(byte); + } + // Only the first continuation is constrained by the + // lead byte. Remaining continuation bytes use the full + // UTF-8 continuation range. + self.utf8_min = 0x80; + self.utf8_max = 0xbf; + continue; + } + // An invalid or truncated UTF-8 sequence cannot hide the + // next control byte. Process this byte again as framing. + self.reset_utf8(); + } + let (continuations, min, max) = utf8_sequence_bounds(byte); + self.utf8_continuations = continuations; + self.utf8_min = min; + self.utf8_max = max; + match self.state { + OscState::Ground => match byte { + 0x1b => self.state = OscState::Escape, + // C1 OSC. This is uncommon in UTF-8 PTYs but valid in an + // 8-bit control stream. + 0x9d => { + self.body.clear(); + self.state = OscState::Body; + } + // C1 DCS, SOS, PM, and APC. Their payloads are ignored + // so an embedded OSC cannot leak metadata. + 0x90 | 0x98 | 0x9e | 0x9f => { + self.state = OscState::IgnoringString; + } + _ => {} + }, + OscState::Escape => match byte { + b']' => { + self.body.clear(); + self.state = OscState::Body; + } + 0x18 | 0x1a => self.state = OscState::Ground, + 0x1b => self.state = OscState::Escape, + b'P' | b'X' | b'^' | b'_' => { + // DCS, SOS, PM, and APC are string controls. Ignore + // their bodies so embedded OSC bytes cannot leak. + self.state = OscState::IgnoringString; + } + _ => self.state = OscState::Ground, + }, + OscState::Body => match byte { + 0x18 | 0x1a => self.cancel(), + 0x07 | 0x9c => self.finish(&mut receive), + 0x1b => self.state = OscState::BodyEscape, + _ => self.push(byte), + }, + OscState::BodyEscape => match byte { + 0x18 | 0x1a => self.cancel(), + b'\\' => self.finish(&mut receive), + 0x07 | 0x9c => self.finish(&mut receive), + 0x1b => { + // A second ESC remains a possible ST prefix. Keep + // one literal ESC in the bounded body and wait. + self.push(0x1b); + if self.state == OscState::Body { + self.state = OscState::BodyEscape; + } + } + _ => { + // The ESC was not an ST prefix. Preserve it and the + // current byte as payload, unless the body overflowed. + self.push(0x1b); + if self.state == OscState::Body { + self.push(byte); + } + } + }, + OscState::IgnoringString => match byte { + 0x18 | 0x1a => self.cancel(), + 0x1b => self.state = OscState::IgnoringStringEscape, + 0x9c => self.state = OscState::Ground, + _ => {} + }, + OscState::IgnoringStringEscape => match byte { + 0x18 | 0x1a => self.cancel(), + b'\\' | 0x9c => self.state = OscState::Ground, + 0x1b => self.state = OscState::IgnoringStringEscape, + _ => self.state = OscState::IgnoringString, + }, + OscState::Discarding => match byte { + 0x18 | 0x1a => self.cancel(), + 0x07 | 0x9c => self.state = OscState::Ground, + 0x1b => self.state = OscState::DiscardingEscape, + _ => {} + }, + OscState::DiscardingEscape => match byte { + 0x18 | 0x1a => self.cancel(), + b'\\' | 0x9c => self.state = OscState::Ground, + 0x1b => self.state = OscState::DiscardingEscape, + _ => self.state = OscState::Discarding, + }, + } + } + } + + fn push(&mut self, byte: u8) { + if self.body.len() >= MAX_OSC_BODY_BYTES { + self.body.clear(); + self.state = OscState::Discarding; + return; + } + self.body.push(byte); + self.state = OscState::Body; + } + + fn finish(&mut self, receive: &mut impl FnMut(&[u8])) { + receive(&self.body); + self.body.clear(); + self.state = OscState::Ground; + self.reset_utf8(); + } + + fn cancel(&mut self) { + self.body.clear(); + self.state = OscState::Ground; + self.reset_utf8(); + } + + fn reset_utf8(&mut self) { + self.utf8_continuations = 0; + self.utf8_min = 0; + self.utf8_max = 0; + } +} + +fn utf8_sequence_bounds(byte: u8) -> (u8, u8, u8) { + match byte { + 0xc2..=0xdf => (1, 0x80, 0xbf), + 0xe0 => (2, 0xa0, 0xbf), + 0xe1..=0xec | 0xee..=0xef => (2, 0x80, 0xbf), + 0xed => (2, 0x80, 0x9f), + 0xf0 => (3, 0x90, 0xbf), + 0xf1..=0xf3 => (3, 0x80, 0xbf), + 0xf4 => (3, 0x80, 0x8f), + _ => (0, 0, 0), + } +} + +fn utf8_continuation_count(byte: u8) -> u8 { + utf8_sequence_bounds(byte).0 +} + +fn is_string_opener(byte: u8) -> bool { + matches!(byte, 0x90 | 0x98 | 0x9d | 0x9f | 0x9e) +} + +/// Generic terminal metadata retained from the output stream. +#[derive(Debug, Default)] +pub(crate) struct TerminalMetadata { + osc: OscCollector, + progress: String, +} + +impl TerminalMetadata { + /// Observe raw child output. The fast path avoids the state machine for + /// ordinary output, which is the common case for non-OSC terminals. + pub(crate) fn observe_output(&mut self, bytes: &[u8]) { + // A framed string can cross reader chunks. Continue feeding bytes + // while the collector is inside a control sequence, even when this + // chunk contains no new ESC or C1 introducer. + if self.osc.state == OscState::Ground + && self.osc.utf8_continuations == 0 + && !bytes.iter().any(|byte| { + *byte == 0x1b || is_string_opener(*byte) || utf8_continuation_count(*byte) != 0 + }) + { + return; + } + let progress = &mut self.progress; + self.osc.observe(bytes, |body| { + let Some(separator) = body.iter().position(|byte| *byte == b';') else { + return; + }; + if &body[..separator] != b"9" { + return; + } + *progress = String::from_utf8_lossy(&body[separator + 1..]) + .chars() + .filter(|character| !character.is_control()) + .take(MAX_PROGRESS_CHARS) + .collect(); + }); + } + + pub(crate) fn osc_progress(&self) -> &str { + &self.progress + } + + /// Restore a progress value carried by an authenticated terminal-host + /// snapshot. Reject malformed values instead of silently changing the + /// host's state at a reconnect boundary. + pub(crate) fn set_osc_progress(&mut self, progress: &str) -> bool { + if progress.chars().count() > MAX_PROGRESS_CHARS || progress.chars().any(char::is_control) { + return false; + } + self.progress.clear(); + self.progress.push_str(progress); + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn captures_bel_st_and_c1_osc_progress() { + let mut metadata = TerminalMetadata::default(); + metadata.observe_output(b"\x1b]9;4;3;\x07"); + assert_eq!(metadata.osc_progress(), "4;3;"); + metadata.observe_output(b"\x1b]9;4;1;50\x1b\\"); + assert_eq!(metadata.osc_progress(), "4;1;50"); + metadata.observe_output(b"\x9d9;4;2;\x9c"); + assert_eq!(metadata.osc_progress(), "4;2;"); + } + + #[test] + fn preserves_chunk_boundaries_and_ignores_other_strings() { + let mut metadata = TerminalMetadata::default(); + metadata.observe_output(b"\x1b]9;4"); + metadata.observe_output(b";2;"); + metadata.observe_output(b"\x07"); + assert_eq!(metadata.osc_progress(), "4;2;"); + metadata.observe_output(b"\x1b]0;title\x07\x1bP+q9;bad\x1b\\"); + assert_eq!(metadata.osc_progress(), "4;2;"); + } + + #[test] + fn preserves_non_st_escape_bytes_inside_an_osc_payload() { + let mut metadata = TerminalMetadata::default(); + metadata.observe_output(b"\x1b]9;before\x1bXafter\x07"); + // The ESC is a control character and is removed from the exposed + // text, but the following byte and the remainder of the payload must + // survive the framing state transition. + assert_eq!(metadata.osc_progress(), "beforeXafter"); + } + + #[test] + fn utf8_continuation_bytes_are_not_c1_framing() { + let mut metadata = TerminalMetadata::default(); + // U+00DD is encoded as C3 9D. The continuation byte is numerically + // equal to C1 OSC, but it is ordinary text in this stream. + metadata.observe_output("Ý".as_bytes()); + let mut first = b"\x1b]9;before".to_vec(); + first.extend_from_slice("Ýafter\x07".as_bytes()); + metadata.observe_output(&first); + assert_eq!(metadata.osc_progress(), "beforeÝafter"); + + // U+00DC is encoded as C3 9C. It must not terminate an OSC payload. + let mut second = b"\x1b]9;left".to_vec(); + second.extend_from_slice("Üright\x07".as_bytes()); + metadata.observe_output(&second); + assert_eq!(metadata.osc_progress(), "leftÜright"); + } + + #[test] + fn invalid_utf8_does_not_swallow_a_c1_string_terminator() { + let mut metadata = TerminalMetadata::default(); + // E0 must be followed by A0..BF as its first UTF-8 continuation. + // 9C is therefore a raw C1 ST here and must close the OSC body. + let mut bytes = vec![0x9d, b'9', b';']; + bytes.extend_from_slice(b"old"); + bytes.extend_from_slice(&[0xe0, 0x9c]); + metadata.observe_output(&bytes); + metadata.observe_output(b"\x1b]9;new\x07"); + assert_eq!(metadata.osc_progress(), "new"); + } + + #[test] + fn c1_string_openers_are_isolated_from_osc() { + let mut metadata = TerminalMetadata::default(); + for opener in [0x90, 0x98, 0x9e, 0x9f] { + let mut bytes = vec![opener]; + bytes.extend_from_slice(b"payload \x9d9;leaked\x9c"); + metadata.observe_output(&bytes); + } + assert_eq!(metadata.osc_progress(), ""); + metadata.observe_output(b"\x1b]9;valid\x07"); + assert_eq!(metadata.osc_progress(), "valid"); + } + + #[test] + fn can_and_sub_cancel_all_string_states() { + let mut metadata = TerminalMetadata::default(); + for cancel in [0x18, 0x1a] { + metadata.observe_output(&[0x1b, b']', b'9', b';', b'b', b'a', cancel]); + metadata.observe_output(b"\x1b]9;valid\x07"); + assert_eq!(metadata.osc_progress(), "valid"); + + metadata.observe_output(&[0x90, b'\x9d', cancel]); + metadata.observe_output(b"\x1b]9;valid-again\x07"); + assert_eq!(metadata.osc_progress(), "valid-again"); + } + } + + #[test] + fn discards_oversized_bodies_and_bounds_text() { + let mut metadata = TerminalMetadata::default(); + let mut body = b"\x1b]9;".to_vec(); + body.extend(std::iter::repeat_n(b'x', MAX_OSC_BODY_BYTES + 1)); + body.push(0x07); + metadata.observe_output(&body); + assert_eq!(metadata.osc_progress(), ""); + + let mut bounded = b"\x1b]9;".to_vec(); + bounded.extend(std::iter::repeat_n(b'x', MAX_PROGRESS_CHARS + 32)); + bounded.push(0x07); + metadata.observe_output(&bounded); + assert_eq!(metadata.osc_progress().chars().count(), MAX_PROGRESS_CHARS); + } +} diff --git a/cmux-tui/crates/cmux-tui-core/src/workspace_registry.rs b/cmux-tui/crates/cmux-tui-core/src/workspace_registry.rs index 47c38af466cf..c85d286b2f85 100644 --- a/cmux-tui/crates/cmux-tui-core/src/workspace_registry.rs +++ b/cmux-tui/crates/cmux-tui-core/src/workspace_registry.rs @@ -125,6 +125,7 @@ const RESOURCE_EFFECT_PEPPER_FILE: &str = "resource-effect-pepper"; const RESOURCE_EFFECT_PEPPER_LOCK_FILE: &str = "resource-effect-pepper.lock"; const RESOURCE_EFFECT_PEPPER_META_KEY: &str = "resource_effect_pepper_id"; const RESOURCE_EFFECT_PEPPER_CLEANUP_META_KEY: &str = "resource_effect_pepper_cleanup_pending"; +const JOURNAL_PLUGIN_GENERATION_META_KEY: &str = "journal_plugin_generation"; const RESOURCE_EFFECT_PEPPER_ID_DOMAIN: &[u8] = b"cmux.resource-effect-pepper-id.v1"; const RESOURCE_INPUT_RECEIPT_DOMAIN: &[u8] = b"cmux.resource-input-receipt.v2"; const WORKSPACE_REGISTRY_FILE: &str = "workspace-registry.sqlite3"; diff --git a/cmux-tui/crates/cmux-tui-core/src/workspace_registry/journal_extensions.rs b/cmux-tui/crates/cmux-tui-core/src/workspace_registry/journal_extensions.rs index 2d6447497bac..b08622ffaef3 100644 --- a/cmux-tui/crates/cmux-tui-core/src/workspace_registry/journal_extensions.rs +++ b/cmux-tui/crates/cmux-tui-core/src/workspace_registry/journal_extensions.rs @@ -486,18 +486,92 @@ fn ensure_built_in_agent_producer(transaction: &Transaction<'_>) -> anyhow::Resu i64::try_from(unix_epoch_ms()?)?, ], )?; - let installed = transaction.query_row( - "SELECT manifest_json FROM journal_producers WHERE producer_id = ?1", + let (installed_namespace, installed_version, installed_json) = transaction.query_row( + "SELECT namespace, manifest_version, manifest_json + FROM journal_producers + WHERE producer_id = ?1", [crate::AGENT_HOOK_PRODUCER_ID], - |row| row.get::<_, String>(0), + |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?, row.get::<_, String>(2)?)), )?; + let installed = serde_json::from_str::(&installed_json)?; + let installed_version = u32::try_from(installed_version) + .context("reserved cmux agent producer manifest version is invalid")?; + anyhow::ensure!( + installed.namespace == installed_namespace + && installed.manifest_version == installed_version, + "reserved cmux agent producer manifest metadata does not match its row" + ); + if installed == manifest { + return Ok(()); + } + + // The reserved producer stayed at manifest version 1 while these two + // additive changes shipped. Rewrite only those exact historical shapes. + // Unknown changes still fail closed, so a damaged or incompatible session + // cannot silently acquire the current producer contract. + let known_legacy = legacy_built_in_agent_producer_manifests(&manifest); anyhow::ensure!( - serde_json::from_str::(&installed)? == manifest, + known_legacy.iter().any(|legacy| legacy == &installed), "reserved cmux agent producer manifest does not match this binary" ); + transaction.execute( + "UPDATE journal_producers + SET namespace = ?1, manifest_version = ?2, manifest_json = ?3 + WHERE producer_id = ?4", + params![ + manifest.namespace, + i64::from(manifest.manifest_version), + manifest_json, + manifest.producer_id, + ], + )?; Ok(()) } +fn legacy_built_in_agent_producer_manifests( + current: &JournalProducerManifest, +) -> Vec { + // Keep this allowlist tied to the shipped manifest shape. If the current + // contract changes again, an explicit migration must be added instead of + // deriving acceptance for an unshipped historical shape. + const CURRENT_EVENT_KINDS: [&str; 13] = [ + "agent.session.started", + "agent.turn.started", + "agent.turn.completed", + "agent.child.spawned", + "agent.child.completed", + "agent.child.failed", + "agent.approval.requested", + "agent.question.requested", + "agent.plan_review.requested", + "agent.error.reported", + "agent.state.changed", + "agent.session.ended", + "agent.plugin.exited", + ]; + if current.events.iter().map(|event| event.kind.as_str()).ne(CURRENT_EVENT_KINDS) { + return Vec::new(); + } + + let mut legacy = current.clone(); + for event in &mut legacy.events { + let Some(pattern) = event + .payload_schema + .get_mut("properties") + .and_then(|value| value.get_mut("adapter")) + .and_then(|value| value.get_mut("properties")) + .and_then(|value| value.get_mut("id")) + .and_then(|value| value.get_mut("pattern")) + else { + return Vec::new(); + }; + *pattern = Value::String("^[a-z0-9_-]+$".into()); + } + let with_legacy_pattern = legacy.clone(); + legacy.events.retain(|event| event.kind != "agent.plugin.exited"); + vec![legacy, with_legacy_pattern] +} + fn migrate_journal_receipt_origins(transaction: &Transaction<'_>) -> anyhow::Result<()> { let operation_columns = table_columns(transaction, "journal_operation_receipts")?; if !operation_columns.contains("origin") { @@ -714,6 +788,27 @@ pub(crate) fn validate_journal_hook_manifest(manifest: &JournalHookManifest) -> } impl WorkspaceRegistry { + /// Look up an exact ingress receipt before the caller validates against the + /// current producer manifest. A retry can carry an older manifest version + /// after a producer upgrade, but an ingress that has never committed must + /// still pass current admission below. + pub(crate) fn replay_journal_ingress( + &self, + ingress: &JournalIngress, + origin: &str, + idempotency_key: &str, + ) -> anyhow::Result> { + validate_journal_ingress_shape(ingress, origin, idempotency_key)?; + let fingerprint = journal_ingress_fingerprint(ingress)?; + ingress_receipt( + &self.connection, + &ingress.producer_id, + origin, + idempotency_key, + fingerprint.as_slice(), + ) + } + #[cfg(test)] pub(crate) fn append_journal_ingress_events( &mut self, @@ -1283,6 +1378,21 @@ impl WorkspaceRegistry { .collect() } + /// Return only manifests that a userland producer can register and use. + /// The reserved cmux hook manifest remains in the internal table because + /// the journal kernel and checkpoint code need it, but it uses the legacy + /// `agent` namespace and is not a userland `plugin.` manifest. + pub(crate) fn userland_journal_producer_manifests( + &self, + ) -> anyhow::Result> { + self.journal_producer_manifests().map(|manifests| { + manifests + .into_iter() + .filter(|manifest| manifest.producer_id != crate::AGENT_HOOK_PRODUCER_ID) + .collect() + }) + } + pub(crate) fn put_journal_producer( &mut self, manifest: &JournalProducerManifest, @@ -1414,17 +1524,8 @@ fn append_journal_ingress_transaction( origin: &str, idempotency_key: &str, ) -> anyhow::Result { - validate_identifier("journal ingress origin", origin)?; - validate_identifier("journal ingress idempotency key", idempotency_key)?; - validate_plugin_component("producer_id", &ingress.producer_id)?; - validate_dotted_kind(&ingress.kind)?; - anyhow::ensure!(ingress.schema_version > 0, "schema_version must be positive"); - anyhow::ensure!( - serde_json::to_vec(&ingress.payload)?.len() <= MAX_EVENT_PAYLOAD_BYTES, - "journal event payload exceeds {MAX_EVENT_PAYLOAD_BYTES} bytes" - ); - let ingress_value = serde_json::to_value(ingress)?; - let fingerprint = Sha256::digest(canonical_json(&ingress_value)?.as_bytes()); + validate_journal_ingress_shape(ingress, origin, idempotency_key)?; + let fingerprint = journal_ingress_fingerprint(ingress)?; if let Some(commit) = ingress_receipt(tx, &ingress.producer_id, origin, idempotency_key, fingerprint.as_slice())? { @@ -3036,14 +3137,36 @@ fn insert_operation_receipt( Ok(()) } +fn validate_journal_ingress_shape( + ingress: &JournalIngress, + origin: &str, + idempotency_key: &str, +) -> anyhow::Result<()> { + validate_identifier("journal ingress origin", origin)?; + validate_identifier("journal ingress idempotency key", idempotency_key)?; + validate_plugin_component("producer_id", &ingress.producer_id)?; + validate_dotted_kind(&ingress.kind)?; + anyhow::ensure!(ingress.schema_version > 0, "schema_version must be positive"); + anyhow::ensure!( + serde_json::to_vec(&ingress.payload)?.len() <= MAX_EVENT_PAYLOAD_BYTES, + "journal event payload exceeds {MAX_EVENT_PAYLOAD_BYTES} bytes" + ); + Ok(()) +} + +fn journal_ingress_fingerprint(ingress: &JournalIngress) -> anyhow::Result<[u8; 32]> { + let ingress_value = serde_json::to_value(ingress)?; + Ok(Sha256::digest(canonical_json(&ingress_value)?.as_bytes()).into()) +} + fn ingress_receipt( - transaction: &Transaction<'_>, + connection: &Connection, producer_id: &str, origin: &str, idempotency_key: &str, fingerprint: &[u8], ) -> anyhow::Result> { - let stored = transaction + let stored = connection .query_row( "SELECT fingerprint, event_id, journal_sequence FROM journal_ingress_receipts @@ -3064,10 +3187,11 @@ fn validate_plugin_component(label: &str, value: &str) -> anyhow::Result<()> { anyhow::ensure!( !value.is_empty() && value.len() <= 64 - && value - .bytes() - .all(|byte| { byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_' }), - "{label} must contain 1 to 64 lowercase ASCII letters, digits, or underscores" + && value.as_bytes().first().is_some_and(|byte| byte.is_ascii_alphanumeric()) + && value.bytes().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_' || byte == b'-' + }), + "{label} must match [a-z0-9][a-z0-9_-]* and contain at most 64 bytes" ); Ok(()) } @@ -3078,11 +3202,15 @@ fn validate_dotted_kind(value: &str) -> anyhow::Result<()> { && value.len() <= 128 && value.split('.').all(|component| { !component.is_empty() + && component.as_bytes().first().is_some_and(|byte| byte.is_ascii_alphanumeric()) && component.bytes().all(|byte| { - byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_' + byte.is_ascii_lowercase() + || byte.is_ascii_digit() + || byte == b'_' + || byte == b'-' }) }), - "journal event kind must be a dotted lowercase ASCII name" + "journal event kind must match dotted [a-z0-9][a-z0-9_-]* components" ); Ok(()) } @@ -3134,6 +3262,115 @@ mod tests { assert!(states[0].enabled); } + #[test] + fn userland_producer_list_excludes_reserved_hook_manifest() { + let mut registry = WorkspaceRegistry::in_memory("userland-producers").unwrap(); + let manifest = JournalProducerManifest { + producer_id: "screen_detector".into(), + namespace: "plugin.screen_detector".into(), + manifest_version: 1, + max_sensitivity: JournalSensitivity::Metadata, + permissions: vec!["journal.append.plugin.screen_detector".into()], + events: vec![JournalEventSchema { + kind: "plugin.screen_detector.agent.state.changed".into(), + schema_version: 1, + class: JournalClass::Observation, + replay: JournalReplayPolicy::Advisory, + sensitivity: JournalSensitivity::Metadata, + payload_schema: json!({"type":"object"}), + }], + }; + registry.put_journal_producer(&manifest, "client_test", "producer_1").unwrap(); + + let all = registry.journal_producer_manifests().unwrap(); + assert!(all.iter().any(|item| item.producer_id == crate::AGENT_HOOK_PRODUCER_ID)); + assert!(all.iter().any(|item| item.producer_id == "screen_detector")); + + let userland = registry.userland_journal_producer_manifests().unwrap(); + assert_eq!(userland.len(), 1); + assert_eq!(userland[0], manifest); + } + + #[test] + fn legacy_built_in_agent_manifest_is_migrated() { + let mut registry = WorkspaceRegistry::in_memory("legacy-agent-manifest").unwrap(); + let current = crate::agent_hooks::built_in_agent_producer_manifest(); + let mut legacy = current.clone(); + legacy.events.retain(|event| event.kind != "agent.plugin.exited"); + for event in &mut legacy.events { + event.payload_schema["properties"]["adapter"]["properties"]["id"]["pattern"] = + Value::String("^[a-z0-9_-]+$".into()); + } + let legacy_json = canonical_json(&serde_json::to_value(&legacy).unwrap()).unwrap(); + registry + .connection + .execute( + "UPDATE journal_producers SET manifest_json = ?1 WHERE producer_id = ?2", + params![legacy_json, crate::AGENT_HOOK_PRODUCER_ID], + ) + .unwrap(); + + let transaction = registry.connection.transaction().unwrap(); + ensure_built_in_agent_producer(&transaction).unwrap(); + transaction.commit().unwrap(); + + let installed = registry + .connection + .query_row( + "SELECT manifest_json FROM journal_producers WHERE producer_id = ?1", + [crate::AGENT_HOOK_PRODUCER_ID], + |row| row.get::<_, String>(0), + ) + .unwrap(); + assert_eq!(serde_json::from_str::(&installed).unwrap(), current); + } + + #[test] + fn legacy_agent_manifest_with_plugin_exit_is_migrated() { + let mut registry = WorkspaceRegistry::in_memory("legacy-agent-plugin-exit").unwrap(); + let current = crate::agent_hooks::built_in_agent_producer_manifest(); + let legacy = legacy_built_in_agent_producer_manifests(¤t).into_iter().nth(1).unwrap(); + let legacy_json = canonical_json(&serde_json::to_value(&legacy).unwrap()).unwrap(); + registry + .connection + .execute( + "UPDATE journal_producers SET manifest_json = ?1 WHERE producer_id = ?2", + params![legacy_json, crate::AGENT_HOOK_PRODUCER_ID], + ) + .unwrap(); + + let transaction = registry.connection.transaction().unwrap(); + ensure_built_in_agent_producer(&transaction).unwrap(); + transaction.commit().unwrap(); + + let installed = registry + .journal_producer_manifests() + .unwrap() + .into_iter() + .find(|manifest| manifest.producer_id == crate::AGENT_HOOK_PRODUCER_ID) + .unwrap(); + assert_eq!(installed, current); + } + + #[test] + fn unknown_built_in_agent_manifest_still_fails_closed() { + let mut registry = WorkspaceRegistry::in_memory("unknown-agent-manifest").unwrap(); + let mut tampered = crate::agent_hooks::built_in_agent_producer_manifest(); + tampered.events[0].kind = "agent.untrusted".into(); + let tampered_json = canonical_json(&serde_json::to_value(&tampered).unwrap()).unwrap(); + registry + .connection + .execute( + "UPDATE journal_producers SET manifest_json = ?1 WHERE producer_id = ?2", + params![tampered_json, crate::AGENT_HOOK_PRODUCER_ID], + ) + .unwrap(); + + let transaction = registry.connection.transaction().unwrap(); + let error = ensure_built_in_agent_producer(&transaction).unwrap_err(); + assert!(error.to_string().contains("does not match this binary")); + } + #[test] fn checkpoint_digest_is_verified_when_read() { let mut registry = WorkspaceRegistry::in_memory("checkpoint-integrity").unwrap(); diff --git a/cmux-tui/crates/cmux-tui-core/src/workspace_registry/public_projection_store.rs b/cmux-tui/crates/cmux-tui-core/src/workspace_registry/public_projection_store.rs index 3060ba9d44b5..06f3cfe91dad 100644 --- a/cmux-tui/crates/cmux-tui-core/src/workspace_registry/public_projection_store.rs +++ b/cmux-tui/crates/cmux-tui-core/src/workspace_registry/public_projection_store.rs @@ -45,6 +45,7 @@ pub struct RegistryAgentProjection { pub source: String, pub updated_at_ms: u64, pub source_session: Option, + pub agent: Option, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -65,6 +66,7 @@ impl RegistryAgentProjection { "source": self.source, "updated_at_ms": self.updated_at_ms.to_string(), "source_session": self.source_session, + "extra": {"agent": self.agent}, }) } } @@ -130,6 +132,8 @@ struct StoredAgent { updated_at_ms: WireDecimal, source_session: Option, #[serde(default)] + agent: Option, + #[serde(default)] extra: Option>, } @@ -161,6 +165,7 @@ enum StoredAgentSource { Hook, Socket, Detected, + Plugin, } impl StoredAgentSource { @@ -169,6 +174,7 @@ impl StoredAgentSource { Self::Hook => "hook", Self::Socket => "socket", Self::Detected => "detected", + Self::Plugin => "plugin", } } } @@ -426,6 +432,13 @@ impl WorkspaceRegistry { source: stored.source.as_str().to_string(), updated_at_ms: stored.updated_at_ms.get(), source_session: stored.source_session, + agent: stored + .extra + .as_ref() + .and_then(|extra| extra.get("agent")) + .and_then(Value::as_str) + .map(str::to_string) + .or(stored.agent), }); } agents.reverse(); diff --git a/cmux-tui/crates/cmux-tui-core/src/workspace_registry/resource_store.rs b/cmux-tui/crates/cmux-tui-core/src/workspace_registry/resource_store.rs index 1a499a731968..0272f4759645 100644 --- a/cmux-tui/crates/cmux-tui-core/src/workspace_registry/resource_store.rs +++ b/cmux-tui/crates/cmux-tui-core/src/workspace_registry/resource_store.rs @@ -631,6 +631,9 @@ impl WorkspaceRegistry { Ok(()) } + // These fields mirror the durable retry key and payload columns. Keep the + // storage boundary explicit so callers cannot accidentally omit a field. + #[allow(clippy::too_many_arguments)] pub(crate) fn enqueue_agent_hook_pending( &mut self, producer_id: &str, @@ -749,7 +752,8 @@ impl WorkspaceRegistry { Ok(()) } - pub fn pending_agent_hook_projections( + #[cfg(test)] + pub(crate) fn pending_agent_hook_projections( &self, ) -> anyhow::Result> { let mut statement = self.connection.prepare( @@ -779,7 +783,7 @@ impl WorkspaceRegistry { .collect() } - pub fn pending_agent_hook_projections_for_terminal( + pub(crate) fn pending_agent_hook_projections_for_terminal( &self, terminal_id: &TerminalPublicId, ) -> anyhow::Result> { @@ -825,7 +829,7 @@ impl WorkspaceRegistry { Ok(pending) } - pub fn pending_agent_hook_projections_page( + pub(crate) fn pending_agent_hook_projections_page( &self, after: Option, ) -> anyhow::Result<(Vec, Option)> { @@ -933,6 +937,7 @@ impl WorkspaceRegistry { result.get("terminal_id").and_then(Value::as_str) == Some(terminal_id.as_str()), "agent projection terminal does not match {terminal_id}" ); + let socket_report = fingerprint.get("source").and_then(Value::as_str) == Some("socket"); let fingerprint = canonical_json(fingerprint)?; let result_json = canonical_json(result)?; let tx = self.connection.transaction()?; @@ -961,6 +966,51 @@ impl WorkspaceRegistry { "resource revision conflict: expected {expected}, current {previous_revision}" ); } + // Socket reporters are observers, not a freshness clock. A second + // client can report the same effective state while the first report + // is still current. Record the new mutation key at the existing + // revision, then return it as a replay-equivalent no-op so this path + // does not churn resource events or roster recency. Hook and plugin + // projections keep their timestamp semantics for arbitration. + if socket_report && hook_state.is_none() && journal_sequence.is_none() { + let existing = tx + .query_row( + "SELECT result_json FROM resource_agent_projections + WHERE terminal_id = ?1", + [terminal_id.as_str()], + |row| row.get::<_, String>(0), + ) + .optional()?; + if let Some(existing_json) = existing { + let existing_value: Value = serde_json::from_str(&existing_json) + .context("stored agent projection is not valid JSON")?; + if same_agent_projection_ignoring_timestamp(&existing_value, result)? { + let stored_result_json = canonical_json(&existing_value)?; + tx.execute( + "INSERT INTO resource_mutations( + origin, idempotency_key, operation, fingerprint, result_json, + committed_revision + ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)", + params![ + mutation.origin, + mutation.id, + OPERATION, + fingerprint, + stored_result_json, + i64::try_from(previous_revision) + .context("resource revision exceeds SQLite range")?, + ], + )?; + prune_resource_mutations(&tx)?; + tx.commit()?; + return Ok(ResourcePatchCommit { + revision: previous_revision, + result: existing_value, + replayed: true, + }); + } + } + } let revision = previous_revision .checked_add(1) .ok_or_else(|| anyhow::anyhow!("resource revision exhausted"))?; @@ -1806,6 +1856,7 @@ impl WorkspaceRegistry { self.connection.execute_batch( "CREATE TEMP TRIGGER cmux_test_fail_resource_patch BEFORE INSERT ON session_journal + WHEN NEW.resource_revision IS NOT NULL BEGIN SELECT RAISE(ABORT, 'forced resource patch failure'); END;", )?; } else { @@ -1854,6 +1905,26 @@ impl WorkspaceRegistry { } } +/// Compare two agent projections while ignoring the local observation clock. +/// The caller has already restricted this to a socket report, so a matching +/// semantic value is safe to acknowledge without another resource revision. +fn same_agent_projection_ignoring_timestamp( + existing: &Value, + incoming: &Value, +) -> anyhow::Result { + let mut existing = existing.clone(); + let mut incoming = incoming.clone(); + let Some(existing_object) = existing.as_object_mut() else { + return Ok(false); + }; + let Some(incoming_object) = incoming.as_object_mut() else { + return Ok(false); + }; + existing_object.remove("updated_at_ms"); + incoming_object.remove("updated_at_ms"); + Ok(canonical_json(&existing)? == canonical_json(&incoming)?) +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct RegistryScreen { pub public_id: ScreenPublicId, diff --git a/cmux-tui/crates/cmux-tui-core/src/workspace_registry/session_journal.rs b/cmux-tui/crates/cmux-tui-core/src/workspace_registry/session_journal.rs index 527ec3b2037c..f2ad838cfeeb 100644 --- a/cmux-tui/crates/cmux-tui-core/src/workspace_registry/session_journal.rs +++ b/cmux-tui/crates/cmux-tui-core/src/workspace_registry/session_journal.rs @@ -1092,6 +1092,38 @@ pub(super) fn append_journal_record( } impl WorkspaceRegistry { + /// Reserve a process generation for the userland journal-plugin + /// supervisor. The value lives in the session registry so a daemon restart + /// can never reuse a generation that a persisted roster fence retired. + pub(crate) fn reserve_journal_plugin_generation(&self) -> anyhow::Result { + let transaction = + Transaction::new_unchecked(&self.connection, rusqlite::TransactionBehavior::Immediate)?; + let current = transaction + .query_row( + "SELECT value FROM meta WHERE key = ?1", + [JOURNAL_PLUGIN_GENERATION_META_KEY], + |row| row.get::<_, String>(0), + ) + .optional()? + .map(|value| { + value.parse::().with_context(|| { + format!("journal plugin generation {JOURNAL_PLUGIN_GENERATION_META_KEY} is not an unsigned integer") + }) + }) + .transpose()? + .unwrap_or(0); + let next = current + .checked_add(1) + .ok_or_else(|| anyhow::anyhow!("journal plugin generation exhausted"))?; + transaction.execute( + "INSERT INTO meta(key, value) VALUES(?1, ?2) + ON CONFLICT(key) DO UPDATE SET value = excluded.value", + rusqlite::params![JOURNAL_PLUGIN_GENERATION_META_KEY, next.to_string()], + )?; + transaction.commit()?; + Ok(next) + } + pub fn session_journal_after( &self, sequence: u64, @@ -1100,6 +1132,65 @@ impl WorkspaceRegistry { query_session_journal_after(&self.connection, sequence, limit) } + /// Return the highest sequence in the active or archived journal without + /// decoding any records. Reducer recovery uses this to reject a stale + /// snapshot cursor while remaining valid when the journal has compacted + /// its earliest records. + pub fn session_journal_head(&self) -> anyhow::Result { + query_journal_head(&self.connection) + } + + /// Persisted fold position of one journal reducer: (version, cursor, + /// snapshot). A version mismatch on load discards the snapshot so the + /// reducer re-folds from the journal head. + pub(crate) fn journal_reducer_state( + &self, + reducer_id: &str, + ) -> anyhow::Result> { + let raw = self + .connection + .query_row( + "SELECT value FROM meta WHERE key = ?1", + [format!("journal_reducer.{reducer_id}")], + |row| row.get::<_, String>(0), + ) + .optional()?; + let Some(raw) = raw else { return Ok(None) }; + let value: Value = serde_json::from_str(&raw) + .with_context(|| format!("journal reducer state for {reducer_id} is not JSON"))?; + let version = value.get("version").and_then(Value::as_u64).unwrap_or(0) as u32; + let cursor = value + .get("cursor") + .and_then(Value::as_str) + .and_then(|cursor| cursor.parse::().ok()) + .unwrap_or(0); + let snapshot = + value.get("snapshot").and_then(Value::as_str).map(str::to_string).unwrap_or_default(); + Ok(Some((version, cursor, snapshot))) + } + + /// Durably record a reducer's fold position and state snapshot. Cursor + /// values are stored as strings so 64-bit sequences survive JSON. + pub(crate) fn put_journal_reducer_state( + &self, + reducer_id: &str, + version: u32, + cursor: u64, + snapshot: &str, + ) -> anyhow::Result<()> { + let value = serde_json::json!({ + "version": version, + "cursor": cursor.to_string(), + "snapshot": snapshot, + }); + self.connection.execute( + "INSERT INTO meta(key, value) VALUES(?1, ?2) + ON CONFLICT(key) DO UPDATE SET value = excluded.value", + params![format!("journal_reducer.{reducer_id}"), value.to_string()], + )?; + Ok(()) + } + /// The most recently started journal output stream for one terminal: /// its generation and the exclusive end offset of its journaled bytes. pub(crate) fn terminal_stream_latest( @@ -2220,6 +2311,7 @@ mod tests { #[test] fn journal_cursor_and_page_limits_fail_closed() { let registry = WorkspaceRegistry::in_memory("limits").unwrap(); + assert_eq!(registry.session_journal_head().unwrap(), 0); assert!(registry.session_journal_after(1, 1).unwrap_err().to_string().contains("ahead")); assert!(registry.session_journal_after(0, 0).unwrap_err().to_string().contains("positive")); assert!( @@ -2257,6 +2349,7 @@ mod tests { .unwrap(); tx.commit().unwrap(); + assert_eq!(registry.session_journal_head().unwrap(), 1); let page = reader.after(0, 1).unwrap(); assert_eq!(page.head_sequence, 1); assert_eq!(page.records[0].kind, "workspace.focus"); diff --git a/cmux-tui/crates/cmux-tui-core/src/workspace_registry/tests.rs b/cmux-tui/crates/cmux-tui-core/src/workspace_registry/tests.rs index 58b47fd45d44..bbb86d6d0236 100644 --- a/cmux-tui/crates/cmux-tui-core/src/workspace_registry/tests.rs +++ b/cmux-tui/crates/cmux-tui-core/src/workspace_registry/tests.rs @@ -68,6 +68,21 @@ fn registry_opens_and_persists_under_a_long_windows_state_root() { fs::remove_dir_all(fixture_root).unwrap(); } +#[test] +fn journal_plugin_generation_reservation_is_monotonic_and_durable() { + let registry = WorkspaceRegistry::in_memory("plugin-generation").unwrap(); + assert_eq!(registry.reserve_journal_plugin_generation().unwrap(), 1); + assert_eq!(registry.reserve_journal_plugin_generation().unwrap(), 2); + registry + .connection + .execute( + "UPDATE meta SET value = ?1 WHERE key = 'journal_plugin_generation'", + [u64::MAX.to_string()], + ) + .unwrap(); + assert!(registry.reserve_journal_plugin_generation().is_err()); +} + #[test] fn interrupted_staged_workspace_keeps_reserved_public_id_without_early_publication() { let root = temp_root("interrupted-workspace-public-id"); @@ -605,6 +620,7 @@ fn terminal_host_reset_holds_structured_live_marker_lock() { supports_clear_history: true, supports_terminate_ack: false, supports_input_ack: false, + supports_terminal_metadata: false, }; let record_path = record.record_path(&root); let live_path = terminal_host_live_marker_path(&record_path, &record); @@ -699,6 +715,7 @@ fn terminal_host_reset_checks_legacy_live_marker_as_orphan() { supports_clear_history: false, supports_terminate_ack: false, supports_input_ack: false, + supports_terminal_metadata: false, }; let record_path = record.record_path(&root); let live_path = terminal_host_live_marker_path(&record_path, &record); @@ -807,6 +824,7 @@ fn reset_accepts_dead_v2_terminal_host_without_creating_live_marker() { supports_clear_history: true, supports_terminate_ack: false, supports_input_ack: false, + supports_terminal_metadata: false, }; let record_path = record.record_path(&host_root); let live_path = terminal_host_live_marker_path(&record_path, &record); diff --git a/cmux-tui/crates/cmux-tui/src/agent_hook_install.rs b/cmux-tui/crates/cmux-tui/src/agent_hook_install.rs index 7e3fc62f30cc..9dec5ae80a77 100644 --- a/cmux-tui/crates/cmux-tui/src/agent_hook_install.rs +++ b/cmux-tui/crates/cmux-tui/src/agent_hook_install.rs @@ -2547,27 +2547,37 @@ mod tests { #[cfg(unix)] #[test] fn hermes_command_reaps_child_when_reaper_spawn_fails() { - let (pid_sender, pid_receiver) = std::sync::mpsc::channel(); + let root = tempfile::tempdir().unwrap(); + let pid_path = root.path().join("hermes.pid"); + let script = format!( + // `exec` keeps the long-running process at the shell's PID and + // avoids a second fork. Hosted macOS runners may deny that fork + // while still allowing the process under test to run. + "printf '%s' $$ > {}; exec /bin/sleep 30", + shell_quote(pid_path.to_string_lossy().as_ref()), + ); let (result_sender, result_receiver) = std::sync::mpsc::sync_channel(1); let started = Instant::now(); let worker = std::thread::spawn(move || { FORCE_HERMES_REAPER_SPAWN_FAILURE.with(|failure| failure.set(true)); - HERMES_TEST_CHILD_SENDER.with(|sender| sender.replace(Some(pid_sender))); let result = run_hermes_command_with_timeout( - Path::new("/bin/sleep"), - &["30"], + Path::new("/bin/sh"), + &["-c", &script], Duration::from_secs(2), ); result_sender.send(result).unwrap(); }); - let pid = libc::pid_t::try_from( - pid_receiver - .recv_timeout(Duration::from_secs(1)) - .expect("Hermes child did not complete startup"), - ) - .unwrap(); - + let startup_deadline = Instant::now() + Duration::from_secs(1); + let pid = loop { + if let Ok(contents) = fs::read_to_string(&pid_path) + && let Ok(pid) = contents.trim().parse::() + { + break pid; + } + assert!(Instant::now() < startup_deadline, "Hermes child did not complete startup"); + std::thread::sleep(Duration::from_millis(5)); + }; let error = result_receiver .recv_timeout(Duration::from_secs(4)) .expect("Hermes timeout worker did not return") diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index 94137233bc8c..32ba3742e1eb 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -87,7 +87,7 @@ use crate::session::{ }; use crate::sidebar_files::{FileBrowser, FileCommand, file_url, shell_single_quote}; use crate::sidebar_projection::{ - ProjectionBranch, ProjectionRailState, ProjectionRow, ProjectionTarget, + AgentOrderCache, ProjectionBranch, ProjectionRailState, ProjectionRow, ProjectionTarget, }; use crate::ui::graphics::{ GraphicPlacement, GraphicSourceRect, kitty_graphic_image, kitty_graphic_placement, @@ -7308,6 +7308,7 @@ pub struct App { pub(crate) tabs_rail_scroll: usize, pub(crate) tabs_footer_scroll: usize, projection_rails: HashMap, + projection_order_cache: AgentOrderCache, pub(crate) machine_rail_follow_selection: bool, pub(crate) workspace_rail_follow_selection: bool, pub(crate) tabs_rail_follow_selection: bool, @@ -9576,6 +9577,7 @@ fn run_with_machine_updates_inner(request: RunRequest) -> anyhow::Result Vec { + pub(crate) fn projection_rows(&mut self, index: usize) -> Vec { let Some(spec) = self.config.sidebar.views.get(index) else { return Vec::new() }; let empty_collapsed = HashSet::new(); let collapsed = self @@ -10354,12 +10356,13 @@ impl App { } else { Vec::new() }; - crate::sidebar_projection::rows( + crate::sidebar_projection::rows_cached( spec, &self.tree, &agents, self.sidebar_workspace_selection, collapsed, + &mut self.projection_order_cache, ) } @@ -25082,32 +25085,32 @@ mod tests { } use super::{ - App, AppEvent, BACKGROUND_REFRESH_RETRIES, BrowserResizeFailure, ContextMenu, - DEFERRED_INPUT_CAPACITY, DeferredInput, DeferredInputAdmission, DeferredInputQueue, - DeferredReplayDisposition, Drag, EventCancellation, FocusTarget, ForwardMuxOutcome, - FrontendJournalQueue, FrontendJournalWorker, GraphicIdentity, GraphicPlacement, - GraphicSourceRect, GraphicsSceneCache, GuardedMouseEncode, HostInputIngress, - HostInputMessage, HostInputRuntime, MachineActionWorker, MachineConnectRoute, MenuAction, - MenuItem, MutationImpact, MuxTitleIngress, OmnibarHit, OmnibarState, OrderedSession, - OuterCursorSpec, PaneArea, PaneAreaProjection, PaneContentGeneration, PaneEdge, - PaneFocusHistory, PaneResizeDragTarget, PaneViewportClip, PendingSessionMutation, - PendingSessionMutationState, PointerHitIdentity, PointerRouteIdentity, PointerRoutePhase, - Prompt, PromptTarget, PtyFailureIngress, PtyMousePressResult, RailKind, RenderAction, - RenderedMenuLevel, RenderedPaneRoute, RenderedPointerFrame, Selection, SelectionMode, - SessionCompletion, SessionCompletionAction, SessionEventSender, ShortcutHelp, - SidebarActionTarget, SidebarLayout, SidebarPluginSyncClaim, SidebarPluginSyncState, - SidebarWidthOverrides, StatusTemplateValues, StatusWorkerStop, StdoutLock, - SurfaceAttachClaimState, SurfaceResizeDecision, SurfaceResizeOwnership, - TERMINAL_PAINT_CADENCE, TerminalInput, TerminalPaintPacer, TerminalPointerAdmission, - TerminalPointerAdmissionResult, TerminalPointerEncoding, TextInput, Toast, - VIEWPORT_ANIMATION_DURATION, ViewportMotion, ViewportPaneAreaProjection, - WorkspaceRailSelection, action_available_in_mode, browser_content_size_for_rect, - browser_frame_source_crop, browser_hover_forward_allowed, browser_source_crop, - canonical_terminal_content, catch_renderer_panic, clamp_split_ratio_for_tab_bars, - client_menu_item, clip_horizontal_rect, content_size_for_rect, - disable_host_keyboard_protocol, enable_host_keyboard_protocol, expand_status_tokens, - first_pane_by_id, forward_host_input, forward_mux_event, forward_mux_events, - host_mouse_capture_escape_if_changed, host_startup_input_modes, + AgentOrderCache, App, AppEvent, BACKGROUND_REFRESH_RETRIES, BrowserResizeFailure, + ContextMenu, DEFERRED_INPUT_CAPACITY, DeferredInput, DeferredInputAdmission, + DeferredInputQueue, DeferredReplayDisposition, Drag, EventCancellation, FocusTarget, + ForwardMuxOutcome, FrontendJournalQueue, FrontendJournalWorker, GraphicIdentity, + GraphicPlacement, GraphicSourceRect, GraphicsSceneCache, GuardedMouseEncode, + HostInputIngress, HostInputMessage, HostInputRuntime, MachineActionWorker, + MachineConnectRoute, MenuAction, MenuItem, MutationImpact, MuxTitleIngress, OmnibarHit, + OmnibarState, OrderedSession, OuterCursorSpec, PaneArea, PaneAreaProjection, + PaneContentGeneration, PaneEdge, PaneFocusHistory, PaneResizeDragTarget, PaneViewportClip, + PendingSessionMutation, PendingSessionMutationState, PointerHitIdentity, + PointerRouteIdentity, PointerRoutePhase, Prompt, PromptTarget, PtyFailureIngress, + PtyMousePressResult, RailKind, RenderAction, RenderedMenuLevel, RenderedPaneRoute, + RenderedPointerFrame, Selection, SelectionMode, SessionCompletion, SessionCompletionAction, + SessionEventSender, ShortcutHelp, SidebarActionTarget, SidebarLayout, + SidebarPluginSyncClaim, SidebarPluginSyncState, SidebarWidthOverrides, + StatusTemplateValues, StatusWorkerStop, StdoutLock, SurfaceAttachClaimState, + SurfaceResizeDecision, SurfaceResizeOwnership, TERMINAL_PAINT_CADENCE, TerminalInput, + TerminalPaintPacer, TerminalPointerAdmission, TerminalPointerAdmissionResult, + TerminalPointerEncoding, TextInput, Toast, VIEWPORT_ANIMATION_DURATION, ViewportMotion, + ViewportPaneAreaProjection, WorkspaceRailSelection, action_available_in_mode, + browser_content_size_for_rect, browser_frame_source_crop, browser_hover_forward_allowed, + browser_source_crop, canonical_terminal_content, catch_renderer_panic, + clamp_split_ratio_for_tab_bars, client_menu_item, clip_horizontal_rect, + content_size_for_rect, disable_host_keyboard_protocol, enable_host_keyboard_protocol, + expand_status_tokens, first_pane_by_id, forward_host_input, forward_mux_event, + forward_mux_events, host_mouse_capture_escape_if_changed, host_startup_input_modes, initial_applied_outer_cursor, initial_host_mouse_capture, keyboard_protocol_accepts, layout_undo_error_completion, negotiate_host_keyboard_protocol_with, outer_cursor_escape, outer_cursor_escape_if_changed, pane_area_projection_work, pane_context_menu_groups, @@ -35476,6 +35479,7 @@ mod tests { state: "working".into(), source: "hook".into(), session: None, + agent: None, updated_at_ms: 1, }, &tx, @@ -35498,6 +35502,7 @@ mod tests { state: "working".into(), source: "hook".into(), session: None, + agent: None, updated_at_ms: 2, }, &tx, @@ -46435,6 +46440,7 @@ mod tests { tabs_rail_scroll: 0, tabs_footer_scroll: 0, projection_rails: HashMap::new(), + projection_order_cache: AgentOrderCache::default(), machine_rail_follow_selection: true, workspace_rail_follow_selection: true, tabs_rail_follow_selection: true, diff --git a/cmux-tui/crates/cmux-tui/src/cli.rs b/cmux-tui/crates/cmux-tui/src/cli.rs index e082e9748ce3..90ecbb1523c2 100644 --- a/cmux-tui/crates/cmux-tui/src/cli.rs +++ b/cmux-tui/crates/cmux-tui/src/cli.rs @@ -692,6 +692,10 @@ USAGE cmux agent report --terminal --state --source cmux agent hook install|uninstall|status [provider...] cmux agent hook emit --source --event [--terminal ] + cmux agent plugin list + cmux agent plugin install [--name ] [--force] + cmux agent plugin use|update|remove + cmux agent plugin use --builtin "; const SIDEBAR_HELP: &str = "\ diff --git a/cmux-tui/crates/cmux-tui/src/cli/command.rs b/cmux-tui/crates/cmux-tui/src/cli/command.rs index 328ecbfba762..472189b4bdb5 100644 --- a/cmux-tui/crates/cmux-tui/src/cli/command.rs +++ b/cmux-tui/crates/cmux-tui/src/cli/command.rs @@ -62,6 +62,7 @@ pub(super) struct PluginPlan { pub name: Option, pub force: bool, pub builtin: bool, + pub kind: crate::plugin_manager::PluginKind, } #[derive(Clone, Debug)] @@ -1481,6 +1482,9 @@ fn parse_notify(words: &[String], flags: &mut Flags) -> Result Result { let selectors = Selectors::default(); match strs(words).as_slice() { + ["plugin", tail @ ..] => { + parse_plugin(tail, flags, crate::plugin_manager::PluginKind::Agent) + } ["hook", action @ ("install" | "uninstall" | "status"), providers @ ..] => { let action = match *action { "install" => crate::agent_hook_install::Action::Install, @@ -1651,12 +1655,18 @@ fn parse_sidebar( insert_selector_or_current(selectors, flags, "view", "sidebar_view", "sidebar_view")?; request(ResourceOperation::SidebarViewReload, selectors, flags, Map::new()) } - ["plugin", tail @ ..] => parse_plugin(tail, flags), + ["plugin", tail @ ..] => { + parse_plugin(tail, flags, crate::plugin_manager::PluginKind::Sidebar) + } _ => usage("sidebar action"), } } -fn parse_plugin(words: &[&str], flags: &mut Flags) -> Result { +fn parse_plugin( + words: &[&str], + flags: &mut Flags, + kind: crate::plugin_manager::PluginKind, +) -> Result { let mut positionals = vec![]; let mut builtin = false; match words { @@ -1681,13 +1691,14 @@ fn parse_plugin(words: &[&str], flags: &mut Flags) -> Result return usage("sidebar plugin action"), + _ => return usage("plugin action"), } let plan = PluginPlan { positionals, name: flags.take("name"), force: flags.boolean("force"), builtin, + kind, }; Ok(CommandPlan::Plugin(plan)) } @@ -2796,6 +2807,7 @@ pub(super) fn run_plugin(global: GlobalArgs, plan: PluginPlan) -> i32 { force: plan.force, builtin: plan.builtin, }, + plan.kind, ) { Ok(result) => super::wire::print_local_success(&result, global.output), Err(error) => { @@ -4120,6 +4132,25 @@ mod tests { assert_eq!(seen, expected); } + #[test] + fn agent_plugin_management_stays_local_and_can_be_disabled() { + let cases = [ + (vec!["agent", "plugin", "list"], false), + (vec!["agent", "plugin", "install", "https://example.com/plugin.git"], false), + (vec!["agent", "plugin", "use", "screen-detector"], false), + (vec!["agent", "plugin", "update", "screen-detector"], false), + (vec!["agent", "plugin", "remove", "screen-detector"], false), + (vec!["agent", "plugin", "use", "--builtin"], true), + ]; + for (args, builtin) in cases { + let CommandPlan::Plugin(plan) = parse(&strings(&args)).unwrap() else { + panic!("agent plugin command did not stay local: {args:?}"); + }; + assert_eq!(plan.kind, crate::plugin_manager::PluginKind::Agent); + assert_eq!(plan.builtin, builtin); + } + } + #[test] fn every_safe_transport_operation_has_a_noun_first_path() { const MACHINE: &str = "machine_00000000000000000000000000000001"; diff --git a/cmux-tui/crates/cmux-tui/src/config.rs b/cmux-tui/crates/cmux-tui/src/config.rs index de229d29a20f..c2eb546c9eca 100644 --- a/cmux-tui/crates/cmux-tui/src/config.rs +++ b/cmux-tui/crates/cmux-tui/src/config.rs @@ -45,6 +45,14 @@ //! "cwd": "/optional" //! } //! }, +//! "agents": { +//! "plugin": { +//! "id": "example_agent_screen_detection", +//! "command": ["/path/to/agent-plugin"], +//! "cwd": "/optional", +//! "revision": "sha256-..." +//! } +//! }, //! "machine_sidebar": { //! "enabled": false, //! "width": 22, @@ -129,7 +137,7 @@ use std::collections::{HashMap, HashSet, VecDeque}; use std::fs::OpenOptions; -use std::io::{Read, Write}; +use std::io::{self, Read, Write}; use std::ops::Deref; #[cfg(unix)] use std::os::unix::process::CommandExt; @@ -148,6 +156,10 @@ use cmux_tui_core::{CursorShape, DefaultColors, Rgb}; use cmux_tui_core::{DEFAULT_SCROLLBACK_LIMIT_BYTES, SurfaceOptions}; const MAX_SCROLLBACK_LIMIT_BYTES: usize = 1_000_000_000; +/// Bound every JSON config read before parsing it into a dynamic value. +/// Normal hand-written configs are far smaller, while a damaged or hostile +/// file must not be allowed to consume unbounded TUI memory. +pub(crate) const CONFIG_FILE_MAX_BYTES: usize = 4 * 1024 * 1024; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use ratatui::buffer::CellWidth; use ratatui::style::Color; @@ -179,6 +191,8 @@ struct RawConfig { #[serde(default)] sidebar: RawSidebar, #[serde(default)] + agents: RawAgents, + #[serde(default)] machine_sidebar: RawMachineSidebar, #[serde(default)] machine_provider: RawMachineProvider, @@ -620,6 +634,22 @@ struct RawSidebarPlugin { cwd: Option, } +#[derive(Debug, Default, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawAgents { + /// Optional background process that reports generic agent journal events. + plugin: Option, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawAgentPlugin { + id: Option, + command: Option>, + cwd: Option, + revision: Option, +} + #[derive(Debug, Default, Deserialize)] #[serde(deny_unknown_fields)] struct RawMachineSidebar { @@ -1030,6 +1060,13 @@ pub struct Sidebar { pub workspace_label: String, } +/// Background agent integrations. The process is optional and runs outside +/// the core detector. Its events enter through the journal producer API. +#[derive(Debug, Clone, Default)] +pub struct Agents { + pub plugin: Option, +} + impl Default for Sidebar { fn default() -> Self { let views = vec![ @@ -3056,6 +3093,7 @@ pub struct Config { pub chrome: ChromeMode, pub tabs: Tabs, pub sidebar: Sidebar, + pub agents: Agents, pub machine_sidebar: MachineSidebar, pub machine_provider: MachineProviderConfig, pub machines: Vec, @@ -3308,6 +3346,14 @@ pub struct SidebarPluginConfig { pub cwd: Option, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AgentPluginConfig { + pub id: String, + pub command: Vec, + pub cwd: Option, + pub revision: Option, +} + /// Load the config: defaults, overlaid with the user's Ghostty selection /// colors, overlaid with `cmux-tui.json` or legacy `mux.json`. pub fn load() -> Config { @@ -3445,13 +3491,11 @@ pub fn load() -> Config { } } if let Some(plugin) = raw.sidebar.plugin { - let command = plugin - .command - .unwrap_or_default() - .into_iter() - .filter(|arg| !arg.is_empty()) - .collect::>(); - if command.is_empty() { + // Preserve every argument after argv[0]. Empty arguments are valid + // process arguments, and filtering them would silently change the + // command a user configured. Only the executable slot is required. + let command = plugin.command.unwrap_or_default(); + if command.first().is_none_or(|arg| arg.trim().is_empty()) { crate::client_log::stderr_log!( "config", "cmux-tui: ignoring sidebar.plugin with empty command" @@ -3463,6 +3507,39 @@ pub fn load() -> Config { }); } } + if let Some(plugin) = raw.agents.plugin { + if let Some(id) = plugin.id { + // Do not filter later argv entries. An empty value can be meaningful + // to a plugin, while an empty executable must still disable config. + let command = plugin.command.unwrap_or_default(); + if command.first().is_none_or(|arg| arg.trim().is_empty()) { + crate::client_log::stderr_log!( + "config", + "cmux-tui: ignoring agents.plugin with empty command" + ); + } else { + let options = cmux_tui_core::JournalPluginOptions { + id, + command, + cwd: plugin.cwd.filter(|cwd| !cwd.trim().is_empty()), + revision: plugin.revision.filter(|revision| !revision.trim().is_empty()), + }; + if let Err(error) = options.validate() { + crate::client_log::stderr_log!( + "config", + "cmux-tui: ignoring invalid agents.plugin: {error}" + ); + } else { + config.agents.plugin = Some(options); + } + } + } else { + crate::client_log::stderr_log!( + "config", + "cmux-tui: ignoring agents.plugin without an explicit id" + ); + } + } if let Some(enabled) = raw.machine_sidebar.enabled { config.machine_sidebar.enabled = enabled; } @@ -4027,7 +4104,7 @@ fn agent_in_title(tabs: &Tabs, title: &str) -> Option { fn load_raw_config() -> RawConfig { let Some(path) = platform::config_path() else { return RawConfig::default() }; - let Ok(text) = std::fs::read_to_string(&path) else { return RawConfig::default() }; + let Ok(text) = read_config_text(&path) else { return RawConfig::default() }; let value: Value = match serde_json::from_str(&text) { Ok(value) => value, Err(e) => { @@ -4052,6 +4129,7 @@ fn load_raw_config() -> RawConfig { "theme", "tabs", "sidebar", + "agents", "machine_sidebar", "machine_provider", "machines", @@ -4092,6 +4170,7 @@ fn load_raw_config() -> RawConfig { section!(theme, "theme"); section!(tabs, "tabs"); section!(sidebar, "sidebar"); + section!(agents, "agents"); section!(machine_sidebar, "machine_sidebar"); section!(machine_provider, "machine_provider"); section!(machines, "machines"); @@ -4121,6 +4200,27 @@ pub fn config_path() -> anyhow::Result { platform::config_path().ok_or_else(|| anyhow::anyhow!("could not resolve mux config path")) } +/// Read a UTF-8 file with an explicit byte bound. The extra byte distinguishes +/// an exact-size file from one that exceeds the limit without allocating an +/// unbounded buffer. +pub(crate) fn read_bounded_utf8_file(path: &Path, max_bytes: usize) -> io::Result { + let file = std::fs::File::open(path)?; + let mut text = String::new(); + file.take(u64::try_from(max_bytes).unwrap_or(u64::MAX).saturating_add(1)) + .read_to_string(&mut text)?; + if text.len() > max_bytes { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("file exceeds {max_bytes}-byte limit"), + )); + } + Ok(text) +} + +pub(crate) fn read_config_text(path: &Path) -> io::Result { + read_bounded_utf8_file(path, CONFIG_FILE_MAX_BYTES) +} + /// The result of replacing the config file. A committed replacement is a /// successful operation even when the parent directory could not be synced. #[must_use = "inspect config durability after a committed write"] @@ -4187,12 +4287,58 @@ pub(crate) fn write_sidebar_plugin_at_path( write_config_value_atomic(path, &root) } +/// Writes the userland agent plugin selection to the configured path. +pub(crate) fn write_agent_plugin( + plugin: Option<&AgentPluginConfig>, +) -> anyhow::Result { + let path = config_path()?; + write_agent_plugin_at_path(&path, plugin) +} + +pub(crate) fn write_agent_plugin_at_path( + path: &Path, + plugin: Option<&AgentPluginConfig>, +) -> anyhow::Result { + let mut root = read_config_value(path)?; + let Some(root_object) = root.as_object_mut() else { + anyhow::bail!("{} must contain a JSON object", path.display()); + }; + match plugin { + Some(plugin) => { + let agents = root_object.entry("agents").or_insert_with(|| json!({})); + if !agents.is_object() { + *agents = json!({}); + } + let agents_object = agents.as_object_mut().expect("agents was just made an object"); + let mut plugin_value = json!({ + "id": &plugin.id, + "command": &plugin.command, + }); + if let Some(cwd) = &plugin.cwd { + plugin_value["cwd"] = json!(cwd); + } + if let Some(revision) = &plugin.revision { + plugin_value["revision"] = json!(revision); + } + agents_object.insert("plugin".to_string(), plugin_value); + } + None => { + if let Some(agents) = root_object.get_mut("agents") + && let Some(agents_object) = agents.as_object_mut() + { + agents_object.remove("plugin"); + } + } + } + write_config_value_atomic(path, &root) +} + fn read_config_value(path: &Path) -> anyhow::Result { - match std::fs::read_to_string(path) { + match read_config_text(path) { Ok(text) if text.trim().is_empty() => Ok(json!({})), Ok(text) => serde_json::from_str(&text) .map_err(|err| anyhow::anyhow!("failed to parse {}: {err}", path.display())), - Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(json!({})), + Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(json!({})), Err(err) => Err(anyhow::anyhow!("failed to read {}: {err}", path.display())), } } @@ -4257,7 +4403,7 @@ fn write_config_value_atomic_with_sync_and_staging( staged = Some((tmp_path, file)); break; } - Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue, Err(error) => return Err(error.into()), } } @@ -4306,7 +4452,7 @@ fn ensure_config_parent_directory(parent: &Path) -> anyhow::Result> } match std::fs::create_dir(¤t) { Ok(()) => created_directories.push(current.clone()), - Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => { if !std::fs::metadata(¤t)?.is_dir() { anyhow::bail!( "config parent component {} is not a directory", @@ -5935,7 +6081,7 @@ mod tests { .join(format!("cmux-tui-config-{label}-{}-{sequence}", std::process::id())); match std::fs::create_dir(&path) { Ok(()) => return Self { path }, - Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue, Err(error) => panic!("create config test directory failed: {error}"), } } @@ -7378,18 +7524,14 @@ mod tests { let descriptor = unsafe { libc::kqueue() }; #[cfg(target_os = "linux")] if descriptor < 0 { - let error = std::io::Error::last_os_error(); + let error = io::Error::last_os_error(); if matches!(error.raw_os_error(), Some(libc::ENOSYS) | Some(libc::EPERM)) { return None; } panic!("observe helper child {pid}: {error}"); } #[cfg(target_vendor = "apple")] - assert!( - descriptor >= 0, - "observe helper child {pid}: {}", - std::io::Error::last_os_error() - ); + assert!(descriptor >= 0, "observe helper child {pid}: {}", io::Error::last_os_error()); // SAFETY: pidfd_open and kqueue return a new owned descriptor. let descriptor = unsafe { std::os::fd::OwnedFd::from_raw_fd(descriptor as libc::c_int) }; @@ -7419,7 +7561,7 @@ mod tests { assert!( registered >= 0, "register helper child {pid} exit: {}", - std::io::Error::last_os_error() + io::Error::last_os_error() ); } @@ -7578,7 +7720,7 @@ mod tests { command.arg("5").process_group(0); let mut child = command.spawn().unwrap(); println!("{READY_MARKER}{}", child.id()); - std::io::stdout().flush().unwrap(); + io::stdout().flush().unwrap(); let _ = child.wait(); return; } @@ -7640,7 +7782,7 @@ mod tests { if unsafe { libc::kill(pid, 0) } == 0 { return true; } - std::io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH) + io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH) } #[cfg(all(unix, not(target_os = "macos")))] @@ -7666,8 +7808,7 @@ mod tests { } assert!(output.len() > 4 * 1024); - let reader = - read_ghostty_helper_output_async(std::io::Cursor::new(output.clone())).unwrap(); + let reader = read_ghostty_helper_output_async(io::Cursor::new(output.clone())).unwrap(); assert_eq!(reader.wait(), Some(output)); } @@ -7676,7 +7817,7 @@ mod tests { fn ghostty_config_helper_output_reader_enforces_byte_limit() { let output = "x".repeat(GHOSTTY_HELPER_OUTPUT_MAX_BYTES as usize + 1); - let reader = read_ghostty_helper_output_async(std::io::Cursor::new(output)).unwrap(); + let reader = read_ghostty_helper_output_async(io::Cursor::new(output)).unwrap(); assert_eq!(reader.wait(), None); } @@ -8106,6 +8247,31 @@ mod tests { assert!(raw.machine_provider.command.as_deref().is_some_and(|c| c[0].trim().is_empty())); } + #[test] + fn agent_plugin_requires_an_explicit_namespace_id() { + let _guard = CONFIG_ENV_LOCK.lock().unwrap(); + let old_cmux_tui_config = std::env::var_os("CMUX_TUI_CONFIG"); + let old_mux_config = std::env::var_os("CMUX_MUX_CONFIG"); + let directory = TestDirectory::new("agent-plugin-id-required"); + let path = directory.path.join("mux.json"); + std::fs::write(&path, r#"{"agents":{"plugin":{"command":["/tmp/agent-plugin"]}}}"#) + .unwrap(); + // SAFETY: environment mutation is serialized by CONFIG_ENV_LOCK. + unsafe { + std::env::remove_var("CMUX_TUI_CONFIG"); + std::env::set_var("CMUX_MUX_CONFIG", &path); + } + + let config = load(); + + restore_env_var("CMUX_TUI_CONFIG", old_cmux_tui_config); + restore_env_var("CMUX_MUX_CONFIG", old_mux_config); + assert!( + config.agents.plugin.is_none(), + "a userland plugin without an explicit producer id must be ignored", + ); + } + #[test] fn zero_static_ssh_port_falls_back_to_the_ssh_default() { assert_eq!(normalize_ssh_machine_port("mini", Some(0)), None); @@ -8212,6 +8378,14 @@ mod tests { "cwd": "/tmp" } }, + "agents": { + "plugin": { + "id": "screen-detector", + "command": ["/tmp/agent-plugin", "", "--mode", "test"], + "cwd": "/tmp", + "revision": "sha256-test" + } + }, "machine_sidebar": { "enabled": true, "width": 26, @@ -8338,6 +8512,15 @@ mod tests { let plugin = config.sidebar.plugin.as_ref().expect("sidebar plugin config"); assert_eq!(plugin.command, vec!["/tmp/sidebar-plugin", "--mode", "test"]); assert_eq!(plugin.cwd.as_deref(), Some("/tmp")); + let agent_plugin = config.agents.plugin.as_ref().expect("agent plugin config"); + assert_eq!(agent_plugin.id, "screen-detector"); + assert_eq!( + agent_plugin.command, + vec!["/tmp/agent-plugin", "", "--mode", "test"], + "empty arguments after argv[0] must remain part of the command" + ); + assert_eq!(agent_plugin.cwd.as_deref(), Some("/tmp")); + assert_eq!(agent_plugin.revision.as_deref(), Some("sha256-test")); assert_eq!(config.scrollbar.position, ScrollbarPosition::Border); assert_eq!(config.theme.border_style, BorderStyle::Rounded); assert_eq!(config.pane.padding, MAX_PANE_PADDING, "padding clamps to the maximum"); diff --git a/cmux-tui/crates/cmux-tui/src/main.rs b/cmux-tui/crates/cmux-tui/src/main.rs index 06196a4ebc3c..7d308efd6796 100644 --- a/cmux-tui/crates/cmux-tui/src/main.rs +++ b/cmux-tui/crates/cmux-tui/src/main.rs @@ -2121,6 +2121,7 @@ fn run_server( owner_host_colors, )); mux.configure_sidebar_plugin(config.sidebar.plugin.clone()); + mux.configure_journal_plugin(config.agents.plugin.clone()); #[cfg(target_os = "linux")] let _provider_management = provider_management_listener .map(|listener| cmux_tui_core::provider_management::serve(listener, mux.clone())) @@ -2217,6 +2218,7 @@ fn run_server( ); } let served_socket = pending_server.into_bound_path(); + mux.start_journal_plugin(served_socket.clone()); let mut served_mux_cleanup = ServedMuxCleanup::new(mux.clone(), served_socket); // Cloud VMs carry coderouter identity in their model-plane env; every // other host resolves no source and gets no poller. diff --git a/cmux-tui/crates/cmux-tui/src/plugin_manager.rs b/cmux-tui/crates/cmux-tui/src/plugin_manager.rs index 502a27ec5bfa..8c9c584aec74 100644 --- a/cmux-tui/crates/cmux-tui/src/plugin_manager.rs +++ b/cmux-tui/crates/cmux-tui/src/plugin_manager.rs @@ -1,5 +1,6 @@ +use std::collections::HashSet; use std::fs; -use std::io::Write; +use std::io::{Read, Write}; use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; use std::thread; @@ -7,8 +8,54 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; + +use crate::config::{self, AgentPluginConfig, SidebarPluginConfig}; + +// A manifest is supplied by a repository that the user asks cmux to install. +// Bound its parser input and argv shape before any build or plugin process is +// started, so a malformed package cannot consume unbounded host resources. +const MAX_PLUGIN_MANIFEST_BYTES: usize = 256 * 1024; +const MAX_PLUGIN_NAME_BYTES: usize = 64; +const MAX_PLUGIN_COMMAND_ARGS: usize = 256; +const MAX_PLUGIN_COMMAND_ARG_BYTES: usize = 4096; +const MAX_PLUGIN_REGISTRY_METADATA_BYTES: usize = 16 * 1024; +const MAX_PLUGIN_GIT_OUTPUT_BYTES: usize = 16 * 1024; +/// Bound the number of filesystem entries inspected by one plugin-manager +/// operation. The registry is user-controlled, so a malicious or stale data +/// directory must not turn `list` or selector resolution into an unbounded +/// scan and allocation. +const MAX_INSTALLED_PLUGIN_ENTRIES: usize = 256; +const ARTIFACT_HASH_BUFFER_BYTES: usize = 64 * 1024; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum PluginKind { + Sidebar, + Agent, +} + +impl PluginKind { + fn manifest_kind(self) -> &'static str { + match self { + Self::Sidebar => "sidebar", + Self::Agent => "agent", + } + } + + fn command_prefix(self) -> &'static str { + match self { + Self::Sidebar => "cmux sidebar plugin", + Self::Agent => "cmux agent plugin", + } + } -use crate::config::{self, SidebarPluginConfig}; + fn id_prefix(self) -> &'static str { + match self { + Self::Sidebar => "sidebar_plugin_", + Self::Agent => "agent_plugin_", + } + } +} /// A userland plugin build must not hold the CLI forever. const PLUGIN_BUILD_TIMEOUT: Duration = Duration::from_secs(300); @@ -90,6 +137,7 @@ impl From for ManagerError { } #[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] struct PluginManifest { plugin: ManifestPlugin, run: ManifestRun, @@ -97,19 +145,23 @@ struct PluginManifest { } #[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] struct ManifestPlugin { name: String, kind: String, version: Option, description: Option, + platforms: Option>, } #[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] struct ManifestRun { command: Vec, } #[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] struct ManifestBuild { command: Vec, } @@ -129,47 +181,61 @@ struct PluginRegistryMetadata { id: String, } -pub(crate) fn execute(positionals: &[String], options: CliOptions) -> Result { +pub(crate) fn execute( + positionals: &[String], + options: CliOptions, + kind: PluginKind, +) -> Result { match positionals.first().map(String::as_str) { - Some("install") => install_command(positionals, &options), - Some("list") => list_command(positionals, &options), - Some("use") => use_command(positionals, &options), - Some("update") => update_command(positionals, &options), - Some("remove") => remove_command(positionals, &options), + Some("install") => install_command(positionals, &options, kind), + Some("list") => list_command(positionals, &options, kind), + Some("use") => use_command(positionals, &options, kind), + Some("update") => update_command(positionals, &options, kind), + Some("remove") => remove_command(positionals, &options, kind), Some(other) => Err(ManagerError::Usage(format!("unknown plugin subcommand {other:?}"))), None => Err(ManagerError::Usage("plugin subcommand is required".to_string())), } } -fn install_command(positionals: &[String], options: &CliOptions) -> Result { +fn install_command( + positionals: &[String], + options: &CliOptions, + kind: PluginKind, +) -> Result { reject_plugin_flags(options, true, true, false)?; if positionals.len() != 2 { - return Err(ManagerError::Usage( - "usage: cmux sidebar plugin install [--name ] [--force]".to_string(), - )); + return Err(ManagerError::Usage(format!( + "usage: {} install [--name ] [--force]", + kind.command_prefix() + ))); } if positionals[1].is_empty() { return Err(ManagerError::validation(Some("git_url"), "plugin git URL must not be empty")); } validate_git_source(&positionals[1]) .map_err(|error| ManagerError::validation(Some("git_url"), error.to_string()))?; - let root = install_root()?; + let root = install_root(kind)?; fs::create_dir_all(&root)?; let temp_dir = root.join(format!(".install-{}-{}", std::process::id(), now_nanos())); + // Keep the user-supplied source after `--` so a value beginning with `-` + // cannot become a git option. The destination remains a separate final + // argument handled by `run_git`. let clone_result = - run_git(["clone", "--depth", "1", positionals[1].as_str()], Some(&temp_dir), None); + run_git(["clone", "--depth", "1", "--", positionals[1].as_str()], Some(&temp_dir), None); if let Err(error) = clone_result { let _ = fs::remove_dir_all(&temp_dir); return Err(error.into()); } let result = (|| -> Result { - let manifest = read_manifest(&temp_dir) + let manifest = read_manifest(&temp_dir, kind) .map_err(|error| ManagerError::validation(None, error.to_string()))?; + ensure_manifest_platform_supported(&manifest) + .map_err(|error| ManagerError::validation(Some("platforms"), error.to_string()))?; let name = installed_name(&manifest, options.name.as_deref()) .map_err(|error| ManagerError::validation(Some("name"), error.to_string()))?; let target = root.join(&name); - if target.exists() && !options.force { + if path_exists(&target)? && !options.force { return Err(ManagerError::validation( Some("name"), format!( @@ -181,21 +247,39 @@ fn install_command(positionals: &[String], options: &CliOptions) -> Result Result<(), ManagerError> { + let command = resolved_run_command(&manifest, &target)?; + let cwd = canonical_path(&target)?; + let revision = artifact_revision(&manifest, &target, &command); + persist_plugin(kind, &id, &command, Some(cwd.display().to_string()), Some(revision)) + })(); + if let Err(error) = config_result { + return match transaction.rollback() { + Ok(()) => Err(error), + Err(rollback_error) => Err(ManagerError::Failure(anyhow::anyhow!( + "plugin configuration failed: {error}; plugin rollback failed: {rollback_error}" + ))), + }; + } + transaction.commit(); + } else { + transaction.commit(); } Ok(json!({"plugin": plugin_json(&InstalledPlugin { id, @@ -211,89 +295,182 @@ fn install_command(positionals: &[String], options: &CliOptions) -> Result Result { +fn list_command( + positionals: &[String], + options: &CliOptions, + kind: PluginKind, +) -> Result { reject_plugin_flags(options, false, false, false)?; if positionals.len() != 1 { - return Err(ManagerError::Usage("usage: cmux sidebar plugin list".to_string())); + return Err(ManagerError::Usage(format!("usage: {} list", kind.command_prefix()))); } - let plugins = installed_plugins()?; + let plugins = installed_plugins(kind)?; Ok(Value::Array(plugins.iter().map(plugin_json).collect())) } -fn use_command(positionals: &[String], options: &CliOptions) -> Result { +fn use_command( + positionals: &[String], + options: &CliOptions, + kind: PluginKind, +) -> Result { reject_plugin_flags(options, false, false, true)?; match (positionals.len(), options.builtin) { - (1, true) => return write_builtin_config(options), + (1, true) => return write_builtin_config(options, kind), (2, false) => {} _ => { - return Err(ManagerError::Usage( - "usage: cmux sidebar plugin use | cmux sidebar plugin use --builtin" - .to_string(), - )); + return Err(ManagerError::Usage(format!( + "usage: {} use | {} use --builtin", + kind.command_prefix(), + kind.command_prefix() + ))); } } - let mut plugin = resolve_installed_plugin(&positionals[1])?; + let mut plugin = resolve_installed_plugin(&positionals[1], kind)?; + ensure_manifest_platform_supported(&plugin.manifest) + .map_err(|error| ManagerError::validation(Some("platforms"), error.to_string()))?; let command = resolved_run_command(&plugin.manifest, &plugin.dir)?; verify_executable(&command[0])?; let cwd = canonical_path(&plugin.dir)?; - persist_sidebar_plugin(Some(&SidebarPluginConfig { - command, - cwd: Some(cwd.display().to_string()), - }))?; + let plugin_id = plugin.id.clone(); + let revision = artifact_revision(&plugin.manifest, &plugin.dir, &command); + persist_plugin(kind, &plugin_id, &command, Some(cwd.display().to_string()), Some(revision))?; plugin.selected = true; Ok(json!({"plugin": plugin_json(&plugin)})) } -fn update_command(positionals: &[String], options: &CliOptions) -> Result { +fn update_command( + positionals: &[String], + options: &CliOptions, + kind: PluginKind, +) -> Result { reject_plugin_flags(options, false, false, false)?; if positionals.len() != 2 { - return Err(ManagerError::Usage( - "usage: cmux sidebar plugin update ".to_string(), - )); + return Err(ManagerError::Usage(format!( + "usage: {} update ", + kind.command_prefix() + ))); + } + let mut plugin = resolve_installed_plugin(&positionals[1], kind)?; + let source = git_text(&plugin.dir, ["remote", "get-url", "origin"]).ok_or_else(|| { + ManagerError::validation( + Some("plugin"), + format!("plugin {} has no readable origin remote", plugin.name), + ) + })?; + validate_git_source(&source) + .map_err(|error| ManagerError::validation(Some("plugin"), error.to_string()))?; + + // Build and validate a fresh clone before touching the active install. + // Updating in place would let a failed pull or build leave the selected + // plugin half-updated, which is unsafe for a daemon-owned process. + let root = install_root(kind)?; + let temp_dir = root.join(format!(".update-{}-{}", std::process::id(), now_nanos())); + let clone_result = + run_git(["clone", "--depth", "1", "--", source.as_str()], Some(&temp_dir), None); + if let Err(error) = clone_result { + let _ = fs::remove_dir_all(&temp_dir); + return Err(error.into()); } - let mut plugin = resolve_installed_plugin(&positionals[1])?; - run_git(["pull", "--ff-only"], None, Some(&plugin.dir))?; - plugin.manifest = read_manifest(&plugin.dir)?; - run_build_if_needed(&plugin.manifest, &plugin.dir)?; - let command = resolved_run_command(&plugin.manifest, &plugin.dir)?; - verify_executable(&command[0])?; - if plugin.selected { - let cwd = canonical_path(&plugin.dir)?; - persist_sidebar_plugin(Some(&SidebarPluginConfig { - command, - cwd: Some(cwd.display().to_string()), - }))?; + + let result = (|| -> Result { + let manifest = read_manifest(&temp_dir, kind) + .map_err(|error| ManagerError::validation(None, error.to_string()))?; + ensure_manifest_platform_supported(&manifest) + .map_err(|error| ManagerError::validation(Some("platforms"), error.to_string()))?; + validate_update_manifest_name(&plugin, &manifest) + .map_err(|error| ManagerError::validation(Some("name"), error.to_string()))?; + // The directory name is the user's install identity. It can be an + // explicit `--name` alias, so an update must keep it even when it + // differs from `[plugin].name` in the manifest. + run_build_if_needed(&manifest, &temp_dir)?; + let command = resolved_run_command(&manifest, &temp_dir)?; + verify_executable(&command[0])?; + let metadata = PluginRegistryMetadata { id: plugin.id.clone() }; + let transaction = + replace_plugin_install(&root, &plugin.name, &temp_dir, &plugin.dir, &metadata, kind)?; + plugin.manifest = manifest; + if plugin.selected { + let config_result = (|| -> Result<(), ManagerError> { + let command = resolved_run_command(&plugin.manifest, &plugin.dir)?; + let cwd = canonical_path(&plugin.dir)?; + let plugin_id = plugin.id.clone(); + let revision = artifact_revision(&plugin.manifest, &plugin.dir, &command); + persist_plugin( + kind, + &plugin_id, + &command, + Some(cwd.display().to_string()), + Some(revision), + ) + })(); + if let Err(error) = config_result { + return match transaction.rollback() { + Ok(()) => Err(error), + Err(rollback_error) => Err(ManagerError::Failure(anyhow::anyhow!( + "plugin configuration failed: {error}; plugin rollback failed: {rollback_error}" + ))), + }; + } + } + transaction.commit(); + Ok(json!({"plugin": plugin_json(&plugin)})) + })(); + if result.is_err() && temp_dir.exists() { + let _ = fs::remove_dir_all(&temp_dir); } - Ok(json!({"plugin": plugin_json(&plugin)})) + result } -fn remove_command(positionals: &[String], options: &CliOptions) -> Result { +fn remove_command( + positionals: &[String], + options: &CliOptions, + kind: PluginKind, +) -> Result { reject_plugin_flags(options, false, false, false)?; if positionals.len() != 2 { - return Err(ManagerError::Usage( - "usage: cmux sidebar plugin remove ".to_string(), - )); + return Err(ManagerError::Usage(format!( + "usage: {} remove ", + kind.command_prefix() + ))); } - let installed = resolve_installed_plugin(&positionals[1])?; + let installed = resolve_installed_plugin(&positionals[1], kind)?; let mut plugin = plugin_json(&installed); if installed.selected { - persist_sidebar_plugin(None)?; + persist_plugin_none(kind)?; } fs::remove_dir_all(&installed.dir)?; - remove_registry_metadata(&install_root()?, &installed.name)?; + remove_registry_metadata(&install_root(kind)?, &installed.name, kind)?; plugin["active"] = Value::Bool(false); plugin["enabled"] = Value::Bool(false); Ok(json!({"plugin": plugin})) } -fn write_builtin_config(_options: &CliOptions) -> Result { - persist_sidebar_plugin(None)?; - let plugins = installed_plugins()?; +fn write_builtin_config(_options: &CliOptions, kind: PluginKind) -> Result { + persist_plugin_none(kind)?; + let plugins = installed_plugins(kind)?; Ok(json!({"plugins": plugins.iter().map(plugin_json).collect::>()})) } -fn persist_sidebar_plugin(plugin: Option<&SidebarPluginConfig>) -> Result<(), ManagerError> { - if let Some(error) = config::write_sidebar_plugin(plugin)?.into_unsynced_error() { +fn persist_plugin( + kind: PluginKind, + id: &str, + command: &[String], + cwd: Option, + revision: Option, +) -> Result<(), ManagerError> { + let outcome = match kind { + PluginKind::Sidebar => config::write_sidebar_plugin(Some(&SidebarPluginConfig { + command: command.to_vec(), + cwd, + }))?, + PluginKind::Agent => config::write_agent_plugin(Some(&AgentPluginConfig { + id: id.to_string(), + command: command.to_vec(), + cwd, + revision, + }))?, + }; + if let Some(error) = outcome.into_unsynced_error() { crate::client_log::stderr_log!( "config", "{}", @@ -303,6 +480,72 @@ fn persist_sidebar_plugin(plugin: Option<&SidebarPluginConfig>) -> Result<(), Ma Ok(()) } +/// Return a content-derived revision for the selected artifact. A source +/// checkout can rebuild to the same path, so a Git commit alone does not +/// prove that the process changed. Hashing the executable also handles local +/// rebuilds and gives the core supervisor a deterministic restart fence. +fn artifact_revision(manifest: &PluginManifest, dir: &Path, command: &[String]) -> String { + let mut digest = Sha256::new(); + if let Some(commit) = git_text(dir, ["rev-parse", "HEAD"]) { + digest.update(b"git\0"); + digest.update(commit.as_bytes()); + } + if let Some(version) = &manifest.plugin.version { + digest.update(b"version\0"); + digest.update(version.as_bytes()); + } + for argument in command { + digest.update(b"arg\0"); + digest.update(argument.as_bytes()); + } + let mut binary_digest = digest.clone(); + binary_digest.update(b"binary\0"); + let binary_hashed = fs::File::open(&command[0]) + .and_then(|file| update_file_digest(file, &mut binary_digest)) + .is_ok(); + if binary_hashed { + digest = binary_digest; + } else if let Ok(metadata) = fs::metadata(&command[0]) { + digest.update(b"metadata\0"); + digest.update(metadata.len().to_le_bytes()); + if let Ok(modified) = metadata.modified() + && let Ok(duration) = modified.duration_since(UNIX_EPOCH) + { + digest.update(duration.as_nanos().to_le_bytes()); + } + } + format!("sha256-{:x}", digest.finalize()) +} + +/// Feed a regular file into a digest without allocating an amount of memory +/// proportional to the executable size. The caller can discard the digest +/// when a read fails and retain the metadata fallback. +fn update_file_digest(mut file: fs::File, digest: &mut Sha256) -> std::io::Result<()> { + let mut buffer = [0_u8; ARTIFACT_HASH_BUFFER_BYTES]; + loop { + let count = file.read(&mut buffer)?; + if count == 0 { + return Ok(()); + } + digest.update(&buffer[..count]); + } +} + +fn persist_plugin_none(kind: PluginKind) -> Result<(), ManagerError> { + let outcome = match kind { + PluginKind::Sidebar => config::write_sidebar_plugin(None)?, + PluginKind::Agent => config::write_agent_plugin(None)?, + }; + if let Some(error) = outcome.into_unsynced_error() { + crate::client_log::stderr_log!( + "config", + "config write was committed but unsynced: {}", + error + ); + } + Ok(()) +} + fn reject_plugin_flags( options: &CliOptions, allow_name: bool, @@ -321,22 +564,11 @@ fn reject_plugin_flags( Ok(()) } -fn installed_plugins() -> anyhow::Result> { - let root = install_root()?; - let selected = selected_plugin_cwd()?; +fn installed_plugins(kind: PluginKind) -> anyhow::Result> { + let root = install_root(kind)?; + let selection = selected_plugin_config(kind)?; let mut plugins = Vec::new(); - let entries = match fs::read_dir(&root) { - Ok(entries) => entries, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(plugins), - Err(error) => { - return Err(anyhow::anyhow!( - "failed to read plugin registry {}: {error}", - root.display() - )); - } - }; - for entry in entries { - let entry = entry?; + for entry in bounded_plugin_registry_entries(&root)? { if !entry.file_type()?.is_dir() { continue; } @@ -345,75 +577,209 @@ fn installed_plugins() -> anyhow::Result> { { continue; } - let manifest = read_manifest(&dir)?; + let manifest = read_manifest(&dir, kind)?; let name = dir .file_name() .and_then(|value| value.to_str()) .ok_or_else(|| anyhow::anyhow!("plugin directory name is not UTF-8"))? .to_string(); validate_plugin_name(&name)?; - let metadata = read_registry_metadata(&root, &name)?; - let selected = selected.as_ref().is_some_and(|cwd| same_path(cwd, &dir)); + let metadata = read_registry_metadata(&root, &name, kind)?; + let selected = plugin_is_selected(selection.as_ref(), &metadata.id, &manifest, &dir); plugins.push(InstalledPlugin { id: metadata.id, name, manifest, dir, selected }); } plugins.sort_by(|a, b| a.name.cmp(&b.name)); Ok(plugins) } -fn resolve_installed_plugin(selector: &str) -> Result { +/// Read at most [`MAX_INSTALLED_PLUGIN_ENTRIES`] entries from an installed +/// plugin root. Count every entry, including hidden transaction leftovers and +/// the registry metadata directory, so an attacker cannot bypass the bound by +/// creating entries the normal list path later ignores. +fn bounded_plugin_registry_entries(root: &Path) -> anyhow::Result> { + let entries = match fs::read_dir(root) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => { + return Err(anyhow::anyhow!( + "failed to read plugin registry {}: {error}", + root.display() + )); + } + }; + + let mut bounded = Vec::with_capacity(MAX_INSTALLED_PLUGIN_ENTRIES); + for (index, entry) in entries.enumerate() { + if index >= MAX_INSTALLED_PLUGIN_ENTRIES { + anyhow::bail!( + "plugin registry {} exceeds the entry limit of {}", + root.display(), + MAX_INSTALLED_PLUGIN_ENTRIES + ); + } + bounded.push(entry?); + } + Ok(bounded) +} + +fn resolve_installed_plugin( + selector: &str, + kind: PluginKind, +) -> Result { let forced_name = selector.strip_prefix("name:"); let selector = forced_name.unwrap_or(selector); - let by_id = forced_name.is_none() && selector.starts_with("sidebar_plugin_"); + let by_id = forced_name.is_none() && selector.starts_with(kind.id_prefix()); if by_id { - validate_plugin_id(selector) - .map_err(|error| ManagerError::validation(Some("sidebar_plugin"), error.to_string()))?; + validate_plugin_id_for(selector, kind) + .map_err(|error| ManagerError::validation(Some("plugin"), error.to_string()))?; } else { validate_plugin_name(selector) - .map_err(|error| ManagerError::validation(Some("sidebar_plugin"), error.to_string()))?; + .map_err(|error| ManagerError::validation(Some("plugin"), error.to_string()))?; } - installed_plugins()? + installed_plugins(kind)? .into_iter() .find(|plugin| if by_id { plugin.id == selector } else { plugin.name == selector }) .ok_or_else(|| { ManagerError::validation( - Some("sidebar_plugin"), + Some("plugin"), format!("plugin {selector:?} is not installed"), ) }) } -fn read_manifest(dir: &Path) -> anyhow::Result { +fn read_manifest(dir: &Path, kind: PluginKind) -> anyhow::Result { let path = dir.join("cmux-plugin.toml"); - let text = fs::read_to_string(&path) + let mut file = fs::File::open(&path) + .map_err(|err| anyhow::anyhow!("failed to read {}: {err}", path.display()))?; + let mut text = String::new(); + Read::by_ref(&mut file) + .take(u64::try_from(MAX_PLUGIN_MANIFEST_BYTES).unwrap_or(u64::MAX).saturating_add(1)) + .read_to_string(&mut text) .map_err(|err| anyhow::anyhow!("failed to read {}: {err}", path.display()))?; - parse_manifest(&text) + if text.len() > MAX_PLUGIN_MANIFEST_BYTES { + anyhow::bail!( + "{} exceeds the {MAX_PLUGIN_MANIFEST_BYTES}-byte manifest limit", + path.display() + ); + } + parse_manifest_for_kind(&text, kind) +} + +fn read_bounded_plugin_text(path: &Path, max_bytes: usize) -> std::io::Result { + config::read_bounded_utf8_file(path, max_bytes) } +#[cfg(test)] fn parse_manifest(text: &str) -> anyhow::Result { + parse_manifest_for_kind(text, PluginKind::Sidebar) +} + +fn parse_manifest_for_kind(text: &str, kind: PluginKind) -> anyhow::Result { let manifest: PluginManifest = toml::from_str(text).map_err(|err| anyhow::anyhow!("invalid cmux-plugin.toml: {err}"))?; - validate_manifest(&manifest)?; + validate_manifest(&manifest, kind)?; Ok(manifest) } -fn validate_manifest(manifest: &PluginManifest) -> anyhow::Result<()> { +fn validate_manifest(manifest: &PluginManifest, kind: PluginKind) -> anyhow::Result<()> { validate_plugin_name(&manifest.plugin.name)?; - if manifest.plugin.kind != "sidebar" { - anyhow::bail!("plugin.kind must be \"sidebar\""); + if manifest.plugin.kind != kind.manifest_kind() { + anyhow::bail!("plugin.kind must be \"{}\"", kind.manifest_kind()); + } + validate_manifest_command(&manifest.run.command, "run.command")?; + if let Some(build) = &manifest.build { + validate_manifest_command(&build.command, "build.command")?; + } + if let Some(version) = &manifest.plugin.version { + validate_manifest_text(version, "plugin.version", 128)?; + } + if let Some(description) = &manifest.plugin.description { + validate_manifest_text(description, "plugin.description", 4096)?; + } + validate_manifest_platforms(manifest.plugin.platforms.as_deref())?; + Ok(()) +} + +fn validate_manifest_command(command: &[String], field: &str) -> anyhow::Result<()> { + if command.is_empty() || command[0].trim().is_empty() { + anyhow::bail!("{field} must not be empty"); + } + if command.len() > MAX_PLUGIN_COMMAND_ARGS { + anyhow::bail!( + "{field} contains {} arguments, max is {MAX_PLUGIN_COMMAND_ARGS}", + command.len() + ); + } + for (index, argument) in command.iter().enumerate() { + if argument.len() > MAX_PLUGIN_COMMAND_ARG_BYTES { + anyhow::bail!("{field}[{index}] exceeds the {MAX_PLUGIN_COMMAND_ARG_BYTES}-byte limit"); + } + if argument.contains('\0') { + anyhow::bail!("{field}[{index}] must not contain NUL"); + } + } + Ok(()) +} + +fn validate_manifest_text(value: &str, field: &str, max_bytes: usize) -> anyhow::Result<()> { + if value.trim().is_empty() { + anyhow::bail!("{field} must not be empty"); + } + if value.len() > max_bytes { + anyhow::bail!("{field} exceeds the {max_bytes}-byte limit"); } - if manifest.run.command.first().is_none_or(|command| command.trim().is_empty()) { - anyhow::bail!("run.command must not be empty"); + if value.bytes().any(|byte| byte == 0) { + anyhow::bail!("{field} must not contain NUL"); } - if let Some(build) = &manifest.build - && build.command.first().is_none_or(|command| command.trim().is_empty()) + Ok(()) +} + +fn current_plugin_platform() -> &'static str { + if cfg!(target_os = "macos") { + "macos" + } else if cfg!(target_os = "linux") { + "linux" + } else if cfg!(windows) { + "windows" + } else { + "other" + } +} + +fn validate_manifest_platforms(platforms: Option<&[String]>) -> anyhow::Result<()> { + let Some(platforms) = platforms else { return Ok(()) }; + if platforms.is_empty() { + anyhow::bail!("plugin.platforms must contain at least one platform"); + } + let mut seen = HashSet::new(); + for platform in platforms { + if !matches!(platform.as_str(), "macos" | "linux" | "windows") { + anyhow::bail!( + "plugin.platforms contains unsupported platform {platform:?}; use macos, linux, or windows" + ); + } + if !seen.insert(platform) { + anyhow::bail!("plugin.platforms contains duplicate platform {platform:?}"); + } + } + Ok(()) +} + +fn ensure_manifest_platform_supported(manifest: &PluginManifest) -> anyhow::Result<()> { + if let Some(platforms) = manifest.plugin.platforms.as_deref() + && !platforms.iter().any(|platform| platform == current_plugin_platform()) { - anyhow::bail!("build.command must not be empty when present"); + anyhow::bail!( + "plugin does not support the current platform ({})", + current_plugin_platform() + ); } Ok(()) } fn validate_plugin_name(name: &str) -> anyhow::Result<()> { if name.is_empty() + || name.len() > MAX_PLUGIN_NAME_BYTES || !name.bytes().all(|byte| { byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-' || byte == b'_' }) @@ -598,6 +964,25 @@ fn installed_name( } } +/// Keep an install alias stable across updates while refusing a package that +/// silently changes its declared identity. The previous manifest is the +/// trusted name recorded by the installed artifact; the directory name may be +/// a user-selected alias and must not be used for this comparison. +fn validate_update_manifest_name( + installed: &InstalledPlugin, + updated: &PluginManifest, +) -> anyhow::Result<()> { + let previous_name = &installed.manifest.plugin.name; + if updated.plugin.name != *previous_name { + anyhow::bail!( + "updated plugin changed its manifest name from {:?} to {:?}; reinstall it to rename", + previous_name, + updated.plugin.name + ); + } + Ok(()) +} + fn run_build_if_needed(manifest: &PluginManifest, dir: &Path) -> anyhow::Result<()> { let Some(build) = &manifest.build else { return Ok(()) }; let mut command = Command::new(&build.command[0]); @@ -615,7 +1000,16 @@ fn resolved_run_command(manifest: &PluginManifest, dir: &Path) -> anyhow::Result let mut command = manifest.run.command.clone(); let first = Path::new(&command[0]); if first.is_relative() { - command[0] = canonical_path(&dir.join(first))?.display().to_string(); + let canonical_dir = canonical_path(dir)?; + let resolved = canonical_path(&canonical_dir.join(first))?; + if resolved.strip_prefix(&canonical_dir).is_err() { + anyhow::bail!( + "run.command[0] {} escapes plugin directory {}", + first.display(), + canonical_dir.display() + ); + } + command[0] = resolved.display().to_string(); } Ok(command) } @@ -676,30 +1070,101 @@ fn run_git( Ok(()) } -fn install_root() -> anyhow::Result { - if let Some(data_home) = non_empty_env_path("XDG_DATA_HOME") { - return Ok(data_home.join("cmux").join("mux-plugins")); - } - let home = cmux_tui_core::platform::home_dir() - .ok_or_else(|| anyhow::anyhow!("could not resolve home directory"))?; - Ok(home.join(".local").join("share").join("cmux").join("mux-plugins")) +fn install_root(kind: PluginKind) -> anyhow::Result { + let root = if let Some(data_home) = non_empty_env_path("XDG_DATA_HOME") { + data_home.join("cmux").join("mux-plugins") + } else { + let home = cmux_tui_core::platform::home_dir() + .ok_or_else(|| anyhow::anyhow!("could not resolve home directory"))?; + home.join(".local").join("share").join("cmux").join("mux-plugins") + }; + Ok(match kind { + PluginKind::Sidebar => root, + PluginKind::Agent => root.join("agent"), + }) } -fn selected_plugin_cwd() -> anyhow::Result> { +#[derive(Debug, Default)] +struct SelectedPluginConfig { + id: Option, + command: Option>, + cwd: Option, +} + +fn selected_plugin_config(kind: PluginKind) -> anyhow::Result> { let path = config::config_path()?; - let text = match fs::read_to_string(&path) { + let text = match config::read_config_text(&path) { Ok(text) => text, Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None), Err(err) => return Err(anyhow::anyhow!("failed to read {}: {err}", path.display())), }; let value: Value = serde_json::from_str(&text) .map_err(|err| anyhow::anyhow!("failed to parse {}: {err}", path.display()))?; - Ok(value - .get("sidebar") - .and_then(|sidebar| sidebar.get("plugin")) - .and_then(|plugin| plugin.get("cwd")) - .and_then(Value::as_str) - .map(PathBuf::from)) + let section = match kind { + PluginKind::Sidebar => "sidebar", + PluginKind::Agent => "agents", + }; + let Some(plugin) = value.get(section).and_then(|section| section.get("plugin")) else { + return Ok(None); + }; + Ok(Some(SelectedPluginConfig { + id: plugin.get("id").and_then(Value::as_str).map(str::to_owned), + command: plugin.get("command").and_then(|value| { + let arguments = value.as_array()?; + arguments + .iter() + .map(Value::as_str) + .collect::>>() + .map(|arguments| arguments.into_iter().map(str::to_owned).collect()) + }), + cwd: plugin.get("cwd").and_then(Value::as_str).map(PathBuf::from), + })) +} + +fn plugin_is_selected( + selection: Option<&SelectedPluginConfig>, + plugin_id: &str, + manifest: &PluginManifest, + dir: &Path, +) -> bool { + let Some(selection) = selection else { + return false; + }; + // The opaque registry id is the strongest identity. Path and command + // checks remain as migration fallbacks for hand-written or pre-id config. + if selection.id.as_deref() == Some(plugin_id) { + return true; + } + // A command is a stronger migration key than `cwd`: two installations + // can intentionally share a working directory. Do not mark both active + // when only their common cwd matches. + if let Some(selected_command) = selection.command.as_deref() { + let selected_command = configured_command(selection, selected_command); + return resolved_run_command(manifest, dir).ok().zip(selected_command).is_some_and( + |(expected_command, selected_command)| { + commands_match(&selected_command, &expected_command) + }, + ); + } + selection.cwd.as_deref().is_some_and(|cwd| same_path(cwd, dir)) +} + +fn configured_command(selection: &SelectedPluginConfig, command: &[String]) -> Option> { + let mut command = command.to_vec(); + let first = PathBuf::from(command.first()?); + if first.is_relative() { + let cwd = selection.cwd.as_deref()?; + command[0] = canonical_path(&cwd.join(first)).ok()?.display().to_string(); + } + Some(command) +} + +fn commands_match(left: &[String], right: &[String]) -> bool { + left.len() == right.len() + && left.iter().zip(right).enumerate().all(|(index, (left, right))| { + index != 0 || same_path(Path::new(left), Path::new(right)) || left == right + }) + && left.iter().skip(1).eq(right.iter().skip(1)) } fn plugin_json(plugin: &InstalledPlugin) -> Value { @@ -719,6 +1184,13 @@ fn plugin_json(plugin: &InstalledPlugin) -> Value { if let Some(description) = &plugin.manifest.plugin.description { extra.insert("description".into(), Value::String(description.clone())); } + if let Some(platforms) = &plugin.manifest.plugin.platforms { + extra.insert("platforms".into(), json!(platforms)); + extra.insert( + "platform_supported".into(), + Value::Bool(platforms.iter().any(|platform| platform == current_plugin_platform())), + ); + } let enabled = resolved_run_command(&plugin.manifest, &plugin.dir) .and_then(|command| verify_executable(&command[0])) .is_ok(); @@ -754,14 +1226,15 @@ fn sanitized_git_source(source: &str) -> String { fn read_registry_metadata( install_root: &Path, name: &str, + kind: PluginKind, ) -> anyhow::Result { validate_plugin_name(name)?; let path = registry_metadata_path(install_root, name); - let text = fs::read_to_string(&path) + let text = read_bounded_plugin_text(&path, MAX_PLUGIN_REGISTRY_METADATA_BYTES) .map_err(|error| anyhow::anyhow!("failed to read {}: {error}", path.display()))?; let metadata: PluginRegistryMetadata = serde_json::from_str(&text) .map_err(|error| anyhow::anyhow!("invalid {}: {error}", path.display()))?; - validate_plugin_id(&metadata.id)?; + validate_plugin_id_for(&metadata.id, kind)?; Ok(metadata) } @@ -773,27 +1246,223 @@ fn replace_registry_metadata( install_root: &Path, name: &str, metadata: &PluginRegistryMetadata, + kind: PluginKind, ) -> anyhow::Result<()> { validate_plugin_name(name)?; - validate_plugin_id(&metadata.id)?; + validate_plugin_id_for(&metadata.id, kind)?; let registry = install_root.join(".registry"); fs::create_dir_all(®istry)?; let path = registry_metadata_path(install_root, name); let temp = registry.join(format!(".{name}.{}-{}.tmp", std::process::id(), now_nanos())); - let encoded = serde_json::to_vec(metadata)?; - let mut file = fs::OpenOptions::new().create_new(true).write(true).open(&temp)?; - file.write_all(&encoded)?; - file.write_all(b"\n")?; - file.sync_all()?; - drop(file); - if let Err(error) = fs::rename(&temp, &path) { + let result = (|| -> anyhow::Result<()> { + let encoded = serde_json::to_vec(metadata)?; + let mut file = fs::OpenOptions::new().create_new(true).write(true).open(&temp)?; + file.write_all(&encoded)?; + file.write_all(b"\n")?; + file.sync_all()?; + drop(file); + fs::rename(&temp, &path) + .map_err(|error| anyhow::anyhow!("failed to persist {}: {error}", path.display())) + })(); + if result.is_err() { + // A failed write or sync can leave a partial temporary file. Remove + // it before returning so a later install cannot inherit stale + // metadata and the registry does not accumulate unbounded debris. let _ = fs::remove_file(&temp); - return Err(anyhow::anyhow!("failed to persist {}: {error}", path.display())); } - Ok(()) + result +} + +/// A completed artifact replacement whose old files remain available until +/// the selected-plugin configuration has also been written. The guard makes +/// install and update a best-effort local transaction across the filesystem +/// and the JSON configuration file. +struct PluginInstallTransaction { + name: String, + target: PathBuf, + target_backup: Option, + metadata_path: PathBuf, + metadata_backup: Option, + finished: bool, } -fn remove_registry_metadata(install_root: &Path, name: &str) -> anyhow::Result<()> { +impl PluginInstallTransaction { + fn commit(mut self) { + if let Some(backup) = self.target_backup.take() + && let Err(error) = remove_path_if_present(&backup) + { + eprintln!( + "cmux-tui: installed plugin {:?}, but could not remove backup {}: {error}", + self.name, + backup.display() + ); + } + if let Some(backup) = self.metadata_backup.take() + && let Err(error) = remove_path_if_present(&backup) + { + eprintln!( + "cmux-tui: installed plugin {:?}, but could not remove metadata backup {}: {error}", + self.name, + backup.display() + ); + } + self.finished = true; + } + + fn rollback(mut self) -> anyhow::Result<()> { + self.rollback_in_place() + } + + fn rollback_in_place(&mut self) -> anyhow::Result<()> { + let mut errors = Vec::new(); + if let Err(error) = remove_path_if_present(&self.target) { + errors.push(format!("remove new plugin {}: {error}", self.target.display())); + } + + if let Some(backup) = self.metadata_backup.take() { + if let Err(error) = remove_path_if_present(&self.metadata_path) { + errors + .push(format!("remove new metadata {}: {error}", self.metadata_path.display())); + } + if let Err(error) = fs::rename(&backup, &self.metadata_path) { + errors.push(format!( + "restore metadata {} from {}: {error}", + self.metadata_path.display(), + backup.display() + )); + } + } else if let Err(error) = remove_path_if_present(&self.metadata_path) { + errors.push(format!("remove new metadata {}: {error}", self.metadata_path.display())); + } + + if let Some(backup) = self.target_backup.take() + && let Err(error) = fs::rename(&backup, &self.target) + { + errors.push(format!( + "restore plugin {} from {}: {error}", + self.target.display(), + backup.display() + )); + } + + self.finished = true; + if errors.is_empty() { Ok(()) } else { anyhow::bail!(errors.join("; ")) } + } +} + +impl Drop for PluginInstallTransaction { + fn drop(&mut self) { + if !self.finished { + let _ = self.rollback_in_place(); + } + } +} + +/// Replace an installed plugin and its registry identity as one local +/// transaction. A failed rename or metadata write restores the previous +/// directory and identity, so `--force` cannot leave a half-installed plugin. +fn replace_plugin_install( + install_root: &Path, + name: &str, + temp_dir: &Path, + target: &Path, + metadata: &PluginRegistryMetadata, + kind: PluginKind, +) -> anyhow::Result { + validate_plugin_name(name)?; + validate_plugin_id_for(&metadata.id, kind)?; + let target_backup = + install_root.join(format!(".{name}.backup-{}-{}", std::process::id(), now_nanos())); + let registry = install_root.join(".registry"); + fs::create_dir_all(®istry)?; + let metadata_path = registry_metadata_path(install_root, name); + let metadata_backup = + registry.join(format!(".{name}.backup-{}-{}.json", std::process::id(), now_nanos())); + let target_exists = path_exists(target)?; + let metadata_exists = path_exists(&metadata_path)?; + let mut target_moved = false; + let mut metadata_moved = false; + let mut target_installed = false; + let mut metadata_installed = false; + + let install_result = (|| -> anyhow::Result<()> { + if target_exists { + fs::rename(target, &target_backup).map_err(|error| { + anyhow::anyhow!("failed to stage {}: {error}", target.display()) + })?; + target_moved = true; + } + if metadata_exists { + fs::rename(&metadata_path, &metadata_backup).map_err(|error| { + anyhow::anyhow!("failed to stage {}: {error}", metadata_path.display()) + })?; + metadata_moved = true; + } + fs::rename(temp_dir, target) + .map_err(|error| anyhow::anyhow!("failed to install {}: {error}", target.display()))?; + target_installed = true; + replace_registry_metadata(install_root, name, metadata, kind)?; + metadata_installed = true; + Ok(()) + })(); + + if let Err(error) = install_result { + // Remove only paths this attempt installed. If staging failed before + // a rename, the old target or metadata must remain untouched. This + // also avoids deleting a path that appeared concurrently after a + // failed rename. + if target_installed { + let _ = remove_path_if_present(target); + } + if metadata_moved { + if metadata_installed { + let _ = remove_path_if_present(&metadata_path); + } + let _ = fs::rename(&metadata_backup, &metadata_path); + } else if metadata_installed { + let _ = remove_path_if_present(&metadata_path); + } + if target_moved { + let _ = fs::rename(&target_backup, target); + } + return Err(error); + } + + Ok(PluginInstallTransaction { + name: name.to_string(), + target: target.to_path_buf(), + target_backup: target_moved.then_some(target_backup), + metadata_path, + metadata_backup: metadata_moved.then_some(metadata_backup), + finished: false, + }) +} + +fn path_exists(path: &Path) -> anyhow::Result { + match fs::symlink_metadata(path) { + Ok(_) => Ok(true), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(anyhow::anyhow!("failed to inspect {}: {error}", path.display())), + } +} + +fn remove_path_if_present(path: &Path) -> anyhow::Result<()> { + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_dir() => fs::remove_dir_all(path) + .map_err(|error| anyhow::anyhow!("failed to remove {}: {error}", path.display())), + Ok(_) => fs::remove_file(path) + .map_err(|error| anyhow::anyhow!("failed to remove {}: {error}", path.display())), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(anyhow::anyhow!("failed to inspect {}: {error}", path.display())), + } +} + +fn remove_registry_metadata( + install_root: &Path, + name: &str, + _kind: PluginKind, +) -> anyhow::Result<()> { + validate_plugin_name(name)?; let path = registry_metadata_path(install_root, name); match fs::remove_file(&path) { Ok(()) => Ok(()), @@ -802,12 +1471,13 @@ fn remove_registry_metadata(install_root: &Path, name: &str) -> anyhow::Result<( } } -fn random_plugin_id() -> anyhow::Result { +fn random_plugin_id_for(kind: PluginKind) -> anyhow::Result { let mut bytes = [0_u8; 16]; getrandom::fill(&mut bytes) .map_err(|error| anyhow::anyhow!("cannot allocate plugin ID: {error}"))?; - let mut id = String::with_capacity("sidebar_plugin_".len() + 32); - id.push_str("sidebar_plugin_"); + let prefix = kind.id_prefix(); + let mut id = String::with_capacity(prefix.len() + 32); + id.push_str(prefix); const HEX: &[u8; 16] = b"0123456789abcdef"; for byte in bytes { id.push(char::from(HEX[usize::from(byte >> 4)])); @@ -816,9 +1486,10 @@ fn random_plugin_id() -> anyhow::Result { Ok(id) } -fn validate_plugin_id(id: &str) -> anyhow::Result<()> { - let Some(payload) = id.strip_prefix("sidebar_plugin_") else { - anyhow::bail!("plugin ID must start with sidebar_plugin_"); +fn validate_plugin_id_for(id: &str, kind: PluginKind) -> anyhow::Result<()> { + let prefix = kind.id_prefix(); + let Some(payload) = id.strip_prefix(prefix) else { + anyhow::bail!("plugin ID must start with {prefix}"); }; if payload.len() != 32 || !payload.bytes().all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) @@ -828,22 +1499,61 @@ fn validate_plugin_id(id: &str) -> anyhow::Result<()> { Ok(()) } +#[cfg(test)] +fn random_plugin_id() -> anyhow::Result { + random_plugin_id_for(PluginKind::Sidebar) +} + +#[cfg(test)] +fn validate_plugin_id(id: &str) -> anyhow::Result<()> { + validate_plugin_id_for(id, PluginKind::Sidebar) +} + fn git_text(dir: &Path, args: [&str; N]) -> Option { - let output = Command::new("git") + let mut child = Command::new("git") .arg("-c") .arg("protocol.file.allow=always") .args(args) .current_dir(dir) - .output() + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() .ok()?; - if !output.status.success() { - return None; - } - let value = String::from_utf8(output.stdout).ok()?; + let value = + String::from_utf8(read_bounded_child_stdout(&mut child, MAX_PLUGIN_GIT_OUTPUT_BYTES)?) + .ok()?; let value = value.trim_end_matches(['\r', '\n']); (!value.is_empty()).then(|| value.to_string()) } +/// Read child stdout with a hard allocation bound. The extra byte detects +/// overflow, then the child is killed before waiting so it cannot remain +/// blocked on a full pipe. +fn read_bounded_child_stdout(child: &mut Child, max_bytes: usize) -> Option> { + let Some(read_limit) = u64::try_from(max_bytes).ok().and_then(|value| value.checked_add(1)) + else { + let _ = child.kill(); + let _ = child.wait(); + return None; + }; + if child.stdout.is_none() { + let _ = child.kill(); + let _ = child.wait(); + return None; + } + let mut bytes = Vec::with_capacity(max_bytes.min(8 * 1024)); + let read_result = { + let stdout = child.stdout.as_mut().expect("stdout was checked above"); + stdout.take(read_limit).read_to_end(&mut bytes) + }; + if read_result.is_err() || bytes.len() > max_bytes { + let _ = child.kill(); + let _ = child.wait(); + return None; + } + child.wait().ok()?.success().then_some(bytes) +} + fn canonical_path(path: &Path) -> anyhow::Result { fs::canonicalize(path) .map_err(|err| anyhow::anyhow!("failed to resolve {}: {err}", path.display())) @@ -903,6 +1613,13 @@ mod tests { assert!(error.contains("[a-z0-9-_]+")); } + #[test] + fn manifest_rejects_overlong_name() { + let name = "a".repeat(MAX_PLUGIN_NAME_BYTES + 1); + let error = parse_manifest(&manifest_text(&name)).unwrap_err().to_string(); + assert!(error.contains("[a-z0-9-_]+")); + } + #[test] fn manifest_rejects_missing_run_command() { let text = r#" @@ -914,6 +1631,182 @@ mod tests { assert!(error.contains("missing field `run`") || error.contains("run.command")); } + #[test] + fn manifest_platforms_are_validated_and_current_platform_is_admitted() { + let current = current_plugin_platform(); + if current == "other" { + return; + } + let text = manifest_text("fzf").replace( + "description = \"test plugin\"", + &format!("description = \"test plugin\"\n platforms = [\"{current}\"]"), + ); + let manifest = parse_manifest(&text).unwrap(); + ensure_manifest_platform_supported(&manifest).unwrap(); + + let unsupported = if current == "macos" { "linux" } else { "macos" }; + let text = manifest_text("fzf").replace( + "description = \"test plugin\"", + &format!("description = \"test plugin\"\n platforms = [\"{unsupported}\"]"), + ); + let manifest = parse_manifest(&text).unwrap(); + assert!(ensure_manifest_platform_supported(&manifest).is_err()); + } + + #[test] + fn manifest_rejects_unknown_and_duplicate_platforms() { + for platforms in + ["platforms = [\"plan9\"]", "platforms = [\"linux\", \"linux\"]", "platforms = []"] + { + let text = manifest_text("fzf").replace( + "description = \"test plugin\"", + &format!("description = \"test plugin\"\n {platforms}"), + ); + assert!(parse_manifest(&text).is_err(), "{platforms}"); + } + } + + #[test] + fn manifest_rejects_unknown_fields_and_unsafe_argv() { + let unknown = manifest_text("fzf").replace( + "description = \"test plugin\"", + "description = \"test plugin\"\n unexpected = true", + ); + assert!(parse_manifest(&unknown).is_err(), "unknown manifest fields must fail closed"); + + let empty_executable = manifest_text("fzf") + .replace("command = [\"bin/sidebar\"]", "command = [\"\", \"kept\"]"); + assert!(parse_manifest(&empty_executable).is_err()); + + let nul_argument = manifest_text("fzf").replace( + "command = [\"bin/sidebar\"]", + "command = [\"bin/sidebar\", \"bad\\u0000arg\"]", + ); + assert!(parse_manifest(&nul_argument).is_err()); + + let too_many = (0..=MAX_PLUGIN_COMMAND_ARGS) + .map(|index| format!("\"arg-{index}\"")) + .collect::>() + .join(", "); + let too_many = manifest_text("fzf").replace( + "command = [\"bin/sidebar\"]", + &format!("command = [\"bin/sidebar\", {too_many}]"), + ); + assert!(parse_manifest(&too_many).is_err()); + } + + #[test] + fn relative_run_command_cannot_escape_plugin_directory() { + let root = std::env::temp_dir().join(format!( + "cmux-plugin-command-boundary-{}-{}", + std::process::id(), + now_nanos() + )); + let plugin_dir = root.join("plugin"); + let outside_dir = root.join("outside"); + fs::create_dir_all(&plugin_dir).unwrap(); + fs::create_dir_all(&outside_dir).unwrap(); + let outside_executable = outside_dir.join("agent"); + fs::write(&outside_executable, b"#!/bin/sh\n").unwrap(); + + let mut manifest = parse_manifest(&manifest_text("fzf")).unwrap(); + manifest.run.command[0] = "../outside/agent".into(); + let error = resolved_run_command(&manifest, &plugin_dir).unwrap_err().to_string(); + assert!(error.contains("escapes plugin directory"), "{error}"); + + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn bounded_manifest_reader_rejects_oversized_files() { + let root = std::env::temp_dir().join(format!( + "cmux-plugin-manifest-limit-{}-{}", + std::process::id(), + now_nanos() + )); + fs::create_dir_all(&root).unwrap(); + let path = root.join("cmux-plugin.toml"); + fs::write(&path, vec![b'x'; MAX_PLUGIN_MANIFEST_BYTES + 1]).unwrap(); + let error = read_manifest(&root, PluginKind::Sidebar).unwrap_err().to_string(); + assert!(error.contains("manifest limit"), "{error}"); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn bounded_plugin_text_reader_rejects_oversized_files() { + let root = std::env::temp_dir().join(format!( + "cmux-plugin-text-limit-{}-{}", + std::process::id(), + now_nanos() + )); + fs::create_dir_all(&root).unwrap(); + let path = root.join("plugin.json"); + fs::write(&path, b"four").unwrap(); + let error = read_bounded_plugin_text(&path, 3).unwrap_err(); + assert_eq!(error.kind(), std::io::ErrorKind::InvalidData); + assert!(error.to_string().contains("3-byte limit"), "{error}"); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn bounded_child_stdout_reader_rejects_oversized_output() { + let mut exact = Command::new("sh") + .args(["-c", "printf four"]) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + assert_eq!(read_bounded_child_stdout(&mut exact, 4), Some(b"four".to_vec())); + + let mut oversized = Command::new("sh") + .args(["-c", "printf four"]) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + assert_eq!(read_bounded_child_stdout(&mut oversized, 3), None); + assert!(oversized.try_wait().unwrap().is_some()); + } + + #[test] + fn plugin_registry_rejects_an_unbounded_entry_count() { + let root = std::env::temp_dir().join(format!( + "cmux-plugin-registry-entry-limit-{}-{}", + std::process::id(), + now_nanos() + )); + fs::create_dir_all(&root).unwrap(); + for index in 0..=MAX_INSTALLED_PLUGIN_ENTRIES { + fs::create_dir(root.join(format!("plugin-{index}"))).unwrap(); + } + + let error = bounded_plugin_registry_entries(&root).unwrap_err().to_string(); + assert!(error.contains("plugin registry") && error.contains("entry limit"), "{error}"); + + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn artifact_digest_handles_multiple_read_chunks() { + let root = std::env::temp_dir().join(format!( + "cmux-plugin-artifact-digest-{}-{}", + std::process::id(), + now_nanos() + )); + fs::create_dir_all(&root).unwrap(); + let path = root.join("plugin"); + let bytes = (0..(ARTIFACT_HASH_BUFFER_BYTES * 2 + 17)) + .map(|index| (index % 251) as u8) + .collect::>(); + fs::write(&path, &bytes).unwrap(); + + let mut actual = Sha256::new(); + update_file_digest(fs::File::open(&path).unwrap(), &mut actual).unwrap(); + assert_eq!(actual.finalize(), Sha256::digest(&bytes)); + + fs::remove_dir_all(root).unwrap(); + } + #[test] fn installed_name_uses_manifest_or_override() { let manifest = parse_manifest(&manifest_text("fzf")).unwrap(); @@ -922,6 +1815,39 @@ mod tests { assert!(installed_name(&manifest, Some("Bad")).is_err()); } + #[test] + fn update_preserves_an_explicit_install_alias() { + let installed_manifest = parse_manifest(&manifest_text("fzf")).unwrap(); + let updated_manifest = parse_manifest(&manifest_text("fzf")).unwrap(); + let installed = InstalledPlugin { + id: "sidebar_plugin_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".into(), + name: "custom-name".into(), + manifest: installed_manifest, + dir: PathBuf::from("/tmp/custom-name"), + selected: false, + }; + + validate_update_manifest_name(&installed, &updated_manifest).unwrap(); + assert_eq!(installed.name, "custom-name"); + } + + #[test] + fn update_rejects_a_manifest_identity_change_even_for_an_alias() { + let installed_manifest = parse_manifest(&manifest_text("fzf")).unwrap(); + let updated_manifest = parse_manifest(&manifest_text("other")).unwrap(); + let installed = InstalledPlugin { + id: "sidebar_plugin_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".into(), + name: "custom-name".into(), + manifest: installed_manifest, + dir: PathBuf::from("/tmp/custom-name"), + selected: false, + }; + + let error = + validate_update_manifest_name(&installed, &updated_manifest).unwrap_err().to_string(); + assert!(error.contains("changed its manifest name"), "{error}"); + } + #[test] fn registry_assigns_and_persists_secure_opaque_ids() { let root = std::env::temp_dir().join(format!( @@ -932,10 +1858,10 @@ mod tests { fs::create_dir_all(&root).unwrap(); let first = PluginRegistryMetadata { id: random_plugin_id().unwrap() }; - replace_registry_metadata(&root, "first", &first).unwrap(); - let replay = read_registry_metadata(&root, "first").unwrap(); + replace_registry_metadata(&root, "first", &first, PluginKind::Sidebar).unwrap(); + let replay = read_registry_metadata(&root, "first", PluginKind::Sidebar).unwrap(); let second = PluginRegistryMetadata { id: random_plugin_id().unwrap() }; - replace_registry_metadata(&root, "second", &second).unwrap(); + replace_registry_metadata(&root, "second", &second, PluginKind::Sidebar).unwrap(); assert_eq!(first.id, replay.id); assert_ne!(first.id, second.id); validate_plugin_id(&first.id).unwrap(); @@ -952,6 +1878,52 @@ mod tests { fs::remove_dir_all(root).unwrap(); } + #[test] + fn install_transaction_restores_the_previous_artifact_and_identity() { + let root = std::env::temp_dir().join(format!( + "cmux-plugin-transaction-test-{}-{}", + std::process::id(), + now_nanos() + )); + let target = root.join("agent-view"); + let staged = root.join(".staged"); + fs::create_dir_all(target.join("bin")).unwrap(); + fs::write(target.join("bin/old"), "old artifact").unwrap(); + fs::create_dir_all(staged.join("bin")).unwrap(); + fs::write(staged.join("bin/new"), "new artifact").unwrap(); + + let old_metadata = + PluginRegistryMetadata { id: "sidebar_plugin_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".into() }; + replace_registry_metadata(&root, "agent-view", &old_metadata, PluginKind::Sidebar).unwrap(); + let new_metadata = + PluginRegistryMetadata { id: "sidebar_plugin_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".into() }; + + let transaction = replace_plugin_install( + &root, + "agent-view", + &staged, + &target, + &new_metadata, + PluginKind::Sidebar, + ) + .unwrap(); + assert!(target.join("bin/new").is_file()); + assert_eq!( + read_registry_metadata(&root, "agent-view", PluginKind::Sidebar).unwrap().id, + new_metadata.id + ); + + transaction.rollback().unwrap(); + assert!(target.join("bin/old").is_file()); + assert!(!target.join("bin/new").exists()); + assert_eq!( + read_registry_metadata(&root, "agent-view", PluginKind::Sidebar).unwrap().id, + old_metadata.id + ); + assert!(!staged.exists()); + fs::remove_dir_all(root).unwrap(); + } + #[test] fn plugin_snapshot_matches_the_closed_catalog_shape() { let root = std::env::temp_dir().join(format!( @@ -1011,6 +1983,32 @@ mod tests { ); } + #[test] + fn git_source_rejects_embedded_credentials_and_query_tokens() { + for source in [ + "https://user:secret@example.com/team/plugin.git", + "https://user@example.com/team/plugin.git", + "https://example.com/team/plugin.git?token=secret", + "http://example.com/team/plugin.git#token", + ] { + assert!( + validate_git_source(source).is_err(), + "unsafe source must be rejected: {source}" + ); + } + + for source in [ + "ssh://git@example.com/team/plugin.git", + "git@example.com:team/plugin.git", + "/tmp/plugin.git", + ] { + assert!( + validate_git_source(source).is_ok(), + "normal source must remain valid: {source}" + ); + } + } + #[test] fn git_source_rejects_custom_transports_and_helpers() { for source in [ @@ -1061,6 +2059,68 @@ mod tests { } } + #[test] + fn selection_matching_uses_id_then_path_or_command_migrations() { + let root = std::env::temp_dir().join(format!( + "cmux-plugin-selection-test-{}-{}", + std::process::id(), + now_nanos() + )); + let dir = root.join("agent-view"); + let executable = dir.join("bin/sidebar"); + fs::create_dir_all(executable.parent().unwrap()).unwrap(); + fs::write(&executable, "#!/bin/sh\n").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&executable, fs::Permissions::from_mode(0o755)).unwrap(); + } + let manifest = parse_manifest(&manifest_text("agent-view")).unwrap(); + let id = "sidebar_plugin_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + + let by_id = SelectedPluginConfig { id: Some(id.into()), ..Default::default() }; + assert!(plugin_is_selected(Some(&by_id), id, &manifest, &dir)); + + let by_path = SelectedPluginConfig { cwd: Some(dir.clone()), ..Default::default() }; + assert!(plugin_is_selected(Some(&by_path), "other", &manifest, &dir)); + + let shared_cwd_with_other_command = SelectedPluginConfig { + cwd: Some(dir.clone()), + command: Some(vec!["/tmp/other-plugin".into()]), + ..Default::default() + }; + assert!(!plugin_is_selected( + Some(&shared_cwd_with_other_command), + "other", + &manifest, + &dir + )); + + let command = resolved_run_command(&manifest, &dir).unwrap(); + let by_command = SelectedPluginConfig { command: Some(command), ..Default::default() }; + assert!(plugin_is_selected(Some(&by_command), "other", &manifest, &dir)); + + let by_relative_command = SelectedPluginConfig { + command: Some(vec!["bin/sidebar".into()]), + cwd: Some(dir.clone()), + ..Default::default() + }; + assert!(plugin_is_selected(Some(&by_relative_command), "other", &manifest, &dir)); + + let sibling_command = SelectedPluginConfig { + command: Some(vec![dir.join("bin/other").display().to_string()]), + ..Default::default() + }; + assert!(!plugin_is_selected(Some(&sibling_command), "other", &manifest, &dir)); + + let unrelated_command = SelectedPluginConfig { + command: Some(vec!["/tmp/plugin".into(), "different".into()]), + ..Default::default() + }; + assert!(!plugin_is_selected(Some(&unrelated_command), id, &manifest, &dir)); + fs::remove_dir_all(root).unwrap(); + } + #[test] fn plugin_build_timeout_is_finite_and_positive() { assert!(PLUGIN_BUILD_TIMEOUT.as_nanos() > 0); diff --git a/cmux-tui/crates/cmux-tui/src/session/mod.rs b/cmux-tui/crates/cmux-tui/src/session/mod.rs index a0535e551b55..0e3d40747e1e 100644 --- a/cmux-tui/crates/cmux-tui/src/session/mod.rs +++ b/cmux-tui/crates/cmux-tui/src/session/mod.rs @@ -85,6 +85,7 @@ pub(crate) fn apply_config_to_local_owner(mux: &Mux, config: &crate::config::Con crate::config::apply_browser_to_surface_options(config, options); }); mux.configure_sidebar_plugin(config.sidebar.plugin.clone()); + mux.configure_journal_plugin(config.agents.plugin.clone()); } #[derive(Clone)] @@ -336,6 +337,9 @@ pub struct AgentInfo { pub state: String, pub source: String, pub session: Option, + /// The reporting adapter id (`claude`, `codex`, ...), when known. + #[serde(default)] + pub agent: Option, pub updated_at_ms: u64, } @@ -979,6 +983,7 @@ impl Session { state: agent.state.as_str().to_string(), source: agent.source.as_str().to_string(), session: agent.session, + agent: agent.agent, updated_at_ms: agent.updated_at_ms, }) .collect(), diff --git a/cmux-tui/crates/cmux-tui/src/session/remote.rs b/cmux-tui/crates/cmux-tui/src/session/remote.rs index 8770df21ad79..6b0264fd0007 100644 --- a/cmux-tui/crates/cmux-tui/src/session/remote.rs +++ b/cmux-tui/crates/cmux-tui/src/session/remote.rs @@ -2427,11 +2427,13 @@ impl RemoteSession { return; }; let session = value.get("session").and_then(Value::as_str).map(str::to_string); + let agent_adapter = value.get("agent").and_then(Value::as_str).map(str::to_string); let agent = AgentInfo { surface, state: state.to_string(), source: source.to_string(), session, + agent: agent_adapter, updated_at_ms, }; let event = MuxEvent::AgentChanged { @@ -2439,6 +2441,7 @@ impl RemoteSession { state: Arc::from(agent.state.as_str()), source: Arc::from(agent.source.as_str()), session: agent.session.as_deref().map(Arc::from), + agent: agent.agent.as_deref().map(Arc::from), updated_at_ms, }; { @@ -7651,6 +7654,7 @@ mod tests { state: "blocked".into(), source: "hook".into(), session: Some("review".into()), + agent: None, updated_at_ms: 41, }] ); @@ -7713,6 +7717,7 @@ mod tests { state: "working".into(), source: "hook".into(), session: Some("review".into()), + agent: None, updated_at_ms: 41, }], 0, @@ -7793,6 +7798,7 @@ mod tests { state: "working".into(), source: "hook".into(), session: Some("review".into()), + agent: None, updated_at_ms: surface, } } @@ -7823,6 +7829,7 @@ mod tests { state: "working".into(), source: "hook".into(), session: Some("review".into()), + agent: None, updated_at_ms: 41, }; let mut cache = RemoteTreeCache::default(); @@ -8094,6 +8101,7 @@ mod tests { state: "working".into(), source: "hook".into(), session: Some("review".into()), + agent: None, updated_at_ms: 41, }, &retired, @@ -8134,6 +8142,7 @@ mod tests { state: "working".into(), source: "hook".into(), session: Some("review".into()), + agent: None, updated_at_ms: 41, }, &retired, @@ -8175,6 +8184,7 @@ mod tests { state: "working".into(), source: "hook".into(), session: Some("review".into()), + agent: None, updated_at_ms: 41, }; cache.update_agent(update.clone(), &retired); diff --git a/cmux-tui/crates/cmux-tui/src/sidebar_projection.rs b/cmux-tui/crates/cmux-tui/src/sidebar_projection.rs index f8ab0d7a5588..09ce4b287d70 100644 --- a/cmux-tui/crates/cmux-tui/src/sidebar_projection.rs +++ b/cmux-tui/crates/cmux-tui/src/sidebar_projection.rs @@ -1,4 +1,9 @@ //! Pure projection of mux resources into configurable native sidebar trees. +//! +//! The agent-priority buckets and row projection are adapted from herdr's +//! `src/app/agent_view.rs` at commit +//! `7b675f42af35508eab66ac42fe1598628597a893` (Apache-2.0). They are modified +//! by manaflow for cmux's configurable resource tree and journal-backed rows. use std::collections::{HashMap, HashSet}; @@ -59,6 +64,104 @@ pub(crate) struct ProjectionRailState { pub collapsed: HashSet, } +/// Reusable ordering for agent rows. The order changes only when the agent +/// roster or terminal topology changes, so renders can reuse the index. +#[derive(Default)] +pub(crate) struct AgentOrderCache { + key: Option, + order: Vec, +} + +#[derive(Clone, PartialEq, Eq)] +struct AgentOrderCacheKey { + tree_workspace_revision: u64, + tree_pane_revision: Option, + tree_surfaces: Vec, + agents: Vec<(SurfaceId, u8, u64)>, +} + +impl AgentOrderCache { + fn ordered_surfaces(&mut self, tree: &TreeView, agents: &[AgentInfo]) -> &[SurfaceId] { + let cache_is_valid = self.key.as_ref().is_some_and(|key| { + if key.tree_workspace_revision != tree.workspace_revision + || key.tree_pane_revision != tree.pane_revision + || key.agents.len() != agents.len() + { + return false; + } + if !key.agents.iter().zip(agents).all( + |(&(surface, attention, updated_at_ms), agent)| { + (surface, attention, updated_at_ms) + == (agent.surface, agent_attention(&agent.state), agent.updated_at_ms) + }, + ) { + return false; + } + tree_surface_sequence_matches(tree, &key.tree_surfaces) + }); + if cache_is_valid { + return &self.order; + } + + let tree_surfaces = tree + .workspaces() + .iter() + .flat_map(|workspace| workspace.screens.iter()) + .flat_map(|screen| screen.panes.iter()) + .flat_map(|pane| pane.tabs.iter()) + .map(|tab| tab.surface) + .collect::>(); + let agent_metadata = agents + .iter() + .map(|agent| (agent.surface, agent_attention(&agent.state), agent.updated_at_ms)) + .collect::>(); + let agent_keys = agents + .iter() + .map(|agent| { + ( + agent.surface, + (u8::MAX - agent_attention(&agent.state), u64::MAX - agent.updated_at_ms), + ) + }) + .collect::>(); + let mut indexed = tree_surfaces + .iter() + .enumerate() + .filter_map(|(index, surface)| { + agent_keys + .get(surface) + .map(|&(attention, recency)| (attention, recency, index, *surface)) + }) + .collect::>(); + indexed.sort_unstable_by_key(|&(attention, recency, index, _)| (attention, recency, index)); + self.order = indexed.into_iter().map(|(_, _, _, surface)| surface).collect(); + self.key = Some(AgentOrderCacheKey { + tree_workspace_revision: tree.workspace_revision, + tree_pane_revision: tree.pane_revision, + tree_surfaces, + agents: agent_metadata, + }); + &self.order + } +} + +fn tree_surface_sequence_matches(tree: &TreeView, expected: &[SurfaceId]) -> bool { + let mut expected = expected.iter(); + for surface in tree + .workspaces() + .iter() + .flat_map(|workspace| workspace.screens.iter()) + .flat_map(|screen| screen.panes.iter()) + .flat_map(|pane| pane.tabs.iter()) + .map(|tab| tab.surface) + { + if expected.next() != Some(&surface) { + return false; + } + } + expected.next().is_none() +} + impl Default for ProjectionRailState { fn default() -> Self { Self { @@ -79,12 +182,25 @@ struct ProjectionContext { pane: Option, } +#[cfg(test)] pub(crate) fn rows( spec: &SidebarViewSpec, tree: &TreeView, agents: &[AgentInfo], selected_workspace: usize, collapsed: &HashSet, +) -> Vec { + let mut order_cache = AgentOrderCache::default(); + rows_cached(spec, tree, agents, selected_workspace, collapsed, &mut order_cache) +} + +pub(crate) fn rows_cached( + spec: &SidebarViewSpec, + tree: &TreeView, + agents: &[AgentInfo], + selected_workspace: usize, + collapsed: &HashSet, + order_cache: &mut AgentOrderCache, ) -> Vec { // Workspace rows are the common projection and can reach roughly 1000 // entries. Reserve that baseline up front so a render does not repeatedly @@ -92,6 +208,13 @@ pub(crate) fn rows( let mut rows = Vec::with_capacity(tree.workspaces().len()); let agents_by_surface: HashMap = agents.iter().map(|agent| (agent.surface, agent)).collect(); + // Tabs and other resource views keep tree order. Do not walk the full + // topology to prepare an agent index when this view has no agent level. + let agent_order = if spec.levels.contains(&SidebarResourceKind::Agents) { + order_cache.ordered_surfaces(tree, agents) + } else { + &[] + }; append_level( &mut rows, &spec.levels, @@ -99,6 +222,7 @@ pub(crate) fn rows( None, tree, &agents_by_surface, + agent_order, selected_workspace.min(tree.workspaces().len().saturating_sub(1)), collapsed, ); @@ -113,6 +237,7 @@ fn append_level( context: Option, tree: &TreeView, agents: &HashMap, + agent_order: &[SurfaceId], selected_workspace: usize, collapsed: &HashSet, ) { @@ -150,6 +275,7 @@ fn append_level( }), tree, agents, + agent_order, selected_workspace, collapsed, ); @@ -201,6 +327,7 @@ fn append_level( }), tree, agents, + agent_order, selected_workspace, collapsed, ); @@ -210,6 +337,12 @@ fn append_level( } SidebarResourceKind::Tabs | SidebarResourceKind::Agents => { let agent_only = resource == SidebarResourceKind::Agents; + // Agents views sort by (attention desc, recency desc): a blocked + // agent outranks a working one regardless of age, and within a + // bucket the latest transition floats up. Tab views keep tree + // order. The herdr-style idle-unseen bucket needs frontend focus + // history and is deferred. + let mut agent_entries = HashMap::::new(); let workspace_index = context.map_or(selected_workspace, |context| context.workspace); let Some(workspace) = tree.workspaces().get(workspace_index) else { return }; for (screen_index, screen) in workspace.screens.iter().enumerate() { @@ -246,7 +379,7 @@ fn append_level( } else { pane.short_id.clone() }; - output.push(ProjectionRow { + let row = ProjectionRow { resource, depth: depth as u16, name, @@ -268,14 +401,36 @@ fn append_level( surface: tab.surface, agent: agent_only, }, - }); + }; + if agent_only { + debug_assert!(agent.is_some(), "agent rows are filtered above"); + agent_entries.insert(tab.surface, row); + } else { + output.push(row); + } } } } + for surface in agent_order { + if let Some(row) = agent_entries.remove(surface) { + output.push(row); + } + } } } } +/// How urgently an agent state needs the user: blocked agents wait on a +/// human, working agents may block next, idle agents are at rest. +fn agent_attention(state: &str) -> u8 { + match state { + "blocked" => 3, + "working" => 2, + "idle" => 1, + _ => 0, + } +} + #[cfg(test)] mod tests { use super::*; @@ -356,6 +511,7 @@ mod tests { state: "working".into(), source: "detected".into(), session: Some("fix sidebar".into()), + agent: None, updated_at_ms: 1, }]; let rows = rows( @@ -425,9 +581,153 @@ mod tests { )); } + #[test] + fn screen_detect_agents_view_sorts_by_attention_then_recency() { + let mut tree = tree(); + tree.workspaces_mut()[0].screens[0].panes[0].tabs = vec![ + tab(4, "idle-late"), + tab(5, "working-old"), + tab(6, "blocked-old"), + tab(7, "working-new"), + tab(8, "shell"), + ]; + let agent = |surface: SurfaceId, state: &str, updated_at_ms: u64| AgentInfo { + surface, + state: state.into(), + source: "detected".into(), + session: None, + agent: Some("codex".into()), + updated_at_ms, + }; + // The most recent update is an idle agent: attention still outranks + // recency, so blocked > working > idle, newest first inside buckets. + let agents = vec![ + agent(4, "idle", 90), + agent(5, "working", 30), + agent(6, "blocked", 10), + agent(7, "working", 40), + ]; + let rows = + rows(&spec(vec![SidebarResourceKind::Agents]), &tree, &agents, 0, &HashSet::new()); + + let order: Vec<&str> = rows.iter().map(|row| row.name.as_str()).collect(); + assert_eq!(order, vec!["blocked-old", "working-new", "working-old", "idle-late"]); + + // Tab views keep tree order even when agents are present. + let tabs = rows_tab_order(&tree, &agents); + assert_eq!(tabs, vec!["idle-late", "working-old", "blocked-old", "working-new", "shell"]); + } + + fn rows_tab_order(tree: &TreeView, agents: &[AgentInfo]) -> Vec { + rows(&spec(vec![SidebarResourceKind::Tabs]), tree, agents, 0, &HashSet::new()) + .into_iter() + .map(|row| row.name) + .collect() + } + #[test] fn flat_agent_view_is_empty_when_no_agents_are_running() { let rows = rows(&spec(vec![SidebarResourceKind::Agents]), &tree(), &[], 0, &HashSet::new()); assert!(rows.is_empty()); } + + #[test] + fn agent_order_cache_reuses_order_until_roster_or_tree_changes() { + let mut tree = tree(); + tree.workspaces_mut()[0].screens[0].panes[0].tabs = + vec![tab(4, "working"), tab(5, "blocked")]; + let agent = |surface: SurfaceId, state: &str, updated_at_ms: u64| AgentInfo { + surface, + state: state.into(), + source: "detected".into(), + session: None, + agent: Some("codex".into()), + updated_at_ms, + }; + let mut agents = vec![agent(4, "working", 10), agent(5, "blocked", 1)]; + let mut cache = AgentOrderCache::default(); + let first = rows_cached( + &spec(vec![SidebarResourceKind::Agents]), + &tree, + &agents, + 0, + &HashSet::new(), + &mut cache, + ); + assert_eq!( + first.iter().map(|row| row.name.as_str()).collect::>(), + ["blocked", "working"] + ); + let cached_order = cache.order.clone(); + let _ = rows_cached( + &spec(vec![SidebarResourceKind::Agents]), + &tree, + &agents, + 0, + &HashSet::new(), + &mut cache, + ); + assert_eq!(cache.order, cached_order); + + agents[0].updated_at_ms = 20; + let _ = rows_cached( + &spec(vec![SidebarResourceKind::Agents]), + &tree, + &agents, + 0, + &HashSet::new(), + &mut cache, + ); + assert_eq!(cache.order, vec![5, 4]); + + agents[0].updated_at_ms = 1; + agents[1].state = "working".into(); + let _ = rows_cached( + &spec(vec![SidebarResourceKind::Agents]), + &tree, + &agents, + 0, + &HashSet::new(), + &mut cache, + ); + assert_eq!(cache.order, vec![4, 5]); + tree.workspaces_mut()[0].screens[0].panes[0].tabs.reverse(); + let rows = rows_cached( + &spec(vec![SidebarResourceKind::Agents]), + &tree, + &agents, + 0, + &HashSet::new(), + &mut cache, + ); + assert_eq!( + rows.iter().map(|row| row.name.as_str()).collect::>(), + ["blocked", "working"] + ); + } + + #[test] + fn tabs_view_does_not_build_agent_order_cache() { + let tree = tree(); + let agents = vec![AgentInfo { + surface: 4, + state: "working".into(), + source: "detected".into(), + session: None, + agent: Some("codex".into()), + updated_at_ms: 1, + }]; + let mut cache = AgentOrderCache::default(); + + let _ = rows_cached( + &spec(vec![SidebarResourceKind::Tabs]), + &tree, + &agents, + 0, + &HashSet::new(), + &mut cache, + ); + + assert!(cache.key.is_none()); + } } diff --git a/cmux-tui/crates/cmux-tui/src/ui/rail.rs b/cmux-tui/crates/cmux-tui/src/ui/rail.rs index 99f28e77b504..8e4899c9a53c 100644 --- a/cmux-tui/crates/cmux-tui/src/ui/rail.rs +++ b/cmux-tui/crates/cmux-tui/src/ui/rail.rs @@ -1,4 +1,9 @@ //! Shared visual primitives for the machine and workspace rails. +//! +//! The agent-row geometry used by the agents rail follows herdr's panel +//! layout in `src/app/agent_view.rs` at commit +//! `7b675f42af35508eab66ac42fe1598628597a893` (Apache-2.0), modified by +//! manaflow for cmux's shared rail metrics and narrow terminals. use cmux_tui_core::Rect; use ratatui::Frame; diff --git a/cmux-tui/crates/cmux-tui/src/ui/sidebar.rs b/cmux-tui/crates/cmux-tui/src/ui/sidebar.rs index b37413acc45d..961e8b8d7e8e 100644 --- a/cmux-tui/crates/cmux-tui/src/ui/sidebar.rs +++ b/cmux-tui/crates/cmux-tui/src/ui/sidebar.rs @@ -2,6 +2,11 @@ //! external plugin PTY. Owns its full column including the status-bar row //! (the status bar starts after the sidebar) and rebuilds the click hit map //! as it draws. +//! +//! The agents-view two-line row and attention presentation are adapted from +//! herdr's agent panel design in `src/app/agent_view.rs` at commit +//! `7b675f42af35508eab66ac42fe1598628597a893` (Apache-2.0), modified by +//! manaflow for cmux localization and configurable sidebar resources. use cmux_tui_core::Rect; use ratatui::Frame; diff --git a/cmux-tui/crates/cmux-tui/tests/cli.rs b/cmux-tui/crates/cmux-tui/tests/cli.rs index 62da08874665..20296dd31aa0 100644 --- a/cmux-tui/crates/cmux-tui/tests/cli.rs +++ b/cmux-tui/crates/cmux-tui/tests/cli.rs @@ -4051,6 +4051,164 @@ fn plugin_install_use_and_list_work_against_local_git_repo() { let _ = fs::remove_dir_all(&dir); } +#[cfg(unix)] +#[test] +fn agent_plugin_install_use_and_remove_work_against_local_git_repo() { + let dir = unique_temp_dir("agent-plugin-install"); + let source = dir.join("source"); + // Keep the fixture executable out of git. The manager must build the + // staged checkout before it verifies the agent command. + fs::create_dir_all(&source).unwrap(); + fs::write( + source.join("cmux-plugin.toml"), + r#" + [plugin] + name = "agent-fixture" + kind = "agent" + version = "0.1.0" + description = "Fixture agent detector" + + [run] + command = ["bin/agent"] + + [build] + command = ["/bin/sh", "build.sh"] + "#, + ) + .unwrap(); + fs::write( + source.join("build.sh"), + concat!( + "#!/bin/sh\n", + "mkdir -p bin\n", + "cat > bin/agent <<'EOF'\n", + "#!/bin/sh\n", + "exit 0\n", + "EOF\n", + "chmod 755 bin/agent\n" + ), + ) + .unwrap(); + git(&source, &["init"]); + git(&source, &["add", "."]); + git( + &source, + &[ + "-c", + "user.name=cmux", + "-c", + "user.email=cmux@example.invalid", + "commit", + "-m", + "fixture", + ], + ); + + let data_home = dir.join("data"); + let config_path = dir.join("config").join("mux.json"); + fs::create_dir_all(config_path.parent().unwrap()).unwrap(); + fs::write(&config_path, r#"{"future":{"keep":true},"agents":{"other":true}}"#).unwrap(); + let missing_socket = dir.join("missing.sock"); + let url = format!("file://{}", source.display()); + + let install = plugin_cli( + &data_home, + &config_path, + &[ + "--json", + "--socket", + missing_socket.to_str().unwrap(), + "agent", + "plugin", + "install", + &url, + ], + ); + assert_success(&install); + let installed = json_output(&install); + assert_eq!(installed["plugin"]["name"].as_str(), Some("agent-fixture")); + assert_eq!(installed["plugin"]["active"].as_bool(), Some(false)); + assert!(installed["plugin"]["id"].as_str().unwrap().starts_with("agent_plugin_")); + assert_eq!(installed["plugin"]["enabled"].as_bool(), Some(true)); + + let installed_dir = + data_home.join("cmux").join("mux-plugins").join("agent").join("agent-fixture"); + assert!(installed_dir.join("cmux-plugin.toml").is_file()); + assert!(installed_dir.join("bin/agent").is_file()); + + let use_plugin = plugin_cli( + &data_home, + &config_path, + &[ + "--json", + "--socket", + missing_socket.to_str().unwrap(), + "agent", + "plugin", + "use", + "agent-fixture", + ], + ); + assert_success(&use_plugin); + let used = json_output(&use_plugin); + assert_eq!(used["plugin"]["name"].as_str(), Some("agent-fixture")); + assert_eq!(used["plugin"]["active"].as_bool(), Some(true)); + let plugin_id = used["plugin"]["id"].as_str().unwrap().to_string(); + + let written: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&config_path).unwrap()).unwrap(); + assert_eq!(written["future"]["keep"].as_bool(), Some(true)); + assert_eq!(written["agents"]["other"].as_bool(), Some(true)); + assert_eq!(written["agents"]["plugin"]["id"].as_str(), Some(plugin_id.as_str())); + let canonical_dir = fs::canonicalize(&installed_dir).unwrap(); + assert_eq!(written["agents"]["plugin"]["cwd"].as_str(), Some(canonical_dir.to_str().unwrap())); + assert_eq!( + written["agents"]["plugin"]["command"][0].as_str(), + Some(canonical_dir.join("bin/agent").to_str().unwrap()) + ); + assert!( + written["agents"]["plugin"]["revision"] + .as_str() + .is_some_and(|value| { value.starts_with("sha256-") }) + ); + + let list = plugin_cli(&data_home, &config_path, &["--json", "agent", "plugin", "list"]); + assert_success(&list); + let listed = json_output(&list); + assert_eq!(listed[0]["name"].as_str(), Some("agent-fixture")); + assert_eq!(listed[0]["active"].as_bool(), Some(true)); + + let builtin = plugin_cli( + &data_home, + &config_path, + &[ + "--json", + "--socket", + missing_socket.to_str().unwrap(), + "agent", + "plugin", + "use", + "--builtin", + ], + ); + assert_success(&builtin); + let written: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&config_path).unwrap()).unwrap(); + assert!(written["agents"].get("plugin").is_none()); + assert_eq!(written["future"]["keep"].as_bool(), Some(true)); + + let remove = plugin_cli( + &data_home, + &config_path, + &["--json", "agent", "plugin", "remove", "agent-fixture"], + ); + assert_success(&remove); + assert_eq!(json_output(&remove)["plugin"]["enabled"].as_bool(), Some(false)); + assert!(!installed_dir.exists()); + + let _ = fs::remove_dir_all(&dir); +} + fn wait_for_screen(server: &HeadlessServer, terminal: &str, marker: &str) -> String { let deadline = Instant::now() + Duration::from_secs(10); let mut last = String::new(); @@ -4069,8 +4227,13 @@ fn wait_for_screen(server: &HeadlessServer, terminal: &str, marker: &str) -> Str fn plugin_cli(data_home: &PathBuf, config_path: &PathBuf, args: &[&str]) -> Output { Command::new(bin()) .args(args) + // Keep plugin builds and their Git subprocesses away from the real + // home directory. The config parent is created by each test and is + // unique to that test invocation. + .env("HOME", config_path.parent().expect("plugin config has a parent")) .env("XDG_DATA_HOME", data_home) .env("CMUX_MUX_CONFIG", config_path) + .env_remove("CMUX_TUI_CONFIG") .env_remove("CMUX_TUI_SOCKET") .output() .unwrap() @@ -4251,6 +4414,7 @@ fn create_live_terminal_host_record(root: &std::path::Path) -> fs::File { supports_clear_history: true, supports_terminate_ack: false, supports_input_ack: false, + supports_terminal_metadata: false, }; let record_path = record.record_path(root); let live_path = record_path.with_extension(format!("{incarnation}-{host_start_nonce}.live")); diff --git a/cmux-tui/docs/configuration.md b/cmux-tui/docs/configuration.md index c2fb522fc106..4efb5aadb525 100644 --- a/cmux-tui/docs/configuration.md +++ b/cmux-tui/docs/configuration.md @@ -99,6 +99,18 @@ Every view has an independent width and drag handle. Lower `collapse_priority` v | `sidebar.plugin.command` | array of strings | unset | External sidebar plugin argv; when set, the sidebar hosts this program in a PTY instead of the built-in list | | `sidebar.plugin.cwd` | string | unset | Working directory for the sidebar plugin process | +### Agent plugin configuration + +Agent plugins are background userland processes. They are configured separately +from the sidebar plugin and never replace the sidebar view. + +| Key | Type | Default | Effect | +| --- | --- | --- | --- | +| `agents.plugin.id` | string | required when `agents.plugin` is present | Stable journal producer ID for the selected userland agent plugin; a missing ID disables the entry | +| `agents.plugin.command` | array of strings | unset | Absolute argv for the background agent plugin process | +| `agents.plugin.cwd` | string | unset | Absolute working directory for the agent plugin process | +| `agents.plugin.revision` | string | unset | Content revision used to restart the process after an artifact update | + Live sidebar dragging also leaves at least 40 columns for pane content. ### Sidebar plugins @@ -129,6 +141,43 @@ Return to the built-in sidebar with: cmux sidebar plugin use --builtin ``` +### Agent plugins + +Agent detection is an optional background userland plugin. It does not run in +the sidebar and it does not add vendor rules to cmux core. Core supervises the +process and folds its journal events. The plugin reads terminal screen and +process metadata, then writes namespaced events. + +Install and select a package with: + +```bash +cmux agent plugin install +cmux agent plugin use +cmux server reload-config +``` + +The manager stores packages in +`~/.local/share/cmux/mux-plugins/agent/` (or the equivalent +`$XDG_DATA_HOME` path). It validates `cmux-plugin.toml`, runs its declared +build command, verifies the executable, and writes an absolute command and +content revision to `agents.plugin`. The manager does not hide a server reload +inside an install command. This keeps the config write and process restart +observable and works when the selected server is remote. + +List, replace, or remove packages with: + +```bash +cmux agent plugin list +cmux agent plugin update +cmux agent plugin remove +``` + +The reference screen detector keeps 21 herdr-derived manifests in its own +package. `cmux-agent-screen-detection update` checks an HTTPS catalog only when +the user invokes it. Startup never performs network access. See +[`spec/plugins.md`](../spec/plugins.md) for the journal envelope, lifecycle, +source precedence, process fallback, and attribution rules. + ## Machines The machine rail is optional. Its position comes from a `sidebar.views` entry whose level is `machines`, or it stays first under the default layout. It activates when `machine_sidebar.enabled` is true, `machines` has a valid entry, or `machine_sidebar.create_sources` is nonempty. diff --git a/cmux-tui/scripts/check-resource-api-boundary.py b/cmux-tui/scripts/check-resource-api-boundary.py index 93d158455b89..e2904dd23227 100644 --- a/cmux-tui/scripts/check-resource-api-boundary.py +++ b/cmux-tui/scripts/check-resource-api-boundary.py @@ -2231,7 +2231,7 @@ def validate_params(operation: str, descriptor: Mapping[str, object]) -> None: agent_fields.get("updated_at_ms", {}).get("type") != {"kind": "primitive", "name": "decimal"} or agent_fields.get("source_session", {}).get("type") != nullable_string - or source_values != ["hook", "socket", "detected"] + or source_values != ["hook", "socket", "detected", "plugin"] or report_source_values != ["hook", "socket"] or "reported_at" in agent_fields ): @@ -2239,7 +2239,7 @@ def validate_params(operation: str, descriptor: Mapping[str, object]) -> None: diagnostics, path, text, - "AgentSnapshot must use exact decimal time, nullable source session, and detected-only snapshot source", + "AgentSnapshot must use exact decimal time, nullable source session, and the complete source enum", "AgentSnapshot", ) diff --git a/cmux-tui/spec/README.md b/cmux-tui/spec/README.md index 4d86cb2b9ad0..d18f108a05ab 100644 --- a/cmux-tui/spec/README.md +++ b/cmux-tui/spec/README.md @@ -28,7 +28,7 @@ high-level SDKs: | [`resource-operations-v2.md`](resource-operations-v2.md) | Human-readable operation inventory | | [`cli.md`](cli.md) | Noun-first public CLI | | [`bindings.md`](bindings.md) | Seven handwritten SDK facades and generated raw layers | -| [`plugins.md`](plugins.md) | Sidebar view and local plugin contract | +| [`plugins.md`](plugins.md) | Sidebar and userland agent plugin contracts | The operation catalog is authoritative for every operation's class, selector scopes, parameter presence, result type, structured errors, stream items, and diff --git a/cmux-tui/spec/bindings.md b/cmux-tui/spec/bindings.md index a490952b7814..1c9e1e4381b3 100644 --- a/cmux-tui/spec/bindings.md +++ b/cmux-tui/spec/bindings.md @@ -15,7 +15,10 @@ The split is deliberate: - A catalog descriptor in every package proves that all 125 transported operations have the same class and wire name. - The six sidebar plugin operations are local CLI/filesystem APIs. Transported - SDK roots expose sidebar views, not plugin resource handles. + SDK roots expose sidebar views, not plugin resource handles. Agent plugin + install, selection, update, and removal are also CLI-only because they build + and replace caller-owned executables; they are specified in `plugins.md`, not + as transport operations. Code generation is a repository build tool. It is never a consumer dependency and does not define the public API. @@ -42,7 +45,11 @@ Every high-level SDK must provide: Decimal wire values remain strings. TypeScript never converts them to `number`; Java uses `BigInteger`; other SDKs validate canonical unsigned -decimal text before an optional native conversion. +decimal text before an optional native conversion. Journal producer versions +are bounded positive uint32 values in every binding, and manifest size, +permission, subject, correlation, and sensitivity limits are checked before a +socket write. JSON Schema compilation and payload authorization remain server +responsibilities. The server may return `mutation.indeterminate` after a crash around an external effect. SDKs retain the structured error and never repeat that key @@ -97,6 +104,11 @@ cursor. The `cmux-sdk` package exports crate `cmux`. Resource handles clone without I/O. Mutation helpers create one secure key; `_with` variants accept explicit mutation options. Typed streams are owned iterators with cancellation handles. +Journal producers use `Session::journal_producers`, +`Session::put_journal_producer`, and `Session::append_journal`; the types are +generic and do not require agent-specific core code. The longer +`put_journal_producer_manifest` and `append_journal_event` spellings remain +source-compatible aliases. The optional `cmux-sidebar` package applies terminal-style render patches to a Ratatui buffer and forwards typed input without adding Ratatui to the base SDK. Private models live under `cmux::raw`. @@ -107,6 +119,9 @@ Private models live under `cmux::raw`. resource graph for `asyncio`. Cancellation closes its dedicated connection and releases reader threads. The package supports Python 3.9 without runtime dependencies. Private models live under `cmux.raw`. +Generic producers use `list_journal_producers`, `put_journal_producer`, and +`append_journal`; the longer `put_journal_producer_manifest` and +`append_journal_event` names remain compatibility aliases. ### TypeScript @@ -115,6 +130,9 @@ browser-safe ESM and accepts an injected WebSocket transport. `cmux-sdk/node` adds Unix socket discovery. Shared modules import no Node built-ins. Stream APIs are `AsyncIterable`, accept `AbortSignal`, and preserve decimal strings. Private models live under `cmux-sdk/raw`. +Generic producers use `listJournalProducers`, `putJournalProducer`, and +`appendJournal`. The public model uses camelCase while the wire keeps the +schema's snake_case names. ### Go @@ -122,6 +140,9 @@ Package `cmux` accepts `context.Context` on blocking operations. Context cancellation stops local waiting and closes dedicated streams; it does not claim to cancel an already executing mutation. A dial function can inject a transport on Windows and in tests. Private models live under `cmux/raw`. +Generic producers use `ListJournalProducers`, `PutJournalProducer`, and +`AppendJournal`; the options and results use typed uint32 versions and owned +JSON payload values. ### Java @@ -129,6 +150,9 @@ Package `com.cmux` uses builders for requests with several optional fields and immutable results. `Client` and `ResourceStream` support try-with-resources. A transport can be injected for WebSockets, non-Unix platforms, and tests. Private models live under `com.cmux.raw`. +Generic producers use `journalProducers`, `putJournalProducer`, and +`appendJournal`. Java represents schema and manifest versions as `long` so the +full uint32 wire range is representable before validation. ### C++20 @@ -136,6 +160,9 @@ Headers under `cmux` expose native C++ value types and no Rust ABI. `cmux::result` separates typed failure categories. The default transport is Unix JSON Lines; applications inject other transports. Private headers live under `cmux/raw`. +Generic producers use `journal_producers`, `put_journal_producer`, and +`append_journal`. `class` and `namespace` stay available through the public +names `journal_class` and `namespace_` where the language reserves them. ### Zig @@ -143,6 +170,10 @@ Public methods accept an explicit allocator for owned data. Results and streams require `deinit`. Errors retain structured remote fields and secret values are zeroized when their owning values are released. Private modules live under `raw`. +Generic producers use `journalProducers`, `putJournalProducer`, and +`appendJournal`. Zig public fields use `journal_class` and `namespace_` for +the reserved wire names `class` and `namespace`; all returned values have an +explicit allocator owner and `deinit`. ## Transport parity diff --git a/cmux-tui/spec/cli.md b/cmux-tui/spec/cli.md index eb3b93c7e44d..4d3d5976ec21 100644 --- a/cmux-tui/spec/cli.md +++ b/cmux-tui/spec/cli.md @@ -363,6 +363,7 @@ notification clear [--terminal ] notification ack --client ... notify [--title ] [--subtitle ] [--body ] [--clear] [--surface ] [--workspace ] agent list|report +agent plugin list|install|use|update|remove pairing request list pairing request respond projection show|put @@ -440,6 +441,17 @@ remains available for transport testing. never open a protocol connection or send a plugin ID to a session. Optional plugin names are slugs matching `[a-z0-9-_]+`. +## Local agent plugins + +`agent plugin` commands read and write local installation state. They clone +and build the selected package, validate its `kind = "agent"` manifest, and +write the selected background command to `agents.plugin`. They do not open a +protocol connection or send a plugin ID to a session. Run `cmux server +reload-config` after changing the selection. The running plugin uses the +generic journal producer and append operations over the server socket. Use +`agent plugin use --builtin` to disable the selected userland plugin and return +to no agent detector. + `provider authority install` is a local Linux host-administration action. It installs the credential for an already running provider-managed session and is not a transported resource operation or cross-machine discovery API. diff --git a/cmux-tui/spec/commands.md b/cmux-tui/spec/commands.md index 0e2eea13fc88..8242669f5ce3 100644 --- a/cmux-tui/spec/commands.md +++ b/cmux-tui/spec/commands.md @@ -3739,7 +3739,7 @@ object{ agents: array @@ -3786,9 +3786,11 @@ to `session.events`. The server generates an internal mutation identity for this raw command. Each live terminal has at most one current agent projection. Hook reports have -authority over socket reports. A socket report received after a hook retains -the hook value while still advancing the resource revision and publishing that -retained value. Restart restores the current projection. Closing the terminal +authority over socket reports. A socket report that does not change the +effective projection is a replay-equivalent no-op at the current revision and +does not publish another event. A socket report received after an unchanged +hook therefore retains the hook value without advancing the resource +revision. Restart restores the current projection. Closing the terminal deletes it, so historical reports cannot recreate an agent. Browser surfaces, surfaces without durable terminal identity, and terminal-less default reports are rejected. @@ -3799,7 +3801,7 @@ Params: | --- | --- | --- | --- | | `surface` | `IdRef` | required | Surface associated with the agent | | `state` | `string` | required | `"working"`, `"blocked"`, `"idle"`, `"done"`, or `"unknown"` | -| `source` | `string` | required | `"socket"` or `"hook"` | +| `source` | `string` | required | `"socket"` or `"hook"` for `report-agent`; list responses can also contain `"detected"` or `"plugin"` | | `session` | `string` | default null | Optional upstream agent session id | Result: diff --git a/cmux-tui/spec/events.md b/cmux-tui/spec/events.md index ac97c67a1b72..92f08bddc4b9 100644 --- a/cmux-tui/spec/events.md +++ b/cmux-tui/spec/events.md @@ -12,7 +12,7 @@ Implemented event lines can appear on subscribe, attach, or control lifecycle st | Stream | How to start | Event names | | --- | --- | --- | -| Subscribe stream | `subscribe` command | `tree-changed`, all workspace/screen/pane/tab deltas, `frontend-projection-changed`, `terminal-registry-changed`, `layout-changed`, `surface-output`, `scroll-changed`, `surface-resized`, `surface-resize-failed`, `surface-exited`, `title-changed`, `bell`, `notification`, `status`, `config-reload-requested`, `window-title-requested`, `machine-usage-changed`, `client-attached`, `client-changed`, `client-detached`, `client-list-invalidated`, `pairing-requested`, `pairing-resolved`, `empty`, `overflow` | +| Subscribe stream | `subscribe` command | `tree-changed`, all workspace/screen/pane/tab deltas, `frontend-projection-changed`, `terminal-registry-changed`, `layout-changed`, `surface-output`, `scroll-changed`, `surface-resized`, `surface-resize-failed`, `surface-exited`, `title-changed`, `agent-changed`, `bell`, `notification`, `status`, `config-reload-requested`, `window-title-requested`, `machine-usage-changed`, `client-attached`, `client-changed`, `client-detached`, `client-list-invalidated`, `pairing-requested`, `pairing-resolved`, `empty`, `overflow` | | Attach stream v5 | `attach-surface` command | `vt-state`, `output`, `detached`, `overflow` | | Attach stream v6 PTY | `attach-surface` command | `vt-state`, `resized`, `output`, `colors-changed`, `notification`, `scroll-changed`, `detached`, `overflow` | | Attach stream v7 render mode | `attach-surface` command | `render-state`, `render-delta`, `scroll-changed`, `detached`, `overflow` | @@ -65,7 +65,7 @@ Control lifecycle notices are sent on the authenticated control queue. They do n | `pairing-resolved` | trusted Unix subscribe | `request` | protocol 7 | | `status` | subscribe | session | protocol 5 internal status line | | `empty` | subscribe | session | protocol 5 | -| `agent-state-changed` | subscribe | `surface` | proposed vNext | +| `agent-changed` | subscribe | `surface` | protocol 11; `agent` is optional since protocol 12 | | `vt-state` | byte attach | `surface` | protocol 5 | | `resized` | byte attach | `surface` | protocol 6 | | `output` | byte attach | `surface` | protocol 5 | @@ -1090,38 +1090,43 @@ Example: {"event":"detached","surface":1} ``` -## Proposed Events - -### agent-state-changed +### agent-changed | Field | Value | | --- | --- | -| event | `agent-state-changed` | -| status | proposed | -| since | proposed protocol 10 | +| event | `agent-changed` | +| status | implemented | +| since | protocol 11; `agent` is optional since protocol 12 | Payload: ```text object{ - event:"agent-state-changed", + event:"agent-changed", surface:Id, - previous:"working"|"blocked"|"idle"|"done"|"unknown"|null, state:"working"|"blocked"|"idle"|"done"|"unknown", - source:"detected"|"socket"|"hook", + source:"plugin"|"detected"|"socket"|"hook", session:string|null, + agent?:string|null, updated_at_ms:uint64 } ``` -Meaning: The authoritative agent state for a surface changed. Hook-authority and socket reports override detection as described in `commands.md`. +Meaning: The current agent state for a surface changed. `agent` is the +adapter identity when the producer knows it. Hook authority and source +precedence follow `commands.md`. Example: ```json -{"event":"agent-state-changed","surface":1,"previous":"working","state":"blocked","source":"hook","session":"abc","updated_at_ms":1710000000000} +{"event":"agent-changed","surface":1,"state":"blocked","source":"hook","session":"abc","agent":"claude","updated_at_ms":1710000000000} ``` +The earlier draft name `agent-state-changed` is not emitted. Clients must +subscribe to `agent-changed`. + +## Proposed Events + ### notification vNext extension | Field | Value | @@ -1168,7 +1173,7 @@ Params: Request: ```json -{"id":1,"cmd":"subscribe","events":["bell","agent-state-changed"],"surfaces":[1,"a8f3k2"]} +{"id":1,"cmd":"subscribe","events":["bell","agent-changed"],"surfaces":[1,"a8f3k2"]} ``` Filtering applies only to events produced after the subscription is registered. Non-surface events are included only when their event name matches `events` or when `events` is absent. diff --git a/cmux-tui/spec/inventory.json b/cmux-tui/spec/inventory.json index 440e2294e3ee..8de7dc606917 100644 --- a/cmux-tui/spec/inventory.json +++ b/cmux-tui/spec/inventory.json @@ -1231,7 +1231,7 @@ "id": "plugins", "wire_status": "partial", "programmability": "partial", - "route": "sidebar-plugin-v0; proposed manifest, permissions, contributions, and transactional management" + "route": "sidebar-plugin-v0 plus userland journal agent plugins; proposed contribution points, permissions, and typed management" }, { "id": "file-sidebar", diff --git a/cmux-tui/spec/plugins.md b/cmux-tui/spec/plugins.md index 03b188c09e26..44d6da1f828d 100644 --- a/cmux-tui/spec/plugins.md +++ b/cmux-tui/spec/plugins.md @@ -1,6 +1,6 @@ # Plugin Contract -This document specifies the mux-side sidebar plugin contract. +This document specifies the mux-side sidebar and journal plugin contracts. ## Sidebar Plugins @@ -66,6 +66,7 @@ name = "fzf" kind = "sidebar" version = "0.1.0" description = "Fuzzy-find workspaces, screens, and panes" +platforms = ["macos", "linux", "windows"] [run] command = ["target/release/cmux-sidebar-fzf"] @@ -78,6 +79,12 @@ The host reads the already-installed command from the cmux-tui config. The plugi manager installs sidebar plugins from git repositories and writes the resolved command into that config file. +`plugin.platforms` is optional. When present, it is a non-empty list from +`macos`, `linux`, and `windows`, with no duplicates. The manager rejects an +install, use, or update when the current platform is not listed. `list` still +shows an incompatible installed plugin and reports `platform_supported=false`, +so a user can remove it or move the installation to a supported host. + ## Install Layout Installed plugins live under: @@ -94,7 +101,7 @@ $XDG_DATA_HOME/cmux/mux-plugins/ `` is either `[plugin].name` from `cmux-plugin.toml` or the `cmux sidebar plugin install --name ` value. Names must match -`[a-z0-9-_]+`; path traversal and mixed-case names are rejected. Install clones +`[a-z0-9-_]+` and be at most 64 bytes; path traversal and mixed-case names are rejected. Install clones to a temporary directory first, validates the manifest, runs `[build].command` when present, verifies the resolved `[run].command[0]` exists and is executable, then moves the directory into place. Existing installs are refused @@ -102,3 +109,310 @@ unless `--force` is supplied. Relative manifest run commands are resolved to absolute paths under the plugin directory before `sidebar plugin use` writes the runnable command into the cmux-tui config. +The manager inspects at most 256 entries in one install root, including hidden +transaction leftovers and registry metadata. A larger root fails closed and +must be cleaned up before `list`, `use`, `update`, or `remove` can continue. +Git sources are passed to `git` as process arguments. HTTP and HTTPS sources +with embedded user information, query strings, or fragments are rejected so +tokens do not enter the process table. Use a Git credential helper or an SSH +key for private repositories; SSH user names, SCP-like sources, and local paths +remain supported. Git metadata stdout is capped at 16 KiB before parsing; +overflow is treated as unavailable. + +## Agent Plugins + +An agent plugin is a server-side background executable. It does not run in a +sidebar PTY and it does not add agent code to cmux core. Core owns process +supervision, environment setup, journal admission, and roster reduction. The +plugin owns process-group discovery, screen sampling, manifest rules, and +agent-specific interpretation. + +Core still accepts the old `detected` source and `ScreenDetect` native event +only when replaying journals written before this boundary existed. Current core +code never creates those records. New detection implementations must use the +generic `plugin..agent.*` journal envelope. + +### Configuration + +The selected plugin is stored in `~/.config/cmux/cmux-tui.json`: + +```json +{ + "agents": { + "plugin": { + "id": "agent_plugin_0123456789abcdef0123456789abcdef", + "command": ["/absolute/path/to/cmux-agent-screen-detection"], + "cwd": "/absolute/path/to/plugin", + "revision": "sha256-..." + } + } +} +``` + +`id` is the stable producer identity and is required. It must match +`[a-z0-9][a-z0-9_-]*` (maximum 64 bytes). `command[0]` and `cwd` must be absolute. +The built-in `cmux_agent` hook producer ID is reserved and cannot be used by a +userland plugin. Core ignores an agent plugin entry without an explicit ID and +never invents a namespace for it. +`revision` is optional for hand-written configuration, but the plugin manager +writes a content-derived value. A changed revision restarts the child even +when the command path is unchanged. Invalid replacement configuration disables +the previous child instead of leaving stale detection active. + +The supervisor passes `CMUX_TUI_SOCKET`, `CMUX_MUX_SOCKET`, +`CMUX_TUI_SESSION_ID`, and the required `CMUX_PLUGIN_ID`, plus +`CMUX_PLUGIN_REVISION`, +`CMUX_PLUGIN_GENERATION`, `CMUX_PLUGIN_PROTOCOL_VERSION=1`, +`CMUX_PLUGIN_KIND=journal`, `CMUX_JOURNAL_PLUGIN=1`, and the compatibility +hint `CMUX_AGENT_PLUGIN=1`. The socket is already bound before the child +starts. A plugin that emits restart-fenced observations should copy +`CMUX_PLUGIN_GENERATION` into its event's `normalized.plugin_generation` field. +The reference screen detector validates this ID before entering its reconnect +loop, so a malformed hand-written configuration fails once instead of retrying +an impossible manifest registration. + +### Lifecycle + +Core starts one child after the resource socket is bound. An unexpected exit +creates a normal `agent.plugin.exited` journal event for that producer, then +restarts it with bounded exponential backoff. The reducer removes only entries +owned by the exited producer and, for a tagged child, by that exact exited +generation. Untagged compatibility rows use the exit timestamp as their limit, +and untagged observations stay fenced after an exit because they cannot prove +that they belong to a replacement. This prevents a crash from removing a +replacement process that has already reported. Config reload stops the old +child before starting the replacement. +On Unix the child starts in a dedicated process group and shutdown signals the +whole group. On Windows the child starts suspended, is assigned to a Job Object +with kill-on-close, and is resumed only after ownership is established. A Unix +plugin that calls `setsid` can leave that group; plugins must keep helper +processes in the inherited group or provide their own cleanup. +Generation fences protect the journal from late records, but Unix PID and +process-group identifier reuse remains a platform race. The detector treats a +foreground group as authoritative only when the host reports the current +group; a public-process fallback cannot prove replacement identity. + +### Journal boundary + +Agent plugins use the generic `session.journal.producer.put` and +`session.journal.append` operations. A producer manifest declares a namespace +of `plugin.`, event schemas, maximum sensitivity, and the +`journal.append.` permission. Event payloads use the stable +`cmux.agent-plugin.v1` envelope when they are intended for the built-in agent +roster reducer: + +```json +{ + "format": "cmux.agent-plugin.v1", + "plugin": {"id": "agent_plugin_...", "version": 1}, + "adapter": {"id": "codex", "version": 1}, + "event": "state.changed", + "normalized": { + "state": "working", + "source_session": "pid:123", + "plugin_generation": "7", + "observed_at_ms": "1730000000000" + }, + "native": {} +} +``` + +The core reducer accepts this envelope without knowing the adapter catalog. +Its source order is hook, plugin, detected legacy replay, then socket. A fresh +hook blocks a plugin observation for 30 seconds. The plugin remains a normal +journal producer, so replay, remote clients, and durable projections use the +same event stream. + +The producer manifest's permission is enforced for journal admission. The +current local Unix resource socket is a same-user trust boundary, so it does +not yet enforce a per-plugin allow-list for every other resource operation. +The reference detector declares and uses only journal append in its own +manifest and does not call input or lifecycle mutation operations. A future +capability-bound plugin socket must be designed as a separate host contract; +an advisory flag here would not provide security. + +The reference detector exposes a read-only `explain --live` command for +diagnosis. It resolves an exact terminal ID or title through `terminal.list`, +then reads `terminal.process.get` and `terminal.screen.read` and evaluates the +same userland manifests as the scanner. It rejects duplicate titles and emits +the process identity source, screen revision, matched rule, evidence, and +manifest provenance. This diagnostic command does not add an agent-specific +daemon operation. + +The reducer clamps `normalized.observed_at_ms` to the journal commit time when +the plugin clock is ahead. Older observation times remain unchanged, so a +delayed append cannot become fresh evidence merely because it arrived late. +This keeps hook precedence bounded by a host-controlled clock. + +`session.journal.producer.list` returns userland producer manifests only. The +reserved cmux hook manifest is kept in the daemon's internal producer table, +but is omitted from this operation because its legacy `agent` namespace is not +a userland `plugin.` namespace. + +### Terminal metadata + +The generic `terminal.screen.read` result may include `revision` and +`osc_progress`. Either field may be absent or null when the server cannot +provide it. `revision` is a coalesced PTY output counter. `osc_progress` is +bounded OSC 9 payload text captured by the terminal protocol layer. The +protocol parser validates lead-specific UTF-8 continuation ranges and +reprocesses malformed bytes as framing, so invalid text cannot swallow a C1 +string terminator. Core does not interpret either field as an agent signal. A +plugin may combine them with the screen text, OSC title, and process metadata. + +The process result includes the PTY foreground executable. Native process-group +inspection remains a plugin concern, so a plugin can add wrapped runtime +arguments and child processes without a daemon schema change. If the host does +not permit inspection, the plugin must use the one-process fallback. + +The reference Linux backend streams `/proc` regular files through a 128 KiB +bound before parsing. Oversized process files fail closed, so a malformed +process cannot force an unbounded allocation in the detector. + +### Manifests and updates + +An agent plugin package declares `kind = "agent"` in `cmux-plugin.toml`: + +```toml +[plugin] +name = "agent-screen-detection" +kind = "agent" +version = "0.1.0" +platforms = ["macos", "linux"] + +[run] +command = ["target/release/cmux-agent-screen-detection"] + +[build] +command = ["cargo", "build", "--release"] +``` + +The manager installs agent packages under +`~/.local/share/cmux/mux-plugins/agent/` (or the equivalent +`$XDG_DATA_HOME` path), validates the manifest, runs its declared build, and +checks the executable before writing the selected config. Installation and +build execute third-party code with the user's permissions. Core does not +sandbox a plugin. Artifact replacement and selected-config replacement use a +local rollback guard, but they are separate filesystem transactions. A power +loss between those writes can leave an old artifact with new configuration (or +the reverse); startup validation disables an invalid selection and the next +explicit install or update repairs it. + +The reference screen detector keeps the 21 herdr-derived manifests in the +plugin package. It loads bundled files first, then a bounded cache, then an +explicit local override directory. `cmux-agent-screen-detection update` is +the only network update path. The scanner never performs implicit network I/O, +so startup does not depend on a catalog, DNS, or a remote service. Update +failures are recorded per agent and never replace a valid cached manifest. +The reference loader also caps an active set at 256 manifests, a source +directory at 512 entries, and each manifest at 256 KiB before parsing. + +The herdr source and Apache-2.0 license attribution are listed in +`cmux-tui/ATTRIBUTIONS.md` and the plugin package `ATTRIBUTIONS.md`. Nineteen +manifest files are unchanged at the manifest snapshot commit. `claude.toml` +is byte-identical to upstream commit `987b070fbfa187e85009b45cd7e208fc6175ff6a`. +`grok.toml` carries one local precedence correction. `github-copilot.toml` is +byte-identical to the snapshot and includes its upstream background-agent rule. +The correction is documented in the attribution files and the manifest README. +Files adapted from herdr carry the upstream path and their source-reference +commit in their header. + +The reference package builds with Cargo `--locked`, so installation uses the +checked-in dependency graph. Other plugins may choose another build tool, but +should provide an equivalent lock or integrity check when their tool supports +one. + +### Herdr capability coverage + +The reference package covers the agent-detection capabilities that can be +shared without importing herdr's application into cmux: + +| Herdr capability | Userland package behavior | +| --- | --- | +| Screen manifests | 21 manifests are bundled and replaceable. The current Claude rules do not treat a background shell as foreground work. Herdr lists 23 agent kinds, but OMP and Mastracode have no screen manifest at the manifest snapshot revision. | +| Identity aliases and wrappers | Manifest aliases, shell/runtime arguments, package launchers, process groups, and a public-process fallback are supported. Attached runtime eval/module flags stop path scans, and flags after the first positional script do not hide its identity. Direct shell scripts and escaped shell command words are decoded; shell command flags follow each runtime's grammar, including fish's separate and inline `--command` forms. Value-taking, no-exec, exit-only, and unknown shell modes fail closed. Visible executable and wrapper evidence is checked before the optional `CMUX_AGENT` or `HERDR_AGENT` process hint, so ordinary scans do not read process environments. Linux can opt into bounded child-group inference with `CMUX_AGENT_PROCESS_DETECTION=child-groups` when a controlling-terminal group is unavailable. | +| Regions and gates | Recent-screen regions, prompt and viewer slices, OSC title/progress regions, `all`, `any`, `not`, literal, regex, and line-regex gates are supported with bounded complexity. | +| Rule priority and visibility | Numeric priority, idle fallback, blocker/working/idle visibility hints, and `skip_state_update` are preserved. | +| Event-driven journal delivery | Terminal journal events wake the plugin only for affected terminals. Quiescence debounce, startup grace, six-miss identity hysteresis, same-name process-group replacement edges, activity expiry, pending idle, blocker refresh, and process-exit edges remain supported. Stable terminals block on the journal stream with no heartbeat or periodic scan. Each edge is committed only after journal admission. An uncertain transport result retains the exact envelope and idempotency key for bounded-backoff replay before a newer edge. | +| Explain and update diagnostics | `explain`, `list`, `status`, and explicit HTTPS `update` commands expose matcher evidence, source precedence, versions, and per-agent failures. | + +The following inventory records the agent-facing herdr capabilities that are +outside this package. This prevents a future change from silently moving +application policy into cmux core. + +| Herdr capability | Status in cmux | Boundary decision | +| --- | --- | --- | +| Agent panel with filter and sort grammar | Native cmux agents view; the filter and sort contract is a separate host feature | Keep presentation in cmux. The detector emits facts only. | +| State-change sounds and desktop notifications | Native cmux notification path; no herdr sound asset is copied | Do not duplicate audio policy in a detector. | +| Agent launch, prompt, and resume | Native terminal and agent CLI paths | A detector observes a terminal. It must not gain input or process-launch authority. | +| Hook integrations and session identity | Existing cmux hook adapter | Keep hook authority in one reducer. A detector cannot safely replace a hook contract. | +| Remote persistence and session restore | cmux journal and session persistence | The plugin has no private durable state to merge with host snapshots. | +| Plugin panes, actions, and link handlers | Sidebar and resource plugin contracts | These are separate plugin kinds. Do not couple them to agent detection. | +| Windows foreground process-group inspection | Not supported by this reference package | The Rust SDK transport and native process backend are Unix-only. A Windows package must add both before publication. | +| OMP and Mastracode screen manifests | Not present at the manifest snapshot revision | Herdr lists these process kinds but ships no screen manifests. Hooks can still cover them. We do not invent state rules. | +| Agent inventory and point lookup (`agent.list`, `agent.get`) | `agent.list` and typed SDK agent handles return the generic `AgentSnapshot`; there is no separate `agent.get` wire operation | Keep lookup terminal-scoped and catalog-independent. A client can refresh a selected opaque agent id. | +| Agent screen and history reads (`agent.read`) | `terminal.screen.read`, `terminal.output_read`, and `terminal.history.read` provide the same data sources to any plugin | Keep reads terminal primitives. Do not add an agent-specific read endpoint. | +| Explain (`agent.explain`) | The reference plugin exposes `explain` with rule evidence, source, version, and fallback reasons; the host does not evaluate vendor rules | Keep explain beside the replaceable manifest engine. Core receives normalized facts only. | +| Input (`agent.send`, `agent.send_keys`) | `terminal.input.keys` and `terminal.input.write` are generic host operations | The reference detector declares and uses only journal append. The current same-user socket does not enforce this per-plugin intent, so an action plugin must use a separate explicit contract and own its policy. | +| Focus (`agent.focus`) and rename (`agent.rename`) | `terminal.input.focus` and `pane.rename` are generic host operations | Keep focus and naming in the host. Detection must not mutate presentation. | +| Start (`agent.start`) | `pane.run`, `pane.create`, and `tab.create_terminal` can start a command, but no agent catalog or launcher is in core | Let a separate launcher plugin choose commands. A detector must not execute an agent. | +| Prompt plus wait (`agent.prompt`) | A client can compose `terminal.input.write` or `terminal.input.keys` with `terminal.wait`; there is no atomic agent prompt operation. Herdr's latest delayed-prompt fix (`8633a398e653eee47b375c963996c78a8a14aa48`) sequences text and Enter inside its PTY actor. | Keep the detector input-free. If cmux needs atomic text-plus-Enter submission, add a generic terminal-input transaction in a separate host contract, not an agent-specific method. | +| Semantic wait (`agent.wait`) | `terminal.wait` handles screen patterns, `terminal.wait_exit` handles process exit, and `session.journal.subscribe` exposes state events; no agent-specific wait helper exists | Filter the generic journal in userland. This keeps wait semantics replaceable and avoids a core agent catalog. | +| Declarative agent view (`agent.view.set`, `agent.view.clear`) | `frontend_projection.put` is generic; native agents-view filters and sort grammar remain host-owned | Keep client-owned presentation state, including the seen bit, out of shared journal state. | +| Lifecycle report (`pane.report_agent`) | `agent.report` and the `cmux.agent-plugin.v1` journal envelope accept generic state facts | Use one reducer with hook, plugin, legacy replay, and socket precedence. | +| Native session report (`pane.report_agent_session`) | Native hook integrations can retain opaque session references; the userland screen plugin does not report or resume them | Do not let an untrusted screen guess authorize a resume command. Add a generic opaque reference only with an explicit host resume contract. | +| Presentation metadata (`pane.report_metadata`) and state labels/tokens | Generic journal `native` and `extra` data can be retained, but it does not override host lifecycle or labels | Keep display metadata in host projections. Do not let plugin payloads change semantic state by side effect. | +| Child-agent topology and rollups | A screen plugin reports one terminal observation. Core has no vendor child graph or rollup policy | Require explicit parent references and a generic graph contract before adding topology. | +| Remote client endpoint compatibility | Herdr's endpoint-generation work (`cc88b3b8e5bb9f7d9f23ed6ae85a52fd7b5b9ed6`) changes its transport endpoint generation, not the userland detector contract | Define and test SDK endpoint-generation compatibility before a standalone binary promises daemon upgrades. Do not import herdr's transport implementation into the detector. | + +This inventory was rechecked against herdr's agent-surface revision +`987b070fbfa187e85009b45cd7e208fc6175ff6a`. It includes the exact Pi bundled +CLI path correction from `b1ff4582e9688f52ffb943cfa8bee4871ae122e4`; the +userland process adapter ports it and rejects non-entrypoint lookalikes. It +also includes the Claude background-shell manifest correction from +`987b070fbfa187e85009b45cd7e208fc6175ff6a`, with tests for idle, working, and +blocked screens. It ported the +first-acquisition OSC retention fix from `82e6a80eb3ae39fb3d3ebd4d1fed19389767e605` inside the +userland tracker. The foreground group-leader CWD fix from +`3a3792622e59c7f2dc20f9c0236167161e4a5035` is already covered by cmux's +generic `foreground_cwd` resource, which reads the controlling foreground +group leader and exposes no herdr policy. Later upstream changes cover +Windows launch, process environment and job handling, and native input +identity. The shell-render refactor `207be3c771d281baae6e5fa0fb74be9a056e97a2` +is application/client architecture, not detector behavior, and is not copied. +The reference package has no Windows SDK transport or native process backend +and does not own launch, input, or remote paste handling, so those changes +remain outside this plugin. Review them before publishing Windows support. + +The latest audited upstream commits, `0032c3b42751b6da9c5b1a91546b3c1a425d67f1` +and `18e69891dca486d669a584facd80644bb51f54a2`, fix remote multiline paste +and OpenSSH mouse input. The endpoint-generation change +`cc88b3b8e5bb9f7d9f23ed6ae85a52fd7b5b9ed6`, independent multi-client tab-view +change `6c0bb273d5d5405a00985621b17e36f8b4d64609`, delayed-prompt change +`8633a398e653eee47b375c963996c78a8a14aa48`, recent-read change +`45484aab84430ac2b18c7bbf44aba15f2b039677`, graphics ownership change +`e22cba35ef7b405758097a5f9436aae8fb4caaf0`, and sidebar-focus change +`94f6d9c0d9bb9cf9ffae99d8bbfb09e9bf2fc9e0` are application, client, PTY, +or generic terminal architecture, not detector policy. The malformed Windows +process environment fix `5616196942cbe752cc0659b9bd0fb616b2a6ed5c` is +portable-pty behavior. These changes are outside the userland detector. Before +publishing a standalone binary, define and test SDK endpoint-generation +compatibility across host versions. The herdr repository tip checked on +2026-09-02 is `94f6d9c0d9bb9cf9ffae99d8bbfb09e9bf2fc9e0`; commits after the +agent-surface revision do not change `src/detect` or the manifests. The +agent-surface revision is the reproducible capability-audit pin. + +Linux child-group inference remains an explicit fallback because it cannot +distinguish foreground from background children without a controlling +terminal. Generic OSC metadata has no agent-specific reset operation. The +scanner preserves OSC evidence on the first agent acquisition, then anchors +the stream revision at each replacement or confirmed exit and ignores retained +title and progress until that revision advances. On older hosts without a +revision, it keeps the compatibility path because the plugin cannot prove +whether retained metadata predates the edge. If a host supplied a revision for +the fence and later omits it, the plugin fails closed until a newer revision is +reported. A local screen hash can schedule a read, but it is never a generation +fence. +Network updates are explicit; the scanner never fetches data during startup. A +different userland plugin can replace the +reference package and emit the same generic journal envelope. diff --git a/cmux-tui/spec/programmability.md b/cmux-tui/spec/programmability.md index bb18c8c51492..4058278272ae 100644 --- a/cmux-tui/spec/programmability.md +++ b/cmux-tui/spec/programmability.md @@ -21,7 +21,7 @@ Every feature belongs to one ownership class: | Terminal host | Durable PTY process and renderer data plane | [`terminal-host.md`](terminal-host.md) | | Machine provider | Machine discovery, lifecycle, scopes, and transport tickets | [`machine-provider.md`](machine-provider.md) | | Provider management | Root-owned authority installation and rotation | [`provider-management.md`](provider-management.md) | -| Plugin host | Installed executable, manifest, permissions, contributions, and lifecycle | [`plugins.md`](plugins.md) | +| Plugin host | Installed executable, manifest, permissions, contributions, and lifecycle for sidebar and userland agent plugins | [`plugins.md`](plugins.md) | An action that combines a frontend choice with a mux mutation has two steps. For example, `browser-edit-url` opens a local prompt, then calls `browser-navigate`. The local prompt is not copied into mux state. diff --git a/cmux-tui/spec/resource-api-v2.md b/cmux-tui/spec/resource-api-v2.md index 48b90252939c..f8005dd70977 100644 --- a/cmux-tui/spec/resource-api-v2.md +++ b/cmux-tui/spec/resource-api-v2.md @@ -199,13 +199,15 @@ are the exceptions to the mutation replay guarantee above. `agent.report` requires a live terminal and has no session-global or default agent record. The registry stores one current projection row per live terminal. Public `agent.report` and raw `report-agent` use the same durable -commit path, advance the public resource revision, and publish one agent -change to `session.events`. A hook report replaces socket state. A later -socket report retains the hook value but still commits the observed durable -order and publishes that retained value. Restart restores agents from the -current projection table rather than scanning report history. Tombstoning a -terminal deletes its projection in the same transaction, so historical -reports cannot resurrect it. +commit path. A report that changes the effective projection advances the +public resource revision and publishes one agent change to `session.events`. +A semantically identical socket report is recorded as a replay-equivalent +receipt at the current revision, without changing the projection or emitting +another event. A hook report replaces socket state; a socket report that is +retained by an unchanged hook therefore also takes this no-op path. Restart +restores agents from the current projection table rather than scanning report +history. Tombstoning a terminal deletes its projection in the same transaction, +so historical reports cannot resurrect it. `terminal.viewport.scroll` changes the session's compatibility inspection viewport. Interactive frontends keep scroll in their own terminal mirror and @@ -348,6 +350,13 @@ Terminal and browser attachments have independent decimal-string sequences. Their initial snapshot is delivered after the open response. Overflow requires a fresh attachment snapshot. +`terminal.screen.read` may include two optional terminal metadata fields. They +may be absent or null when the server cannot provide them. `revision` is a +coalesced PTY output counter, and `osc_progress` is bounded OSC 9 progress text. +These fields are generic protocol data. The daemon does not identify agents or +apply vendor rules. A userland plugin can combine the metadata with process +information and screen text before appending its own journal event. + Every `browser.attach` frame also carries a required nullable `pointer_frame_seq`. A null token permits rendering but forbids pointer input. `browser.input.mouse` and `browser.input.wheel` require the exact non-null @@ -394,7 +403,20 @@ defines the catalog format. Unknown parameter and result fields are rejected. | `mutation` | Durable mutation. A non-empty idempotency key is required. | | `stream_open` | Opens a stream. The client supplies stream_id. No idempotency key. | | `connection_control` | Connection-local control. No idempotency key. | -| `local` | Filesystem-only sidebar plugin action. It never uses a protocol request envelope. | +| `local` | Filesystem-only plugin manager action. It never uses a protocol request envelope. The agent plugin manager is a CLI entrypoint; it is not a transported resource operation. | + +Userland agent plugins use the normal journal operations. They register a +`JournalProducerManifest` with `session.journal.producer.put`, then append +`JournalIngress` events with `session.journal.append`. The manifest namespace is +`plugin.`, and the append permission is +`journal.append.`. Core validates the manifest and event schema but +does not know the plugin's agent catalog or screen grammar. The Rust SDK names +these types generically. `Session::journal_producers` reads the installed +manifests for diagnostics. Bindings that exposed the preview `AgentPlugin*` +names retain source-compatible aliases. +There are deliberately no `agent_plugin.*` transport operation names. The +commands are local CLI plans, and the selected process uses the generic journal +operations after the server socket is bound. | Class | Operations | | --- | --- | @@ -499,6 +521,10 @@ machine. Its origin is always `local`. `session.list`, `session.get`, and Sidebar plugin installation and selection are local filesystem operations. Transported sidebar view operations never send a `sidebar_plugin_` ID. Optional install names are filesystem slugs matching `[a-z0-9-_]+`. +Agent plugin installation and selection are also local filesystem operations. +They write `agents.plugin` and never send an `agent_plugin_` ID as a resource +selector. The selected background process uses the journal operations above +after the server socket is bound. `screen.layout.undo` accepts `confirm_close`, default false, and an optional opaque `confirmation_token` of 1 through 128 UTF-8 bytes. If the undo would @@ -543,6 +569,7 @@ cmux workspace ws_… screen current pane current split --right cmux terminal term_… screen read cmux terminal term_… keys ctrl-c cmux sidebar plugin list +cmux agent plugin list ``` Root control scopes are `machine`, `session`, `client`, `workspace`, `screen`, @@ -560,9 +587,11 @@ event. `--quiet` suppresses success output. Results use stdout. Diagnostics use stderr. Exit codes are 0 success, 1 operation failure, 2 usage, and 3 transport. -Local filesystem actions are `sidebar plugin install|update|remove|use` and -configuration discovery. Their results use the same output modes but they do -not cross the session protocol. +Local filesystem actions are `sidebar plugin install|update|remove|use`, +`agent plugin install|list|update|remove|use`, and configuration discovery. +Their results use the same output modes but they do not cross the session +protocol. Agent plugin actions select the userland background detector and +write `agents.plugin`; they do not add agent implementation to core. ## SDK boundary diff --git a/cmux-tui/spec/resource-operations-v2.json b/cmux-tui/spec/resource-operations-v2.json index cd5444f0874d..397edcfc1044 100644 --- a/cmux-tui/spec/resource-operations-v2.json +++ b/cmux-tui/spec/resource-operations-v2.json @@ -461,7 +461,7 @@ }, "extra": false, "constraints": [ - "namespace must equal plugin..", + "namespace must equal plugin.", "Manifest versions increase monotonically.", "Storage v1 rejects secret producer authority until encrypted retention is implemented." ] @@ -557,6 +557,8 @@ }, "extra": false, "constraints": [ + "producer_id must be a valid plugin component and kind must start with plugin..", + "The event kind must be declared by the installed producer manifest.", "The installed producer manifest determines class, replay policy, and default sensitivity.", "The payload must validate against the declared JSON Schema." ] @@ -2109,6 +2111,14 @@ "name": "TerminalLifecycle" } }, + "stream_revision": { + "required": false, + "type": { + "kind": "primitive", + "name": "decimal" + }, + "description": "Coalesced PTY output revision. It is absent when no live PTY is available and may be absent from older servers." + }, "exit": { "required": false, "type": { @@ -2589,7 +2599,8 @@ "values": [ "hook", "socket", - "detected" + "detected", + "plugin" ] } }, @@ -3712,6 +3723,28 @@ "name": "string" } }, + "revision": { + "required": false, + "type": { + "kind": "nullable", + "value": { + "kind": "primitive", + "name": "decimal" + } + }, + "description": "Coalesced PTY output revision. Older servers may omit this field or send null when unavailable." + }, + "osc_progress": { + "required": false, + "type": { + "kind": "nullable", + "value": { + "kind": "primitive", + "name": "string" + } + }, + "description": "Latest bounded OSC 9 progress payload. The daemon exposes terminal metadata only; plugins decide how to interpret it. Older servers may omit this field or send null when unavailable." + }, "cols": { "required": true, "type": { @@ -4061,6 +4094,17 @@ }, "description": "Working directory of the process group that owns the PTY, read at request time. Null when the lookup fails. All current servers emit this field. Protocol 2 SDK decoders accept it being absent from older servers and treat absence as null." }, + "foreground_executable": { + "required": true, + "type": { + "kind": "nullable", + "value": { + "kind": "primitive", + "name": "string" + } + }, + "description": "Executable path or name of the PTY foreground process-group leader. Null when the lookup fails. Older servers may omit this field." + }, "children": { "required": true, "type": { diff --git a/cmux-tui/spec/resource-operations-v2.md b/cmux-tui/spec/resource-operations-v2.md index b1125f631884..5f82dbf21048 100644 --- a/cmux-tui/spec/resource-operations-v2.md +++ b/cmux-tui/spec/resource-operations-v2.md @@ -60,6 +60,12 @@ never use a protocol envelope: High-level transported SDKs expose sidebar views, not plugin resource handles. The noun-first CLI exposes the local operations under `sidebar plugin`. +The noun-first CLI also exposes `agent plugin install|list|use|update|remove`. +Those commands clone, build, replace, and configure an executable on the +caller filesystem, so they stay outside this transport catalog by design. +The agent-plugin lifecycle and its generic journal contract are specified in +[`plugins.md`](plugins.md). + Browser attachment frames carry a required nullable `pointer_frame_seq`. Mouse and wheel mutations require the exact non-null decimal token from the rendered frame used to choose their coordinates. A null or stale token cannot diff --git a/cmux-tui/spec/sdk-schema.json b/cmux-tui/spec/sdk-schema.json index 75008528ae86..8217ebe04c39 100644 --- a/cmux-tui/spec/sdk-schema.json +++ b/cmux-tui/spec/sdk-schema.json @@ -585,6 +585,7 @@ "AgentSource": { "kind": "enum", "values": [ + "plugin", "detected", "socket", "hook" @@ -3597,6 +3598,16 @@ "nullable": true, "since": 12, "description": "Working directory of the process group that owns the PTY, read at request time. Null when the lookup fails; absent from daemons that predate the field. Clients treat absence as null." + }, + "foreground_executable": { + "type": { + "kind": "scalar", + "name": "string" + }, + "presence": "optional", + "nullable": true, + "since": 12, + "description": "Executable path or name of the PTY foreground process-group leader, read at request time. Null when the lookup fails; absent from daemons that predate the field. Clients treat absence as null." } }, "additional_properties": false @@ -10723,6 +10734,16 @@ "payload": { "kind": "object", "fields": { + "agent": { + "type": { + "kind": "scalar", + "name": "string" + }, + "presence": "optional", + "nullable": true, + "since": 12, + "description": "Adapter identity when the producer knows it; absent from protocol-11 event senders and null when no adapter was identified." + }, "event": { "type": { "kind": "literal", diff --git a/cmux-tui/spec/terminal-host.md b/cmux-tui/spec/terminal-host.md index 5707cbc17e38..c7040f08ec77 100644 --- a/cmux-tui/spec/terminal-host.md +++ b/cmux-tui/spec/terminal-host.md @@ -76,10 +76,12 @@ it may publish the snapshot or send input. | `HostHello`, 40 bytes | `selected_version:u16, reserved:u16=0, granted_rights:u32, terminal_id:[u8;16], incarnation:[u8;16]` | `ClientHello.sequence` is zero. Its permitted flags are -`FLAG_VIEWER_SIZE_ACKS` and `FLAG_SMART_RENDERER`. The host echoes viewer-size -acknowledgements only when `RESIZE` was granted, and echoes smart mode only for -renderer or admin roles negotiating protocol v3 or newer. Daemon adoption -applies a two-second read and write handshake timeout. +`FLAG_VIEWER_SIZE_ACKS`, `FLAG_SMART_RENDERER`, and +`FLAG_TERMINAL_METADATA`. The host echoes viewer-size acknowledgements only +when `RESIZE` was granted, and echoes smart mode only for renderer or admin +roles negotiating protocol v3 or newer. A v4 host echoes terminal metadata +only when the client requests it. Daemon adoption applies a two-second read +and write handshake timeout. For a newly launched v4 host, the first authenticated owner `HostHello` also sets `FLAG_LAUNCH_ACTIVATION_REQUIRED`. The PTY reader remains behind a launch @@ -205,7 +207,13 @@ kitty_replay_state:KittyReplayState PID zero means absent. Snapshot `argc` may be zero. Protocol v2 appends a Kitty image-alias table and cell pixel width and height. Protocol v3 appends Kitty graphics limits, the replay cursor offset, and the primary and alternate -image-id cursors. Protocol v4 keeps the v3 snapshot payload unchanged. +image-id cursors. Protocol v4 keeps the v3 snapshot payload unchanged unless +`FLAG_TERMINAL_METADATA` was negotiated. A negotiated v4 snapshot then +appends `osc_progress:string`, a bounded OSC 9 progress value with at most 256 +Unicode characters and no control characters. The value is captured while +the host's authoritative VT parser is at the same snapshot boundary. A client +that does not negotiate the flag receives the original v4 bytes and must +reject an unnegotiated trailing field. Legacy `Resized` producer payload: @@ -301,6 +309,11 @@ size and local scroll viewport. `HostHello` sent to the first authenticated launch owner. `Activate` has zero flags, request id zero, sequence zero, and an empty payload. +`FLAG_TERMINAL_METADATA` is bit 4 and is valid only in protocol-v4 +`ClientHello` and `HostHello`. It negotiates the optional `Snapshot` metadata +tail described above. The metadata is a generic terminal primitive. It does +not identify agents or select plugin policy. + ## Ordering and recovery A renderer applies every live sequence exactly once. A gap, duplicate, flagged frame without the required next `Colors`, or invalid flag is fatal. The renderer disconnects and obtains a new `Snapshot`; continuing from a damaged sequence would corrupt its mirror. @@ -390,6 +403,14 @@ legacy fire-and-forget input remains available. Record directories are mode `0700`; records and sockets are mode `0600`. +Discovery records use JSON `record_version:4`. A host that supports the +optional snapshot tail advertises `supports_terminal_metadata:true`; records +from older hosts omit the field and default it to false. Terminal and +incarnation are 32-character lowercase UUIDv4 hex, owner token and process +nonce are 64-character lowercase hex, the Unix-socket path is canonical, and +the host PID is nonzero. Record directories are mode `0700`; records and +sockets are mode `0600`. + ## Durability boundary The append-only journal is exact while a mux daemon owns the authenticated host @@ -408,17 +429,18 @@ across an unplanned no-tap interval until a durable host spool exists. Protocol v1 carries the base snapshot and legacy replay stream. Protocol v2 adds Kitty image aliases and cell-pixel metrics. Protocol v3 adds Kitty replay state, Kitty quota controls, and the smart raw-byte stream. Protocol v4 adds -the launch activation barrier. The daemon's adoption path can still connect -to legacy host records at an older version; smart renderers require v3 and -restart their handshake on any gap or `ResyncRequired` frame. This document -specifies the daemon-side v4 framing; renderer interoperability remains -partial. Newly launched daemon hosts enforce v4-only renderer handshakes by -passing `PROTOCOL_VERSION..=PROTOCOL_VERSION` to `CapabilityStore::accept`; -the one-use token minted by -`CapabilityStore::mint` carries no protocol version. The higher-level legacy -renderer-grant response reports the selected host version, but no grant API -offers mutually supported downgrade negotiation. The current cross-language -renderer accepts only v1-v3 and pins its `ClientHello` to the selected version. -This spec therefore does not advertise renderer attach to newly launched -hosts; renderer v4 support or explicit mutually supported version negotiation -remains future work. +the launch activation barrier and the optional negotiated terminal metadata +snapshot tail. A v4 client may negotiate v1 or v2 only in legacy mode; smart +renderers require v3 and restart their handshake on any gap or +`ResyncRequired` frame. The daemon's adoption path can still connect to legacy +host records at an older version. This document specifies the daemon-side v4 +framing; renderer interoperability remains partial. Newly launched daemon +hosts enforce v4-only renderer handshakes by passing +`PROTOCOL_VERSION..=PROTOCOL_VERSION` to `CapabilityStore::accept`; the one-use +token minted by `CapabilityStore::mint` carries no protocol version. The +higher-level legacy renderer-grant response reports the selected host version, +but no grant API offers mutually supported downgrade negotiation. The current +cross-language renderer accepts only v1-v3 and pins its `ClientHello` to the +selected version. This spec therefore does not advertise renderer attach to +newly launched hosts; renderer v4 support or explicit mutually supported +version negotiation remains future work. diff --git a/cmux-tui/spec/transports.md b/cmux-tui/spec/transports.md index 3d7e5c533ad1..65cab257b748 100644 --- a/cmux-tui/spec/transports.md +++ b/cmux-tui/spec/transports.md @@ -353,7 +353,7 @@ GET /api/v1/events Optional query parameters mirror proposed `subscribe` filters: ```text -GET /api/v1/events?events=bell,agent-state-changed&surfaces=1,a8f3k2 +GET /api/v1/events?events=bell,agent-changed&surfaces=1,a8f3k2 ``` Each event is sent as: diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index b7db1f442a46..5d483ff816d1 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -764,6 +764,7 @@ C5616E96DCEC5AD0263BB9A4 /* CloudGuestURLTestContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAA597EF5D62E0147E16DCAF /* CloudGuestURLTestContainer.swift */; }; D0110080000000000000000A /* CloudHubConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = D01100800000000000000009 /* CloudHubConnection.swift */; }; E58AA0A516704DF28E795531 /* CloudHubConnector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03D533A672094C0F8F6B0097 /* CloudHubConnector.swift */; }; 7A0CE100000000000000070E /* CloudHubDialing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A0CE100000000000000070D /* CloudHubDialing.swift */; }; + 052EED2B7B94A277BCD1CEBD /* CloudHubConnectorHedgeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AD17DD6D0DA4B0877464615 /* CloudHubConnectorHedgeTests.swift */; }; 7A0CE100000000000000071A /* CloudHubPortForwarder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A0CE1000000000000000719 /* CloudHubPortForwarder.swift */; }; 7A0CE100000000000000070C /* CloudHubSocketClaim.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A0CE100000000000000070B /* CloudHubSocketClaim.swift */; }; 124760000000000000000005 /* CloudImagePasteError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 124770000000000000000005 /* CloudImagePasteError.swift */; }; @@ -3282,6 +3283,7 @@ C0DE70530000000000000002 /* submit-cmux-profile in Copy CLI */ = {isa = PBXBuildFile; fileRef = C0DE70530000000000000001 /* submit-cmux-profile */; }; D7AB00000000000000B041 /* SupersededPhoneDismissBuffer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB00000000000000B040 /* SupersededPhoneDismissBuffer.swift */; }; C935C833B18F4CC18711BD77 /* SurfaceAgentNaming.swift in Sources */ = {isa = PBXBuildFile; fileRef = 019F9382B99C46AAAEB9C91C /* SurfaceAgentNaming.swift */; }; + FB3A15A096C8270E929D34B6 /* SurfaceCatalog+AgentIcons.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECA5950A12D41190CC94F10A /* SurfaceCatalog+AgentIcons.swift */; }; F11347000000000000000007 /* SurfaceCatalog+CloudBinding.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11347000000000000000008 /* SurfaceCatalog+CloudBinding.swift */; }; 78F8D449BA0D4860AE1162C7 /* SurfaceCatalog+CloudDirectoryMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B2248ED1422420F90E0D71D /* SurfaceCatalog+CloudDirectoryMetadata.swift */; }; F11347000000000000000001 /* SurfaceCatalog+CloudPorts.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11347000000000000000002 /* SurfaceCatalog+CloudPorts.swift */; }; @@ -3332,6 +3334,7 @@ D84DEF61FE7641A280419E24 /* SurfaceProvider+MaterializationValidation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B84F249415045AF98A4730A /* SurfaceProvider+MaterializationValidation.swift */; }; EE1DE3F7E67DF1366F214AC7 /* SurfaceProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAED489C1C23533995AF55EA /* SurfaceProvider.swift */; }; 58FE3B39F41CF68A632CA083 /* SurfaceRemotePlacement.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA467FE5247EDFC122C88129 /* SurfaceRemotePlacement.swift */; }; + 07AFFFB52EA184A2DE379926 /* SurfaceResource+AgentIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0314DFABAF2F627A93F4AF9B /* SurfaceResource+AgentIcon.swift */; }; 733F8CFB9036496EB237A178 /* SurfaceResource+CatalogOrdering.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC6ACCF8253744008F186C73 /* SurfaceResource+CatalogOrdering.swift */; }; 91007F48E782474596D8FB7D /* SurfaceResource+CloudTitle.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7E2A1163D9B452EA3D55B5D /* SurfaceResource+CloudTitle.swift */; }; F96CAE8A14774122A5F8613B /* SurfaceResourceDragPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48A1307BCCDB493F49179E0B /* SurfaceResourceDragPayload.swift */; }; @@ -3702,6 +3705,7 @@ 12476000000000000000000A /* TerminalSurface+ImageTransferTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12477000000000000000000A /* TerminalSurface+ImageTransferTarget.swift */; }; D36A00090000000000000001 /* TerminalSurface+RendererRealizationSurface.swift in Sources */ = {isa = PBXBuildFile; fileRef = D36A00090000000000000002 /* TerminalSurface+RendererRealizationSurface.swift */; }; C750500000000000000000B1 /* TerminalSurfaceRuntimeWiring.swift in Sources */ = {isa = PBXBuildFile; fileRef = C750500000000000000000B2 /* TerminalSurfaceRuntimeWiring.swift */; }; + A6A6A6A60000000000000001 /* TerminalTabAgentIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6A6A6A60000000000000002 /* TerminalTabAgentIcon.swift */; }; A91C0D0F0000000000000002 /* TerminalTabIconRegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A91C0D0F0000000000000001 /* TerminalTabIconRegressionTests.swift */; }; F83620020000000000000001 /* TerminalTTYSessionIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = F83620020000000000000002 /* TerminalTTYSessionIdentity.swift */; }; EC010A01 /* TerminalUploadCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC010A02 /* TerminalUploadCommand.swift */; }; @@ -5002,6 +5006,7 @@ DAA597EF5D62E0147E16DCAF /* CloudGuestURLTestContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudGuestURLTestContainer.swift"; sourceTree = ""; }; D01100800000000000000009 /* CloudHubConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudHubConnection.swift; sourceTree = ""; }; 03D533A672094C0F8F6B0097 /* CloudHubConnector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudHubConnector.swift"; sourceTree = ""; }; 7A0CE100000000000000070D /* CloudHubDialing.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CloudHubDialing.swift; sourceTree = ""; }; + 9AD17DD6D0DA4B0877464615 /* CloudHubConnectorHedgeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudHubConnectorHedgeTests.swift"; sourceTree = ""; }; 7A0CE1000000000000000719 /* CloudHubPortForwarder.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CloudHubPortForwarder.swift; sourceTree = ""; }; 7A0CE100000000000000070B /* CloudHubSocketClaim.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CloudHubSocketClaim.swift; sourceTree = ""; }; 124770000000000000000005 /* CloudImagePasteError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sources/Cloud/CloudImagePasteError.swift"; sourceTree = SOURCE_ROOT; }; @@ -7388,6 +7393,7 @@ C0DE70530000000000000001 /* submit-cmux-profile */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = "Resources/bin/submit-cmux-profile"; sourceTree = SOURCE_ROOT; }; D7AB00000000000000B040 /* SupersededPhoneDismissBuffer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupersededPhoneDismissBuffer.swift; sourceTree = ""; }; 019F9382B99C46AAAEB9C91C /* SurfaceAgentNaming.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SurfaceAgentNaming.swift"; sourceTree = ""; }; + ECA5950A12D41190CC94F10A /* SurfaceCatalog+AgentIcons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SurfaceCatalog+AgentIcons.swift"; sourceTree = ""; }; F11347000000000000000008 /* SurfaceCatalog+CloudBinding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SurfaceCatalog+CloudBinding.swift"; sourceTree = ""; }; 7B2248ED1422420F90E0D71D /* SurfaceCatalog+CloudDirectoryMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SurfaceCatalog+CloudDirectoryMetadata.swift"; sourceTree = ""; }; F11347000000000000000002 /* SurfaceCatalog+CloudPorts.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "SurfaceCatalog+CloudPorts.swift"; sourceTree = ""; }; @@ -7438,6 +7444,7 @@ 3B84F249415045AF98A4730A /* SurfaceProvider+MaterializationValidation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SurfaceProvider+MaterializationValidation.swift"; sourceTree = ""; }; AAED489C1C23533995AF55EA /* SurfaceProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SurfaceProvider.swift; sourceTree = ""; }; CA467FE5247EDFC122C88129 /* SurfaceRemotePlacement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SurfaceRemotePlacement.swift"; sourceTree = ""; }; + 0314DFABAF2F627A93F4AF9B /* SurfaceResource+AgentIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SurfaceResource+AgentIcon.swift"; sourceTree = ""; }; FC6ACCF8253744008F186C73 /* SurfaceResource+CatalogOrdering.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sidebar/SurfaceResource+CatalogOrdering.swift"; sourceTree = ""; }; A7E2A1163D9B452EA3D55B5D /* SurfaceResource+CloudTitle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SurfaceResource+CloudTitle.swift"; sourceTree = ""; }; 48A1307BCCDB493F49179E0B /* SurfaceResourceDragPayload.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SurfaceResourceDragPayload.swift; sourceTree = ""; }; @@ -7807,6 +7814,7 @@ 12477000000000000000000A /* TerminalSurface+ImageTransferTarget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sources/TerminalSurface+ImageTransferTarget.swift"; sourceTree = SOURCE_ROOT; }; D36A00090000000000000002 /* TerminalSurface+RendererRealizationSurface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "App/TerminalSurface+RendererRealizationSurface.swift"; sourceTree = ""; }; C750500000000000000000B2 /* TerminalSurfaceRuntimeWiring.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSurfaceRuntimeWiring.swift; sourceTree = ""; }; + A6A6A6A60000000000000002 /* TerminalTabAgentIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalTabAgentIcon.swift; sourceTree = ""; }; A91C0D0F0000000000000001 /* TerminalTabIconRegressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalTabIconRegressionTests.swift; sourceTree = ""; }; F83620020000000000000002 /* TerminalTTYSessionIdentity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalTTYSessionIdentity.swift; sourceTree = ""; }; EC010A02 /* TerminalUploadCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalUploadCommand.swift; sourceTree = ""; }; @@ -8712,7 +8720,9 @@ DD9E59E46A6A91958EE22451 /* CloudEnvDelivery.swift */, A79DE8A46550805CDAB18D09 /* SurfacePaneFactory+ProjectionLayout.swift */, F0508401C3A0232FEDD7F97D /* CmuxTuiSurfaceProvider+ProjectionLayout.swift */, + 0314DFABAF2F627A93F4AF9B /* SurfaceResource+AgentIcon.swift */, A7E2A1163D9B452EA3D55B5D /* SurfaceResource+CloudTitle.swift */, + 1A5D0FC63C854C90B2974F39 /* CloudWorkspaceProjectionTask.swift */, 085BB53F552B4C6A8728941E /* Workspace+CloudLayoutProjection.swift */, 2C422AAD63384578846BC05E /* TerminalController+CloudTerminalCreation.swift */, @@ -8740,6 +8750,7 @@ 0DFE35D27805468DBA885560 /* CloudWorkspaceProjectionEnvironment.swift */, A5A9286A232F4C3FAD571015 /* CloudWorkspaceProjectionCoordinator.swift */, 4A4A80857B8A4530BD5BC498 /* SurfaceCatalog+CloudRenameSubmission.swift */, + ECA5950A12D41190CC94F10A /* SurfaceCatalog+AgentIcons.swift */, AB74882A29884E378E36D645 /* SurfaceCatalog+CloudWorkspaceProjection.swift */, C12575000000000000000031 /* CloudWorkspaceDeletionLedger.swift */, C12575000000000000000035 /* SurfaceCatalog+Snapshot.swift */, @@ -10445,6 +10456,7 @@ A5001099 /* TerminalNotificationScrollPosition.swift */, 7490D0107490D0107490D010 /* TerminalNotificationStore+MemoryPressure.swift */, 596100000000000000000004 /* NativeNotificationDeliveryHooks.swift */, + A6A6A6A60000000000000002 /* TerminalTabAgentIcon.swift */, D7AB00000000000000B040 /* SupersededPhoneDismissBuffer.swift */, A5001097 /* TerminalNotificationPolicy.swift */, F1A0C0DE0000000000000001 /* FindTextFieldSupport.swift */, @@ -12459,6 +12471,7 @@ 6B7DF8AE123341DEBA066731 /* CloudDisplayCatalogTests.swift */, A6D3896BB28B4549B3D33A83 /* CloudPortAccessLateCoordinatorTests.swift */, C51600000000000000000002 /* CLIExecInheritedSignalStateTests.swift */, + 9AD17DD6D0DA4B0877464615 /* CloudHubConnectorHedgeTests.swift */, C0DE5A100000000000000002 /* KeyboardShortcutSavedLayoutTemplateTests.swift */, C0DE31410000000000000104 /* DebugEventLogSerializedAppendTests.swift */, C7D27B959838305B918269C2 /* WorkspaceSSHFishProcessDrainTests.swift */, @@ -13194,6 +13207,8 @@ + + A11CE0040000000000000001 /* AboutLicenseContent.swift in Sources */, A5C017000000000000000001 /* AccountSignInFlow.swift in Sources */, A5C017000000000000000003 /* AccountSignInModel.swift in Sources */, @@ -15174,6 +15189,7 @@ D35B71010000000000000001 /* StartupBreadcrumbLog.swift in Sources */, D7AB00000000000000B041 /* SupersededPhoneDismissBuffer.swift in Sources */, C935C833B18F4CC18711BD77 /* SurfaceAgentNaming.swift in Sources */, + FB3A15A096C8270E929D34B6 /* SurfaceCatalog+AgentIcons.swift in Sources */, F11347000000000000000007 /* SurfaceCatalog+CloudBinding.swift in Sources */, 78F8D449BA0D4860AE1162C7 /* SurfaceCatalog+CloudDirectoryMetadata.swift in Sources */, F11347000000000000000001 /* SurfaceCatalog+CloudPorts.swift in Sources */, @@ -15219,6 +15235,7 @@ D84DEF61FE7641A280419E24 /* SurfaceProvider+MaterializationValidation.swift in Sources */, EE1DE3F7E67DF1366F214AC7 /* SurfaceProvider.swift in Sources */, 58FE3B39F41CF68A632CA083 /* SurfaceRemotePlacement.swift in Sources */, + 07AFFFB52EA184A2DE379926 /* SurfaceResource+AgentIcon.swift in Sources */, 733F8CFB9036496EB237A178 /* SurfaceResource+CatalogOrdering.swift in Sources */, 91007F48E782474596D8FB7D /* SurfaceResource+CloudTitle.swift in Sources */, F96CAE8A14774122A5F8613B /* SurfaceResourceDragPayload.swift in Sources */, @@ -15521,6 +15538,7 @@ 12476000000000000000000A /* TerminalSurface+ImageTransferTarget.swift in Sources */, D36A00090000000000000001 /* TerminalSurface+RendererRealizationSurface.swift in Sources */, C750500000000000000000B1 /* TerminalSurfaceRuntimeWiring.swift in Sources */, + A6A6A6A60000000000000001 /* TerminalTabAgentIcon.swift in Sources */, F83620020000000000000001 /* TerminalTTYSessionIdentity.swift in Sources */, EC010A01 /* TerminalUploadCommand.swift in Sources */, 5154BEAB50364B86A9E36E4B /* TerminalViewportUITestRecorder.swift in Sources */, @@ -16402,6 +16420,7 @@ 57BC801370636A4E2C1B31B7 /* CloudFileDeliveryTests.swift in Sources */, F7925DA2C43A12723611F26E /* CloudGuestURLRoutingTests.swift in Sources */, C5616E96DCEC5AD0263BB9A4 /* CloudGuestURLTestContainer.swift in Sources */, + 052EED2B7B94A277BCD1CEBD /* CloudHubConnectorHedgeTests.swift in Sources */, 124760000000000000000030 /* CloudImagePasteMirrorIntegrationTests.swift in Sources */, 124760000000000000000001 /* CloudImagePasteRoutingTests.swift in Sources */, C12542000000000000000002 /* CloudInitialWorkspaceNamingTests.swift in Sources */, diff --git a/cmux.xcodeproj/xcshareddata/xcschemes/cmux-ci.xcscheme b/cmux.xcodeproj/xcshareddata/xcschemes/cmux-ci.xcscheme index b37eac3de257..cae26c54dc40 100644 --- a/cmux.xcodeproj/xcshareddata/xcschemes/cmux-ci.xcscheme +++ b/cmux.xcodeproj/xcshareddata/xcschemes/cmux-ci.xcscheme @@ -14,15 +14,6 @@ - - - - - - - - - diff --git a/cmux.xcodeproj/xcshareddata/xcschemes/cmux-unit.xcscheme b/cmux.xcodeproj/xcshareddata/xcschemes/cmux-unit.xcscheme index 210006cfbcec..246adc0f36e8 100644 --- a/cmux.xcodeproj/xcshareddata/xcschemes/cmux-unit.xcscheme +++ b/cmux.xcodeproj/xcshareddata/xcschemes/cmux-unit.xcscheme @@ -14,15 +14,6 @@ - - - - - - - - - diff --git a/cmuxTests/AuthEnvironmentTests.swift b/cmuxTests/AuthEnvironmentTests.swift index 3d74df811bf4..34fcf1d380c8 100644 --- a/cmuxTests/AuthEnvironmentTests.swift +++ b/cmuxTests/AuthEnvironmentTests.swift @@ -10,6 +10,34 @@ import Testing @Suite("Auth environment") struct AuthEnvironmentTests { + @Test("debug file overrides win over stale inherited routing values and never import unknown keys") + func debugFileOverridesWinOverStaleInheritedRoutingValues() { + let merged = AuthEnvironment.mergedRuntimeEnvironment( + environment: [ + "CMUX_API_BASE_URL": "https://stale.example", + "CMUX_VM_API_BASE_URL": "https://stale.example", + "CMUX_AUTH_ENVIRONMENT": "development", + "UNSAFE_SECRET": "process-secret", + ], + fileOverrides: [ + "CMUX_API_BASE_URL": " https://fresh.example:4626/ ", + "CMUX_VM_API_BASE_URL": "https://fresh.example:4626/", + "UNSAFE_SECRET": "file-secret", + ] + ) + + #expect(merged["CMUX_API_BASE_URL"] == "https://fresh.example:4626/") + #expect(merged["CMUX_VM_API_BASE_URL"] == "https://fresh.example:4626/") + #expect(merged["UNSAFE_SECRET"] == "process-secret") + } + + @Test("debug override parser accepts quoted values and ignores comments") + func debugOverrideParserAcceptsQuotedValues() { + let contents = "# comment\nCMUX_VM_API_BASE_URL = \"https://fresh.example:4626/\"\nOTHER=ignored\n" + #expect(AuthEnvironment.parseDebugOverride(key: "CMUX_VM_API_BASE_URL", contents: contents) == "https://fresh.example:4626/") + #expect(AuthEnvironment.parseDebugOverride(key: "CMUX_API_BASE_URL", contents: contents) == nil) + } + @Test("macOS production auth override selects the production Stack project") func macOSProductionAuthOverrideSelectsProductionStackProject() { #expect(AuthEnvironment.resolvedStackAuthEnvironment( diff --git a/cmuxTests/CLISSHPTYAttachReplayBoundaryTests.swift b/cmuxTests/CLISSHPTYAttachReplayBoundaryTests.swift index 51bb1685fa86..b6768626a385 100644 --- a/cmuxTests/CLISSHPTYAttachReplayBoundaryTests.swift +++ b/cmuxTests/CLISSHPTYAttachReplayBoundaryTests.swift @@ -259,8 +259,10 @@ struct CLISSHPTYAttachReplayBoundaryTests { #expect(TerminalFlags(fd: attach.slaveFD) == attach.initialFlags) _ = fcntl(attach.slaveFD, F_SETFL, O_NONBLOCK) var buffer = [UInt8](repeating: 0, count: 128) - #expect(Darwin.read(attach.slaveFD, &buffer, buffer.count) == -1) - #expect(errno == EAGAIN) + let readResult = Darwin.read(attach.slaveFD, &buffer, buffer.count) + let readErrno = errno + #expect(readResult == -1) + #expect(readErrno == EAGAIN) } } } diff --git a/cmuxTests/CloudHubConnectorHedgeTests.swift b/cmuxTests/CloudHubConnectorHedgeTests.swift new file mode 100644 index 000000000000..4567b76c1bfb --- /dev/null +++ b/cmuxTests/CloudHubConnectorHedgeTests.swift @@ -0,0 +1,99 @@ +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +/// The fresh-machine connect policy: attempts started before a new VM is +/// reachable are lost, so later attempts must win without waiting for the +/// earlier ones' retransmit backoff. +@Suite +struct CloudHubConnectorHedgeTests { + private final class Ledger: @unchecked Sendable { + private let lock = NSLock() + private var started = 0 + private var discarded: [Int] = [] + func start() -> Int { lock.withLock { started += 1; return started } } + func discard(_ value: Int) { lock.withLock { discarded.append(value) } } + var startedCount: Int { lock.withLock { started } } + var discardedValues: [Int] { lock.withLock { discarded } } + } + + @Test("A redial wins as soon as the machine becomes reachable, while early attempts are still stuck") + func laterAttemptWinsOverStuckEarlyAttempt() async throws { + let ledger = Ledger() + let reachableAt = ContinuousClock.now + .milliseconds(120) + let started = ContinuousClock.now + let value = try await CloudHubConnector.hedged( + candidates: 1, + fallbackDelay: .milliseconds(50), + redialInterval: .milliseconds(20), + maxRedials: 50, + timeout: .seconds(10), + clock: ContinuousClock(), + attempt: { _ in + let attempt = ledger.start() + // An attempt started before the machine is reachable loses its + // SYNs and would only succeed after a long backoff. + if ContinuousClock.now < reachableAt { + try await Task.sleep(for: .seconds(10)) + } + return attempt + }, + discard: { ledger.discard($0) } + ) + let elapsed = ContinuousClock.now - started + #expect(elapsed < .seconds(1), "The winner must not wait for the stuck first attempt") + #expect(value > 1) + #expect(ledger.startedCount > 1) + } + + @Test("Nothing reachable: fails at the deadline and stops redialing after the cap") + func unreachableFailsAtDeadlineWithBoundedAttempts() async { + let ledger = Ledger() + await #expect(throws: (any Error).self) { + _ = try await CloudHubConnector.hedged( + candidates: 2, + fallbackDelay: .milliseconds(5), + redialInterval: .milliseconds(10), + maxRedials: 3, + timeout: .milliseconds(200), + clock: ContinuousClock(), + attempt: { _ -> Int in + _ = ledger.start() + try await Task.sleep(for: .seconds(10)) + return 0 + }, + discard: { ledger.discard($0) } + ) + } + // One initial round plus three redials, for each of two addresses. + #expect(ledger.startedCount == 8) + } + + @Test("Every success other than the winner is discarded, so no stream leaks") + func extraSuccessesAreDiscarded() async throws { + let ledger = Ledger() + let value = try await CloudHubConnector.hedged( + candidates: 2, + fallbackDelay: .zero, + redialInterval: .milliseconds(5), + maxRedials: 5, + timeout: .seconds(5), + clock: ContinuousClock(), + attempt: { index in + let attempt = ledger.start() + // Both addresses answer; cancellation is ignored to model a + // handshake that completes while the race is being decided. + try? await Task.sleep(for: .milliseconds(30)) + return attempt * 10 + index + }, + discard: { ledger.discard($0) } + ) + #expect(!ledger.discardedValues.contains(value)) + #expect(ledger.discardedValues.count == ledger.startedCount - 1) + } +} diff --git a/cmuxTests/CloudSidebarConsistencyTests.swift b/cmuxTests/CloudSidebarConsistencyTests.swift index 07e4cfba7d3e..f6875fc56f8c 100644 --- a/cmuxTests/CloudSidebarConsistencyTests.swift +++ b/cmuxTests/CloudSidebarConsistencyTests.swift @@ -230,6 +230,26 @@ struct CloudSidebarConsistencyTests { #expect(workspace.panelCustomTitleSources[panelID] == .user) } + @Test("Supported Cloud providers resolve their bundled marks") + func supportedCloudProviderMarks() { + let expected = [ + "claude": "AgentIcons/Claude", "codex": "AgentIcons/Codex", + "opencode": "AgentIcons/OpenCode", "pi": "AgentIcons/Pi", + "amp": "AgentIcons/Amp", "cursor": "AgentIcons/Cursor", + "gemini": "AgentIcons/Gemini", "kiro": "AgentIcons/Kiro", + "copilot": "AgentIcons/Copilot", "codebuddy": "AgentIcons/CodeBuddy", + "factory": "AgentIcons/Factory", "qoder": "AgentIcons/Qoder", + "kimi": "AgentIcons/Kimi", "ollama": "AgentIcons/Ollama" + ] + for (provider, asset) in expected { + let badge = SurfaceAgentBadge(state: "working", source: "hook", agent: provider) + #expect(badge.agent == provider) + #expect(CmuxTaskManagerCodingAgentDefinition.builtIns.first(where: { $0.id == provider })?.assetName == asset) + } + #expect(TerminalTabAgentIconResolver().assetName(forStatusKey: "codex") == "AgentIcons/Codex") + #expect(TerminalTabAgentIconResolver().assetName(forStatusKey: "gemini") == "AgentIcons/Gemini") + } + @Test("A bound native tab receives canonical names, process titles, and ignores delayed graph callbacks", arguments: [false, true]) func nativeNameParity(named: Bool) throws { let manager = TabManager() diff --git a/cmuxTests/CmuxTuiSurfaceProviderRegistryDiscoveryTests.swift b/cmuxTests/CmuxTuiSurfaceProviderRegistryDiscoveryTests.swift index f8edfc4aa95e..ac21e2104407 100644 --- a/cmuxTests/CmuxTuiSurfaceProviderRegistryDiscoveryTests.swift +++ b/cmuxTests/CmuxTuiSurfaceProviderRegistryDiscoveryTests.swift @@ -23,7 +23,7 @@ struct CmuxTuiSurfaceProviderRegistryDiscoveryTests { ) registry.start(catalog: catalog) let scope = registry.creationScope - registry.recordCreatedMachine(summary, scope: scope) + await registry.recordCreatedMachine(summary, scope: scope) #expect(catalog.snapshot.machines.first?.name == "bright-teal-otter") #expect(registry.provider(machineID: summary.id) == nil) #expect(lists == 0) @@ -33,20 +33,70 @@ struct CmuxTuiSurfaceProviderRegistryDiscoveryTests { var renamed = discovered renamed.displayName = "My renamed machine" provider?.update(summary: renamed) - registry.recordCreatedMachine(summary, scope: scope) + await registry.recordCreatedMachine(summary, scope: scope) #expect(catalog.snapshot.machines.first?.name == "My renamed machine") #expect(registry.provider(machineID: summary.id) === provider) #expect(catalog.snapshot.machines.count == 1) await registry.accessDidEnd() registry.start(catalog: catalog) - registry.recordCreatedMachine(summary, scope: scope) + await registry.recordCreatedMachine(summary, scope: scope) #expect(catalog.snapshot.machines.isEmpty) - registry.recordCreatedMachine(summary, scope: registry.creationScope) + await registry.recordCreatedMachine(summary, scope: registry.creationScope) #expect(catalog.snapshot.machines.count == 1) await registry.accessDidEnd() #expect(catalog.snapshot.machines.isEmpty, "Account teardown also removes receipts that have no provider yet") } + @Test("New Machine: an addressed snapshot-v2 receipt registers the provider and serves one attach with no network") + func addressedTrustedReceiptSkipsDiscoveryAndAttach() async { + let catalog = SurfaceCatalog() + var created = machine("vm-fresh") + created.addressIPv4 = "10.16.0.9" + created.cmuxTuiContract = CmuxTuiSurfaceProviderRegistry.trustedCarrierContract + var lists = 0 + let home = FileManager.default.temporaryDirectory.appendingPathComponent("cmux-receipt-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: home) } + let paths = CloudTuiClientPaths(home: home) + let registry = CmuxTuiSurfaceProviderRegistry( + links: CloudMachineLinkManager(paths: paths, clientURL: nil, hub: nil, hostThemeColors: { nil }), + allowsBackgroundWork: { false }, + listPage: { lists += 1; return VMListPage(vms: [], limits: nil) } + ) + registry.start(catalog: catalog) + await registry.recordCreatedMachine(created, scope: registry.creationScope) + + #expect(registry.provider(machineID: created.id) != nil) + #expect(await registry.takeCreatedTrustedCarrierRoute(machineID: created.id) == "ws://10.16.0.9:1337/v1/link") + #expect(await registry.takeCreatedTrustedCarrierRoute(machineID: created.id) == nil, + "The receipt answers one attach; later opens use the saved device path") + #expect(lists == 0, "Neither registration nor the attach answer re-read the fleet") + #expect(paths.deviceFingerprint(for: created.id) == CloudTuiClientPaths.carrierDeviceMarker, + "The first link dials --carrier without a control-plane attach request") + await registry.accessDidEnd() + } + + @Test("A receipt without the snapshot-v2 contract registers but still asks the control plane to attach") + func addressedUntrustedReceiptKeepsAttach() async { + let catalog = SurfaceCatalog() + var created = machine("vm-legacy") + created.addressIPv4 = "10.16.0.10" + let home = FileManager.default.temporaryDirectory.appendingPathComponent("cmux-receipt-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: home) } + let paths = CloudTuiClientPaths(home: home) + let registry = CmuxTuiSurfaceProviderRegistry( + links: CloudMachineLinkManager(paths: paths, clientURL: nil, hub: nil, hostThemeColors: { nil }), + allowsBackgroundWork: { false }, + listPage: { VMListPage(vms: [], limits: nil) } + ) + registry.start(catalog: catalog) + await registry.recordCreatedMachine(created, scope: registry.creationScope) + + #expect(registry.provider(machineID: created.id) != nil) + #expect(await registry.takeCreatedTrustedCarrierRoute(machineID: created.id) == nil) + #expect(paths.deviceFingerprint(for: created.id) == nil, "An untrusted receipt keeps the attach request") + await registry.accessDidEnd() + } + @Test("A stale fleet page cannot prune a machine create receipt before discovery observes it") func stalePageRetainsCreatedMachineReceipt() async { let catalog = SurfaceCatalog() @@ -63,7 +113,7 @@ struct CmuxTuiSurfaceProviderRegistryDiscoveryTests { refreshProvider: { _, _ in true } ) registry.start(catalog: catalog) - registry.recordCreatedMachine(created, scope: registry.creationScope) + await registry.recordCreatedMachine(created, scope: registry.creationScope) #expect(await registry.refresh(force: true)) #expect(lists == 1) @@ -102,7 +152,7 @@ struct CmuxTuiSurfaceProviderRegistryDiscoveryTests { registry.start(catalog: catalog) let discovery = Task { await registry.providerRefreshingIfMissing(machineID: "vm-a") } #expect(await boundedResult(requested)) - registry.recordCreatedMachine(machine("vm-b"), scope: registry.creationScope) + await registry.recordCreatedMachine(machine("vm-b"), scope: registry.creationScope) release.resolve(true) #expect(await discovery.value != nil) #expect(lists == 1) @@ -121,8 +171,8 @@ struct CmuxTuiSurfaceProviderRegistryDiscoveryTests { ) registry.start(catalog: catalog) let oldScope = registry.creationScope - registry.recordCreatedMachine(machine("VM-First"), scope: oldScope) - registry.recordCreatedMachine(machine("VM-Second"), scope: oldScope) + await registry.recordCreatedMachine(machine("VM-First"), scope: oldScope) + await registry.recordCreatedMachine(machine("VM-Second"), scope: oldScope) registry.machineWasDeleted("vm-first") #expect(catalog.machines[.cloud("VM-First")] == nil) #expect(await registry.refresh(force: true)) @@ -130,13 +180,13 @@ struct CmuxTuiSurfaceProviderRegistryDiscoveryTests { notifications.post(name: .cmuxCloudVMAccessDidEnd, object: nil, userInfo: ["cmux.teamSwitch": true]) #expect(catalog.machines.isEmpty) - registry.recordCreatedMachine(machine("late-old-team"), scope: oldScope) + await registry.recordCreatedMachine(machine("late-old-team"), scope: oldScope) #expect(catalog.machines.isEmpty) #expect(registry.creationScope == nil) #expect(await registry.refresh(force: true) == false) await registry.accessDidEnd() registry.start(catalog: catalog) - registry.recordCreatedMachine(machine("new-team"), scope: registry.creationScope) + await registry.recordCreatedMachine(machine("new-team"), scope: registry.creationScope) #expect(await registry.refresh(force: true)) #expect(Set(catalog.machines.keys) == [.cloud("new-team")]) await registry.accessDidEnd() diff --git a/cmuxTests/CmuxTuiSurfaceProviderTests.swift b/cmuxTests/CmuxTuiSurfaceProviderTests.swift index fbe089598f1e..e9c01d6d6f46 100644 --- a/cmuxTests/CmuxTuiSurfaceProviderTests.swift +++ b/cmuxTests/CmuxTuiSurfaceProviderTests.swift @@ -134,6 +134,7 @@ import Testing #expect(build.detail == "/root/work/app") #expect(build.lifecycle == .running) #expect(build.agent == SurfaceAgentBadge(state: "working", source: "claude")) + #expect(build.terminalAgentIconAssetName == "AgentIcons/Claude") #expect(build.remoteWorkspace == SurfaceRemoteWorkspace(id: "ws_main", name: "main", index: 0, focused: true)) #expect(build.remoteViews?.map(\.tabID) == ["tab_1", "tab_4"]) #expect(build.remoteWorkspaces.map(\.id) == ["ws_main", "ws_api"]) @@ -156,6 +157,15 @@ import Testing #expect(detached.lifecycle == .running) } + @Test func providerAwareAgentFieldResolvesCodexMark() throws { + var snapshot = Self.sessionSnapshot + snapshot["agents"] = [["id": "agent_1", "terminal_id": "term_build", "state": "working", "source": "hook", "agent": "codex"]] + let resources = CmuxTuiSnapshotParser.terminals(fromSnapshot: snapshot, machine: Self.machine) + let terminal = try #require(resources.first { $0.id.key == "term_build" }) + #expect(terminal.agent?.agent == "codex") + #expect(terminal.terminalAgentIconAssetName == "AgentIcons/Codex") + } + @Test func userTabNameStaysOnTheIndividualRemoteView() throws { var snapshot = Self.sessionSnapshot snapshot["tabs"] = [ diff --git a/cmuxTests/SimulatorPanelIntegrationTests.swift b/cmuxTests/SimulatorPanelIntegrationTests.swift index 50f4c3970d92..7cca60aac3fb 100644 --- a/cmuxTests/SimulatorPanelIntegrationTests.swift +++ b/cmuxTests/SimulatorPanelIntegrationTests.swift @@ -175,10 +175,7 @@ struct SimulatorPanelIntegrationTests { let firstCoordinator = panel.coordinator flags.setOverride(false, for: simulatorFlag) - for _ in 0..<100 { - if await firstClient.stopCount != 0 { break } - await Task.yield() - } + try await waitUntil { await firstClient.stopCount != 0 } #expect(await firstClient.stopCount == 1) flags.setOverride(true, for: simulatorFlag) @@ -189,17 +186,14 @@ struct SimulatorPanelIntegrationTests { #expect(await secondClient.discoveryCount == 0) await firstClient.releaseStop() - for _ in 0..<100 { - if await secondClient.discoveryCount != 0 { break } - await Task.yield() - } + try await waitUntil { await secondClient.discoveryCount != 0 } #expect(panel.coordinator !== firstCoordinator) #expect(panel.isFeatureReady) #expect(await secondClient.discoveryCount == 1) } @Test("Awaitable close does not finish before worker rollback") - func awaitableCloseWaitsForWorkerRollback() async { + func awaitableCloseWaitsForWorkerRollback() async throws { let client = SimulatorFeatureFlagPaneClient(blockStop: true) let panel = SimulatorPanel(client: client) let completion = SimulatorCloseCompletionProbe() @@ -208,10 +202,7 @@ struct SimulatorPanelIntegrationTests { await completion.markCompleted() } - for _ in 0..<100 { - if await client.stopCount != 0 { break } - await Task.yield() - } + try await waitUntil { await client.stopCount != 0 } #expect(await client.stopCount == 1) #expect(!(await completion.isCompleted)) @@ -222,7 +213,7 @@ struct SimulatorPanelIntegrationTests { } @Test("Application termination retains cleanup after a closed panel deallocates") - func applicationTerminationRetainsOrphanedClose() async { + func applicationTerminationRetainsOrphanedClose() async throws { let client = SimulatorFeatureFlagPaneClient(blockStop: true) weak var releasedPanel: SimulatorPanel? do { @@ -230,10 +221,7 @@ struct SimulatorPanelIntegrationTests { releasedPanel = panel panel.close() } - for _ in 0..<100 { - if await client.stopCount != 0 { break } - await Task.yield() - } + try await waitUntil { await client.stopCount != 0 } #expect(await client.stopCount == 1) #expect(releasedPanel == nil) @@ -254,7 +242,7 @@ struct SimulatorPanelIntegrationTests { } @Test("Cancelling application termination restores the live Simulator panel") - func cancelledApplicationTerminationRestoresPanel() async { + func cancelledApplicationTerminationRestoresPanel() async throws { let flags = CmuxFeatureFlags.shared let simulatorFlag = CmuxFeatureFlags.simulatorFlag let previousOverride = flags.overrideValue(for: simulatorFlag) @@ -267,17 +255,11 @@ struct SimulatorPanelIntegrationTests { let panel = SimulatorPanel(clientFactory: { clients.removeFirst() }) defer { panel.close() } panel.setVisibleInUI(true) - for _ in 0..<100 { - if await firstClient.discoveryCount != 0 { break } - await Task.yield() - } + try await waitUntil { await firstClient.discoveryCount != 0 } let firstCoordinator = panel.coordinator let cleanupTasks = SimulatorPanel.beginApplicationTerminationCleanup() - for _ in 0..<100 { - if await firstClient.stopCount != 0 { break } - await Task.yield() - } + try await waitUntil { await firstClient.stopCount != 0 } #expect(await firstClient.stopCount == 1) SimulatorPanel.cancelApplicationTerminationCleanup() @@ -285,14 +267,11 @@ struct SimulatorPanelIntegrationTests { for task in cleanupTasks { await task.value } - for _ in 0..<100 { + try await waitUntil { let replacementStarted = await secondClient.discoveryCount == 1 - if panel.isFeatureReady, - panel.coordinator !== firstCoordinator, - replacementStarted { - break - } - await Task.yield() + return panel.isFeatureReady + && panel.coordinator !== firstCoordinator + && replacementStarted } #expect(panel.isFeatureReady) @@ -816,6 +795,25 @@ struct SimulatorPanelIntegrationTests { .foregroundApplication(nil) ) == .object(["application": .null])) } + + /// Polls `condition` until it holds, then requires it at the deadline. + /// + /// A fixed yield count is an implicit bound that tightens under CI load, so + /// it fails on correct code on a busy runner. Requiring the predicate here + /// rather than at each call site means a wait that runs out reports itself + /// instead of falling through into a weaker downstream assertion. + private func waitUntil( + timeout: Duration = .seconds(10), + sourceLocation: SourceLocation = #_sourceLocation, + _ condition: () async -> Bool + ) async throws { + let deadline = ContinuousClock.now + timeout + while ContinuousClock.now < deadline { + if await condition() { return } + try await Task.sleep(for: .milliseconds(5)) + } + try #require(await condition(), sourceLocation: sourceLocation) + } } private actor SimulatorFeatureFlagPaneClient: SimulatorPaneClient { diff --git a/cmuxTests/SimulatorPanelThemeTests.swift b/cmuxTests/SimulatorPanelThemeTests.swift index cce63831cd45..2b68a8e07d37 100644 --- a/cmuxTests/SimulatorPanelThemeTests.swift +++ b/cmuxTests/SimulatorPanelThemeTests.swift @@ -15,7 +15,7 @@ import Testing @Suite("Simulator panel visibility", .serialized) struct SimulatorPanelVisibilityTests { @Test("Mobile demand starts a hidden Simulator panel") - func mobileDemandStartsHiddenPanel() async { + func mobileDemandStartsHiddenPanel() async throws { let client = SimulatorThemePaneClient(devices: []) let panel = SimulatorPanel(client: client) let consumerID = UUID() @@ -26,10 +26,7 @@ struct SimulatorPanelVisibilityTests { panel.setMobileFrameDemand(true, consumerID: consumerID) - for _ in 0..<100 { - if await client.discoveryCount > 0 { break } - await Task.yield() - } + try await waitUntil { await client.discoveryCount > 0 } #expect(await client.discoveryCount == 1) } @@ -83,10 +80,7 @@ struct SimulatorPanelVisibilityTests { defer { window.orderOut(nil) } settle(root) - for _ in 0..<100 { - if await client.discoveryCount > 0 { break } - await Task.yield() - } + try await waitUntil { await client.discoveryCount > 0 } #expect(await client.discoveryCount == 1) await client.emit(.status(.streaming)) await client.emit(.frameTransport(SimulatorFrameTransportDescriptor( @@ -97,10 +91,7 @@ struct SimulatorPanelVisibilityTests { slotCount: 2, sharedMemoryByteCount: 256 ))) - for _ in 0..<100 { - if panel.coordinator.frameTransport != nil { break } - await Task.yield() - } + try await waitUntil { panel.coordinator.frameTransport != nil } #expect(panel.coordinator.frameTransport != nil) firstHost?.removeFromSuperview() @@ -150,6 +141,25 @@ struct SimulatorPanelVisibilityTests { RunLoop.main.run(until: Date().addingTimeInterval(0.01)) } } + + /// Polls `condition` until it holds, then requires it at the deadline. + /// + /// A fixed yield count is an implicit bound that tightens under CI load, so + /// it fails on correct code on a busy runner. Requiring the predicate here + /// rather than at each call site means a wait that runs out reports itself + /// instead of falling through into a weaker downstream assertion. + private func waitUntil( + timeout: Duration = .seconds(10), + sourceLocation: SourceLocation = #_sourceLocation, + _ condition: () async -> Bool + ) async throws { + let deadline = ContinuousClock.now + timeout + while ContinuousClock.now < deadline { + if await condition() { return } + try await Task.sleep(for: .milliseconds(5)) + } + try #require(await condition(), sourceLocation: sourceLocation) + } } @MainActor diff --git a/cmuxTests/SurfaceCatalogQueryServiceTests.swift b/cmuxTests/SurfaceCatalogQueryServiceTests.swift index 9f34e72c96f7..13bfcc69af83 100644 --- a/cmuxTests/SurfaceCatalogQueryServiceTests.swift +++ b/cmuxTests/SurfaceCatalogQueryServiceTests.swift @@ -40,6 +40,45 @@ struct SurfaceCatalogQueryServiceTests { } } + @Test("New Machine open: a linked read discovers and connects a just-created machine without forcing a pass") + func linkedReadDiscoversAndJoinsCurrentPass() async throws { + let catalog = SurfaceCatalog() + let machine = SurfaceMachineID.cloud("vm-new") + let provider = try CloudCatalogQueryTestProvider(machine: machine, catalog: catalog) + var discoveries: [String] = [] + let query = SurfaceCatalogQueryService(catalog: catalog) { id in + discoveries.append(id) + catalog.register(provider) + } + + // The regression: `cmux vm open` for a fresh VM read the cached catalog, + // found no machine row, and failed with "sessions are unavailable". + let result = await query.read(machine: machine, mode: .linked) + + #expect(discoveries == ["vm-new"]) + #expect(provider.forcedRefreshes == [false]) + let payload = TerminalController.surfaceCatalogPayload(result, machine: machine) + #expect(VMRemoteWorkspaceResolver().resolveVMMachineTerminal(machine: machine.rawValue, catalog: payload) + == .resolved(workspaceID: "ws-1", terminalID: "term-seeded", tabID: "tab-1")) + } + + @Test("Reopen: a linked read of an already-connected machine does no provider work") + func linkedReadOfConnectedMachineIsFree() async throws { + let catalog = SurfaceCatalog() + let machine = SurfaceMachineID.cloud("vm-live") + let provider = try CloudCatalogQueryTestProvider(machine: machine, catalog: catalog) + catalog.register(provider) + await provider.refresh(force: false) + var discoveries: [String] = [] + let query = SurfaceCatalogQueryService(catalog: catalog) { discoveries.append($0) } + + let result = await query.read(machine: machine, mode: .linked) + + #expect(discoveries.isEmpty) + #expect(provider.forcedRefreshes == [false]) + #expect(result.catalog.resources.map(\.id.key) == ["term-seeded"]) + } + @Test("A cached catalog read does not discover or wake an unknown machine") func cachedReadRemainsReadOnly() async { let catalog = SurfaceCatalog() diff --git a/cmuxTests/TabManagerSessionSnapshotTests.swift b/cmuxTests/TabManagerSessionSnapshotTests.swift index b0fd628e2fc0..d7a377aa310e 100644 --- a/cmuxTests/TabManagerSessionSnapshotTests.swift +++ b/cmuxTests/TabManagerSessionSnapshotTests.swift @@ -101,24 +101,6 @@ final class TabManagerSessionSnapshotTests: XCTestCase { XCTAssertEqual(restored.selectedTabId, secondWorkspace.id) } - func testFocusHistoryNavigatesWithinWorkspacePanels() throws { - let manager = TabManager() - let workspace = try XCTUnwrap(manager.selectedWorkspace) - let pane = try XCTUnwrap(workspace.bonsplitController.allPaneIds.first) - let firstPanelId = try XCTUnwrap(workspace.focusedPanelId) - let secondPanelId = try XCTUnwrap(workspace.newTerminalSurface(inPane: pane, focus: true)?.id) - - workspace.focusPanel(firstPanelId) - workspace.focusPanel(secondPanelId) - - XCTAssertTrue(manager.canNavigateBack) - - manager.navigateBack() - - XCTAssertEqual(workspace.focusedPanelId, firstPanelId) - XCTAssertTrue(manager.canNavigateForward) - } - func testFocusHistoryBackFallsBackWhenRecordedPanelWasClosed() throws { let manager = TabManager() let firstWorkspace = try XCTUnwrap(manager.selectedWorkspace) @@ -163,100 +145,6 @@ final class TabManagerSessionSnapshotTests: XCTestCase { XCTAssertEqual(manager.selectedTabId, secondWorkspace.id) } - func testFocusHistoryBackSkipsStaleEntriesThatResolveToCurrentPanel() throws { - let manager = TabManager() - let workspace = try XCTUnwrap(manager.selectedWorkspace) - let pane = try XCTUnwrap(workspace.bonsplitController.allPaneIds.first) - let closedPanelId = try XCTUnwrap(workspace.focusedPanelId) - let fallbackPanelId = try XCTUnwrap(workspace.newTerminalSurface(inPane: pane, focus: true)?.id) - - workspace.focusPanel(closedPanelId) - _ = workspace.closePanel(closedPanelId, force: true) - drainMainQueue() - - XCTAssertEqual(workspace.focusedPanelId, fallbackPanelId) - XCTAssertFalse(manager.canNavigateBack) - - var notificationCount = 0 - let observer = NotificationCenter.default.addObserver( - forName: .tabManagerFocusHistoryRevisionDidChange, - object: manager, - queue: nil - ) { _ in - notificationCount += 1 - } - defer { - NotificationCenter.default.removeObserver(observer) - } - - manager.navigateBack() - - XCTAssertEqual(workspace.focusedPanelId, fallbackPanelId) - XCTAssertEqual(notificationCount, 0) - } - - func testFocusHistoryRevisionInvalidatesWhenClosedPanelChangesAvailability() throws { - let manager = TabManager() - let workspace = try XCTUnwrap(manager.selectedWorkspace) - let pane = try XCTUnwrap(workspace.bonsplitController.allPaneIds.first) - let closedPanelId = try XCTUnwrap(workspace.focusedPanelId) - let fallbackPanelId = try XCTUnwrap(workspace.newTerminalSurface(inPane: pane, focus: true)?.id) - - workspace.focusPanel(closedPanelId) - workspace.focusPanel(fallbackPanelId) - XCTAssertTrue(manager.canNavigateBack) - - var notificationCount = 0 - let observer = NotificationCenter.default.addObserver( - forName: .tabManagerFocusHistoryRevisionDidChange, - object: manager, - queue: nil - ) { _ in - notificationCount += 1 - } - defer { - NotificationCenter.default.removeObserver(observer) - } - let revision = manager.focusHistoryRevision - - _ = workspace.closePanel(closedPanelId, force: true) - - XCTAssertGreaterThan(manager.focusHistoryRevision, revision) - XCTAssertGreaterThan(notificationCount, 0) - XCTAssertFalse(manager.canNavigateBack) - } - - func testFocusHistoryRevisionInvalidatesWhenClosedPaneChangesAvailability() throws { - let manager = TabManager() - let workspace = try XCTUnwrap(manager.selectedWorkspace) - let leftPanelId = try XCTUnwrap(workspace.focusedPanelId) - let leftPaneId = try XCTUnwrap(workspace.paneId(forPanelId: leftPanelId)) - let rightPanel = try XCTUnwrap(workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal)) - - workspace.focusPanel(leftPanelId) - workspace.focusPanel(rightPanel.id) - XCTAssertTrue(manager.canNavigateBack) - - var notificationCount = 0 - let observer = NotificationCenter.default.addObserver( - forName: .tabManagerFocusHistoryRevisionDidChange, - object: manager, - queue: nil - ) { _ in - notificationCount += 1 - } - defer { - NotificationCenter.default.removeObserver(observer) - } - let revision = manager.focusHistoryRevision - - XCTAssertTrue(workspace.bonsplitController.closePane(leftPaneId)) - - XCTAssertGreaterThan(manager.focusHistoryRevision, revision) - XCTAssertGreaterThan(notificationCount, 0) - XCTAssertFalse(manager.canNavigateBack) - } - func testFocusHistoryRevisionInvalidatesWhenClosedWorkspaceChangesAvailability() throws { let manager = TabManager() let firstWorkspace = try XCTUnwrap(manager.selectedWorkspace) @@ -308,31 +196,6 @@ final class TabManagerSessionSnapshotTests: XCTestCase { XCTAssertEqual(manager.selectedTabId, secondWorkspace.id) } - func testGhosttyFocusSurfaceIdRecordsMappedPanelInFocusHistory() throws { - let manager = TabManager() - let workspace = try XCTUnwrap(manager.selectedWorkspace) - let pane = try XCTUnwrap(workspace.bonsplitController.allPaneIds.first) - let secondPanelId = try XCTUnwrap(workspace.newTerminalSurface(inPane: pane, focus: true)?.id) - let secondSurfaceId = try XCTUnwrap(workspace.surfaceIdFromPanelId(secondPanelId)) - XCTAssertNotEqual(secondSurfaceId.uuid, secondPanelId) - - let firstPanelId = try XCTUnwrap(workspace.panels.keys.first { $0 != secondPanelId }) - workspace.focusPanel(firstPanelId) - let revision = manager.focusHistoryRevision - - NotificationCenter.default.post( - name: .ghosttyDidFocusSurface, - object: nil, - userInfo: [ - GhosttyNotificationKey.tabId: workspace.id, - GhosttyNotificationKey.surfaceId: secondSurfaceId.uuid, - ] - ) - drainMainQueue() - - XCTAssertGreaterThan(manager.focusHistoryRevision, revision) - } - func testFocusHistoryNavigatesBetweenFreshWorkspaces() throws { let manager = TabManager() let firstWorkspace = try XCTUnwrap(manager.selectedWorkspace) @@ -478,23 +341,6 @@ final class TabManagerSessionSnapshotTests: XCTestCase { XCTAssertEqual(forwardSnapshot.items.map(\.workspaceTitle), ["Second", "Third"]) } - func testFocusHistoryMenuSnapshotReflectsRenamedWorkspaceAndPanel() throws { - let manager = TabManager() - let firstWorkspace = try XCTUnwrap(manager.selectedWorkspace) - let panelId = try XCTUnwrap(firstWorkspace.focusedPanelId) - firstWorkspace.setCustomTitle("Renamed Workspace") - firstWorkspace.setPanelCustomTitle(panelId: panelId, title: "Renamed Pane") - - _ = manager.addWorkspace(select: true) - - let snapshot = manager.focusHistoryMenuSnapshot(direction: .back) - let item = try XCTUnwrap(snapshot.items.first) - - XCTAssertEqual(item.workspaceTitle, "Renamed Workspace") - XCTAssertEqual(item.panelTitle, "Renamed Pane") - XCTAssertEqual(FocusHistoryMenuFormatter.title(for: item), "Renamed Workspace - Renamed Pane") - } - func testRecentlyFocusedMenuSnapshotCombinesDirectionsByFocusedTime() throws { let workspaceId = UUID() let older = FocusHistoryMenuItem( diff --git a/cmuxTests/WindowKeyDownReplayGuardTests.swift b/cmuxTests/WindowKeyDownReplayGuardTests.swift index eff9b0783025..cc33ab2ab65c 100644 --- a/cmuxTests/WindowKeyDownReplayGuardTests.swift +++ b/cmuxTests/WindowKeyDownReplayGuardTests.swift @@ -213,7 +213,10 @@ struct WindowKeyDownReplayGuardTests { return previousMenu } - private func installResponderChainUndoMenu() -> NSMenu? { + /// Installs a Cmd+Z menu item. A nil target exercises AppKit responder-chain + /// resolution; tests whose contract is cmux routing can pass an explicit + /// editable responder so headless app activation does not decide the result. + private func installResponderChainUndoMenu(target: AnyObject? = nil) -> NSMenu? { let previousMenu = NSApp.mainMenu let menu = NSMenu(title: "Main") let undoItem = NSMenuItem( @@ -221,6 +224,7 @@ struct WindowKeyDownReplayGuardTests { action: #selector(EditableUndoProbeTextView.undo(_:)), keyEquivalent: "z" ) + undoItem.target = target undoItem.keyEquivalentModifierMask = [.command] menu.addItem(undoItem) NSApp.mainMenu = menu @@ -425,10 +429,12 @@ struct WindowKeyDownReplayGuardTests { _ = NSApplication.shared AppDelegate.installWindowResponderSwizzlesForTesting() - let previousMenu = installResponderChainUndoMenu() + let (window, terminal, textView) = makeWindowWithTerminalHostedEditableResponder() + // This assertion is about cmux routing ownership. AppKit's nil-target + // lookup depends on NSApp.keyWindow, which headless test hosts may lack. + let previousMenu = installResponderChainUndoMenu(target: textView) defer { NSApp.mainMenu = previousMenu } - let (window, terminal, textView) = makeWindowWithTerminalHostedEditableResponder() defer { window.orderOut(nil) window.close() diff --git a/docs/ci-runners.md b/docs/ci-runners.md index 7c9f3f7423c0..491498ee565b 100644 --- a/docs/ci-runners.md +++ b/docs/ci-runners.md @@ -22,18 +22,34 @@ gh variable list --repo manaflow-ai/cmux | --- | --- | --- | --- | | `LINUX_RUNNER` | every Linux job (`ci.yml` web/typecheck/db, presence, cloud-vm, nightly/ios decide jobs, claude, homebrew, tmux fuzz) | `blacksmith-4vcpu-ubuntu-2404` | `blacksmith-4vcpu-ubuntu-2404` | | `LINUX_ARM64_RUNNER` | native ARM64 package entrypoint verification | `ubuntu-24.04-arm` | `ubuntu-24.04-arm` | -| `MACOS_RUNNER_15` | the macOS 15 default: `macos-compile-admission`, `app-host-unit-tests`, nightly helper and test-cache jobs | `blacksmith-6vcpu-macos-15` | `blacksmith-6vcpu-macos-15` | +| `MACOS_RUNNER_15` | the macOS 15 default: `macos-compile-admission`, `app-host-unit-tests`, nightly helper and test-cache jobs, `iroh-release-gate.yml` streamed validation | `blacksmith-6vcpu-macos-15` | `blacksmith-6vcpu-macos-15` | | `MACOS_RUNNER_PR` | **pull-request** macOS jobs only, in `ci-macos.yml`, `cli-pipe-regressions.yml`, `terminal-hang-diagnostics.yml`, `ci.yml` (`claude-wrapper`) and `nightly.yml` (`refresh-test-compilation-cache`) | unset (see "Lanes" below) | `blacksmith-6vcpu-macos-15` | | `MACOS_RUNNER_TESTS` | the manual test-debugging lanes: `test-e2e.yml` and `test-depot.yml` | unset (see "Lanes" below) | `blacksmith-6vcpu-macos-26` for `test-e2e.yml`, `blacksmith-6vcpu-macos-15` for `test-depot.yml` | | `MACOS_RUNNER_DUAL_XCODE` | `swift-package-tests` (SDK 15 release helper, then SDK 26 package tests) on **every** event, pull requests included | `blacksmith-6vcpu-macos-15` | `blacksmith-6vcpu-macos-15` | -| `MACOS_RUNNER_26` | macOS 26 compatibility jobs and nightly sign/notarize | `blacksmith-6vcpu-macos-26` | `blacksmith-6vcpu-macos-26` | -| `MACOS_RUNNER_26_NIGHTLY_BUILD` | changed-revision universal Nightly app builds | `blacksmith-12vcpu-macos-26` | `blacksmith-6vcpu-macos-26` | -| `MACOS_RUNNER_26_RELEASE` | disk-heavy `release-build` universal app | `blacksmith-6vcpu-macos-26` | `blacksmith-6vcpu-macos-26` | +| `MACOS_RUNNER_26` | the macOS 26 image: compatibility jobs, `release.yml` and nightly sign/notarize, the disk-heavy `release-build` universal app, and the nightly compilation-cache warmer | `blacksmith-6vcpu-macos-26` | `blacksmith-6vcpu-macos-26` | +| `MACOS_RUNNER_26_LARGE` | the larger macOS 26 machine: changed-revision universal Nightly app builds | `blacksmith-12vcpu-macos-26` | `blacksmith-12vcpu-macos-26` | | `MACOS_RUNNER_DISPLAY` | macOS GUI, XCUITest, and virtual-display tests (`tests-build-and-lag`) | `blacksmith-6vcpu-macos-15` | `blacksmith-6vcpu-macos-15` | -| `MACOS_RUNNER_IOS` | iOS simulator tests + TestFlight upload (`test-ios.yml`, `ios-testflight.yml`) | `blacksmith-6vcpu-macos-26` | `blacksmith-6vcpu-macos-26` | -| `MACOS_RUNNER_STREAMED_VALIDATION` | `ios-streamed-validate.yml`, `iroh-release-gate.yml` streamed validation | `blacksmith-6vcpu-macos-15` | `blacksmith-6vcpu-macos-26` and `blacksmith-6vcpu-macos-15` respectively | +| `MACOS_RUNNER_IOS` | the iOS image: simulator tests, TestFlight upload, and `ios-streamed-validate.yml` (`test-ios.yml`, `ios-testflight.yml`) | `blacksmith-6vcpu-macos-26` | `blacksmith-6vcpu-macos-26` | +| `CI_PAID_MACOS_OVERFLOW` | the repository-side switch for metered capacity; gates the four paid-overflow variables above (see "Break-glass" below) | unset (free capacity) | unset means the Blacksmith fallback wins | | `MACOS_RUNNER_BACKGROUND` | non-urgent macOS work only: `build-ghosttykit`, the macOS legs of `cmux-tui-artifacts` (post-merge) and `cmux-tui-nightly` (on demand). See "Background lane" below | unset | `macos-15` (GitHub-hosted, free) | +A runner variable names a **machine capability** — an OS version, a GUI, a +simulator, both SDKs, or a larger instance — and every job needing that +capability reads the same one. It does not name the lane asking, which the +workflow already knows. `MACOS_RUNNER_PR` and `MACOS_RUNNER_TESTS` are the +deliberate lane exceptions documented below. + +Two capability variables may hold the same label today and still mean different +things. `MACOS_RUNNER_26` and `MACOS_RUNNER_26_LARGE` both name macOS 26, +while the latter requires the larger instance. + +The paid-overflow gate and the runner-value policy answer different questions. +`CI_PAID_MACOS_OVERFLOW` covers only variables whose purpose is paid overflow. +Other variables, including `MACOS_RUNNER_26`, are checked by +`scripts/ci/runner_label_policy.py` through the CI health report. Gating a free +pool would make repointing it at owned hardware require enabling a flag whose +meaning is permission to spend. + The pull-request lane also has a toolchain variable, set together with `MACOS_RUNNER_PR`: @@ -55,9 +71,10 @@ the same cost profile or the same urgency. `MACOS_RUNNER_*` variables above. This is the lane where a slow or queued runner blocks a merge or a ship, so it is the lane worth paying for if paid capacity is ever warranted. -- **Pull requests** resolve through `MACOS_RUNNER_PR` first. Unset means - Blacksmith. PR runs are cancelled on supersession by design, so they are the - wrong place to spend elastic paid capacity. +- **Pull requests on `manaflow-ai/cmux`** resolve through `MACOS_RUNNER_PR` + first. Unset means the Blacksmith fallback. PR runs are cancelled on + supersession by design, so they are the wrong place to spend elastic paid + capacity. A fork uses the GitHub-hosted branch described below instead. - **Manual test debugging** (`test-e2e.yml`, `test-depot.yml`) resolves through `MACOS_RUNNER_TESTS`, and deliberately does **not** follow `MACOS_RUNNER_15`. Re-running one test to chase a flake should never reach for paid capacity. @@ -123,18 +140,25 @@ request both resolve through `MACOS_RUNNER_PR`, so a job reading only not on. `check_macos_runner_identity_env_tracks_routing` in `tests/test_ci_self_hosted_guard.sh` enforces that. -Workflows reference them as `runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}`. -If a variable is unset the job uses the fallback, so CI is never broken by a -missing variable. Pull requests from forks never see repository variables, so -the fallback is where they always run: it must be a Blacksmith label, never the -paid Warp overflow. `tests/test_ci_self_hosted_guard.sh` enforces that, and -also asserts that no workflow names a Warp label as a literal anywhere. - -Because forks cannot see repository variables, a fork pull request resolves -`MACOS_RUNNER_PR` and `MACOS_RUNNER_TESTS` to empty and lands on the Blacksmith -fallback, never on paid capacity. `test-e2e.yml` is `workflow_dispatch`-only, -so a fork never reaches its fallback at all; for the lanes a fork does reach, -the fallback is still the runner they used before these variables existed. +Every workflow exercised by a `pull_request` — including local reusable +workflows reached through `workflow_call` — has an explicit repository-owner +branch before runner variables are consulted. On `manaflow-ai/cmux`, existing +repository variables and their Blacksmith fallbacks behave exactly as above. On +every other owner, Linux jobs use `ubuntu-24.04` and macOS jobs use +`macos-15` from GitHub Actions. + +That is the fork contract: **a fork needs zero runner variables and zero runner +provider setup to run its pull-request workflows.** Blacksmith is an +organization-level GitHub App; naming a `blacksmith-*` label in a personal +fork does not produce a useful error, it leaves the job queued indefinitely. +The fork branch therefore short-circuits before any `MACOS_RUNNER_*` or +`LINUX_RUNNER` value can select organization-only capacity. + +`tests/test_ci_fork_runner_routing.py` discovers every `pull_request` +workflow, recursively follows its local reusable-workflow calls, and requires +every variable-routed `runs-on` in that closure to contain a hosted fork +branch. The upstream branch still keeps literal Blacksmith fallbacks so deleting +a repository variable cannot silently change `manaflow-ai/cmux` capacity. ## Background lane @@ -305,14 +329,44 @@ There is no automatic overflow. If the Tart pool is unavailable or its queue is too long, set the affected variable to a paid provider. Restore Tart after the fleet recovers. +Four runner variables exist to name **metered WarpBuild capacity**, so they are +read through a second switch that lives in this repository rather than in +repository settings: + +| | Effect | +| --- | --- | +| `CI_PAID_MACOS_OVERFLOW` unset or not `1` | `MACOS_RUNNER_15`, `MACOS_RUNNER_DISPLAY`, `MACOS_RUNNER_DUAL_XCODE` and `MACOS_RUNNER_26_LARGE` are **not read**; every lane takes its free Blacksmith fallback | +| `CI_PAID_MACOS_OVERFLOW` = `1` | those four variables select the pool | + +Turning paid capacity **on** therefore needs two admin actions: a runner variable +pointing at Warp *and* `CI_PAID_MACOS_OVERFLOW=1`. Turning it **off** needs +either — including a pull request anyone with push access can merge. Between +2026-09-19 and 2026-09-23 these four variables, plus the former release-specific +runner variable now folded into `MACOS_RUNNER_26`, pointed at Warp, so main and +the merge queue ran metered while pull requests ran free. + +`MACOS_RUNNER_26` stays ungated because it names the ordinary free macOS 26 +pool used by several jobs. Its safety check is the value policy described above. +Moving `release-build` onto a paid pool therefore takes one admin action: +repointing `MACOS_RUNNER_26`. + +`tests/test_ci_repo_variable_defaults.py` fails if a workflow reads one of the +four paid-overflow variables without the gate, and +`scripts/ci/ci_health_report.py` reports how many metered runner minutes each +window actually contained. + +```bash +gh variable set CI_PAID_MACOS_OVERFLOW --repo manaflow-ai/cmux -b 1 # enable paid overflow +gh variable delete CI_PAID_MACOS_OVERFLOW --repo manaflow-ai/cmux # back to free capacity +``` + ```bash gh variable set LINUX_RUNNER --repo manaflow-ai/cmux -b blacksmith-4vcpu-ubuntu-2404 gh variable set LINUX_ARM64_RUNNER --repo manaflow-ai/cmux -b ubuntu-24.04-arm gh variable set MACOS_RUNNER_15 --repo manaflow-ai/cmux -b blacksmith-6vcpu-macos-15 gh variable set MACOS_RUNNER_DUAL_XCODE --repo manaflow-ai/cmux -b blacksmith-6vcpu-macos-15 gh variable set MACOS_RUNNER_26 --repo manaflow-ai/cmux -b blacksmith-6vcpu-macos-26 -gh variable set MACOS_RUNNER_26_NIGHTLY_BUILD --repo manaflow-ai/cmux -b blacksmith-12vcpu-macos-26 -gh variable set MACOS_RUNNER_26_RELEASE --repo manaflow-ai/cmux -b blacksmith-6vcpu-macos-26 +gh variable set MACOS_RUNNER_26_LARGE --repo manaflow-ai/cmux -b blacksmith-12vcpu-macos-26 gh variable set MACOS_RUNNER_DISPLAY --repo manaflow-ai/cmux -b blacksmith-6vcpu-macos-15 gh variable set MACOS_RUNNER_IOS --repo manaflow-ai/cmux -b blacksmith-6vcpu-macos-26 ``` @@ -321,14 +375,19 @@ Leave `MACOS_RUNNER_PR` and `MACOS_RUNNER_TESTS` unset in either recipe. They exist to hold the pull-request and manual test lanes on Blacksmith independently of whatever the pool above is set to. -Restore the self-hosted pool with explicit labels: +Restore the self-hosted pool with explicit labels. The gate above applies +here too: `MACOS_RUNNER_15`, `MACOS_RUNNER_DISPLAY` and the other gated +variables are read only when `CI_PAID_MACOS_OVERFLOW=1`, so Tart needs that +flag set even though Tart is free. Without it, these values are ignored and +every lane stays on its Blacksmith fallback, with no error. `MACOS_RUNNER_26` +is ungated, so repointing the ordinary macOS 26 pool does not require the paid +overflow switch. ```bash gh variable set MACOS_RUNNER_15 --repo manaflow-ai/cmux -b tart-macos-15 gh variable set MACOS_RUNNER_DUAL_XCODE --repo manaflow-ai/cmux -b blacksmith-6vcpu-macos-15 gh variable set MACOS_RUNNER_26 --repo manaflow-ai/cmux -b blacksmith-6vcpu-macos-26 -gh variable set MACOS_RUNNER_26_NIGHTLY_BUILD --repo manaflow-ai/cmux -b blacksmith-12vcpu-macos-26 -gh variable set MACOS_RUNNER_26_RELEASE --repo manaflow-ai/cmux -b blacksmith-6vcpu-macos-26 +gh variable set MACOS_RUNNER_26_LARGE --repo manaflow-ai/cmux -b blacksmith-12vcpu-macos-26 gh variable set MACOS_RUNNER_DISPLAY --repo manaflow-ai/cmux -b tart-gui gh variable set MACOS_RUNNER_IOS --repo manaflow-ai/cmux -b tart-ios ``` @@ -364,8 +423,11 @@ in a workflow with no pull request, merge-queue or `workflow_call` trigger, apart from the pinned macOS 14 / Intel compatibility legs in `ci-macos-compat.yml` and `relay-publish-npm.yml`. It also asserts every paid macOS job references `vars.MACOS_RUNNER_*` or a Blacksmith/Warp/Depot label so it can never silently -fall back to a free runner. Bare paid-provider labels (`blacksmith-*`, `warp-*`, -`depot-*`) stay allowed for deliberate single-runner pins. Keep new labels in +fall back to a free runner. Bare third-party provider labels (`blacksmith-*`, `warp-*`, +`depot-*`) stay allowed for deliberate single-runner pins. "Paid" there means +"not a GitHub-hosted free runner"; of the three, only Warp and Depot bill this +repository per minute, since Blacksmith is sponsored for this organization. +The CI health report counts those two. Keep new labels in `.github/actionlint.yaml`. The fleet-label guard allows Tart labels only as exact manual canary choices. diff --git a/docs/ci/health-report.md b/docs/ci/health-report.md index b7448c8a7f68..6c40bd855d5b 100644 --- a/docs/ci/health-report.md +++ b/docs/ci/health-report.md @@ -73,6 +73,14 @@ them wait. When p90 climbs while minutes-per-job stay flat, no build regressed - **Fork pull requests.** Fork PRs cannot read the repository's Actions cache, so their minutes are cache misses somebody pays for twice. The line reports sampled fork jobs and their minutes. +- **Paid runner capacity.** Minutes on metered third-party labels (`warp-*`, + `depot-*`), split by label. Blacksmith is sponsored for this organization and + GitHub-hosted runners are free on a public repo, so neither appears here. + The runner label is the only place the difference shows: a lane that drifts + onto metered capacity reads as an ordinary row everywhere else in the report. + Check any entry against the intended steady state in `docs/ci-runners.md` — + minutes that are not a deliberate, temporary overflow mean a + `MACOS_RUNNER_*` variable has drifted. ### Comparison against the previous window diff --git a/docs/cloud-cmux-tui-daemon.md b/docs/cloud-cmux-tui-daemon.md index 37fb62b86878..4fd8eb212d6b 100644 --- a/docs/cloud-cmux-tui-daemon.md +++ b/docs/cloud-cmux-tui-daemon.md @@ -92,8 +92,8 @@ never on a connection-scoped lease. ## Freestyle delivery and state ownership Freestyle is the only active provider. One pinned -`cmux-tui-x86_64-unknown-linux-musl` artifact is installed by the Freestyle -driver at create or restore time, then started by the snapshot's systemd unit. +`cmux-tui-x86_64-unknown-linux-musl` artifact is baked into the active +snapshot, then started by the snapshot's systemd unit. The active snapshot and its provenance are recorded in `web/services/vms/images/manifest.json`. There is no provider-specific daemon protocol or alternate image selector. diff --git a/scripts/check-test-determinism.py b/scripts/check-test-determinism.py index 0e96abde8fd6..401a66472cb6 100755 --- a/scripts/check-test-determinism.py +++ b/scripts/check-test-determinism.py @@ -31,6 +31,12 @@ lines) by an assertion, where the sleep is NOT a loop body (i.e. not a poll). This is the "sleep as synchronization" ban. Deadline-bounded polls and scenario-pacing sleeps with no trailing assert are allowed. +- yield-count-poll (Swift): `for _ in 0.. bo return False +# `for _ in 0..<100 {` / `for _ in 1...256 {`: a loop bounded only by a literal +# iteration count. A named loop variable means per-iteration work, not a poll. +_YIELD_COUNT_LOOP_HEADER = re.compile( + r"^\s*for\s+_\s+in\s+\(?\s*\d[\d_]*\s*(?:\.\.<|\.\.\.)\s*\d[\d_]*\s*\)?\s*\{" +) +_TASK_YIELD = re.compile(r"\bawait\s+Task\.yield\(\s*\)") +_POLL_EXIT = re.compile(r"\b(?:break|return)\b") +# Any clock reading in the body means the loop is already deadline-bounded and +# the count only caps spinning. +_POLL_DEADLINE = re.compile( + r"\bdeadline\b|\bContinuousClock\b|\bSuspendingClock\b|\bDate\s*\(\s*\)|" + r"\bDate\.now\b|\bDispatchTime\.now\b|\.now\b|\bCFAbsoluteTimeGetCurrent\b" +) +_CANCELLATION_ONLY_EXIT = re.compile( + r"^\s*(?:if|guard)\s+!?\s*Task\.isCancelled\s*(?:else\s*)?\{\s*(?:break|return)\b[^}]*\}\s*$" +) +_YIELD_COUNT_LOOP_MAX_LINES = 60 + + +def detect_yield_count_poll(lines: list[str], idx: int, path_suffix: str) -> bool: + """A Swift condition poll bounded by a Task.yield() count, not a deadline.""" + if path_suffix != ".swift" or not _YIELD_COUNT_LOOP_HEADER.search(lines[idx]): + return False + body: list[str] = [] + depth = 0 + for j in range(idx, min(idx + _YIELD_COUNT_LOOP_MAX_LINES, len(lines))): + line = lines[j] + body.append(line) + depth += line.count("{") - line.count("}") + if depth <= 0: + break + text = "\n".join(body) + if not _TASK_YIELD.search(text) or _POLL_DEADLINE.search(text): + return False + exits = "\n".join(line for line in body if not _CANCELLATION_ONLY_EXIT.match(line)) + return bool(_POLL_EXIT.search(exits)) + + # --------------------------------------------------------------------------- # File scanning # --------------------------------------------------------------------------- @@ -3823,6 +3869,8 @@ def scan_text(rel_posix: str, text: str) -> list[Finding]: findings.append(Finding(rel_posix, line_no, RULE_FIXED_PORT_BIND, snippet)) if detect_sleep_then_assert(code_lines, i, suffix): findings.append(Finding(rel_posix, line_no, RULE_SLEEP_THEN_ASSERT, snippet)) + if detect_yield_count_poll(code_lines, i, suffix): + findings.append(Finding(rel_posix, line_no, RULE_YIELD_COUNT_POLL, snippet)) return findings @@ -4885,6 +4933,44 @@ def _self_test() -> int: "sleep 0.3\nassert \"$actual\" \"$expected\"\n", {RULE_SLEEP_THEN_ASSERT}, ), + # A condition poll bounded by a Task.yield() count, not a deadline + # (#13903). The observed failure: SimulatorPanelThemeTests, 100 yields. + ( + "cmuxTests/yield_poll.swift", + ( + " for _ in 0..<100 {\n" + " if panel.coordinator.frameTransport != nil { break }\n" + " await Task.yield()\n" + " }\n" + " #expect(panel.coordinator.frameTransport != nil)\n" + ), + {RULE_YIELD_COUNT_POLL}, + ), + ( + "Packages/macOS/Kit/Tests/KitTests/yield_poll_return.swift", + ( + " for _ in 0 ..< 1_000 {\n" + " if await gate.pendingCount >= count { return }\n" + " await Task.yield()\n" + " }\n" + ), + {RULE_YIELD_COUNT_POLL}, + ), + ( + "cmuxTests/yield_poll_guard.swift", + ( + " for _ in 1...256 {\n" + " await Task.yield()\n" + " guard session.state != .connected else { return }\n" + " }\n" + ), + {RULE_YIELD_COUNT_POLL}, + ), + ( + "cmuxTests/yield_poll_one_line.swift", + "for _ in 0..<20 { if model.isReady { break }; await Task.yield() }\n", + {RULE_YIELD_COUNT_POLL}, + ), ] negatives: list[tuple[str, str]] = [ @@ -5480,6 +5566,54 @@ def _self_test() -> int: " _must('ok' in body, body)\n" ), ), + # A yield-count loop that already carries a deadline is the allowed + # deadline-bounded poll; the count only caps spinning. + ( + "cmuxTests/n22.swift", + ( + " for _ in 0..<100 {\n" + " guard !Task.isCancelled, ContinuousClock.now < deadline else { return nil }\n" + " if let value = probe() { return value }\n" + " await Task.yield()\n" + " }\n" + ), + ), + # Draining yields with no condition is a different shape (usually a wait + # for a non-event) and is not what this rule targets. + ( + "cmuxTests/n23.swift", + " for _ in 0..<100 { await Task.yield() }\n #expect(sent.isEmpty)\n", + ), + # Exiting only on cancellation does not poll a condition. + ( + "cmuxTests/n24.swift", + ( + " for _ in 0..<256 {\n" + " if Task.isCancelled { return }\n" + " await Task.yield()\n" + " }\n" + ), + ), + # A loop that uses its index is doing per-iteration work, not polling. + ( + "cmuxTests/n25.swift", + ( + " for step in 0..<300 {\n" + " if step == 150 { break }\n" + " await Task.yield()\n" + " }\n" + ), + ), + # The same shape outside Swift is out of scope for this rule. + ( + "tests/n26.py", + "for _ in range(100):\n if ready():\n break\n await asyncio.sleep(0)\n", + ), + # A yield-count poll that lives in a string fixture is not code. + ( + "cmuxTests/n27.swift", + 'let source = "for _ in 0..<100 { if ready { break }; await Task.yield() }"\n', + ), ] failures: list[str] = [] diff --git a/scripts/ci/app_host_result_accounting.py b/scripts/ci/app_host_result_accounting.py index dc5a20cbe92c..af7f0d23be10 100644 --- a/scripts/ci/app_host_result_accounting.py +++ b/scripts/ci/app_host_result_accounting.py @@ -285,6 +285,35 @@ def run_is_complete(log_text: str) -> tuple[bool, str]: return True, "no interruption marker" +def recorded_failure_diagnostics( + results: dict[str, str], + known: dict[str, dict[str, Any]], +) -> list[str]: + """Name every recorded failure without deciding the run's verdict. + + The ratchet below fails fast: it reports new failures and returns without + mentioning known ones, because the verdict is already decided. A run that + is being reported for some other reason wants the opposite -- the complete + picture, since its verdict does not depend on what this finds. + """ + failures = { + identifier for identifier, result in results.items() if result == "Failed" + } + new_failures = sorted(failures - set(known)) + known_failures = sorted(failures & set(known)) + messages = [f"RATCHET_NEW_FAILURE {identifier}" for identifier in new_failures] + messages += [f"RATCHET_KNOWN_FAILURE {identifier}" for identifier in known_failures] + if messages: + # Mirror the summary the complete path prints. Without it, a reader + # scanning shard output for "the accounting ran" sees the same silence + # here that the missing verdicts themselves used to produce. + messages.append( + f"recorded verdicts: {len(new_failures)} new, " + f"{len(known_failures)} known-main; typed test cases: {len(results)}" + ) + return messages + + def check_run( *, inventory: set[str], @@ -306,6 +335,9 @@ def check_run( complete, reason = run_is_complete(log_text) if not complete: messages.append(f"incomplete app-host run: {reason}") + # A restart or timeout ends the run, not the verdicts recorded before + # it; same reasoning as the missing-result gate below. + messages.extend(recorded_failure_diagnostics(results, known)) return False, messages if not results: @@ -324,6 +356,12 @@ def check_run( messages.append( f"... {len(missing_execution) - 20} additional selected Test Case(s) missing" ) + # An incomplete result set still carries a verdict for everything that + # did finish. Naming those costs nothing and is the only way to tell a + # shard whose remaining tests regressed from one whose remaining tests + # went green -- without it both print the same "incomplete" line, and a + # full suite can be red while naming no regression at all. + messages.extend(recorded_failure_diagnostics(results, known)) return False, messages if xcode_status not in {0, 65}: diff --git a/scripts/ci/build_input_fingerprint.py b/scripts/ci/build_input_fingerprint.py old mode 100755 new mode 100644 index 339240045468..6ff2c4272fb4 --- a/scripts/ci/build_input_fingerprint.py +++ b/scripts/ci/build_input_fingerprint.py @@ -18,6 +18,7 @@ import json import subprocess import sys +from typing import Optional import product_input_identity as product_inputs @@ -26,11 +27,13 @@ def fingerprint( tree_lines: list[str], workflow: str, extra: list[str], + e2e_workflow: Optional[str] = None, ) -> str: value = { "product_inputs": product_inputs.identity_from_tree_lines( tree_lines, workflow, + e2e_workflow, ), "extra": list(extra), } @@ -51,7 +54,11 @@ def local_fingerprint(revision: str, extra: list[str]) -> str: ["git", "show", f"{revision}:{product_inputs.CI_WORKFLOW}"], text=True, ) - return fingerprint(tree_lines, workflow, extra) + e2e_workflow = subprocess.check_output( + ["git", "show", f"{revision}:{product_inputs.E2E_WORKFLOW}"], + text=True, + ) + return fingerprint(tree_lines, workflow, extra, e2e_workflow) def main(argv: list[str]) -> int: diff --git a/scripts/ci/choose_ci_suite.py b/scripts/ci/choose_ci_suite.py index 077981d5d27b..4193b60cb032 100755 --- a/scripts/ci/choose_ci_suite.py +++ b/scripts/ci/choose_ci_suite.py @@ -31,10 +31,19 @@ COMPILE_ONLY_POLICY = "compile-only" FULL_SUITE_LABEL = "full-ci" SUITE_OPT_OUT_LABEL = "no-full-ci" +UNIT_SUITE_LABEL = "unit-ci" # Editing these runs no code under compile admission, which builds the test # bundle and stops. Nothing else in a pull request observes them. -UNJUDGED_BY_COMPILE_PREFIXES = ("cmuxTests/", "cmuxUITests/") +# +# They differ in what could observe them. `app-host unit tests` runs cmuxTests/ +# against the product compile admission already built, so asking for that one +# job is enough to judge a cmuxTests/ diff. No pull request job runs +# cmuxUITests/ at all -- only the dispatch-only test-e2e lane does -- so those +# stay unobserved until someone takes the full suite or records the skip. +UNIT_JUDGED_PREFIXES = ("cmuxTests/",) +UNJUDGED_BY_ANY_PR_JOB_PREFIXES = ("cmuxUITests/",) +UNJUDGED_BY_COMPILE_PREFIXES = UNIT_JUDGED_PREFIXES + UNJUDGED_BY_ANY_PR_JOB_PREFIXES def diff_needs_the_suite(paths: Iterable[str] | None) -> bool: @@ -62,6 +71,34 @@ def wants_full_suite(event_name: str, pull_request_policy: str, labels: Iterable return FULL_SUITE_LABEL in {label.strip() for label in labels} +def wants_unit_suite( + event_name: str, + pull_request_policy: str, + labels: Iterable[str] | None, + paths: Iterable[str] | None = (), +) -> bool: + """True when this run should execute `app-host unit tests`. + + The full suite already includes them, so it implies this. Otherwise the + diff decides: a change under cmuxTests/ is judged by exactly this job and + by nothing compile admission does, so it selects the job itself rather + than failing `suite-coverage` and waiting for someone to add a label that + this module could already have derived. An unreadable diff (`paths` is + None) runs it too. The `unit-ci` label still asks for it on any diff. + + Only this job is selected: the package tests, the lag lane, release + admission and the Release build the full suite also unlocks cost a paid + runner and judge nothing about a change to cmuxTests/. + """ + if wants_full_suite(event_name, pull_request_policy, labels): + return True + if UNIT_SUITE_LABEL in {label.strip() for label in labels or ()}: + return True + if paths is None: + return True + return any(path.strip().startswith(UNIT_JUDGED_PREFIXES) for path in paths) + + def labels_from_event(event_path: str | Path) -> list[str] | None: """Read the pull request labels captured in this workflow run's event payload.""" try: @@ -95,17 +132,29 @@ def coverage_gap( full_suite: bool, paths: Iterable[str] | None, labels: Iterable[str] | None, + unit_suite: bool = False, ) -> bool: """True when this run skips the only check that could judge its diff. An explicit opt-out label records the decision on the pull request, which is the point: the skip stops being silent. + + `unit_suite` closes the gap only for the paths `app-host unit tests` can + actually judge. A cmuxUITests/ diff stays a gap however this run is routed, + because no pull request job executes it. """ if full_suite or event_name != "pull_request": return False if labels is not None and SUITE_OPT_OUT_LABEL in {label.strip() for label in labels}: return False - return diff_needs_the_suite(paths) + if paths is None: + return True + stripped = [path.strip() for path in paths] + if any(path.startswith(UNJUDGED_BY_ANY_PR_JOB_PREFIXES) for path in stripped): + return True + if unit_suite: + return False + return any(path.startswith(UNIT_JUDGED_PREFIXES) for path in stripped) def main(argv: list[str]) -> int: @@ -141,9 +190,11 @@ def main(argv: list[str]) -> int: paths = None full = wants_full_suite(args.event_name, args.pull_request_policy, labels) - gap = coverage_gap(args.event_name, full, paths, labels) + unit = wants_unit_suite(args.event_name, args.pull_request_policy, labels, paths) + gap = coverage_gap(args.event_name, full, paths, labels, unit_suite=unit) lines = [ f"full_suite={'true' if full else 'false'}", + f"unit_suite={'true' if unit else 'false'}", f"coverage_gap={'true' if gap else 'false'}", ] for line in lines: diff --git a/scripts/ci/ci_health_report.py b/scripts/ci/ci_health_report.py index 93b8c03748cf..8815012d2444 100644 --- a/scripts/ci/ci_health_report.py +++ b/scripts/ci/ci_health_report.py @@ -61,6 +61,10 @@ linux_only_workflow_paths, parse_time, ) +from runner_label_policy import ( # noqa: E402 + PolicyUnreadable, + drifted_runner_variables, +) API = "https://api.github.com" @@ -553,6 +557,100 @@ def unchanged_tree_reruns( return repeated +# Third-party providers this repo pays per minute. Depot is permitted by +# `tests/test_ci_self_hosted_guard.sh` and documented alongside Warp, so a +# single-prefix check would total zero and report "none in the window" the +# moment a variable is pinned to it -- exactly the silent drift this measures. +PAID_RUNNER_PREFIXES = ("warp-", "depot-") + + +def paid_runner_minutes( + rows: Iterable[JobRow], +) -> tuple[int, float, list[tuple[str, int, float]]]: + """Jobs that ran on metered capacity, and the minutes they billed. + + WarpBuild and Depot bill this repository per minute, at roughly double + the rate on 12-vCPU labels. Blacksmith is sponsored for this organization + and GitHub-hosted runners are free on a public repo, so neither shows up + on an invoice today. The runner label is the only place that difference is + visible, so a lane that drifts onto metered capacity reads as an ordinary + row in the tables above and nobody notices until somebody reads a bill. + + docs/ci-runners.md records an intended steady state for every + MACOS_RUNNER_* variable. Minutes here that are not a deliberate, temporary + overflow mean a variable has drifted away from that steady state. + """ + per_label: dict[str, tuple[int, float]] = {} + jobs = 0 + minutes = 0.0 + for row in rows: + if not row.label.startswith(PAID_RUNNER_PREFIXES): + continue + jobs += 1 + minutes += row.minutes + label_jobs, label_minutes = per_label.get(row.label, (0, 0.0)) + per_label[row.label] = (label_jobs + 1, label_minutes + row.minutes) + breakdown = sorted( + ((label, n, m) for label, (n, m) in per_label.items()), + key=lambda item: -item[2], + ) + return jobs, minutes, breakdown + + +RUNNER_VARIABLES_ENV = "CMUX_CI_RUNNER_VARIABLES" + + +def _runner_variable_drift_lines() -> list[str]: + """What the runner repository variables currently hold, if we can see them. + + Everything else in this report is measured from jobs that already ran, so + it can only show drift after the minutes are spent. This shows the + configuration itself, which is the only way to catch a variable that has + been repointed but whose lane has not fired yet. + + The workflow passes one `NAME=value` line per runner variable, read from + the expression context, because a variable's value is readable there + without any token scope -- this report's token is deliberately + `actions: read` and cannot query the variables API. When the environment + variable is absent (a local run, or an older workflow), say so rather than + claiming the configuration is clean. + """ + raw = os.environ.get(RUNNER_VARIABLES_ENV, "").strip() + if not raw: + return [ + "**Runner variable values:** not checked — " + f"`{RUNNER_VARIABLES_ENV}` was not set for this run." + ] + variables = {} + for line in raw.splitlines(): + name, separator, value = line.strip().partition("=") + if not separator or not name: + return [f"**Runner variable values:** unreadable (line {_escape(line.strip())!r})."] + variables[name] = value + + try: + drifted = drifted_runner_variables(variables) + except PolicyUnreadable as error: + return [f"**Runner variable values:** policy unreadable ({_escape(str(error))})."] + + if not drifted: + return [ + "**Runner variable values:** every runner variable holds a label " + "`tests/test_ci_self_hosted_guard.sh` would accept in a workflow." + ] + detail = "; ".join( + f"`{_escape(name)}` = `{_escape(value)}` ({reason})" + for name, value, reason in drifted + ) + return [ + f"**Runner variable values:** {len(drifted)} variable(s) hold a label " + f"that would fail `check_no_self_hosted_fleet_runners` if it appeared in " + f"a workflow file — {detail}. Nothing lints variable values, so this is " + "the only place it shows up; fix with `gh variable set`, or widen the " + "allow-list in that guard if the label is genuinely approved." + ] + + def fork_runs_without_cache(rows: Iterable[JobRow]) -> tuple[int, float]: """Jobs from forks and the minutes they spent. @@ -1003,6 +1101,29 @@ def render_report( lines.append("_None in the window._") lines.append("") + paid_jobs, paid_minutes, paid_breakdown = paid_runner_minutes(current.rows) + if paid_jobs: + detail = ", ".join( + f"{_escape(label)} {n} job(s)/{m:.0f} min" for label, n, m in paid_breakdown + ) + lines.append( + f"**Paid runner capacity:** {paid_jobs} sampled job(s), " + f"{paid_minutes:.0f} runner minutes — {detail}. These are the metered " + "third-party labels; Blacksmith is sponsored for this organization and " + "GitHub-hosted runners are free on a public repo. Check these against the " + "intended steady state in `docs/ci-runners.md`; a lane that is not " + "deliberate overflow should be moved back." + ) + else: + lines.append( + "**Paid runner capacity:** none in the window." + ) + lines.append("") + + for line in _runner_variable_drift_lines(): + lines.append(line) + lines.append("") + fork_jobs, fork_minutes = fork_runs_without_cache(current.rows) lines.append( f"**Fork pull requests (no cache access):** {fork_jobs} sampled job(s), " diff --git a/scripts/ci/e2e_warm_derived_data.py b/scripts/ci/e2e_warm_derived_data.py new file mode 100755 index 000000000000..fcc237d820df --- /dev/null +++ b/scripts/ci/e2e_warm_derived_data.py @@ -0,0 +1,189 @@ +#!/usr/bin/env python3 +"""Let an E2E build start from the last DerivedData main compiled. + + e2e_warm_derived_data.py record WORKSPACE MANIFEST + e2e_warm_derived_data.py replay WORKSPACE MANIFEST + e2e_warm_derived_data.py restore WORKSPACE DERIVED_DATA KEY + +The compiled product archive carries Build/Products only. Without the build +database and intermediates next to it, xcodebuild cannot tell what is already +built, so a revision that changes one test file recompiles the whole app host: +691 of the 735 seconds `build-for-testing` spends is the app scheme. + +Xcode decides what to rebuild from modification times, and a fresh checkout +stamps every file with the checkout time. `record` writes the content digest +and modification time of every build input before a compile. `replay` restores +the recorded time only onto files whose content is byte-identical, so an +unchanged file looks as old as the build that consumed it, and stamps every +other file with the current time. A changed file cannot keep an old time: files +unpacked from an archive (GhosttyKit, SwiftPM binary artifacts) carry the +archive's times, which may predate the producer's build. Correctness never depends on how close +the adopted DerivedData is to this revision; distance only costs compile time. + +`restore` adopts the newest DerivedData archive for KEY that a `main` run of +this workflow published. Any miss, expiry or transfer failure is a cold build. +""" +from __future__ import annotations + +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tarfile +import tempfile +import zipfile + +WORKFLOW_PATH = ".github/workflows/test-e2e.yml" +ARCHIVE = "derived-data.tar.gz" +MANIFEST = "cmux-e2e-input-mtimes.json" +PREFIX = "e2e-derived-data-v1-" +# Never walk into build outputs or git metadata: they are not inputs, and +# DerivedData lives inside the workspace on every runner pool. +SKIPPED_DIRECTORIES = frozenset({".git", "DerivedData"}) +# Beyond this a download loses to the compile it replaces. +MAX_ARTIFACT_BYTES = 12 * 1024**3 + + +def digest(path: Path) -> str: + checksum = hashlib.sha256() + with path.open("rb") as stream: + for block in iter(lambda: stream.read(1024 * 1024), b""): + checksum.update(block) + return checksum.hexdigest() + + +def inputs(workspace: Path): + for root, directories, files in os.walk(workspace): + directories[:] = sorted(d for d in directories if d not in SKIPPED_DIRECTORIES) + for name in sorted(files): + path = Path(root, name) + if path.is_symlink() or not path.is_file(): + continue + yield path.relative_to(workspace).as_posix(), path + + +def record(workspace: Path) -> dict[str, list]: + return { + relative: [digest(path), path.stat().st_mtime_ns] + for relative, path in inputs(workspace) + } + + +def replay(workspace: Path, recorded: dict[str, list]) -> tuple[int, int]: + restored = changed = 0 + for relative, path in inputs(workspace): + entry = recorded.get(relative) + if entry is None or entry[0] != digest(path): + os.utime(path) + changed += 1 + continue + os.utime(path, ns=(entry[1], entry[1])) + restored += 1 + return restored, changed + + +def api(path: str) -> dict: + output = subprocess.run(["gh", "api", path], check=True, capture_output=True, text=True).stdout + return json.loads(output) + + +def trusted(artifact: dict, repository: str) -> bool: + """Only main's own runs of this workflow may seed a build of another ref.""" + run = artifact.get("workflow_run") or {} + if artifact.get("expired") or run.get("head_branch") != "main": + return False + if run.get("head_repository_id") not in (None, run.get("repository_id")): + return False + details = api(f"repos/{repository}/actions/runs/{run['id']}") + return details.get("path") == WORKFLOW_PATH and details.get("event") == "workflow_dispatch" + + +def newest(repository: str, key: str) -> dict | None: + listing = api(f"repos/{repository}/actions/artifacts?name={PREFIX}{key}&per_page=20") + candidates = sorted(listing.get("artifacts", []), key=lambda a: a.get("created_at", ""), reverse=True) + return next((a for a in candidates if trusted(a, repository)), None) + + +def extract(archive: Path, destination: Path) -> None: + with tarfile.open(archive) as bundle: + for member in bundle.getmembers(): + target = (destination / member.name).resolve() + if destination.resolve() not in target.parents and target != destination.resolve(): + raise ValueError(f"archive member escapes DerivedData: {member.name}") + if member.issym() or member.islnk(): + # Xcode links within DerivedData, sometimes by absolute path; + # the key pins that path, so it is the same on both sides. + link = Path(member.linkname) + base = destination if member.islnk() or link.is_absolute() else target.parent + resolved = (base / link).resolve() + if destination.resolve() not in resolved.parents and resolved != destination.resolve(): + raise ValueError(f"archive link escapes DerivedData: {member.name}") + if hasattr(tarfile, "tar_filter"): + # Every member and link target is bounded above. The default + # `data` filter would also refuse Xcode's absolute in-tree links. + bundle.extractall(destination, filter="tar") + else: + bundle.extractall(destination) + + +def restore(workspace: Path, derived: Path, key: str) -> dict[str, object]: + repository = os.environ["GITHUB_REPOSITORY"] + artifact = newest(repository, key) + if artifact is None: + return {"hit": "false", "reason": "no-main-derived-data"} + if int(artifact.get("size_in_bytes") or 0) > MAX_ARTIFACT_BYTES: + return {"hit": "false", "reason": "derived-data-too-large"} + with tempfile.TemporaryDirectory() as staging: + bundle = Path(staging, "artifact.zip") + with bundle.open("wb") as stream: + subprocess.run( + ["gh", "api", f"repos/{repository}/actions/artifacts/{artifact['id']}/zip"], + check=True, stdout=stream, + ) + with zipfile.ZipFile(bundle) as archive: + archive.extractall(staging) + extract(Path(staging, ARCHIVE), derived) + recorded = json.loads((derived / MANIFEST).read_text()) + restored, changed = replay(workspace, recorded) + return { + "hit": "true", + "producer_run_id": str(artifact["workflow_run"]["id"]), + "unchanged_inputs": str(restored), + "changed_inputs": str(changed), + } + + +def main(argv: list[str]) -> int: + if len(argv) == 4 and argv[1] in {"record", "replay"}: + workspace, manifest = Path(argv[2]).resolve(), Path(argv[3]) + if argv[1] == "record": + manifest.write_text(json.dumps(record(workspace), sort_keys=True)) + print(f"Recorded {len(json.loads(manifest.read_text()))} build inputs") + else: + restored, changed = replay(workspace, json.loads(manifest.read_text())) + print(f"Replayed {restored} unchanged inputs; {changed} changed or new") + return 0 + if len(argv) == 5 and argv[1] == "restore": + derived = Path(argv[3]) + try: + result = restore(Path(argv[2]).resolve(), derived, argv[4]) + except Exception as error: # noqa: BLE001 - every failure means a cold build + # A half-extracted DerivedData is worse than none: start cold. + shutil.rmtree(derived, ignore_errors=True) + derived.mkdir(parents=True, exist_ok=True) + result = {"hit": "false", "reason": f"{type(error).__name__}: {error}"[:200]} + print(json.dumps(result, sort_keys=True)) + if "GITHUB_OUTPUT" in os.environ: + with open(os.environ["GITHUB_OUTPUT"], "a") as handle: + for name, value in result.items(): + handle.write(f"{name}={value}\n") + return 0 + print(__doc__, file=sys.stderr) + return 2 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/scripts/ci/product_input_identity.py b/scripts/ci/product_input_identity.py index 7f464041bd8a..6cd8c5c70f36 100644 --- a/scripts/ci/product_input_identity.py +++ b/scripts/ci/product_input_identity.py @@ -10,11 +10,13 @@ import subprocess import sys from pathlib import Path -from typing import Iterable +from typing import Iterable, Optional CI_WORKFLOW = ".github/workflows/ci-macos.yml" -IDENTITY_SCHEMA = "cmux-app-host-product-inputs/v1" +E2E_WORKFLOW = ".github/workflows/test-e2e.yml" +IDENTITY_SCHEMA = "cmux-app-host-product-inputs/v2" MACOS_ADMISSION_JOB = "macos-compile-admission" +E2E_BUILD_JOB = "build" # These checked-in CI helpers can change the actual product or its relocation # contract. Other scripts/ci files are admission/control-plane implementation, @@ -22,6 +24,7 @@ PRODUCT_CI_INPUTS = frozenset({ "scripts/ci/app_host_test_products.py", "scripts/ci/compile-app-host-test-product.sh", + "scripts/ci/e2e_warm_derived_data.py", "scripts/ci/canonical-build-root.sh", "scripts/ci/sanitize-xcode-source-packages-cache.py", }) @@ -39,6 +42,13 @@ "CMUX_SKIP_ZIG_BUILD", }) +E2E_REQUIRED_PRODUCT_JOB_ENV_KEYS = frozenset({ + "TEST_REF", + "CMUX_CI_MAX_MACOS_SDK_MAJOR", + "CMUX_SKIP_ZIG_BUILD", + "CMUX_PRODUCT_RUNNER", +}) + NON_PRODUCT_JOB_ENV_KEYS = frozenset({ "CMUX_NODE_PRODUCT_CACHE_ROOT", "CMUX_NODE_PRODUCT_CACHE_MAX_BYTES", @@ -308,17 +318,96 @@ def recipe_fingerprint(workflow: str) -> str: return hashlib.sha256(raw).hexdigest() +def _e2e_product_job_environment(block: str) -> dict[str, str]: + """Keep every E2E build env value so new build controls fail closed.""" + lines = block.splitlines() + if not lines or lines[0].strip() != "env:": + raise ValueError("E2E build env block is unreadable") + + values: dict[str, str] = {} + seen: set[str] = set() + for line in lines[1:]: + if not line.strip() or line.lstrip().startswith("#"): + continue + match = re.match(r"^ ([A-Za-z_][A-Za-z0-9_]*):\s*(.*?)\s*$", line) + if match is None: + raise ValueError("E2E build env contains an unsupported value shape") + name, value = match.groups() + if name in seen: + raise ValueError(f"E2E build env key is not unique: {name!r}") + seen.add(name) + values[name] = value + + missing = E2E_REQUIRED_PRODUCT_JOB_ENV_KEYS - seen + if missing: + raise ValueError( + "E2E build is missing required product env keys: " + + ", ".join(sorted(missing)) + ) + return values + + +def e2e_recipe_projection(workflow: str) -> dict[str, object]: + """Project the dispatch build recipe conservatively. + + Every named step is retained. That is intentionally broader than the + compile-admission projection: this workflow is now a reusable-product + producer, so an inserted pre-build source mutation, a new build env value, + or a changed setup action must invalidate its products. + """ + job = _job_block(workflow, E2E_BUILD_JOB) + controls: dict[str, object] = {} + seen_job_keys: set[str] = set() + for name, block in _job_level_blocks(job): + seen_job_keys.add(name) + if name in IGNORED_JOB_LEVEL_KEYS: + continue + if name == "env": + controls["env"] = _e2e_product_job_environment(block) + continue + if name == "defaults": + controls["defaults"] = block + continue + if name == "steps": + continue + raise ValueError(f"unclassified E2E build job-level key: {name!r}") + + if "env" not in controls or "steps" not in seen_job_keys: + raise ValueError("E2E build job is missing product controls") + + steps = dict(_step_blocks(job)) + if not steps: + raise ValueError("E2E build product recipe is empty") + return {"job_controls": controls, "steps": steps} + + +def e2e_recipe_fingerprint(workflow: str) -> str: + raw = json.dumps( + e2e_recipe_projection(workflow), + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + return hashlib.sha256(raw).hexdigest() + + def algorithm_fingerprint() -> str: return hashlib.sha256(Path(__file__).read_bytes()).hexdigest() -def identity_from_tree_lines(tree_lines: Iterable[str], workflow: str) -> dict[str, str]: - return { +def identity_from_tree_lines( + tree_lines: Iterable[str], + workflow: str, + e2e_workflow: Optional[str] = None, +) -> dict[str, str]: + value = { "schema": IDENTITY_SCHEMA, "algorithm": algorithm_fingerprint(), "source": source_fingerprint(tree_lines), "recipe": recipe_fingerprint(workflow), } + if e2e_workflow is not None: + value["e2e_recipe"] = e2e_recipe_fingerprint(e2e_workflow) + return value def local_identity(revision: str = "HEAD") -> dict[str, str]: @@ -330,7 +419,11 @@ def local_identity(revision: str = "HEAD") -> dict[str, str]: ["git", "show", f"{revision}:{CI_WORKFLOW}"], text=True, ) - return identity_from_tree_lines(tree_lines, workflow) + e2e_workflow = subprocess.check_output( + ["git", "show", f"{revision}:{E2E_WORKFLOW}"], + text=True, + ) + return identity_from_tree_lines(tree_lines, workflow, e2e_workflow) def main(argv: list[str]) -> int: diff --git a/scripts/ci/replay_app_host_verdict.py b/scripts/ci/replay_app_host_verdict.py new file mode 100755 index 000000000000..ef427575c5a1 --- /dev/null +++ b/scripts/ci/replay_app_host_verdict.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 +"""Replay app-host shard verdicts offline from a CI run's artifacts. + +Answers "what would this shard have reported?" without a Mac or a CI round +trip -- including which selected tests never produced a terminal result, which +the shard's own output may omit entirely. + +Usage: + # once per run+shard (downloads ~100-300 MB; keep off /tmp, it is a tmpfs) + gh run download --repo manaflow-ai/cmux \ + -n cmux-app-host-diagnostics-shard--run-1 -D /shard + gh run download --repo manaflow-ai/cmux \ + -n cmux-app-host-test-inventory--1 -D /inventory + + python3 replay_app_host_verdict.py --artifacts --shard --repo + python3 replay_app_host_verdict.py --artifacts --shard --repo \ + --accounting /scripts/ci/app_host_result_accounting.py +""" +import argparse +import importlib.util +import shlex +import sys +import tempfile +from pathlib import Path + + +def load_accounting(path: Path): + spec = importlib.util.spec_from_file_location("acct_under_test", path) + module = importlib.util.module_from_spec(spec) + sys.modules["acct_under_test"] = module + spec.loader.exec_module(module) + return module + + +def selectors_from_meta(meta: Path) -> list[str]: + """Recover the batch's -only-testing selectors from its recorded argv. + + run-app-host-xcodebuild.sh writes these with `printf 'arg=%q\\n'`, so the + value is shell-quoted. Since #13831 gave Swift Testing selectors their + trailing parens, %q escapes them -- `testFoo\\(\\)` -- and a selector read + literally matches nothing in the inventory. Unquote with shlex rather than + stripping one quote character. + """ + out = [] + for line in meta.read_text(encoding="utf-8").splitlines(): + if not line.startswith("arg="): + continue + raw = line[4:].strip() + # %q falls back to ANSI-C $'...' when the value holds a non-printable + # character. shlex does not decode that form and does not raise on it: + # it yields a leading '$' and a literal backslash escape, which fails + # the prefix test below and drops the selector silently. Unreachable + # for today's identifiers, but a silently shorter selector set is the + # exact failure this tool exists to expose, so refuse it. + if raw.startswith("$'"): + raise SystemExit( + f"{meta.name}: ANSI-C quoted argv is not decodable here: {line!r}" + ) + try: + fields = shlex.split(raw) + except ValueError: + # An unbalanced quote means this line is not recoverable; skipping + # it would silently shrink the selector set, so fail loudly. + raise SystemExit(f"{meta.name}: cannot unquote argv line: {line!r}") + if not fields: + continue + if len(fields) > 1: + # %q output is one token by construction, so more than one means + # the recorded argv is not what this parser assumes. + raise SystemExit( + f"{meta.name}: argv line split into {len(fields)} tokens: {line!r}" + ) + value = fields[0] + if value.startswith("-only-testing:"): + out.append(value.split("-only-testing:", 1)[1]) + return out + + +def xcode_status_from_log(log_text: str) -> int: + """Recover the batch's exit status from xcodebuild's own closing banner. + + The .meta does not record it, and check_run treats it as evidence: a batch + that really exited 0 replayed as 65 reports "xcodebuild exited 65 without a + typed failed Test Case", a contradiction that never happened. + """ + if "** TEST SUCCEEDED **" in log_text: + return 0 + return 65 + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--artifacts", required=True, type=Path) + ap.add_argument("--shard", required=True) + ap.add_argument("--repo", required=True, type=Path, help="a cmux checkout") + ap.add_argument("--accounting", type=Path, help="override the module under test") + ap.add_argument( + "--xcode-status", + type=int, + default=None, + help="override; by default each batch's status comes from its own log", + ) + args = ap.parse_args() + + acct = load_accounting( + args.accounting or args.repo / "scripts/ci/app_host_result_accounting.py" + ) + shard_dir = args.artifacts / f"shard{args.shard}" + inventory_files = sorted((args.artifacts / "inventory").glob("*test-inventory*.json")) + if not inventory_files: + print("no inventory artifact under --artifacts/inventory", file=sys.stderr) + return 2 + inventory = acct.load_inventory(inventory_files[0]) + known = acct.load_catalog(args.repo / "scripts/ci/app-host-known-failures.json") + + metas = sorted(shard_dir.glob(f"captures/*unit-physical-{args.shard}-logical-*.meta")) + if not metas: + print(f"no unit batches in {shard_dir}/captures", file=sys.stderr) + return 2 + + total_new = 0 + for meta in metas: + tag = meta.name.replace("cmux-app-host-xcodebuild-", "").split("-pid-")[0] + tests_json = sorted(shard_dir.glob(f"xcresults/*{tag}*.tests.json")) + log = sorted(shard_dir.glob(f"captures/*{tag}*.log")) + if not tests_json or not log: + print(f"{tag}: missing typed results or log, skipped") + continue + selectors = selectors_from_meta(meta) + if not selectors: + print(f"{tag}: no -only-testing selectors in argv, skipped") + continue + # Write the selector file outside the artifact tree: these downloads are + # 100-300 MB and get reused across --accounting runs, so a replay should + # not mutate its own input. + with tempfile.NamedTemporaryFile( + "w", suffix=".selectors", encoding="utf-8" + ) as sel_file: + sel_file.write("\n".join(selectors) + "\n") + sel_file.flush() + loaded = acct.load_selectors(Path(sel_file.name)) + results = acct.merge_result_files([tests_json[0]]) + log_text = log[0].read_text(encoding="utf-8", errors="replace") + status = ( + args.xcode_status + if args.xcode_status is not None + else xcode_status_from_log(log_text) + ) + passed, messages = acct.check_run( + inventory=inventory, + selectors=loaded, + results=results, + known=known, + log_text=log_text, + xcode_status=status, + ) + new = [m for m in messages if m.startswith("RATCHET_NEW_FAILURE")] + total_new += len(new) + print( + f"\n=== {tag} selectors={len(selectors)} typed={len(results)} " + f"status={status} passed={passed}" + ) + for m in messages: + print(" ", m) + print(f"\ntotal RATCHET_NEW_FAILURE across shard {args.shard}: {total_new}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/ci/reuse_app_host_products.py b/scripts/ci/reuse_app_host_products.py index 6fc94c6968ab..293ec8efdf22 100644 --- a/scripts/ci/reuse_app_host_products.py +++ b/scripts/ci/reuse_app_host_products.py @@ -36,6 +36,12 @@ # Current product archives are ~0.8 GiB compressed. Bound every expansion layer # independently, including hardlink copies, with room for the UI product set. MAX_ARCHIVE_BYTES = 2 * 1024**3 +# Below this rate a download is losing to the compile it exists to replace, so +# giving up and compiling is the right answer. Above it, the budget has to +# cover the largest archive this will accept -- a flat 120 s did not, and an +# 785 MB product was silently refused after two minutes on every attempt. +MIN_TRANSFER_BYTES_PER_SECOND = 8 * 1024**2 +DOWNLOAD_TIMEOUT = math.ceil(MAX_ARCHIVE_BYTES / MIN_TRANSFER_BYTES_PER_SECOND) MAX_MEMBER_BYTES = 4 * 1024**3 MAX_EXPANDED_BYTES = 16 * 1024**3 MAX_TAR_BYTES = 20 * 1024**3 @@ -51,9 +57,35 @@ # Producer events permitted for each consumer event. Pull-request consumers are # further restricted to the same pull request; merge groups may adopt an exact # product from either an in-repository PR or an earlier merge-group run. +# +# A dispatch consumer is at least as trusted as a merge group, because starting +# one requires write access, so it may adopt any exact product CI compiled as +# well as the ones earlier dispatches of its own lane compiled. Nothing adopts a +# dispatch product in the other direction: CI's trust surface is unchanged. PERMITTED_PRODUCERS = { "pull_request": {"pull_request"}, "merge_group": {"pull_request", "merge_group"}, + "workflow_dispatch": {"pull_request", "merge_group", "workflow_dispatch"}, +} + +# The workflow each event is trusted to run from, keyed by event so a future +# dispatchable ci.yml or pull-request-triggered E2E lane cannot inherit the +# other one's trust by accident. +TRUSTED_WORKFLOWS = { + "pull_request": ".github/workflows/ci.yml", + "merge_group": ".github/workflows/ci.yml", + "workflow_dispatch": ".github/workflows/test-e2e.yml", +} + +# The job, and the step inside it, that must have compiled a product before that +# workflow's artifact may be adopted. +COMPILE_JOBS = { + ".github/workflows/ci.yml": ( + "macOS compile admission", "Compile app-host test product", + ), + ".github/workflows/test-e2e.yml": ( + "build", "Build the app-host and UI test product", + ), } @@ -105,25 +137,29 @@ def github_product_identity(api, revision): if not isinstance(entries, list): raise ValueError("GitHub tree is unavailable") - workflow_entry = next( - ( - entry for entry in entries - if isinstance(entry, dict) - and entry.get("path") == product_inputs.CI_WORKFLOW - and entry.get("type") == "blob" - ), - None, - ) - if workflow_entry is None or not isinstance(workflow_entry.get("sha"), str): - raise ValueError("CI workflow blob is unavailable") - blob = api.get(f"git/blobs/{workflow_entry['sha']}") - if blob.get("encoding") != "base64" or not isinstance(blob.get("content"), str): - raise ValueError("CI workflow blob encoding is invalid") - workflow = base64.b64decode(blob["content"]).decode("utf-8") - + def workflow_text(path): + workflow_entry = next( + ( + entry for entry in entries + if isinstance(entry, dict) + and entry.get("path") == path + and entry.get("type") == "blob" + ), + None, + ) + if workflow_entry is None or not isinstance(workflow_entry.get("sha"), str): + raise ValueError(f"workflow blob is unavailable: {path}") + blob = api.get(f"git/blobs/{workflow_entry['sha']}") + if blob.get("encoding") != "base64" or not isinstance(blob.get("content"), str): + raise ValueError(f"workflow blob encoding is invalid: {path}") + return base64.b64decode(blob["content"]).decode("utf-8") + + workflow = workflow_text(product_inputs.CI_WORKFLOW) + e2e_workflow = workflow_text(product_inputs.E2E_WORKFLOW) value = product_inputs.identity_from_tree_lines( product_inputs.github_tree_lines(entries), workflow, + e2e_workflow, ) cache[revision] = value return value @@ -139,7 +175,7 @@ def get(self, path): def download(self, artifact_id, target): with target.open("wb") as out: subprocess.run(["gh", "api", f"repos/{self.repository}/actions/artifacts/{artifact_id}/zip"], - stdout=out, check=True, timeout=120) + stdout=out, check=True, timeout=DOWNLOAD_TIMEOUT) def record_reason(reasons, reason): @@ -212,6 +248,17 @@ def attested_producer_revision(api, run, revision, product_inputs): not just the head it names -- has to carry these product inputs. """ head = run.get("head_sha") + if run.get("event") == "workflow_dispatch": + # A dispatch's head names the workflow definition, while its sealed + # revision names the checkout it compiled. Bind both: the actual E2E + # build recipe GitHub ran must equal the recipe in the product identity, + # and the sealed checkout must still re-fingerprint to that identity. + if not isinstance(head, str) or not re.fullmatch(r"[0-9a-f]{6,40}", head): + return False + actual_workflow = github_product_identity(api, head) + if actual_workflow.get("e2e_recipe") != product_inputs.get("e2e_recipe"): + return False + return github_product_identity(api, revision) == product_inputs if revision == head: return True if run.get("event") != "pull_request": @@ -226,11 +273,12 @@ def attested_producer_revision(api, run, revision, product_inputs): def trusted_ci_run(run, repository): - """Require the repository CI workflow and an in-repository event source.""" + """Require the event's own trusted workflow and an in-repository source.""" head_repository = run.get("head_repository") + event = run.get("event") return ( - run.get("path") == ".github/workflows/ci.yml" - and run.get("event") in PERMITTED_PRODUCERS + event in PERMITTED_PRODUCERS + and run.get("path") == TRUSTED_WORKFLOWS[event] and isinstance(head_repository, dict) and str(head_repository.get("full_name", "")).casefold() == repository.casefold() ) @@ -262,13 +310,13 @@ def elapsed_seconds(started_at, completed_at): return max(0.0, (completed - started).total_seconds()) -def compile_step_seconds(job): +def compile_step_seconds(job, step_name="Compile app-host test product"): """Return the producer's actual compile-step duration when it compiled.""" steps = job.get("steps") if not isinstance(steps, list): return None for step in steps: - if (step.get("name") == "Compile app-host test product" + if (step.get("name") == step_name and step.get("status") == "completed" and step.get("conclusion") == "success"): return elapsed_seconds(step.get("started_at"), step.get("completed_at")) @@ -285,11 +333,18 @@ def load_consumer(api, value, current_run, current_attempt, current_revision, re if not trusted_ci_run(run, api.repository): record_reason(reasons, "consumer_untrusted") return None - head = run.get("head_sha") + # A dispatch takes the revision under test as a workflow input, so its + # `head_sha` names the workflow definition's ref and attests nothing + # about the checkout. The binding that matters is the same either way: + # the tree this job fingerprinted has to equal GitHub's immutable copy + # of the revision it claims, which is checked directly below. A locally + # modified checkout still cannot adopt anything. + dispatched = run.get("event") == "workflow_dispatch" + head = current_revision if dispatched else run.get("head_sha") if not isinstance(head, str) or not re.fullmatch(r"[0-9a-f]{6,40}", head): record_reason(reasons, "consumer_revision_invalid") return None - if not attested_checkout(run, current_revision): + if not dispatched and not attested_checkout(run, current_revision): record_reason(reasons, "consumer_revision_mismatch") return None if github_product_identity(api, head) != value["product_inputs"]: @@ -377,11 +432,20 @@ def select(api, value, current_run, current_attempt, consumer, reasons): # GitHub's immutable Git objects, not a candidate-authored receipt, # establish product compatibility before download. Admission-only # source changes may differ while compiled-product inputs stay exact. + # head = run.get("head_sha") if not isinstance(head, str) or not re.fullmatch(r"[0-9a-f]{6,40}", head): record_reason(reasons, "producer_revision_invalid") continue - if github_product_identity(api, head) != value["product_inputs"]: + if run.get("event") == "workflow_dispatch": + # The dispatch head attests the workflow recipe, not the checkout. + # Reject a product before download when that actual recipe differs + # from the E2E recipe sealed into this contract. + actual_workflow = github_product_identity(api, head) + if actual_workflow.get("e2e_recipe") != value["product_inputs"].get("e2e_recipe"): + record_reason(reasons, "producer_recipe_mismatch") + continue + elif github_product_identity(api, head) != value["product_inputs"]: record_reason(reasons, "producer_product_inputs_mismatch") continue jobs = [] @@ -399,8 +463,9 @@ def select(api, value, current_run, current_attempt, consumer, reasons): # A reusable workflow reports " / ", so this # is "macos / macOS compile admission" when ci.yml reaches the job # through ci-macos.yml. Match the final segment. + compile_name, compile_step = COMPILE_JOBS[run["path"]] compile_job = next((job for job in jobs - if str(job.get("name") or "").rsplit(" / ", 1)[-1] == "macOS compile admission" + if str(job.get("name") or "").rsplit(" / ", 1)[-1] == compile_name and job.get("status") == "completed" and job.get("conclusion") == "success"), None) if compile_job is None: @@ -411,7 +476,7 @@ def select(api, value, current_run, current_attempt, consumer, reasons): record_reason(reasons, "artifact_digest_missing") continue run = dict(run) - run["_compile_seconds"] = compile_step_seconds(compile_job) + run["_compile_seconds"] = compile_step_seconds(compile_job, compile_step) run["_producer_attempt"] = producer_attempt yield artifact, run except (TypeError, AttributeError, ValueError, KeyError, OSError, diff --git a/scripts/ci/runner_label_policy.py b/scripts/ci/runner_label_policy.py new file mode 100644 index 000000000000..7d69650810a8 --- /dev/null +++ b/scripts/ci/runner_label_policy.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +"""Which runner labels this repository may use, applied to values rather than text. + +`tests/test_ci_self_hosted_guard.sh` already decides this for workflow *text*: +`check_no_self_hosted_fleet_runners` refuses a `runs-on:` naming a physical +fleet host, a Tart VM, or a paid label outside the approved set. Every guard in +this repository works the same way, on files. + +Runner choice does not live in files. It lives in `MACOS_RUNNER_*` repository +variables, and a variable's value never appears in a diff, so none of those +guards can see it. `warp-macos-26-arm64-12x` sat in the release and large-nightly runner +variables from 2026-09-20 to 2026-09-23 even though +the guard rejects that exact label on sight -- it matches the `macos-26` fleet +pattern and is absent from the allow-list. + +This module applies the guard's own patterns to a label value. It does not keep +a second copy of them: it reads them out of the guard script, and +`tests/test_runner_label_policy.py` fails if that read stops working, so the +two cannot drift apart. +""" + +from __future__ import annotations + +import re +from functools import cache +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +GUARD_SCRIPT = ROOT / "tests" / "test_ci_self_hosted_guard.sh" + + +class PolicyUnreadable(RuntimeError): + """The guard script no longer declares a pattern this module needs. + + Raised rather than falling back to a built-in copy: a silently stale + pattern would report "no drift" forever, which is worse than not running. + """ + + +GUARD_FUNCTION = "check_no_self_hosted_fleet_runners" + + +def _guard_function(source: str) -> str: + """The body of the guard function that owns the patterns. + + Reading only this body is what stops a same-named local elsewhere in the + 2000-line script from being taken for the policy. + """ + match = re.search(rf"^{GUARD_FUNCTION}\(\) \{{\n(.*?)^\}}", source, re.M | re.S) + if match is None: + raise PolicyUnreadable( + f"{GUARD_SCRIPT.name} no longer defines `{GUARD_FUNCTION}`; " + f"runner label policy cannot be read from it" + ) + return match.group(1) + + +def _shell_local(body: str, name: str) -> str: + """The single-quoted value of the one `local ='...'` in a function. + + Any other assignment to the name (`name+=...`, a second `local`) raises: + reading only the first piece of a pattern built in several steps would + narrow the policy without anything failing. + """ + assignments = re.findall(rf"^\s*(?:local\s+)?{re.escape(name)}\+?=", body, re.M) + match = re.search(rf"^\s*local {re.escape(name)}='([^']*)'\s*$", body, re.M) + if match is None or len(assignments) != 1: + raise PolicyUnreadable( + f"{GUARD_SCRIPT.name} no longer declares `{name}` as exactly one " + f"`local {name}='...'` in {GUARD_FUNCTION}; runner label policy " + f"cannot be read from it" + ) + return match.group(1) + + +@cache +def _patterns() -> tuple[str, str, str]: + body = _guard_function(GUARD_SCRIPT.read_text(encoding="utf-8")) + return ( + _shell_local(body, "fleet"), + _shell_local(body, "allowed"), + _shell_local(body, "selfhosted"), + ) + + +def forbidden_reason(label: str) -> str | None: + """Why this runner label is not allowed, or None when it is fine. + + Mirrors the guard exactly: strip the approved cloud labels first, then look + for a forbidden pattern in what is left. Stripping first is what lets + `blacksmith-6vcpu-macos-26` through while `warp-macos-26-arm64-12x` is + caught, even though both contain `macos-26`. + """ + if not label: + return None + fleet, allowed, selfhosted = _patterns() + remainder = re.sub(f"({allowed})", "", label) + if re.search(f"({fleet})", remainder): + return ( + "names the self-hosted fleet or a macOS image outside the approved " + "cloud labels" + ) + if re.search(f"({selfhosted})", remainder): + return "targets a self-hosted runner directly" + return None + + +def drifted_runner_variables( + variables: dict[str, str], +) -> list[tuple[str, str, str]]: + """(name, value, reason) for every runner variable holding a bad label. + + Sorted by name so the report is stable between windows and a reader can + diff two reports without spurious reordering. + """ + drifted = [] + for name, value in variables.items(): + if "RUNNER" not in name: + continue + if not isinstance(value, str): + continue + reason = forbidden_reason(value.strip()) + if reason is not None: + drifted.append((name, value.strip(), reason)) + return sorted(drifted) diff --git a/scripts/ci/workflow_guard_groups.py b/scripts/ci/workflow_guard_groups.py index c30102046694..bbc83f997f65 100644 --- a/scripts/ci/workflow_guard_groups.py +++ b/scripts/ci/workflow_guard_groups.py @@ -109,6 +109,7 @@ "scripts/ci/detect_linux_guard_changes.py", "scripts/ci/workflow_guard_groups.py", "tests/test_ci_change_areas.py", + "tests/test_ci_fork_runner_routing.py", "tests/test_ci_linux_guard_routing.py", "tests/test_ci_guard_workflow_structure.py", "tests/test_ci_app_host_guard_structure.py", diff --git a/scripts/verify-cmux-tui-hosted.sh b/scripts/verify-cmux-tui-hosted.sh index 53136edfc35a..763bbe984ce2 100755 --- a/scripts/verify-cmux-tui-hosted.sh +++ b/scripts/verify-cmux-tui-hosted.sh @@ -17,7 +17,8 @@ The reserved `chatmux_relay` filter runs the complete `chatmux-relay` package; Cargo test names do not include their package name, so a plain test-name filter cannot select that crate. --full runs the cross-platform merge gate, including real Windows execution. -Both modes build and download a macOS arm64 cmux-tui artifact from the exact pushed HEAD. +Both modes build and download matching macOS arm64 cmux-tui and userland agent-plugin +artifacts from the exact pushed HEAD. EOF } @@ -331,17 +332,34 @@ gh run download \ --name cmux-tui-aarch64-apple-darwin \ --dir "$temp_dir" +plugin_download_dir="$temp_dir/agent-plugin" +mkdir -p "$plugin_download_dir" +gh run download \ + --repo "$REPO" \ + "$run_id" \ + --name cmux-agent-screen-detection-aarch64-apple-darwin \ + --dir "$plugin_download_dir" + downloaded_binary="$(find "$temp_dir" -type f -name cmux-tui-aarch64-apple-darwin -print | sed -n '1p')" if [[ -z "$downloaded_binary" ]]; then echo "error: the macOS arm64 artifact did not contain cmux-tui" >&2 exit 1 fi +downloaded_plugin="$(find "$plugin_download_dir" -type f -name cmux-agent-screen-detection-aarch64-apple-darwin -print | sed -n '1p')" +if [[ -z "$downloaded_plugin" ]]; then + echo "error: the macOS arm64 artifact did not contain the agent screen-detection plugin" >&2 + exit 1 +fi + artifact_dir="cmux-tui/target/hosted/$commit" artifact_binary="$artifact_dir/cmux-tui" +artifact_plugin="$artifact_dir/cmux-agent-screen-detection" mkdir -p "$artifact_dir" install -m 0755 "$downloaded_binary" "$artifact_binary" +install -m 0755 "$downloaded_plugin" "$artifact_plugin" echo "Hosted verification passed: $run_url" echo "Artifact: $artifact_binary" +echo "Artifact: $artifact_dir/cmux-agent-screen-detection" echo "Dogfood: $artifact_binary --session verify-${commit:0:8}" diff --git a/tests/test-execution.toml b/tests/test-execution.toml index 1923506be239..c241f6c4edee 100644 --- a/tests/test-execution.toml +++ b/tests/test-execution.toml @@ -6,6 +6,11 @@ # `manual` entries require a reason. version = 1 +[[test]] +path = "tests/test_omp_auto_naming.py" +lane = "macos-cli-no-socket" +requirements = ["cmux-cli"] + [[test]] path = "tests/test_open_wrapper.py" lane = "macos-shell" @@ -314,6 +319,10 @@ lane = "linux-guard" path = "tests/test_ci_app_host_failure_census.py" lane = "linux-guard" +[[test]] +path = "tests/test_ci_replay_app_host_verdict.py" +lane = "linux-guard" + [[test]] path = "tests/test_ci_app_host_guard_structure.py" lane = "linux-guard" @@ -334,6 +343,10 @@ lane = "linux-guard" path = "tests/test_ci_change_areas.py" lane = "linux-guard" +[[test]] +path = "tests/test_ci_fork_runner_routing.py" +lane = "linux-guard" + [[test]] path = "tests/test_ci_checkout_network_diagnostics.py" lane = "linux-guard" @@ -366,6 +379,10 @@ lane = "linux-guard" path = "tests/test_ci_health_report.py" lane = "linux-guard" +[[test]] +path = "tests/test_runner_label_policy.py" +lane = "linux-guard" + [[test]] path = "tests/test_ci_linux_guard_routing.py" lane = "linux-guard" @@ -486,6 +503,10 @@ lane = "linux-guard" path = "tests/test_ios_appstore_lane_identity.py" lane = "linux-guard" +[[test]] +path = "tests/test_ios_appstore_upload_marker.py" +lane = "linux-guard" + [[test]] path = "tests/test_ios_selected_test_execution.py" lane = "linux-guard" @@ -566,6 +587,10 @@ lane = "linux-guard" path = "tests/test_preflight_trust.py" lane = "linux-guard" +[[test]] +path = "tests/test_e2e_warm_derived_data.py" +lane = "linux-guard" + [[test]] path = "tests/test_reuse_app_host_products.py" lane = "linux-guard" @@ -794,6 +819,10 @@ lane = "linux-guard" path = "tests/test_cmux_settings_supported_paths.py" lane = "linux-guard" +[[test]] +path = "tests/test_settings_configuration_review_paths.py" +lane = "linux-guard" + [[test]] path = "tests/test_codex_feed_hooks.py" lane = "legacy" diff --git a/tests/test_ci_app_host_home_isolation.py b/tests/test_ci_app_host_home_isolation.py index 922dbb45654b..ffeab78a73b9 100644 --- a/tests/test_ci_app_host_home_isolation.py +++ b/tests/test_ci_app_host_home_isolation.py @@ -3,6 +3,7 @@ from pathlib import Path import os +import re import subprocess import tempfile import xml.etree.ElementTree as ET @@ -181,6 +182,57 @@ def acceptance_gate_problem(condition: object, preparation_id: str) -> str: return "" +def published_derived_data_value(job, steps) -> str | None: + """Return the CMUX_DERIVED_DATA_PATH a job publishes, before expansion. + + Both callers compute the path in a shell variable and export it through + `GITHUB_ENV`, so the literal that matters is the assignment, not the echo. + """ + environment = job.get("env") + if isinstance(environment, dict) and environment.get("CMUX_DERIVED_DATA_PATH"): + return str(environment["CMUX_DERIVED_DATA_PATH"]) + for step in steps: + script = str(step.get("run", "")) + export = re.search( + r'CMUX_DERIVED_DATA_PATH=(?P[^"\n]*)"?\s*>>\s*"?\$(?:\{)?GITHUB_ENV', + script, + ) + if export is None: + continue + value = export.group("value").strip() + name = re.fullmatch(r"\$\{?(?P[A-Za-z_][A-Za-z0-9_]*)\}?", value) + if name is None: + return value + assignment = re.search( + rf'^\s*{name.group("name")}="(?P[^"]*)"', script, re.MULTILINE + ) + return assignment.group("path") if assignment else None + return None + + +def require_derived_data_under_runner_temp(where, job, steps) -> None: + """Hold app-host callers to the boundary cleanup enforces at runtime. + + `cleanup-app-host-home.sh` refuses to inspect a host whose DerivedData + lives outside `RUNNER_TEMP`, and it runs under `if: always()`, so a job + that parks DerivedData anywhere else goes red *after* its tests pass. + `test-e2e.yml` shipped exactly that: the split lane inherited a + workspace-rooted path from the single-job form, which no other check + looked at because no earlier version of that lane cleaned up at all. + """ + value = published_derived_data_value(job, steps) + if value is None: + raise SystemExit( + f"FAIL: {where} prepares an app-host home without publishing " + "CMUX_DERIVED_DATA_PATH; cleanup requires it" + ) + if not re.match(r"\$\{?RUNNER_TEMP\}?/", value): + raise SystemExit( + f"FAIL: {where} puts DerivedData at {value!r}; app-host cleanup " + "only inspects hosts whose DerivedData is under RUNNER_TEMP" + ) + + def check_every_app_host_home_is_identified_and_cleaned() -> None: """Hold every job that prepares an app-host home to the same contract. @@ -216,6 +268,7 @@ def check_every_app_host_home_is_identified_and_cleaned() -> None: f"FAIL: {where} must publish CMUX_APP_HOST_SHARD; " "cmux_resolve_app_host_identity rejects an empty shard" ) + require_derived_data_under_runner_temp(where, job, steps) cleanups = [ step for step in steps if "cleanup-app-host-home.sh" in str(step.get("run", "")) diff --git a/tests/test_ci_app_host_result_accounting.py b/tests/test_ci_app_host_result_accounting.py index 70e974110c60..1995e0eb506e 100644 --- a/tests/test_ci_app_host_result_accounting.py +++ b/tests/test_ci_app_host_result_accounting.py @@ -185,6 +185,46 @@ def test_missing_selected_test_result_never_passes() -> None: ] +def test_incomplete_run_still_names_the_failures_it_recorded() -> None: + """A shard with one missing result must still report what actually failed. + + On main's full suite at f3d204a462 all six app-host shards returned at the + incompleteness gate, so not one RATCHET_NEW_FAILURE line was printed across + the whole run even though the logs carried real assertion failures. A red + suite that names no regression cannot tell anyone whether a fix landed. + """ + passed, messages = accounting.check_run( + inventory={"FooTests/testOne()", "BarTests/testTwo()", "BazTests/testThree()"}, + selectors=["FooTests", "BarTests", "BazTests"], + results={ + "FooTests/testOne()": "Failed", + "BazTests/testThree()": "Failed", + }, + known={"BazTests/testThree()": "known on main"}, + log_text="", + xcode_status=65, + ) + assert passed is False + assert "typed xcresult is incomplete: 1 selected Test Case(s) have no terminal result" in messages + assert "missing typed test result: BarTests/testTwo()" in messages + assert "RATCHET_NEW_FAILURE FooTests/testOne()" in messages + assert "RATCHET_KNOWN_FAILURE BazTests/testThree()" in messages + assert "recorded verdicts: 1 new, 1 known-main; typed test cases: 2" in messages + + +def test_incomplete_run_without_failures_adds_no_ratchet_noise() -> None: + passed, messages = accounting.check_run( + inventory={"FooTests/testOne()", "BarTests/testTwo()"}, + selectors=["FooTests", "BarTests"], + results={"FooTests/testOne()": "Passed"}, + known={}, + log_text="", + xcode_status=0, + ) + assert passed is False + assert not [m for m in messages if m.startswith("RATCHET_")] + + def test_partial_suite_result_never_passes() -> None: passed, messages = accounting.check_run( inventory={"FooTests/testOne()", "FooTests/testTwo()"}, @@ -245,6 +285,32 @@ def test_restart_or_outer_timeout_is_never_ratcheted_green() -> None: assert messages[0].startswith("incomplete app-host run:") +def test_interrupted_run_still_names_the_failures_it_recorded() -> None: + """A restarted app host must not hide the failures recorded before it. + + On main's full suite at 638aaa717 (run 35862070143), shard 4 recorded a + failed XCTest case, then reported `app host restarted after test + execution` and printed no RATCHET line, so the run never named it. + """ + passed, messages = accounting.check_run( + inventory={"FooTests/testBad()", "FooTests/testGood()", "BazTests/testKnown()"}, + selectors=["FooTests", "BazTests"], + results={ + "FooTests/testBad()": "Failed", + "FooTests/testGood()": "Passed", + "BazTests/testKnown()": "Failed", + }, + known={"BazTests/testKnown()": "known on main"}, + log_text="Restarting after unexpected exit, crash, or test timeout\n", + xcode_status=65, + ) + assert passed is False + assert messages[0] == "incomplete app-host run: app host restarted after test execution" + assert "RATCHET_NEW_FAILURE FooTests/testBad()" in messages + assert "RATCHET_KNOWN_FAILURE BazTests/testKnown()" in messages + assert "recorded verdicts: 1 new, 1 known-main; typed test cases: 3" in messages + + def _catalog(tests: dict[str, dict[str, object]]) -> dict[str, object]: return { "bootstrap_main_sha": "1" * 40, diff --git a/tests/test_ci_app_host_test_output.py b/tests/test_ci_app_host_test_output.py index 1a7125bc5a62..c048db17b713 100644 --- a/tests/test_ci_app_host_test_output.py +++ b/tests/test_ci_app_host_test_output.py @@ -391,6 +391,84 @@ def test_selected_suite_requires_positive_summary_and_keeps_log_artifact(self) - else: self.assertIn("category=pre-test build/setup failure", completed.stdout) + def test_selected_single_test_passes_its_selector_and_names_its_log(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + root = pathlib.Path(temporary_directory) + fake_ci = root / "scripts/ci" + fake_ci.mkdir(parents=True) + shutil.copy2(SCRIPT, fake_ci / SCRIPT.name) + argv_log = root / "argv.log" + fake_runner = fake_ci / "xcodebuild_noninteractive.py" + fake_runner.write_text( + "#!/usr/bin/env python3\n" + "import os, sys\n" + "with open(os.environ['ARGV_LOG'], 'a') as log:\n" + " log.write(' '.join(a for a in sys.argv if a.startswith('-only-testing:')) + '\\n')\n" + "print('Test run with 1 test in 1 suite passed after 0.01 seconds.')\n", + encoding="utf-8", + ) + fake_runner.chmod(0o755) + results = root / "results" + completed = subprocess.run( + ["bash", "-c", TEST_DEPOT_RUN_UNIT_TESTS], + cwd=root, + env={ + **os.environ, + "UNIT_TEST_SUITES": "Foo/testSwift(),Bar/testXC,Baz", + "TEST_RESULTS_ROOT": str(results), + "ARGV_LOG": str(argv_log), + }, + capture_output=True, + text=True, + check=False, + ) + + self.assertEqual(completed.returncode, 0, completed.stderr) + self.assertEqual( + argv_log.read_text(encoding="utf-8").splitlines(), + [ + "-only-testing:cmuxTests/Foo/testSwift()", + "-only-testing:cmuxTests/Bar/testXC", + "-only-testing:cmuxTests/Baz", + ], + ) + self.assertTrue((results / "Foo.testSwift.log").is_file()) + self.assertTrue((results / "Bar.testXC.log").is_file()) + self.assertTrue((results / "Baz.log").is_file()) + + def test_selector_rejects_anything_but_suite_or_suite_slash_test(self) -> None: + for value in ("Foo/../Bar", "Foo/bar/baz", "Foo;true", "Foo/bar(x)", "/Foo", "Foo/"): + with self.subTest(value=value), tempfile.TemporaryDirectory() as temporary_directory: + root = pathlib.Path(temporary_directory) + fake_ci = root / "scripts/ci" + fake_ci.mkdir(parents=True) + shutil.copy2(SCRIPT, fake_ci / SCRIPT.name) + marker = root / "ran" + fake_runner = fake_ci / "xcodebuild_noninteractive.py" + fake_runner.write_text( + "#!/usr/bin/env python3\n" + f"open({str(marker)!r}, 'w').close()\n" + "print('Test run with 1 test in 1 suite passed after 0.01 seconds.')\n", + encoding="utf-8", + ) + fake_runner.chmod(0o755) + completed = subprocess.run( + ["bash", "-c", TEST_DEPOT_RUN_UNIT_TESTS], + cwd=root, + env={ + **os.environ, + "UNIT_TEST_SUITES": value, + "TEST_RESULTS_ROOT": str(root / "results"), + }, + capture_output=True, + text=True, + check=False, + ) + + self.assertNotEqual(completed.returncode, 0) + self.assertIn("Invalid unit suite identifier", completed.stdout + completed.stderr) + self.assertFalse(marker.exists()) + def test_singular_summary_is_supported(self) -> None: passed, _ = MODULE.classify("Executed 1 test, with 0 failures (0 unexpected)\n") diff --git a/tests/test_ci_change_areas.py b/tests/test_ci_change_areas.py index bd2a89141f9b..b575f4290fbb 100755 --- a/tests/test_ci_change_areas.py +++ b/tests/test_ci_change_areas.py @@ -465,7 +465,7 @@ def test_release_build_waits_for_linux_preflight_admission() -> None: release = workflow_job_block("release-build", MACOS_WORKFLOW) status = workflow_job_block("macos-status", MACOS_WORKFLOW) - assert "runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}" in admission + assert "runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}" in admission assert 'TARGET_JOB: "linux-preflight"' in admission assert "actions/runs/{run_id}/jobs?filter=latest&per_page=100" in admission assert "- release-admission" in release @@ -2671,10 +2671,15 @@ def test_macos_workflow_call_starts_after_cheap_static_gate() -> None: assert " - linux-preflight" not in caller assert "uses: ./.github/workflows/ci-macos.yml" in caller assert "needs.changes.outputs.macos != 'false'" in caller - assert "needs.changes.outputs.full_suite == 'true' || needs.changes.outputs.compile_admitted != 'true'" in caller + assert ( + "needs.changes.outputs.full_suite == 'true' " + "|| needs.changes.outputs.unit_suite == 'true' " + "|| needs.changes.outputs.compile_admitted != 'true'" + ) in caller for route in ( "macos", "full_suite", + "unit_suite", "compile_admitted", "release_build", "source_identity_valid", @@ -2715,6 +2720,118 @@ def test_macos_admission_waits_for_pull_request_debounce() -> None: assert " - macos-debounce" in workflow_job_block("ci-status") +JOBS_ONE_FAILURE = """{"jobs":[{"name":"changes","conclusion":"success"}, +{"name":"linux-preflight","conclusion":"failure"}, +{"name":"guards","conclusion":null}]}""" +JOBS_CLEAN = """{"jobs":[{"name":"changes","conclusion":"success"}, +{"name":"guards","conclusion":null}]}""" +JOBS_CANCELLED_ONLY = """{"jobs":[{"name":"web","conclusion":"cancelled"}]}""" +JOBS_EMPTY = """{"jobs":[]}""" +JOBS_ERROR_BODY = """{"message":"Not Found"}""" + + +def run_macos_debounce( + *, + jobs_payload: str = JOBS_CLEAN, + pulls_sha: str = "abc", + gh_broken: bool = False, + attempt: str = "1", + debounce_seconds: str = "1", +) -> subprocess.CompletedProcess: + """Run the real debounce step against a fake `gh` and a real `jq`. + + The fake serves the payload and then runs the step's own `--jq` program + over it, so a wrong accessor in the workflow shows up here as the + fail-open it would be in CI rather than passing on a pre-digested answer. + """ + script = workflow_job_step_script("macos-debounce", "Wait for follow-up pushes") + with tempfile.TemporaryDirectory() as raw: + root = Path(raw) + fake_bin = root / "bin" + fake_bin.mkdir() + # The step sleeps for the debounce window before it reads anything. + (fake_bin / "sleep").write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + (fake_bin / "sleep").chmod(0o755) + payload = root / "jobs.json" + payload.write_text(jobs_payload, encoding="utf-8") + gh = fake_bin / "gh" + if gh_broken: + gh.write_text("#!/bin/sh\nexit 1\n", encoding="utf-8") + else: + gh.write_text( + "#!/bin/sh\n" + 'prog=""\n' + 'prev=""\n' + 'for a in "$@"; do\n' + ' [ "$prev" = "--jq" ] && prog="$a"\n' + ' prev="$a"\n' + "done\n" + 'case "$*" in\n' + f" *pulls*) printf %s '{pulls_sha}' ;;\n" + f' *jobs*) jq -r "$prog" < "{payload}" ;;\n' + " *) exit 1 ;;\n" + "esac\n", + encoding="utf-8", + ) + gh.chmod(0o755) + return subprocess.run( + ["bash", "-c", script], + env={ + "PATH": f"{fake_bin}:{os.environ['PATH']}", + "GITHUB_RUN_ATTEMPT": attempt, + "GITHUB_REPOSITORY": "manaflow-ai/cmux", + "GITHUB_RUN_ID": "9", + "DEBOUNCE_SECONDS": debounce_seconds, + "PR_NUMBER": "1", + "HEAD_SHA": "abc", + "GH_TOKEN": "token", + }, + capture_output=True, + text=True, + check=False, + ) + + +def test_macos_admission_declines_a_run_that_already_failed() -> None: + declined = run_macos_debounce(jobs_payload=JOBS_ONE_FAILURE) + assert declined.returncode == 1 + assert "already failed" in declined.stdout + declined.stderr + # Declining by failing is load-bearing: `macos` is skipped either way, and + # only a failed dependency makes "Re-run failed jobs" re-run it. + debounce = workflow_job_block("macos-debounce") + assert " actions: read" in debounce + assert "$GITHUB_RUN_ID/jobs" in debounce + # The check must sit after the wait, so a re-run attempt and a zero-second + # window bypass it along with the debounce itself. + assert "$GITHUB_RUN_ID/jobs" in debounce.split('sleep "$DEBOUNCE_SECONDS"', 1)[1] + # macOS admission stays uncoupled from the Linux suites as a dependency. + assert "needs.linux-preflight" not in workflow_job_block("macos") + + +def test_macos_admission_admits_whenever_it_cannot_prove_a_failure() -> None: + for label, kwargs in ( + ("no failures", {"jobs_payload": JOBS_CLEAN}), + # A cancelled run is already going away; it is not a verdict. + ("cancelled only", {"jobs_payload": JOBS_CANCELLED_ONLY}), + ("no jobs yet", {"jobs_payload": JOBS_EMPTY}), + # An error body makes the step's own jq program fail, which must admit. + ("api error body", {"jobs_payload": JOBS_ERROR_BODY}), + ("gh unusable", {"gh_broken": True}), + # A re-run is asking for the results this would withhold. + ("re-run attempt", {"attempt": "2", "jobs_payload": JOBS_ONE_FAILURE}), + ("wait disabled", {"debounce_seconds": "0", "jobs_payload": JOBS_ONE_FAILURE}), + ): + result = run_macos_debounce(**kwargs) + assert result.returncode == 0, f"{label}: {result.stdout}{result.stderr}" + assert "already failed" not in result.stdout + result.stderr, label + + +def test_macos_admission_still_declines_a_moved_head_first() -> None: + moved = run_macos_debounce(pulls_sha="def", jobs_payload=JOBS_CLEAN) + assert moved.returncode == 1 + assert "head moved" in (moved.stdout + moved.stderr).lower() + + def run_tests_gate(needs: dict) -> subprocess.CompletedProcess: script = workflow_job_step_script("tests", "Check platform workflow routing") body = script.split("python3 - <<'PY'\n", 1)[1].rsplit("\nPY", 1)[0] @@ -3285,6 +3402,127 @@ def test_a_skipped_suite_is_refused_when_only_the_suite_could_judge_the_diff() - assert coverage_gap(event, False, tests_diff, []) is False +def test_unit_ci_asks_for_the_unit_tests_without_the_expensive_lanes() -> None: + sys.path.insert(0, str(ROOT / "scripts/ci")) + from choose_ci_suite import wants_unit_suite + + # The full suite already runs them, so it implies the cheaper tier. + assert wants_unit_suite("pull_request", "compile-only", ["full-ci"]) is True + assert wants_unit_suite("pull_request", "compile-only", ["unit-ci"]) is True + assert wants_unit_suite("pull_request", "compile-only", []) is False + # Unreadable labels keep the full suite, which includes the unit tests. + assert wants_unit_suite("pull_request", "compile-only", None) is True + for event in ("merge_group", "workflow_dispatch", "push"): + assert wants_unit_suite(event, "compile-only", []) is True + + + +def test_a_cmux_tests_diff_selects_the_unit_tests_without_a_label() -> None: + sys.path.insert(0, str(ROOT / "scripts/ci")) + from choose_ci_suite import coverage_gap, wants_unit_suite + + tests_diff = ["cmuxTests/WorkspaceUnitTests.swift", "Sources/Workspace.swift"] + # The diff already says which job can judge it; no label is needed. + assert wants_unit_suite("pull_request", "compile-only", [], tests_diff) is True + assert ( + coverage_gap( + "pull_request", + False, + tests_diff, + [], + unit_suite=wants_unit_suite("pull_request", "compile-only", [], tests_diff), + ) + is False + ) + # A diff outside cmuxTests/ keeps the cheap path. + assert wants_unit_suite("pull_request", "compile-only", [], ["Sources/Workspace.swift"]) is False + # cmuxUITests/ is not run by this job, so it does not select it, and the + # gap it leaves is still refused. + ui_diff = ["cmuxUITests/LaunchUITests.swift"] + assert wants_unit_suite("pull_request", "compile-only", [], ui_diff) is False + assert coverage_gap("pull_request", False, ui_diff, [], unit_suite=False) is True + # An unreadable diff runs the unit tests rather than guessing. + assert wants_unit_suite("pull_request", "compile-only", [], None) is True + +def test_the_unit_tier_closes_only_the_gap_its_job_can_judge() -> None: + sys.path.insert(0, str(ROOT / "scripts/ci")) + from choose_ci_suite import coverage_gap + + tests_diff = ["cmuxTests/WorkspaceUnitTests.swift"] + ui_diff = ["cmuxUITests/LaunchUITests.swift"] + + # `app-host unit tests` executes cmuxTests/, so asking for it observes + # the diff and there is nothing left to refuse. + assert coverage_gap("pull_request", False, tests_diff, ["unit-ci"], unit_suite=True) is False + # No pull request job runs cmuxUITests/, so the cheap tier cannot clear it. + assert coverage_gap("pull_request", False, ui_diff, ["unit-ci"], unit_suite=True) is True + assert ( + coverage_gap("pull_request", False, tests_diff + ui_diff, ["unit-ci"], unit_suite=True) + is True + ) + # An unreadable diff is never cleared by the cheap tier either. + assert coverage_gap("pull_request", False, None, ["unit-ci"], unit_suite=True) is True + # Default stays exactly as before for every caller that does not pass it. + assert coverage_gap("pull_request", False, tests_diff, []) is True + + +def test_the_unit_tier_is_routed_end_to_end() -> None: + caller = CI_WORKFLOW.read_text(encoding="utf-8") + assert " unit_suite: ${{ steps.suite.outputs.unit_suite }}" in caller + assert " unit_suite: ${{ needs.changes.outputs.unit_suite }}" in caller + + # The macOS workflow must be reachable for a unit-ci run whose compile was + # already admitted, or the label would route nothing. + macos_call = workflow_job_block("macos") + assert "needs.changes.outputs.unit_suite == 'true'" in macos_call + + called = MACOS_WORKFLOW.read_text(encoding="utf-8") + assert " unit_suite:" in called + + # The cheap tier runs the unit tests and nothing else. + unit_gate = workflow_job_block("app-host-unit-tests", MACOS_WORKFLOW) + assert "inputs.unit_suite == 'true'" in unit_gate + for job in ("tests-build-and-lag", "release-admission", "release-build"): + assert "inputs.unit_suite" not in workflow_job_block(job, MACOS_WORKFLOW), job + + +def test_a_unit_ci_run_still_requires_the_macos_workflow_to_pass() -> None: + # The tests job restates the macos `if:` as a result contract; a routed + # unit-ci run that skipped macOS must not read as legitimately unrouted. + gate = workflow_job_block("tests") + assert 'unit_suite = outputs.get("unit_suite") == "true"' in gate + assert "unit_suite" in gate.split("macos_work_required")[1].split(")")[0] + + +def test_a_unit_ci_run_cannot_pass_with_the_unit_tests_skipped() -> None: + # unit-ci clears suite-coverage, so the app-host tests it asked for are the + # only thing that judges the diff. Reusing an earlier run's compile skips + # compile admission, and app-host hangs off admission succeeding -- the + # usual label-after-first-push run would go green having run nothing. + for step in ( + "Skip compile when build inputs are unchanged", + "Look for an earlier run that compiled these inputs", + ): + condition = workflow_step_block("changes", step) + assert "steps.suite.outputs.unit_suite != 'true'" in condition, step + + # And the status fails closed if the job the label asked for still skipped. + inputs = { + "macos": "true", + "full_suite": "false", + "unit_suite": "true", + "compile_admitted": "true", + "release_build": "false", + "source_identity_valid": "true", + "source_tree": "tree", + "source_parent1": "parent", + } + skipped = dict.fromkeys(MACOS_JOBS, "skipped") + assert run_macos_status(inputs=inputs, results=skipped).returncode != 0 + ran = {**skipped, "app-host-unit-tests": "success"} + assert run_macos_status(inputs=inputs, results=ran).returncode == 0 + + def test_ci_status_requires_the_suite_coverage_gate() -> None: block = workflow_job_block("ci-status") assert " - suite-coverage" in block @@ -4056,7 +4294,7 @@ def test_app_host_multi_batch_failure_cannot_reuse_prior_expected_summary() -> N assert runner_invoked assert result.returncode != 0, result.stdout - assert "simulated app-host crash before test summary" in result.stdout + assert result.stdout.count("simulated app-host crash before test summary") == 1 def test_app_host_catalogued_failure_is_tolerated_with_red_xcode_status() -> None: @@ -4367,9 +4605,32 @@ def test_reuse_lookups_match_the_job_name_github_actually_reports() -> None: assert not admission_job_name(None) reuse = (ROOT / "scripts/ci/reuse_app_host_products.py").read_text(encoding="utf-8") - assert '.rsplit(" / ", 1)[-1] == "macOS compile admission"' in reuse, ( + assert '.rsplit(" / ", 1)[-1] == compile_name' in reuse, ( "reuse_app_host_products.py must match the final segment of the job name" ) + # Each trusted producer workflow names the job that has to have compiled. + sys.path.insert(0, str(ROOT / "scripts/ci")) + import reuse_app_host_products + + assert reuse_app_host_products.COMPILE_JOBS[".github/workflows/ci.yml"][0] == ADMISSION_JOB, ( + "reuse_app_host_products.py must look for ci.yml's admission job by its real name" + ) + # A run reports its caller as `path`, so ci.yml's producer job is defined + # in the reusable workflow it calls rather than in ci.yml itself. + definitions = { + ".github/workflows/ci.yml": ".github/workflows/ci-macos.yml", + ".github/workflows/test-e2e.yml": ".github/workflows/test-e2e.yml", + } + for path, (job_name, step_name) in reuse_app_host_products.COMPILE_JOBS.items(): + workflow = yaml.safe_load((ROOT / definitions[path]).read_text(encoding="utf-8")) + producer = next( + (job for job in workflow["jobs"].values() + if job.get("name", "") == job_name), None, + ) or workflow["jobs"].get(job_name) + assert producer is not None, f"{path} has no job named {job_name!r}" + assert any(step.get("name") == step_name for step in producer["steps"]), ( + f"{path} job {job_name!r} has no step named {step_name!r}" + ) diff --git a/tests/test_ci_e2e_compilation_cache.py b/tests/test_ci_e2e_compilation_cache.py index a832e46609d2..00edb21d08c1 100644 --- a/tests/test_ci_e2e_compilation_cache.py +++ b/tests/test_ci_e2e_compilation_cache.py @@ -204,6 +204,35 @@ def test_cleanup_removes_only_owned_paths(self): self.assertFalse(Path(values['CMUX_DERIVED_DATA_PATH']).exists()) self.assertFalse(Path(values['CMUX_E2E_COMPILATION_CACHE']).exists()) + def prepare_test_job(self): + for file in ('env', 'output'): + (self.root / file).write_text('') + result = self.run_step('Prepare isolated DerivedData', 'test') + self.assertEqual(result.returncode, 0, result.stderr) + return dict(line.split('=', 1) for file in ('env', 'output') + for line in (self.root / file).read_text().splitlines()) + + def test_the_test_job_cleans_up_the_product_it_restored(self): + # This cleanup runs under `if: always()`, so an ownership pattern that + # does not match the job's own prepared path turns a passing test run + # red after the tests have already succeeded. The path also has to stay + # under RUNNER_TEMP: app-host cleanup refuses to inspect a host whose + # DerivedData lives anywhere else. + values = self.prepare_test_job() + derived = Path(values['CMUX_DERIVED_DATA_PATH']) + self.assertTrue(derived.is_relative_to(self.root)) + self.assertFalse(derived.is_relative_to(self.workspace)) + self.assertNotIn('CMUX_E2E_COMPILATION_CACHE', values) + unrelated = self.root / 'keep' + unrelated.mkdir() + rejected = self.run_step('Clean owned DerivedData', 'test', + **dict(values, CMUX_DERIVED_DATA_PATH=str(unrelated))) + self.assertNotEqual(rejected.returncode, 0) + self.assertTrue(unrelated.exists()) + result = self.run_step('Clean owned DerivedData', 'test', **values) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertFalse(derived.exists()) + def test_only_successful_trusted_main_build_can_seed(self): values = self.prepare() cache = Path(values['CMUX_E2E_COMPILATION_CACHE']) diff --git a/tests/test_ci_fork_runner_routing.py b/tests/test_ci_fork_runner_routing.py new file mode 100644 index 000000000000..cd69a5387a3d --- /dev/null +++ b/tests/test_ci_fork_runner_routing.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +"""Fork pull-request workflows must use GitHub-hosted runners with zero setup.""" + +from __future__ import annotations + +import re +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +WORKFLOWS = ROOT / ".github" / "workflows" + +FORK_LINUX_BRANCH = "github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04'" +FORK_MACOS_BRANCH = "github.repository_owner != 'manaflow-ai' && 'macos-15'" +LOCAL_WORKFLOW_CALL = re.compile( + r"uses:\s+\./\.github/workflows/([A-Za-z0-9_.-]+\.ya?ml)" +) + + +def pull_request_workflows() -> list[Path]: + result: list[Path] = [] + for path in sorted(WORKFLOWS.glob("*.y*ml")): + text = path.read_text(encoding="utf-8") + if re.search(r"(?m)^ pull_request:\s*(?:$|\[|\{)", text): + result.append(path) + return result + + +def fork_exercised_workflows() -> list[Path]: + """PR workflows plus every local reusable workflow reachable from them.""" + pending = list(pull_request_workflows()) + seen: set[Path] = set() + while pending: + path = pending.pop() + if path in seen: + continue + seen.add(path) + text = path.read_text(encoding="utf-8") + for name in LOCAL_WORKFLOW_CALL.findall(text): + called = WORKFLOWS / name + if called.is_file() and called not in seen: + pending.append(called) + return sorted(seen) + + +class ForkRunnerRoutingTests(unittest.TestCase): + def test_pull_request_graph_is_nonempty_and_includes_reusable_workflows(self) -> None: + roots = pull_request_workflows() + graph = fork_exercised_workflows() + self.assertTrue(roots) + self.assertGreater(len(graph), len(roots)) + + def test_every_fork_exercised_runner_has_a_hosted_path(self) -> None: + """No fork PR may queue forever on organization-only capacity.""" + saw_linux = 0 + saw_macos = 0 + + for path in fork_exercised_workflows(): + text = path.read_text(encoding="utf-8") + for number, line in enumerate(text.splitlines(), start=1): + if "runs-on:" not in line: + continue + + # A few trust-boundary workflows already choose GitHub-hosted + # capacity specifically for pull_request and use the repository + # pool for push/main. That is equivalent to the owner branch. + pull_request_linux = bool( + re.search( + r"github\.event_name == 'pull_request'.*'ubuntu-[^']+'", + line, + ) + ) + pull_request_macos = bool( + re.search( + r"github\.event_name == 'pull_request'.*'macos-[^']+'", + line, + ) + ) + hosted_linux = FORK_LINUX_BRANCH in line or pull_request_linux + hosted_macos = FORK_MACOS_BRANCH in line or pull_request_macos + + with self.subTest(workflow=path.name, line=number): + if "vars.LINUX_RUNNER" in line: + saw_linux += 1 + self.assertTrue( + hosted_linux, + f"{path.name}:{number} has no GitHub-hosted Linux fork branch", + ) + if "vars.MACOS_RUNNER" in line: + saw_macos += 1 + self.assertTrue( + hosted_macos, + f"{path.name}:{number} has no GitHub-hosted macOS fork branch", + ) + if "blacksmith-" in line: + self.assertTrue( + hosted_linux or hosted_macos, + f"{path.name}:{number} can queue forever in a fork: {line.strip()}", + ) + if re.search(r"\b(?:warp|depot|tart)-", line): + self.assertTrue( + hosted_linux or hosted_macos, + f"{path.name}:{number} can route a fork onto non-GitHub capacity", + ) + + self.assertGreater(saw_linux, 0) + self.assertGreater(saw_macos, 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_ci_guard_workflow_structure.py b/tests/test_ci_guard_workflow_structure.py index ce5829d96384..db441c14f89d 100644 --- a/tests/test_ci_guard_workflow_structure.py +++ b/tests/test_ci_guard_workflow_structure.py @@ -13,6 +13,7 @@ "python3 tests/test_ci_guard_workflow_structure.py", "python3 tests/test_app_host_test_products.py", "python3 tests/test_reuse_app_host_products.py", + "python3 tests/test_e2e_warm_derived_data.py", "python3 tests/test_ci_product_publication.py", ] diff --git a/tests/test_ci_health_report.py b/tests/test_ci_health_report.py index 13436efebf34..3a86ecf16207 100644 --- a/tests/test_ci_health_report.py +++ b/tests/test_ci_health_report.py @@ -16,6 +16,7 @@ import json import sys import unittest +from dataclasses import replace from pathlib import Path from typing import Any @@ -266,6 +267,50 @@ def test_fork_jobs_and_their_uncached_minutes_are_totalled(self): self.assertEqual(jobs, 1) self.assertAlmostEqual(minutes, 60.0) + def test_paid_runner_minutes_counts_only_metered_labels(self): + # Blacksmith and GitHub-hosted labels are free to this repository, so a + # report that totals them alongside Warp hides the only line that costs + # money. Free labels must contribute nothing. + free = [r for r in self.rows if not r.label.startswith("warp-")] + jobs, minutes, breakdown = report.paid_runner_minutes(free) + self.assertEqual((jobs, minutes, breakdown), (0, 0.0, [])) + + def test_every_metered_provider_prefix_is_counted(self): + # Depot is a paid provider too -- permitted by the self-hosted guard and + # documented alongside Warp. A warp-only check would total zero and print + # "none in the window" the moment a variable is pinned to it, which is + # the exact silent drift this line exists to catch. + sample = self.rows[0] + rows = [ + replace(sample, label="warp-macos-15-arm64-6x", minutes=4.0), + replace(sample, label="depot-macos-latest", minutes=6.0), + replace(sample, label="blacksmith-4vcpu-ubuntu-2404", minutes=90.0), + ] + jobs, minutes, breakdown = report.paid_runner_minutes(rows) + self.assertEqual(jobs, 2) + self.assertAlmostEqual(minutes, 10.0) + self.assertEqual( + breakdown, + [("depot-macos-latest", 1, 6.0), ("warp-macos-15-arm64-6x", 1, 4.0)], + ) + + def test_paid_runner_minutes_totals_and_splits_by_label(self): + sample = self.rows[0] + rows = [ + replace(sample, label="warp-macos-15-arm64-6x", minutes=10.0), + replace(sample, label="warp-macos-15-arm64-6x", minutes=5.0), + replace(sample, label="warp-macos-26-arm64-12x", minutes=20.0), + replace(sample, label="blacksmith-6vcpu-macos-26", minutes=99.0), + ] + jobs, minutes, breakdown = report.paid_runner_minutes(rows) + self.assertEqual(jobs, 3) + self.assertAlmostEqual(minutes, 35.0) + # Ordered by minutes descending, so the costliest lane reads first. + self.assertEqual( + breakdown, + [("warp-macos-26-arm64-12x", 1, 20.0), ("warp-macos-15-arm64-6x", 2, 15.0)], + ) + class SamplingTests(unittest.TestCase): def setUp(self) -> None: @@ -435,6 +480,10 @@ def test_the_waste_patterns_reach_the_output(self): self.assertIn("macOS / app-host tests", self.text) self.assertIn("Reruns of an unchanged tree", self.text) self.assertIn("Fork pull requests (no cache access):", self.text) + # Without this the whole render block can be deleted and every test + # still passes: the accumulator is covered, the rendering was not. + self.assertIn("Paid runner capacity", self.text) + def test_partial_data_is_announced_instead_of_failing(self): partial = metrics_from(load("window.json"), CURRENT_WINDOW, partial=("rate limited on runs",)) diff --git a/tests/test_ci_product_publication.py b/tests/test_ci_product_publication.py index 35e7cde1af4c..b645eaddb3b3 100644 --- a/tests/test_ci_product_publication.py +++ b/tests/test_ci_product_publication.py @@ -12,7 +12,7 @@ ROOT = Path(__file__).resolve().parents[1] -def condition(expression, *, full_suite, publish="true"): +def condition(expression, *, full_suite, publish="true", unit_suite="false"): """Evaluate the small boolean subset used by these actual workflow gates.""" expression = expression.removeprefix("${{").removesuffix("}}").strip() expression = expression.replace("!cancelled()", "True") @@ -22,6 +22,8 @@ def value(match): return repr("success") if name.endswith(".outputs.full_suite") or name == "inputs.full_suite": return repr(full_suite) + if name.endswith(".outputs.unit_suite") or name == "inputs.unit_suite": + return repr(unit_suite) if name.endswith(".outputs.compile_admitted") or name == "inputs.compile_admitted": return repr("false") if name.endswith(".outputs.publish"): @@ -46,12 +48,13 @@ def setUpClass(cls): cls.workflow = yaml.safe_load((ROOT / ".github/workflows/ci-macos.yml").read_text()) cls.job = cls.workflow["jobs"]["macos-compile-admission"] - def publication(self, *, full_suite, event="pull_request", head="contributor/cmux", repo="manaflow-ai/cmux"): + def publication(self, *, full_suite, unit_suite="false", event="pull_request", head="contributor/cmux", repo="manaflow-ai/cmux"): step = next((s for s in self.job["steps"] if s.get("id") == "publish-products"), None) if step is None: return "true" # The previous workflow always packaged and uploaded. self.assertEqual(step["env"], { "PRODUCT_FULL_SUITE": "${{ inputs.full_suite }}", + "PRODUCT_UNIT_SUITE": "${{ inputs.unit_suite }}", "PRODUCT_EVENT": "${{ github.event_name }}", "PRODUCT_HEAD_REPOSITORY": "${{ github.event.pull_request.head.repo.full_name }}", "PRODUCT_REPOSITORY": "${{ github.repository }}", @@ -59,6 +62,7 @@ def publication(self, *, full_suite, event="pull_request", head="contributor/cmu with tempfile.TemporaryDirectory() as directory: output = Path(directory) / "output" env = dict(os.environ, GITHUB_OUTPUT=str(output), PRODUCT_FULL_SUITE=full_suite, + PRODUCT_UNIT_SUITE=unit_suite, PRODUCT_EVENT=event, PRODUCT_HEAD_REPOSITORY=head, PRODUCT_REPOSITORY=repo) subprocess.run(["bash", "-e", "-c", step["run"]], env=env, text=True, capture_output=True, check=True) @@ -67,6 +71,7 @@ def publication(self, *, full_suite, event="pull_request", head="contributor/cmu def test_only_known_compile_only_forks_skip_packaging_and_upload(self): cases = [ ({"full_suite": "false"}, "false"), + ({"full_suite": "false", "unit_suite": "true"}, "true"), ({"full_suite": "true"}, "true"), ({"full_suite": ""}, "true"), ({"full_suite": "unknown"}, "true"), @@ -94,6 +99,13 @@ def test_all_actual_artifact_consumers_are_excluded_from_compile_only(self): self.assertFalse(condition(job["if"], full_suite="false"), name) self.assertTrue(condition(job["if"], full_suite="true"), name) self.assertEqual(set(consumers), {"app-host-unit-tests", "tests-build-and-lag"}) + # `unit-ci` admits exactly one consumer under the compile-only policy, + # and the product it reads is published for it (see the unit-tier case + # in test_only_known_compile_only_forks_skip_packaging_and_upload). + unit_if = self.workflow["jobs"]["app-host-unit-tests"]["if"] + self.assertTrue(condition(unit_if, full_suite="false", unit_suite="true")) + lag_if = self.workflow["jobs"]["tests-build-and-lag"]["if"] + self.assertFalse(condition(lag_if, full_suite="false", unit_suite="true")) for name in consumers: self.assertNotIn("reuse-products", str(self.workflow["jobs"][name]["if"])) diff --git a/tests/test_ci_release_sdk_lane.sh b/tests/test_ci_release_sdk_lane.sh index 40b7869a29a5..35bf31d46cbc 100755 --- a/tests/test_ci_release_sdk_lane.sh +++ b/tests/test_ci_release_sdk_lane.sh @@ -32,7 +32,7 @@ require_job_contains() { require_job_contains \ "$RELEASE_FILE" \ "build-ghostty-cli-helper" \ - 'runs-on: ${{ vars.MACOS_RUNNER_15 || '\''blacksmith-6vcpu-macos-15'\'' }}' \ + 'runs-on: ${{ vars.CI_PAID_MACOS_OVERFLOW == '\''1'\'' && vars.MACOS_RUNNER_15 || '\''blacksmith-6vcpu-macos-15'\'' }}' \ "release must build the real Ghostty CLI helper on macOS 15" require_job_contains \ @@ -44,8 +44,8 @@ require_job_contains \ require_job_contains \ "$CI_FILE" \ "release-build" \ - 'runs-on: ${{ vars.MACOS_RUNNER_26_RELEASE || '\''blacksmith-6vcpu-macos-26'\'' }}' \ - "CI release-build must compile the app on macOS 26 using the release-specific runner variable" + 'runs-on: ${{ github.repository_owner != '\''manaflow-ai'\'' && '\''macos-15'\'' || (vars.MACOS_RUNNER_26 || '\''blacksmith-6vcpu-macos-26'\'') }}' \ + "CI release-build must use GitHub-hosted macOS on forks and the macOS 26 runner variable upstream" for workflow in "$CI_FILE" "$RELEASE_FILE"; do if ! grep -Fq "CMUX_SKIP_ZIG_BUILD=1 xcodebuild" "$workflow"; then @@ -73,7 +73,7 @@ swift_package_section="$(job_section "$CI_FILE" "swift-package-tests")" # Every event, pull requests included: this job builds the Release Ghostty CLI # helper against an SDK 15 Xcode, which only the macos-15 image carries, so it # must not follow MACOS_RUNNER_PR onto whatever pool that lane points at. -if [[ "$swift_package_section" != *'runs-on: ${{ vars.MACOS_RUNNER_DUAL_XCODE || '\''blacksmith-6vcpu-macos-15'\'' }}'* ]]; then +if [[ "$swift_package_section" != *'runs-on: ${{ github.repository_owner != '\''manaflow-ai'\'' && '\''macos-15'\'' || (vars.CI_PAID_MACOS_OVERFLOW == '\''1'\'' && vars.MACOS_RUNNER_DUAL_XCODE || '\''blacksmith-6vcpu-macos-15'\'') }}'* ]]; then echo "FAIL: CI swift-package-tests must use the dual-Xcode runner lane on every event" >&2 exit 1 fi diff --git a/tests/test_ci_replay_app_host_verdict.py b/tests/test_ci_replay_app_host_verdict.py new file mode 100644 index 000000000000..52b384b5b47f --- /dev/null +++ b/tests/test_ci_replay_app_host_verdict.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +"""The replay tool must not invent a missing selector out of shell quoting. + +`run-app-host-xcodebuild.sh` records each argument with `printf 'arg=%q\n'`. +Today's test identifiers are bare under `%q`, but a selector carrying a +character it escapes would, under a naive quote strip, come back mangled -- +and a mangled selector looks exactly like a test that the batch never +selected. That is the same shape of finding this tool exists to report, so a +parse it cannot do correctly has to fail loudly rather than quietly. +""" + +from __future__ import annotations + +import importlib.util +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "scripts" / "ci" / "replay_app_host_verdict.py" + +spec = importlib.util.spec_from_file_location("replay_app_host_verdict", SCRIPT) +assert spec and spec.loader +replay = importlib.util.module_from_spec(spec) +sys.modules[spec.name] = replay +spec.loader.exec_module(replay) + + +def write_meta(body: str) -> Path: + handle = tempfile.NamedTemporaryFile("w", suffix=".meta", delete=False, encoding="utf-8") + handle.write(body) + handle.close() + return Path(handle.name) + + +class SelectorsFromMetaTests(unittest.TestCase): + def test_bare_identifiers_round_trip(self) -> None: + meta = write_meta( + "shard=6\n" + "arg=-xctestrun\n" + "arg=/tmp/cmux-unit.xctestrun\n" + "arg=-only-testing:cmuxTests/FooTests/testOne()\n" + "arg=-only-testing:cmuxTests/BarTests\n" + ) + self.assertEqual( + replay.selectors_from_meta(meta), + ["cmuxTests/FooTests/testOne()", "cmuxTests/BarTests"], + ) + + def test_parenthesised_identifier_matches_printf_q_output(self) -> None: + """Pin the decoding against what bash actually emits, not an assumption.""" + identifier = "cmuxTests/FooTests/testParameterised(value:)" + quoted = subprocess.run( + ["bash", "-c", 'printf "%q" "$1"', "_", f"-only-testing:{identifier}"], + capture_output=True, + text=True, + check=True, + ).stdout + meta = write_meta(f"arg={quoted}\n") + self.assertEqual(replay.selectors_from_meta(meta), [identifier]) + + def test_shell_metacharacters_survive_printf_q(self) -> None: + """A selector %q must escape still decodes to the original string.""" + identifier = "cmuxTests/FooTests/test a b()" + quoted = subprocess.run( + ["bash", "-c", 'printf "%q" "$1"', "_", f"-only-testing:{identifier}"], + capture_output=True, + text=True, + check=True, + ).stdout + self.assertNotEqual(quoted, f"-only-testing:{identifier}", "fixture must be escaped") + meta = write_meta(f"arg={quoted}\n") + self.assertEqual(replay.selectors_from_meta(meta), [identifier]) + + def test_unparseable_argv_refuses_instead_of_guessing(self) -> None: + meta = write_meta("arg=-only-testing:cmuxTests/FooTests/'unbalanced\n") + with self.assertRaises(SystemExit): + replay.selectors_from_meta(meta) + + def test_ansi_c_quoting_refuses_instead_of_mangling(self) -> None: + meta = write_meta("arg=$'-only-testing:cmuxTests/FooTests/test\\tOne()'\n") + with self.assertRaises(SystemExit): + replay.selectors_from_meta(meta) + + def test_multi_token_argv_refuses_instead_of_taking_the_first(self) -> None: + """%q emits one token per argument, so two means the record is not %q. + + Reading only the first would drop the rest of the line without a word, + which is the same silent shrink of the selector set the other refusals + exist to prevent. + """ + meta = write_meta("arg=-only-testing:cmuxTests/FooTests -only-testing:cmuxTests/BarTests\n") + with self.assertRaises(SystemExit): + replay.selectors_from_meta(meta) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_ci_repo_variable_defaults.py b/tests/test_ci_repo_variable_defaults.py index 3c3ae5590764..40e02eb96ff1 100644 --- a/tests/test_ci_repo_variable_defaults.py +++ b/tests/test_ci_repo_variable_defaults.py @@ -40,6 +40,72 @@ VARS_REFERENCE = re.compile(r"vars\.([A-Z0-9_]+)") RUNS_ON = re.compile(r"^\s*runs-on:\s*(.+?)\s*$") +# The repository-side switch for metered macOS capacity. Unset is the cheap +# reading, so a fork pull request and a repository with no admin action both +# land on the free Blacksmith fallback. +PAID_OVERFLOW_GATE = "CI_PAID_MACOS_OVERFLOW" + +# Runner variables whose purpose is the paid overflow path, and the free label +# each must fall back to (the "Intended steady state" in docs/ci-runners.md). +# Each selects a lane that runs on every push to main or in the merge queue, +# where nobody is watching a check name closely enough to notice the pool +# changed under it. The fallback is pinned because a gate with the wrong +# literal moves the lane silently: the nightly builder once fell back to 6vcpu, +# half its intended 12. +# +# This is not a list of every runner variable, and adding one here is not a +# free safety improvement. The gate asks "may we spend money", so a variable +# that selects a free pool does not belong: gating it would mean repointing +# that pool -- at owned Mac hardware, say -- required turning the paid-overflow +# flag on. MACOS_RUNNER_26 is deliberately absent for that reason. What guards +# a variable outside this table is the value policy in runner_label_policy.py. +PAID_CAPABLE_RUNNER_VARS = { + "MACOS_RUNNER_15": "blacksmith-6vcpu-macos-15", + "MACOS_RUNNER_DISPLAY": "blacksmith-6vcpu-macos-15", + "MACOS_RUNNER_DUAL_XCODE": "blacksmith-6vcpu-macos-15", + "MACOS_RUNNER_26_LARGE": "blacksmith-12vcpu-macos-26", +} + +# The gate as it must appear immediately before the read. The lookbehind keeps +# `inputs.CI_PAID_MACOS_OVERFLOW == '1' && ` from passing for the repository's +# own flag. +PAID_OVERFLOW_GATE_PREFIX = re.compile( + rf"(? why this read of a paid-capable variable is not a +# runner selection and must NOT carry the gate. +# +# The gate answers "should this job run on metered capacity". A read that +# reports the variable's value is asking a different question, and gating it +# inverts the answer: with the gate unset, `vars.GATE == '1' && vars.NAME` +# evaluates to false, so the reporter would see an empty string and conclude +# the configuration is clean no matter what the variable actually holds. The +# one place that can see runner values would go blind exactly when it matters. +GATE_EXEMPT_REPORTING_READS = { + ("ci-health-report.yml", "CMUX_CI_RUNNER_VARIABLES"): + "reports each runner variable's value; gating would report empty", +} + + +def reporting_env_key(lines: list[str], number: int) -> str | None: + """The env key whose block scalar contains line `number`, if any. + + Reads inside a `KEY: |` block are values being collected, not an + expression selecting a runner. + """ + indent = len(lines[number - 1]) - len(lines[number - 1].lstrip()) + for previous in range(number - 2, -1, -1): + text = lines[previous] + if not text.strip() or text.lstrip().startswith("#"): + continue + current = len(text) - len(text.lstrip()) + if current >= indent: + continue + matched = re.match(r"\s*([A-Za-z_][A-Za-z0-9_]*):\s*[|>]", text) + return matched.group(1) if matched else None + return None + def workflow_files() -> list[Path]: return sorted(WORKFLOWS.glob("*.y*ml")) @@ -115,15 +181,96 @@ def check_cheap_defaults(path: Path, errors: list[str]) -> None: offset += len(line) + 1 +def check_paid_overflow_gate(path: Path, errors: list[str]) -> None: + """Reading a paid-capable runner variable requires the repository's own flag. + + Rule 1 covers the variable being *unset*. This covers it being *set*, which + is the case the repository actually got wrong: between 2026-09-19 and + 2026-09-23 every variable in the table above, plus the release lane's former variable, pointed at WarpBuild, so main and the merge + queue ran on metered capacity while pull requests ran free on Blacksmith. + Nothing in the repository could see it, because a variable's value is not + reviewable and every other guard here reads workflow text. + + The gate restores the polarity the rest of this file assumes: stopping spend + is a pull request anyone with push access can merge, and starting it needs + both an admin-set runner variable and CI_PAID_MACOS_OVERFLOW=1. Unset, the + literal Blacksmith fallback wins, which is the cheap path. + """ + lines = path.read_text(encoding="utf-8").splitlines() + for number, line in enumerate(lines, start=1): + if line.lstrip().startswith("#"): + continue + for name, free_label in PAID_CAPABLE_RUNNER_VARS.items(): + for read in re.finditer(rf"vars\.{name}\b", line): + if not PAID_OVERFLOW_GATE_PREFIX.search(line[: read.start()]): + key = reporting_env_key(lines, number) + if key is not None and (path.name, key) in GATE_EXEMPT_REPORTING_READS: + continue + errors.append( + f"{path.name}:{number}: vars.{name} is read without the " + f"paid overflow gate. It can hold a metered WarpBuild " + f"label, so write `vars.{PAID_OVERFLOW_GATE} == '1' && " + f"vars.{name} || '{free_label}'`" + ) + continue + fallback = re.match(r"\s*\|\|\s*'([^']+)'", line[read.end():]) + if fallback is None or fallback.group(1) != free_label: + actual = fallback.group(1) if fallback else "nothing" + errors.append( + f"{path.name}:{number}: gated vars.{name} falls back to " + f"{actual!r}, but its free steady state is {free_label!r}" + ) + + +def self_test_gate_matcher(errors: list[str]) -> None: + """The reporting exemption must not become a hole in the gate. + + An exemption that quietly widened would disable the check for the exact + variables it exists to protect, and nothing else here would notice: the + guard would keep printing PASS. These probes pin both directions. + """ + import tempfile + + free = PAID_CAPABLE_RUNNER_VARS["MACOS_RUNNER_15"] + probes = ( + ("other.yml", + f"runs-on: ${{{{ vars.MACOS_RUNNER_15 || '{free}' }}}}", 1, + "an ungated runs-on"), + ("other.yml", + f"runs-on: ${{{{ vars.{PAID_OVERFLOW_GATE} == '1' && vars.MACOS_RUNNER_15" + f" || '{free}' }}}}", + 0, "a gated runs-on"), + ("other.yml", + " env:\n SOME_OTHER_KEY: |\n A=${{ vars.MACOS_RUNNER_15 }}", + 1, "an ungated read under a non-exempt env key"), + ("ci-health-report.yml", + " env:\n CMUX_CI_RUNNER_VARIABLES: |\n A=${{ vars.MACOS_RUNNER_15 }}", + 0, "the exempt reporting block"), + ) + with tempfile.TemporaryDirectory() as directory: + for filename, body, expected, description in probes: + probe = Path(directory) / filename + probe.write_text(body + "\n", encoding="utf-8") + found: list[str] = [] + check_paid_overflow_gate(probe, found) + if len(found) != expected: + errors.append( + f"paid-overflow gate self-test: {description} produced " + f"{len(found)} error(s), expected {expected}" + ) + + def main() -> int: errors: list[str] = [] files = workflow_files() if not files: print(f"no workflows found under {WORKFLOWS}", file=sys.stderr) return 1 + self_test_gate_matcher(errors) for path in files: check_runs_on(path, errors) check_cheap_defaults(path, errors) + check_paid_overflow_gate(path, errors) if errors: print("Repository variables that an unset value makes expensive:", file=sys.stderr) diff --git a/tests/test_ci_self_hosted_guard.sh b/tests/test_ci_self_hosted_guard.sh index 1e4724d24dd3..bacd6a66309d 100755 --- a/tests/test_ci_self_hosted_guard.sh +++ b/tests/test_ci_self_hosted_guard.sh @@ -6,8 +6,10 @@ # see docs/ci-runners.md. The one sanctioned free lane is MACOS_RUNNER_BACKGROUND, # whose fallback is GitHub-hosted macos-15 and whose members must stay off the # pull request and merge path (check_background_macos_lane). -# Fork PRs are gated by GitHub's built-in "Require approval for outside -# collaborators" setting, so workflow-level fork guards are not needed. +# Fork execution has a separate portability rule: the normal CI graph routes +# every non-manaflow-ai repository owner to GitHub-hosted runners, because a +# Blacksmith label in a personal fork queues forever. The upstream branch of +# each expression retains the repository-variable routing checked below. set -euo pipefail ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" @@ -73,17 +75,28 @@ check_display_runner_identity_guard() { } check_release_build_runner_disk_capacity() { - if ! awk ' + # Pin the whole expression, not the variable name and the literal as two + # independent substring matches. Those two can both be satisfied by a line + # whose effective fallback is a different machine: + # + # vars.MACOS_RUNNER_26 || 'blacksmith-12vcpu-macos-26' || 'blacksmith-6vcpu-macos-26' + # + # still contains the name and still contains blacksmith-6vcpu-macos-26, but + # resolves to the 12vcpu pool. Matching the whole string also catches a + # paid-overflow gate appearing here, which does not belong: MACOS_RUNNER_26 + # is the free macOS 26 pool and is read ungated everywhere. See + # docs/ci-runners.md for why the gate must not grow to cover it. + if ! awk -v release_runner="runs-on: \${{ github.repository_owner != 'manaflow-ai' && 'macos-15' || (vars.MACOS_RUNNER_26 || 'blacksmith-6vcpu-macos-26') }}" ' /^ release-build:/ { in_job=1; next } in_job && /^ [^[:space:]#][^:]*:[[:space:]]*(#.*)?$/ { in_job=0 } - in_job && /runs-on:/ && /vars\.MACOS_RUNNER_26_RELEASE/ && /blacksmith-6vcpu-macos-26/ { saw_release_runner=1 } + in_job && index($0, release_runner) { saw_release_runner=1 } END { exit !saw_release_runner } ' "$CI_MACOS_FILE"; then - echo "FAIL: release-build must use the release-specific macOS 26 runner var with a cloud (Blacksmith) fallback for disk-heavy universal builds" + echo "FAIL: release-build must run the disk-heavy universal build on the macOS 26 runner variable with the exact blacksmith-6vcpu-macos-26 fallback" exit 1 fi - echo "PASS: release-build uses release-specific macOS 26 runner fallback" + echo "PASS: release-build uses the macOS 26 runner variable and its exact Blacksmith fallback" } check_build_lag_deriveddata_cache_path() { @@ -185,13 +198,18 @@ import sys import yaml document = yaml.safe_load(open(sys.argv[1])) -# Compilation caching and the fast artifact transport are optimizations with +# Compilation caching, adopted DerivedData and the fast artifact transport are optimizations with # canonical fallbacks. Everything else must fail the job it runs in. allowed = { ("build", "compilation-cache-restore", "Restore E2E compilation cache", "actions/cache/restore"), ("build", None, "Save E2E compilation cache", "actions/cache/save"), ("build", "compilation-cache-bound", "Bound E2E compilation cache", ""), ("build", "revision-on-main", "Check the selected revision against main", ""), + ("build", "reuse", "Reuse a compiled product instead of building one", ""), + ("build", "warm", "Adopt main's DerivedData", ""), + ("build", "record-inputs", "Record build input times", ""), + ("build", "warm-package", "Package DerivedData for later builds", ""), + ("build", None, "Publish DerivedData for later builds", "actions/upload-artifact"), ("test", "parallel-product", "Read the compiled test product over parallel range requests", ""), } for job_id, job in document["jobs"].items(): @@ -285,11 +303,11 @@ check_release_build_disk_cleanup() { } check_release_helper_artifact_from_package_lane() { - if ! awk ' + if ! awk -v dual_runner="runs-on: \${{ github.repository_owner != 'manaflow-ai' && 'macos-15' || (vars.CI_PAID_MACOS_OVERFLOW == '1' && vars.MACOS_RUNNER_DUAL_XCODE || 'blacksmith-6vcpu-macos-15') }}" ' /^ swift-package-tests:/ { in_job=1; next } in_job && /^ [^[:space:]#][^:]*:[[:space:]]*(#.*)?$/ { in_job=0 } - in_job && /runs-on:[[:space:]]*\$\{\{ vars\.MACOS_RUNNER_DUAL_XCODE \|\| '\''blacksmith-6vcpu-macos-15'\'' \}\}/ { saw_dual_runner=1 } + in_job && index($0, dual_runner) { saw_dual_runner=1 } in_job && /vars\.MACOS_RUNNER_PR/ { saw_pr_lane=1 } in_job && /timeout-minutes:[[:space:]]*40/ { saw_timeout=1 } in_job && /CMUX_CI_HELPER_XCODE_APP:/ { saw_helper_xcode_env=1 } @@ -1811,6 +1829,75 @@ CASES echo "PASS: pull request workflows with macOS jobs cancel superseded runs" } +check_macos_xcode_pin_tracks_pull_request_lane() { + # A pull-request macOS job picks its pool through MACOS_RUNNER_PR and its + # toolchain through CMUX_CI_XCODE_APP. The two macOS images carry different + # Xcodes and scripts/select-ci-xcode.sh exits non-zero on a pinned path that + # is not installed, so a value naming the macos-15 Xcode in a job whose pool + # can move is a job that fails at Xcode selection the moment the lane moves. + # + # Default-deny rather than an allowlist of covered jobs: every env value under + # .github/workflows that names the macos-15 Xcode must also read the + # pull-request variant, unless its exact (file, job, key) is exempted below + # with a reason. A macOS job added next month inherits the rule for free. + local violations + violations="$(python3 - "$ROOT_DIR/.github/workflows" <<'PYTHON' +import sys +from pathlib import Path + +import yaml + +# (workflow file, job id, env key) -> why this site keeps the macos-15 Xcode +# regardless of where the pull-request lane points. +EXEMPT = { + ("iroh-release-gate.yml", "tailscale-version-skew", "CMUX_CI_XCODE_APP"): + "uses the macOS 15 Xcode configuration; runs on MACOS_RUNNER_15 for paid overflow or blacksmith-6vcpu-macos-15 otherwise", + ("ci-macos.yml", "swift-package-tests", "CMUX_CI_XCODE_APP"): + "builds the SDK 15 Ghostty helper; stays on MACOS_RUNNER_DUAL_XCODE", + ("ci-macos.yml", "swift-package-tests", "CMUX_CI_HELPER_XCODE_APP"): + "same job's SDK 15 release-helper pin", +} + +PINNED = ("CMUX_CI_XCODE_APP_MACOS_15", "CMUX_CI_HELPER_XCODE_APP_MACOS_15") +LANE = ("CMUX_CI_XCODE_APP_PR", "CMUX_CI_HELPER_XCODE_APP_PR") + +violations = [] +for path in sorted(Path(sys.argv[1]).glob("*.yml")): + try: + document = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + except yaml.YAMLError as error: + violations.append(f"{path.name}: unparseable ({error})") + continue + for job_id, job in (document.get("jobs") or {}).items(): + if not isinstance(job, dict): + continue + scopes = [job] + scopes.extend(step for step in (job.get("steps") or []) if isinstance(step, dict)) + for scope in scopes: + for key, value in ((scope.get("env") or {})).items(): + if not isinstance(value, str): + continue + if not any(name in value for name in PINNED): + continue + if any(name in value for name in LANE): + continue + if (path.name, job_id, key) in EXEMPT: + continue + violations.append(f"{path.name}::{job_id}: {key}: {value.strip()}") + +print("\n".join(violations)) +PYTHON +)" + if [ -n "$violations" ]; then + echo "FAIL: a macos-15 Xcode pin does not follow the pull-request lane" + echo " Route it through CMUX_CI_XCODE_APP_PR, or add its (file, job, key) to" + echo " EXEMPT with a reason." + printf '%s\n' "$violations" + exit 1 + fi + echo "PASS: every macos-15 Xcode pin either follows the pull-request lane or is exempt with a reason" +} + check_macos_runner_identity_env_tracks_routing() { # A macOS job picks its pool in `runs-on`, and some jobs then restate that # pool in an env value: `CMUX_PRODUCT_RUNNER` becomes a field of the compiled @@ -1879,10 +1966,10 @@ PYTHON } check_no_paid_overflow_fallbacks() { - # Repository variables are not exposed to pull requests from forks, so the - # `vars.X || 'label'` fallback is where every fork pull request runs. Warp is - # the paid overflow provider: allowed as an explicit workflow_dispatch choice, - # never as a default. + # Forks take the explicit GitHub-hosted owner branch before any repository + # variable is read. The upstream fallback must still avoid Warp: it is the + # paid overflow provider, allowed as an explicit workflow_dispatch choice, + # never as an implicit default. local hits hits="$(grep -rnE "\\|\\|[[:space:]]*'warp-" "$ROOT_DIR/.github/workflows" || true)" if [ -n "$hits" ]; then @@ -1917,8 +2004,13 @@ background_lane_blocking_events() { } strip_background_lane_expr() { - awk -v e="vars.MACOS_RUNNER_BACKGROUND || 'macos-15'" '{ + # Ignore the two sanctioned GitHub-hosted macOS forms before looking for a + # stray hosted label: the non-blocking background lane, and the explicit + # non-manaflow-ai fork branch used by the normal CI graph. + awk -v e="vars.MACOS_RUNNER_BACKGROUND || 'macos-15'" \ + -v f="github.repository_owner != 'manaflow-ai' && 'macos-15' || " '{ while ((i = index($0, e)) > 0) $0 = substr($0, 1, i - 1) substr($0, i + length(e)) + while ((i = index($0, f)) > 0) $0 = substr($0, 1, i - 1) substr($0, i + length(f)) print }' } @@ -2020,4 +2112,5 @@ check_pr_macos_workflows_cancel_superseded_runs check_ios_only_tests_stay_under_ios check_no_paid_overflow_fallbacks check_macos_runner_identity_env_tracks_routing +check_macos_xcode_pin_tracks_pull_request_lane check_background_macos_lane diff --git a/tests/test_cli_tmux_compat_targeted_read_budget.py b/tests/test_cli_tmux_compat_targeted_read_budget.py index 9a9dca01e511..fbb8f1954d89 100644 --- a/tests/test_cli_tmux_compat_targeted_read_budget.py +++ b/tests/test_cli_tmux_compat_targeted_read_budget.py @@ -148,13 +148,14 @@ def serve(directory, limiter, fault=None, wire_reply=None): path.unlink(missing_ok=True) -def run(cli, path, directory, arguments, timeout=15): +def run(cli, path, directory, arguments, timeout=15, extra_env=None): env = {k: v for k, v in os.environ.items() if not k.startswith(("CMUX", "TMUX"))} env.update({ "CMUX_SOCKET_PATH": str(path), "CMUX_WORKSPACE_ID": WORKSPACE_ID, "CMUX_SURFACE_ID": SURFACE_ID, "CMUX_PANE_ID": PANE_ID, "TMUX_PANE": PANE, "CMUXTERM_CLI_RESPONSE_TIMEOUT_SEC": str(timeout), "HOME": str(directory), }) + env.update(extra_env or {}) return subprocess.run( [cli, "--socket", str(path), *arguments], env=env, text=True, capture_output=True, timeout=30, @@ -195,6 +196,71 @@ def tmux_flow(cli, directory, limiter): print("PASS: targeted display, detached split, command delivery, and multi-pane list under real rate limits") +def managed_teammate_flow(cli, directory, limiter): + """Exercise the real-session launcher, not its --version fallback. + + Claude Code 2.1.280 reads TMUX_PANE, looks up #{window_id} with -t, + counts that window's panes, then splits the leader with -d -h -l 70%. + The stand-in runs that sequence through the launcher's managed tmux shim. + """ + managed = directory / "cmux-cli-shims" / SURFACE_ID + managed.mkdir(parents=True, mode=0o700) + real_bin = directory / "real-bin" + real_bin.mkdir() + wrapper = managed / "claude" + wrapper.write_text( + '#!/bin/sh\nset -eu\n' + '[ "${CMUX_CLAUDE_TEAMS_WRAPPER_LAUNCH:-}" = 1 ]\n' + 'exec "$CMUX_TEST_REAL_CLAUDE" "$@"\n' + ) + wrapper.chmod(0o700) + agent = real_bin / "claude" + agent.write_text(r'''#!/bin/sh +set -eu +[ "$1" = --teammate-mode ] && [ "$2" = auto ] +[ "$CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS" = 1 ] +[ "$TMUX_PANE" = "$CMUX_TEST_PANE" ] +# Restore a shell snapshot containing only the app's managed wrapper root. +# This must still reach cmux's tmux shim, without a launcher-only PATH entry. +export PATH="$CMUX_TEST_SNAPSHOT_PATH" +[ "$(command -v tmux)" = "$CMUX_CLAUDE_WRAPPER_SHIM_ROOT/tmux" ] +window="$(tmux display-message -t "$TMUX_PANE" -p '#{window_id}')" +[ "$window" = "$CMUX_TEST_WINDOW" ] +[ "$(tmux list-panes -t "$window" -F '#{pane_id}')" = "$TMUX_PANE" ] +teammate="$(tmux split-window -d -t "$TMUX_PANE" -h -l 70% -P -F '#{pane_id}' -- sleep 20)" +[ "$teammate" = "$CMUX_TEST_NEW_PANE" ] +[ "$(tmux display-message -t "$TMUX_PANE" -p '#S:#I.#P')" = cmux:0.0 ] +[ "$(tmux display-message -t "$teammate" -p '#P')" = 1 ] +tmux list-panes -t "$window" -F '#{pane_id}' +''') + agent.chmod(0o700) + extra_env = { + "PATH": f"{managed}:{real_bin}:/usr/bin:/bin", + "CMUX_CLAUDE_WRAPPER_SHIM_ROOT": str(managed), + "CMUX_CLAUDE_WRAPPER_SHIM": str(wrapper), + "CMUX_CUSTOM_CLAUDE_PATH": str(agent), + "CMUX_TEST_REAL_CLAUDE": str(agent), + "CMUX_TEST_SNAPSHOT_PATH": f"{managed}:/usr/bin:/bin", + "CMUX_TEST_PANE": PANE, + "CMUX_TEST_WINDOW": WINDOW, + "CMUX_TEST_NEW_PANE": NEW_PANE, + } + # Each fresh launch gets fresh socket state and a fresh polling budget. + for _ in range(2): + with serve(directory, limiter) as (server, path): + success(run(cli, path, directory, [ + "claude-teams", "--teammate-mode", "auto", + ], extra_env=extra_env), PANE + "\n" + NEW_PANE) + assert server.state.split_count == 1 + assert not server.state.focus_new, "a detached teammate must not steal leader focus" + assert server.state.sent_text == ["sleep 20\r"] + assert server.limited, "the real launcher flow must exercise polling backpressure" + assert not server.early_retries + for connection, request, _ in server.limited: + assert server.requests.count((connection, request)) >= 2 + print("PASS: fresh managed Claude Teams launches discover and split teammates under real rate limits") + + def error_contract(cli, directory, limiter): for method in ("pane.list", "surface.split"): for code, hint in [ @@ -291,6 +357,7 @@ def main(): limiter = ProductionLimiter(directory) try: tmux_flow(cli, directory, limiter) + managed_teammate_flow(cli, directory, limiter) error_contract(cli, directory, limiter) limiter_isolation(directory, limiter) finally: diff --git a/tests/test_e2e_warm_derived_data.py b/tests/test_e2e_warm_derived_data.py new file mode 100644 index 000000000000..509d22e381d8 --- /dev/null +++ b/tests/test_e2e_warm_derived_data.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +"""Adopting main's DerivedData must rebuild exactly the inputs that changed.""" +import io +import os +from pathlib import Path +import sys +import tarfile +import tempfile +import unittest +from unittest import mock + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts/ci")) +import e2e_warm_derived_data as warm + +BUILD_TIME_NS = 1_700_000_000_000_000_000 + + +class ReplayTimes(unittest.TestCase): + def setUp(self): + self.root = Path(tempfile.mkdtemp()) + self.producer = self.root / "producer" + self.consumer = self.root / "consumer" + for workspace in (self.producer, self.consumer): + (workspace / "Sources").mkdir(parents=True) + (workspace / "cmuxTests").mkdir() + (workspace / "Sources/App.swift").write_text("let app = 1\n") + (workspace / "cmuxTests/AppTests.swift").write_text("let test = 1\n") + for path in self.producer.rglob("*.swift"): + os.utime(path, ns=(BUILD_TIME_NS, BUILD_TIME_NS)) + (self.producer / "DerivedData").mkdir() + (self.producer / "DerivedData/output.o").write_text("object") + (self.producer / ".git").mkdir() + (self.producer / ".git/index").write_text("git") + + def mtime(self, relative): + return (self.consumer / relative).stat().st_mtime_ns + + def test_unchanged_inputs_take_the_producer_time_and_changed_inputs_do_not(self): + recorded = warm.record(self.producer) + (self.consumer / "cmuxTests/AppTests.swift").write_text("let test = 2\n") + (self.consumer / "cmuxTests/NewTests.swift").write_text("let added = 1\n") + + restored, changed = warm.replay(self.consumer, recorded) + + self.assertEqual((restored, changed), (1, 2)) + self.assertEqual(self.mtime("Sources/App.swift"), BUILD_TIME_NS) + self.assertGreater(self.mtime("cmuxTests/AppTests.swift"), BUILD_TIME_NS) + self.assertGreater(self.mtime("cmuxTests/NewTests.swift"), BUILD_TIME_NS) + + def test_a_changed_input_unpacked_with_an_old_time_is_still_rebuilt(self): + recorded = warm.record(self.producer) + # An archive-extracted file (GhosttyKit, SwiftPM binaries) keeps the + # archive's time, which can predate the producer's build. + header = self.consumer / "Sources/App.swift" + header.write_text("let app = 2\n") + os.utime(header, ns=(BUILD_TIME_NS - 10**12, BUILD_TIME_NS - 10**12)) + + warm.replay(self.consumer, recorded) + + self.assertGreater(self.mtime("Sources/App.swift"), BUILD_TIME_NS) + + def test_build_outputs_and_git_metadata_are_not_inputs(self): + recorded = warm.record(self.producer) + self.assertEqual(sorted(recorded), ["Sources/App.swift", "cmuxTests/AppTests.swift"]) + + +class TrustedProducers(unittest.TestCase): + def artifact(self, branch="main", expired=False): + return {"expired": expired, "workflow_run": {"id": 7, "head_branch": branch, "repository_id": 1, "head_repository_id": 1}} + + def test_only_main_dispatches_of_this_workflow_are_adopted(self): + run = {"path": warm.WORKFLOW_PATH, "event": "workflow_dispatch"} + with mock.patch.object(warm, "api", return_value=run): + self.assertTrue(warm.trusted(self.artifact(), "o/r")) + self.assertFalse(warm.trusted(self.artifact(branch="feature"), "o/r")) + self.assertFalse(warm.trusted(self.artifact(expired=True), "o/r")) + with mock.patch.object(warm, "api", return_value={**run, "path": ".github/workflows/other.yml"}): + self.assertFalse(warm.trusted(self.artifact(), "o/r")) + + +class ArchiveBounds(unittest.TestCase): + def archive(self, name, link=None): + path = Path(tempfile.mkdtemp(), "derived-data.tar.gz") + with tarfile.open(path, "w:gz") as bundle: + member = tarfile.TarInfo(name) + if link is not None: + member.type, member.linkname = tarfile.SYMTYPE, link + bundle.addfile(member) + else: + member.size = 1 + bundle.addfile(member, io.BytesIO(b"x")) + return path + + def test_members_outside_derived_data_are_rejected(self): + destination = Path(tempfile.mkdtemp()) + for archive in (self.archive("../escape"), self.archive("link", link="/etc/passwd")): + with self.assertRaises(ValueError): + warm.extract(archive, destination) + self.assertEqual(list(destination.iterdir()), []) + + def test_an_absolute_link_inside_derived_data_is_accepted(self): + destination = Path(tempfile.mkdtemp()) + target = str(destination / "Build/Products/Debug/PackageFrameworks") + warm.extract(self.archive("Build/Products/link", link=target), destination) + self.assertTrue((destination / "Build/Products/link").is_symlink()) + + def test_a_contained_archive_extracts(self): + destination = Path(tempfile.mkdtemp()) + warm.extract(self.archive("Build/Intermediates.noindex/a.o"), destination) + self.assertTrue((destination / "Build/Intermediates.noindex/a.o").is_file()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_ios_appstore_upload_marker.py b/tests/test_ios_appstore_upload_marker.py new file mode 100644 index 000000000000..44c085765371 --- /dev/null +++ b/tests/test_ios_appstore_upload_marker.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +"""The cmux.app upload marker must follow Apple's receipt, not the step's exit. + +ios-appstore-upload.yml skips a scheduled poll when a completed run for the +same main SHA left a `cmux-app-testflight-upload` artifact: the marker says +"Apple already has this revision", and the next poll only retries group +assignment. The two steps that write and retain that marker ran only on +success, so a failure after App Store Connect had accepted the IPA (issue +#13690: an unbound array in the notes step, after "uploaded": true) left no +marker. Every hourly poll then archived and uploaded the same revision again. + +These tests run the marker step's own script against a fake runner directory, +with and without an upload receipt, and check that both steps are allowed to +run after the upload step fails. +""" + +import json +import os +import subprocess +import tempfile +import unittest +from pathlib import Path + +import yaml + +ROOT = Path(__file__).resolve().parents[1] +WORKFLOW = ROOT / ".github/workflows/ios-appstore-upload.yml" +RECORD = "Record completed upload before group assignment" +RETAIN = "Retain completed upload for assignment retries" +APP_ID = "6783338052" +RECEIPT = {"uploadId": "41b619d5", "fileName": "cmux-resigned.ipa", "uploaded": True} + + +def upload_steps(): + workflow = yaml.safe_load(WORKFLOW.read_text(encoding="utf-8")) + return workflow["jobs"]["upload"]["steps"] + + +def step(name): + for candidate in upload_steps(): + if candidate.get("name") == name: + return candidate + raise AssertionError(f"missing step {name!r}") + + +def runs_after_a_failed_step(condition): + # GitHub skips a step after a failure unless its condition carries a + # status function that allows it. + return any(fn in str(condition or "") for fn in ("always()", "failure()", "!cancelled()")) + + +class UploadMarkerTests(unittest.TestCase): + def record(self, outcome, receipt=None, build_number_file="20260922133206", output_build=""): + """Run the marker step's script; return (GITHUB_OUTPUT dict, marker or None).""" + record = step(RECORD) + with tempfile.TemporaryDirectory() as directory: + temp = Path(directory) + upload_dir = temp / "cmux-ios-upload" + upload_dir.mkdir() + if receipt is not None: + (upload_dir / "upload.log").write_text(receipt, encoding="utf-8") + if build_number_file is not None: + (temp / "cmux-final-build-number.txt").write_text( + build_number_file + "\n", encoding="utf-8" + ) + output = temp / "github_output" + output.touch() + env = { + **os.environ, + "RUNNER_TEMP": str(temp), + "GITHUB_OUTPUT": str(output), + "GITHUB_SHA": "head-sha", + } + for key, value in (record.get("env") or {}).items(): + value = str(value) + value = value.replace("${{ runner.temp }}", str(temp)) + value = value.replace("${{ steps.upload.outcome }}", outcome) + value = value.replace("${{ steps.upload.outputs.final_build_number }}", output_build) + env[key] = value + result = subprocess.run( + ["bash", "-e", "-c", record["run"]], env=env, capture_output=True, text=True + ) + self.assertEqual(result.returncode, 0, result.stderr) + outputs = dict( + line.split("=", 1) for line in output.read_text().splitlines() if "=" in line + ) + marker_path = temp / "cmux-app-upload-marker" / "upload.json" + marker = json.loads(marker_path.read_text()) if marker_path.exists() else None + return outputs, marker + + def test_marker_steps_run_after_a_failed_upload_step(self): + self.assertTrue(runs_after_a_failed_step(step(RECORD).get("if")), step(RECORD).get("if")) + retain = str(step(RETAIN).get("if") or "") + self.assertTrue(runs_after_a_failed_step(retain), retain) + # Retention follows what the record step decided, so a failure before + # Apple accepted anything never publishes a marker. + self.assertIn("steps.record_upload.outputs.recorded == 'true'", retain) + self.assertEqual(step(RECORD).get("id"), "record_upload") + + def test_failure_after_the_receipt_records_the_upload(self): + outputs, marker = self.record("failure", receipt=json.dumps(RECEIPT) + "\n") + self.assertEqual(outputs.get("recorded"), "true") + self.assertEqual( + marker, {"sha": "head-sha", "app_id": APP_ID, "build_number": "20260922133206"} + ) + + def test_receipt_among_other_log_lines_is_found(self): + log = "Uploading cmux-resigned.ipa (53037920 bytes)\n" + json.dumps(RECEIPT, indent=2) + "\n" + outputs, marker = self.record("failure", receipt=log) + self.assertEqual(outputs.get("recorded"), "true") + self.assertEqual(marker["build_number"], "20260922133206") + + def test_failure_before_the_receipt_records_nothing(self): + # upload-testflight.sh writes the build number file before archiving, + # so the file alone must never produce a marker. + for receipt in (None, "", "error: export failed\n", json.dumps({**RECEIPT, "uploaded": False})): + with self.subTest(receipt=receipt): + outputs, marker = self.record("failure", receipt=receipt) + self.assertNotEqual(outputs.get("recorded"), "true") + self.assertIsNone(marker) + + def test_receipt_without_a_numeric_build_number_records_nothing(self): + for build_number in (None, "", "unknown"): + with self.subTest(build_number=build_number): + outputs, marker = self.record( + "failure", receipt=json.dumps(RECEIPT), build_number_file=build_number + ) + self.assertNotEqual(outputs.get("recorded"), "true") + self.assertIsNone(marker) + + def test_successful_upload_still_records(self): + outputs, marker = self.record( + "success", receipt=json.dumps(RECEIPT), output_build="20260922133206" + ) + self.assertEqual(outputs.get("recorded"), "true") + self.assertEqual(marker["build_number"], "20260922133206") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_nightly_universal_build.sh b/tests/test_nightly_universal_build.sh index d6a283751604..df0b27758c43 100644 --- a/tests/test_nightly_universal_build.sh +++ b/tests/test_nightly_universal_build.sh @@ -85,12 +85,15 @@ if grep -Fq 'github.rest.repos.getBranch' "$WORKFLOW_FILE"; then exit 1 fi -if ! awk ' +if ! awk -v refresh_runner="runs-on: \${{ vars.MACOS_RUNNER_26 || 'blacksmith-6vcpu-macos-26' }}" ' /^ refresh-compilation-cache:/ { in_refresh=1; next } in_refresh && /^ [a-zA-Z0-9_-]+:/ { in_refresh=0 } in_refresh && /timeout-minutes: 90/ { saw_cold_build_timeout=1 } in_refresh && /if: github\.event_name == '\''schedule'\'' && github\.event\.schedule == '\''17 \*\/6 \* \* \*'\''/ { saw_schedule_gate=1 } - in_refresh && /runs-on: \$\{\{ vars\.MACOS_RUNNER_26_RELEASE/ { saw_release_runner=1 } + # Match the whole expression, not a prefix of it. `.*vars\.NAME` also matches + # the ungated form, so this guard would keep passing if somebody dropped the + # paid-overflow gate from the lane it exists to pin. + in_refresh && index($0, refresh_runner) { saw_release_runner=1 } in_refresh && /CMUX_CI_XCODE_APP_MACOS_26/ { saw_release_xcode=1 } in_refresh && /select-ci-xcode\.sh/ { saw_xcode_selection=1 } in_refresh && /^ - name: Restore Xcode compilation cache/ { saw_lookup=1 } @@ -168,18 +171,20 @@ if ! awk ' exit 1 fi -if ! awk ' +if ! awk -v helper_runner="runs-on: \${{ needs.decide.outputs.fast_build == 'true' && 'blacksmith-6vcpu-macos-15' || vars.CI_PAID_MACOS_OVERFLOW == '1' && vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15' }}" \ + -v app_runner="runs-on: \${{ needs.decide.outputs.fast_build == 'true' && 'blacksmith-12vcpu-macos-26' || vars.CI_PAID_MACOS_OVERFLOW == '1' && vars.MACOS_RUNNER_26_LARGE || 'blacksmith-12vcpu-macos-26' }}" ' /^ build-nightly-ghostty-cli-helper:/ { job="helper"; next } /^ build-nightly-app:/ { job="app"; next } /^ build-sign-notarize-nightly:/ { job="publish"; next } /^ [a-zA-Z0-9_-]+:/ { job="" } # Fast branch dogfood pins Blacksmith. Normal Nightly uses the repository - # override. Both must retain the macOS 15 helper lane. - job == "helper" && /runs-on: \$\{\{ .*vars\.MACOS_RUNNER_15/ { saw_helper_runner=1 } + # override. Both must retain the macOS 15 helper lane. Match the whole + # expression: a `.*vars\.NAME` prefix would also accept the ungated form. + job == "helper" && index($0, helper_runner) { saw_helper_runner=1 } job == "helper" && /build-ghostty-cli-helper\.sh --universal/ { saw_build=1 } job == "helper" && /lipo .* -verify_arch arm64 x86_64/ { saw_arch_assert=1 } job == "helper" && /name: cmux-nightly-ghostty-cli-helper/ { saw_helper_artifact=1 } - job == "app" && /runs-on: \$\{\{ .*vars\.MACOS_RUNNER_26_NIGHTLY_BUILD/ { saw_app_runner=1 } + job == "app" && index($0, app_runner) { saw_app_runner=1 } job == "app" && /CMUX_CI_XCODE_APP_MACOS_26/ { saw_app_xcode=1 } job == "app" && /select-ci-xcode\.sh/ { saw_app_selection=1 } job == "app" && /name: cmux-nightly-unsigned-app/ { saw_app_artifact=1 } diff --git a/tests/test_omp_auto_naming.py b/tests/test_omp_auto_naming.py new file mode 100644 index 000000000000..6092086596c9 --- /dev/null +++ b/tests/test_omp_auto_naming.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +"""Exercise bundled CLI naming against strict Pi/OMP subprocess fixtures. + +Run with --cli /path/to/tagged/app/Contents/Resources/bin/cmux. +The fake socket and HOME are isolated; no running app or provider is required. +""" +import argparse +import json +import os +from pathlib import Path +import socket +import subprocess +import tempfile +import threading +import time +import uuid + + +def run_case(cli, agent, override=None): + with tempfile.TemporaryDirectory(prefix="omp-naming-", dir="/tmp") as temporary: + root = Path(temporary) + workspace, surface = str(uuid.uuid4()), str(uuid.uuid4()) + session = "naming-regression" + selected = override or agent + agent_binary = root / selected + flags = ["--print", "--no-tools", "--no-session", "--no-extensions", "--no-skills"] + flags += ["--no-rules"] if selected == "omp" else ["--no-prompt-templates", "--no-context-files"] + agent_binary.write_text( + "#!/usr/bin/python3\nimport json, pathlib, sys\n" + f"pathlib.Path({str(root / 'argv.json')!r}).write_text(json.dumps(sys.argv[1:]))\n" + f"expected = {flags!r}\n" + "if sys.argv[1:1+len(expected)] != expected: sys.exit(2)\n" + "assert len(sys.argv) == len(expected) + 3\n" + "prompt = pathlib.Path(sys.argv[-2][1:])\n" + "assert prompt.is_file() and prompt.stat().st_mode & 0o777 == 0o600\n" + "assert 'Repair OMP workspace naming' in prompt.read_text()\n" + "print('Repair OMP Naming')\n" + ) + agent_binary.chmod(0o755) + now = time.time() + store = root / f"{agent}-hook-sessions.json" + store.write_text(json.dumps({"version": 1, "sessions": {session: { + "sessionId": session, "workspaceId": workspace, "surfaceId": surface, + "startedAt": now, "updatedAt": now, + "autoNameMessageSequence": 20, + "autoNameRecentMessages": [ + {"role": "user", "text": "Repair OMP workspace naming"}, + {"role": "assistant", "text": "Use the supported isolation flags."} + ] + }}})) + requests = [] + socket_path = str(root / "control.sock") + listener = socket.socket(socket.AF_UNIX) + listener.bind(socket_path) + listener.listen() + listener.settimeout(0.2) + stopped = threading.Event() + + def serve(): + while not stopped.is_set(): + try: + connection, _ = listener.accept() + except socket.timeout: + continue + with connection: + stream = connection.makefile("rwb") + for raw in stream: + request = json.loads(raw) + requests.append(request) + result = {"enabled": True, "workspace_user_owned": False, + "workspace_applied": True, "panel_applied": True} + if override: + result["summarizer_agent"] = override + stream.write((json.dumps({"id": request["id"], "ok": True, + "result": result}) + "\n").encode()) + stream.flush() + + server = threading.Thread(target=serve) + server.start() + try: + environment = { + "HOME": temporary, "PATH": f"{temporary}:/usr/bin:/bin", + "CMUX_AGENT_HOOK_STATE_DIR": temporary, + "CMUX_CLAUDE_HOOK_STATE_PATH": str(store), + "CMUX_SOCKET_PATH": socket_path, + "CMUX_CLI_SENTRY_DISABLED": "1", + } + result = subprocess.run([ + cli, "--socket", socket_path, "hooks", agent, "auto-name", + "--session", session, "--workspace", workspace, "--surface", surface + ], env=environment, capture_output=True, text=True, timeout=20) + finally: + stopped.set() + server.join(timeout=2) + listener.close() + assert result.returncode == 0, result.stderr + applied = [r["params"] for r in requests if r.get("params", {}).get("title")] + argv = (root / "argv.json").read_text() if (root / "argv.json").exists() else "not invoked" + assert applied and applied[-1]["title"] == "Repair OMP Naming", (requests, argv) + assert applied[-1]["workspace_id"] == workspace + assert applied[-1]["panel_id"] == surface + record = json.loads(store.read_text())["sessions"][session] + assert record["autoNameLastTitle"] == "Repair OMP Naming", record + assert not record.get("autoNameInFlightAt"), record + print(f"PASS {agent} / {override or 'auto'}: title applied and persisted; argv={argv}") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--cli", default=os.environ.get("CMUX_CLI_BIN")) + args = parser.parse_args() + if not args.cli: + parser.error("--cli or CMUX_CLI_BIN is required") + for agent, override in [("omp", None), ("pi", None), ("pi", "omp")]: + run_case(str(Path(args.cli).resolve()), agent, override) diff --git a/tests/test_reuse_app_host_products.py b/tests/test_reuse_app_host_products.py index 5930c6a3f686..17c43f60d862 100644 --- a/tests/test_reuse_app_host_products.py +++ b/tests/test_reuse_app_host_products.py @@ -25,10 +25,11 @@ def setUp(self): super().setUp() self.contract = { "product_inputs": { - "schema": "cmux-app-host-product-inputs/v1", + "schema": "cmux-app-host-product-inputs/v2", "algorithm": "a" * 64, "source": "b" * 64, "recipe": "c" * 64, + "e2e_recipe": "d" * 64, }, "xcode": "same-xcode", "sdk": "same-sdk", @@ -281,6 +282,38 @@ def mutate_admission(old: str, new: str) -> str: self.assertTrue(identity.reaches_product("scripts/ci/compile-app-host-test-product.sh")) self.assertTrue(identity.reaches_product("cmuxTests/WorkspaceTests.swift")) + def test_product_identity_binds_the_e2e_build_recipe(self): + identity = reuse.product_inputs + root = Path(__file__).resolve().parents[1] + workflow = (root / ".github/workflows/ci-macos.yml").read_text() + e2e_workflow = (root / ".github/workflows/test-e2e.yml").read_text() + tree = [f"100644 blob {'1' * 40}\tSources/App.swift"] + + base = identity.identity_from_tree_lines(tree, workflow, e2e_workflow) + + changed_env = e2e_workflow.replace( + ' CMUX_SKIP_ZIG_BUILD: "1"\n', + ' CMUX_SKIP_ZIG_BUILD: "1"\n' + ' XCODE_XCCONFIG_FILE: /tmp/override.xcconfig\n', + 1, + ) + self.assertNotEqual( + base, + identity.identity_from_tree_lines(tree, workflow, changed_env), + ) + + changed_step = e2e_workflow.replace( + " - name: Build the app-host and UI test product\n", + " - name: Future product mutation\n" + " run: touch Sources/App.swift\n\n" + " - name: Build the app-host and UI test product\n", + 1, + ) + self.assertNotEqual( + base, + identity.identity_from_tree_lines(tree, workflow, changed_step), + ) + def test_bundled_paste_worker_source_reaches_product(self): """cmux.xcodeproj compiles this into the bundle, so reuse must see it.""" identity = reuse.product_inputs @@ -312,7 +345,9 @@ def test_bundled_paste_worker_source_reaches_product(self): ) def test_github_product_identity_is_recomputed_from_git_objects(self): - workflow = (Path(__file__).resolve().parents[1] / ".github/workflows/ci-macos.yml").read_text() + root = Path(__file__).resolve().parents[1] + workflow = (root / ".github/workflows/ci-macos.yml").read_text() + e2e_workflow = (root / ".github/workflows/test-e2e.yml").read_text() entries = [ {"path": "Sources/App.swift", "mode": "100644", "type": "blob", "sha": "1" * 40}, { @@ -321,6 +356,12 @@ def test_github_product_identity_is_recomputed_from_git_objects(self): "type": "blob", "sha": "2" * 40, }, + { + "path": ".github/workflows/test-e2e.yml", + "mode": "100644", + "type": "blob", + "sha": "4" * 40, + }, ] class GitObjects: @@ -334,12 +375,18 @@ def get(self, path): "encoding": "base64", "content": base64.b64encode(workflow.encode()).decode(), } + if path == f"git/blobs/{'4' * 40}": + return { + "encoding": "base64", + "content": base64.b64encode(e2e_workflow.encode()).decode(), + } raise AssertionError(path) actual = reuse.github_product_identity(GitObjects(), "abc123") expected = reuse.product_inputs.identity_from_tree_lines( reuse.product_inputs.github_tree_lines(entries), workflow, + e2e_workflow, ) self.assertEqual(actual, expected) @@ -766,6 +813,180 @@ def test_api_failure_cli_falls_back_to_compile(self): self.assertFalse(self.consumer.exists()) + def dispatch_consumer(self): + """Make the consumer an E2E dispatch. + + Its `head_sha` names the workflow definition's ref, never the revision + under test, because that arrives as a workflow input. + """ + self.api.consumer_run.update({ + "path": ".github/workflows/test-e2e.yml", + "event": "workflow_dispatch", + "pull_requests": [], + "head_sha": "aaa999", + }) + + def dispatch_producer(self): + self.api.run.update({ + "path": ".github/workflows/test-e2e.yml", + "event": "workflow_dispatch", + # GitHub associates same-repo dispatches with an open PR. Keeping + # this populated makes the dispatch->PR prohibition exercise the + # event matrix instead of failing earlier on PR-number mismatch. + "pull_requests": [{"number": 7}], + "head_sha": "aaa999", + }) + self.api.product_identities["aaa999"] = self.contract["product_inputs"] + self.api.job = { + "name": "build", + "conclusion": "success", + "status": "completed", + "steps": [{ + "name": "Build the app-host and UI test product", + "conclusion": "success", + "status": "completed", + "started_at": "2026-09-21T08:00:00Z", + "completed_at": "2026-09-21T08:10:00Z", + }], + } + + def test_a_dispatch_adopts_the_product_ci_already_compiled(self): + # `head_sha` here is "aaa999", which has no product identity at all, so + # a hit proves the dispatch was admitted on its checkout instead. + self.dispatch_consumer() + report = {} + self.assertTrue(self.restore_reuse(report=report)) + self.assertEqual(report["reason"], "hit") + self.assertEqual(report["producer_run_id"], "12") + + def test_a_dispatch_checkout_must_still_match_githubs_copy(self): + self.dispatch_consumer() + self.api.product_identities["def456"] = { + **self.contract["product_inputs"], "source": "z" * 64, + } + self.assertFalse(self.restore_reuse()) + self.assertFalse(self.consumer.exists()) + + def test_one_dispatch_adopts_an_earlier_dispatch_product(self): + # Two dispatches of the same revision on the same pool compile the same + # product; the second should download the first one instead. + self.dispatch_consumer() + self.dispatch_producer() + report = {} + self.assertTrue(self.restore_reuse(report=report)) + self.assertEqual(report["reason"], "hit") + # Found through the E2E lane's own compile job and step names. + self.assertEqual(report["compile_seconds_avoided"], 600.0) + + def test_a_dispatch_producer_cannot_seal_a_revision_it_did_not_build(self): + # Nothing binds a dispatch producer's run to what it compiled, so the + # sealed revision is re-fingerprinted against GitHub. A receipt naming + # a revision whose tree carries other product inputs is a miss, and the + # products never reach the consumer's DerivedData. + self.dispatch_consumer() + self.dispatch_producer() + self.api.product_identities["abc123"] = { + **self.contract["product_inputs"], "source": "z" * 64, + } + self.assertFalse(self.restore_reuse()) + self.assertFalse(self.consumer.exists()) + + def test_a_dispatch_producer_recipe_must_match_the_workflow_github_ran(self): + self.dispatch_consumer() + self.dispatch_producer() + self.api.product_identities["aaa999"] = { + **self.contract["product_inputs"], + "e2e_recipe": "f" * 64, + } + report = {} + with mock.patch.object(self.api, "download") as download: + self.assertFalse(self.restore_reuse(report=report)) + download.assert_not_called() + self.assertIn("producer_recipe_mismatch", report["miss_reasons"]) + self.assertFalse(self.consumer.exists()) + + def test_ci_never_adopts_a_dispatch_product(self): + # Trust runs one way: a dispatch compiles a dispatcher-chosen revision, + # so CI's own lanes must not pick its products up. + self.dispatch_producer() + self.assertFalse(self.restore_reuse()) + self.assertFalse(self.consumer.exists()) + + def test_the_download_budget_is_derived_from_the_archive_ceiling(self): + # A flat 120 s budget against a 2 GiB ceiling meant any product past + # roughly 700 MB timed out, recorded a miss, and compiled instead -- + # invisibly, because a miss looks exactly like a normal build. The + # budget has to come from the ceiling, not from a literal that ages + # out the next time the product grows. + seen = {} + + def capture(args, **kwargs): + seen.update(kwargs) + return subprocess.CompletedProcess(args, 0) + + target = self.producer.parent / "probe.zip" + with mock.patch.object(reuse.subprocess, "run", side_effect=capture): + reuse.GitHub("manaflow-ai/cmux").download(42, target) + self.assertEqual(seen.get("timeout"), reuse.DOWNLOAD_TIMEOUT) + self.assertGreaterEqual( + reuse.DOWNLOAD_TIMEOUT * reuse.MIN_TRANSFER_BYTES_PER_SECOND, + reuse.MAX_ARCHIVE_BYTES, + ) + + def test_a_download_that_runs_out_of_time_is_a_miss_not_a_crash(self): + with mock.patch.object( + type(self.api), "download", + side_effect=subprocess.TimeoutExpired("gh", reuse.DOWNLOAD_TIMEOUT), + ): + report = {} + self.assertFalse(self.restore_reuse(report=report)) + self.assertIn("artifact_download_error", report["miss_reasons"]) + self.assertFalse(self.consumer.exists()) + + def test_each_event_is_trusted_only_from_its_own_workflow(self): + for event, path, trusted in ( + ("pull_request", ".github/workflows/ci.yml", True), + ("pull_request", ".github/workflows/test-e2e.yml", False), + ("merge_group", ".github/workflows/ci.yml", True), + ("workflow_dispatch", ".github/workflows/test-e2e.yml", True), + ("workflow_dispatch", ".github/workflows/ci.yml", False), + ("schedule", ".github/workflows/nightly.yml", False), + ): + with self.subTest(event=event, path=path): + run = { + "event": event, + "path": path, + "head_repository": {"full_name": self.api.repository}, + } + self.assertEqual( + reuse.trusted_ci_run(run, self.api.repository), trusted + ) + + def test_dispatch_pairs_extend_the_matrix_in_one_direction(self): + ci = { + "path": ".github/workflows/ci.yml", + "head_repository": {"full_name": self.api.repository}, + "pull_requests": [{"number": 7}], + } + dispatch = { + "path": ".github/workflows/test-e2e.yml", + "head_repository": {"full_name": self.api.repository}, + "event": "workflow_dispatch", + "pull_requests": [{"number": 7}], + } + for name, producer, consumer, expected in ( + ("pr_to_dispatch", {**ci, "event": "pull_request"}, dispatch, True), + ("merge_group_to_dispatch", {**ci, "event": "merge_group"}, dispatch, True), + ("dispatch_to_dispatch", dispatch, dispatch, True), + ("dispatch_to_pr", dispatch, {**ci, "event": "pull_request"}, False), + ("dispatch_to_merge_group", dispatch, {**ci, "event": "merge_group"}, False), + ): + with self.subTest(name=name): + self.assertEqual( + reuse.permitted_pair(producer, consumer, self.api.repository), + expected, + ) + def test_permitted_producer_consumer_matrix(self): base = { "path": ".github/workflows/ci.yml", diff --git a/tests/test_runner_label_policy.py b/tests/test_runner_label_policy.py new file mode 100644 index 000000000000..cbdf347ae8d7 --- /dev/null +++ b/tests/test_runner_label_policy.py @@ -0,0 +1,217 @@ +#!/usr/bin/env python3 +"""The runner label policy must stay the guard's policy, not a second copy of it. + +`scripts/ci/runner_label_policy.py` reads its patterns out of +`tests/test_ci_self_hosted_guard.sh`. That read is the whole design: a private +copy would go stale the first time somebody widened the guard's allow-list, and +a stale copy reports "no drift" forever, which is worse than not running. + +So the cases here are the ones that would catch the read breaking, plus the +label that motivated the module: `warp-macos-26-arm64-12x`, which the guard +rejects in a workflow file and which sat in two repository variables for three +days because nothing reads variable values. +""" + +from __future__ import annotations + +import os +import re +import sys +import unittest +from pathlib import Path +from unittest import mock + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts" / "ci")) + +from runner_label_policy import ( # noqa: E402 + GUARD_FUNCTION, + GUARD_SCRIPT, + PolicyUnreadable, + _guard_function, + _shell_local, + drifted_runner_variables, + forbidden_reason, +) + + +class PolicyIsReadFromTheGuard(unittest.TestCase): + def test_the_three_patterns_are_still_declared(self) -> None: + body = _guard_function(GUARD_SCRIPT.read_text(encoding="utf-8")) + for name in ("fleet", "allowed", "selfhosted"): + with self.subTest(pattern=name): + self.assertTrue(_shell_local(body, name)) + + def test_a_renamed_pattern_raises_instead_of_reporting_clean(self) -> None: + with self.assertRaises(PolicyUnreadable): + _shell_local("local something_else='x'\n", "fleet") + + def test_a_missing_guard_function_raises(self) -> None: + with self.assertRaises(PolicyUnreadable): + _guard_function("other_check() {\n local fleet='x'\n}\n") + + def test_a_same_named_local_outside_the_guard_is_not_the_policy(self) -> None: + # The guard script is 2000 lines and other functions already use a + # local named `allowed`. Only the owning function's copy counts. + source = ( + "earlier_check() {\n local fleet='nothing-matches-this'\n}\n" + f"{GUARD_FUNCTION}() {{\n local fleet='macos-26'\n}}\n" + ) + self.assertEqual(_shell_local(_guard_function(source), "fleet"), "macos-26") + + def test_a_pattern_built_in_several_steps_raises(self) -> None: + # Reading only `local fleet='macos-26'` here would silently drop + # everything appended after it. + for body in ( + " local fleet='macos-26'\n fleet+='|tart-[a-z0-9-]+'\n", + " local fleet='macos-26'\n local fleet='tart-canary'\n", + " local fleet='macos-26'\n fleet='tart-canary'\n", + ): + with self.subTest(body=body): + with self.assertRaises(PolicyUnreadable): + _shell_local(body, "fleet") + + +class ApprovedLabelsPass(unittest.TestCase): + def test_every_label_the_repository_actually_uses(self) -> None: + for label in ( + "blacksmith-6vcpu-macos-15", + "blacksmith-6vcpu-macos-26", + "blacksmith-12vcpu-macos-26", + "blacksmith-4vcpu-ubuntu-2404", + "warp-macos-15-arm64-6x", + "ubuntu-24.04-arm", + "macos-15", + ): + with self.subTest(label=label): + self.assertIsNone(forbidden_reason(label)) + + def test_an_unset_variable_is_not_drift(self) -> None: + self.assertIsNone(forbidden_reason("")) + + +class ForbiddenLabelsAreCaught(unittest.TestCase): + def test_the_label_that_motivated_this_module(self) -> None: + # Live in MACOS_RUNNER_26 and MACOS_RUNNER_26_LARGE + # from 2026-09-20. It matches the guard's `macos-26` fleet pattern and + # is absent from the allow-list, which only carries the 6x macOS 15 Warp + # label, so the guard would reject it on sight in a workflow file. + self.assertIsNotNone(forbidden_reason("warp-macos-26-arm64-12x")) + + def test_fleet_and_self_hosted_labels(self) -> None: + for label in ( + "tart-macos-15", + "cmux-persistent-compile", + "macfleet", + "mac-mini-3", + "self-hosted", + ): + with self.subTest(label=label): + self.assertIsNotNone(forbidden_reason(label)) + + def test_an_approved_label_does_not_mask_a_forbidden_one(self) -> None: + # Stripping the allow-list first is what lets blacksmith-6vcpu-macos-26 + # through; it must not also launder a fleet label sitting beside it. + self.assertIsNotNone( + forbidden_reason("blacksmith-6vcpu-macos-26,cmux-persistent-compile") + ) + + +class DriftReportingOverVariables(unittest.TestCase): + def test_only_runner_variables_are_inspected(self) -> None: + drifted = drifted_runner_variables( + { + "CI_HEALTH_REPORT_ISSUE": "cmux-persistent-compile", + "MACOS_RUNNER_26": "warp-macos-26-arm64-12x", + } + ) + self.assertEqual([name for name, _, _ in drifted], ["MACOS_RUNNER_26"]) + + def test_clean_configuration_reports_nothing(self) -> None: + self.assertEqual( + drifted_runner_variables( + { + "MACOS_RUNNER_15": "blacksmith-6vcpu-macos-15", + "LINUX_RUNNER": "blacksmith-4vcpu-ubuntu-2404", + "MACOS_RUNNER_PR": "", + } + ), + [], + ) + + def test_findings_are_sorted_so_two_reports_diff_cleanly(self) -> None: + drifted = drifted_runner_variables( + { + "MACOS_RUNNER_26": "warp-macos-26-arm64-12x", + "MACOS_RUNNER_26_LARGE": "warp-macos-26-arm64-12x", + } + ) + self.assertEqual( + [name for name, _, _ in drifted], + ["MACOS_RUNNER_26", "MACOS_RUNNER_26_LARGE"], + ) + + def test_surrounding_whitespace_does_not_hide_a_bad_label(self) -> None: + drifted = drifted_runner_variables( + {"MACOS_RUNNER_15": " warp-macos-26-arm64-12x "} + ) + self.assertEqual(len(drifted), 1) + self.assertEqual(drifted[0][1], "warp-macos-26-arm64-12x") + + def test_a_non_string_value_is_ignored_rather_than_crashing(self) -> None: + self.assertEqual(drifted_runner_variables({"MACOS_RUNNER_15": None}), []) + + +HEALTH_REPORT_WORKFLOW = ROOT / ".github" / "workflows" / "ci-health-report.yml" + + +def reported_runner_variables() -> set[str]: + text = HEALTH_REPORT_WORKFLOW.read_text(encoding="utf-8") + return set(re.findall(r"^\s+([A-Z0-9_]+)=\$\{\{ vars\.\1\b", text, re.M)) + + +class TheReportSeesEveryRunnerVariable(unittest.TestCase): + def test_every_runner_variable_a_workflow_reads_is_reported(self) -> None: + # The report is passed an explicit list rather than toJSON(vars), which + # would print every repository variable in a public step log. A list + # can fall behind; this is what keeps it complete. + read = set() + for path in (ROOT / ".github" / "workflows").glob("*.y*ml"): + read |= set( + re.findall(r"vars\.([A-Z0-9_]*RUNNER[A-Z0-9_]*)", path.read_text(encoding="utf-8")) + ) + self.assertTrue(read) + missing = read - reported_runner_variables() + self.assertEqual(missing, set(), f"add to CMUX_CI_RUNNER_VARIABLES in {HEALTH_REPORT_WORKFLOW.name}") + + +class TheReportParsesItsInput(unittest.TestCase): + def lines(self, value: str | None) -> list[str]: + import ci_health_report + + env = {} if value is None else {ci_health_report.RUNNER_VARIABLES_ENV: value} + with mock.patch.dict(os.environ, env, clear=False): + if value is None: + os.environ.pop(ci_health_report.RUNNER_VARIABLES_ENV, None) + return ci_health_report._runner_variable_drift_lines() + + def test_absent_input_is_not_reported_as_clean(self) -> None: + self.assertIn("not checked", self.lines(None)[0]) + + def test_unset_variables_arrive_empty_and_are_clean(self) -> None: + self.assertIn( + "every runner variable holds", + self.lines("MACOS_RUNNER_15=blacksmith-6vcpu-macos-15\nMACOS_RUNNER_PR=\n")[0], + ) + + def test_a_drifted_value_is_named(self) -> None: + line = self.lines("MACOS_RUNNER_26=warp-macos-26-arm64-12x\n")[0] + self.assertIn("MACOS_RUNNER_26", line) + self.assertIn("1 variable(s)", line) + + def test_a_malformed_line_is_unreadable_not_clean(self) -> None: + self.assertIn("unreadable", self.lines("MACOS_RUNNER_15\n")[0]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_settings_configuration_review_paths.py b/tests/test_settings_configuration_review_paths.py new file mode 100755 index 000000000000..897879c6e350 --- /dev/null +++ b/tests/test_settings_configuration_review_paths.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 +"""Every cmux.json path a settings row advertises must appear in the declared +supported-path set. + +`CmuxSettingsFileStore+SupportedPaths.swift` says of its set: "Settings UI rows +validate against this set so new persisted settings need an explicit cmux.json +review." Nothing enforced that, so a row could advertise a path absent from the +set and nobody noticed. + +Be precise about what this proves. `supportedSettingsJSONPaths` has **no +production consumer** -- it is read by this guard and one test, and by nothing +that parses cmux.json. What actually accepts a key is the hand-written section +parsers in `KeyboardShortcutSettingsFileStore.swift` and +`CmuxSettingsFileStore+AppSection.swift`. So this is a consistency check between +two declarations (the UI row and the documented set), not proof that writing the +key does anything. + +The gap is real in both directions. `canvas.paneGap` and +`canvas.snappingEnabled` are advertised by rows, are listed in the supported +set, and therefore pass this guard -- yet `root["canvas"]` is never read by any +parser, so writing them does nothing. Catching that class needs an oracle +derived from the parsers; see the tracking issue. Until then, a pass here means +"the row and the documented set agree", nothing stronger. +""" + +import re +import unittest +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +SUPPORTED = REPO_ROOT / "Sources" / "CmuxSettingsFileStore+SupportedPaths.swift" +UI_ROOT = REPO_ROOT / "Packages" / "macOS" / "CmuxSettingsUI" / "Sources" +SOURCE_ROOTS = (REPO_ROOT / "Sources", REPO_ROOT / "Packages") + +# `configurationReview: .json("a", "b")` may list several paths for one row. +REVIEW = re.compile(r"configurationReview:\s*\.json\(([^)]*)\)") +STRING = re.compile(r'"([^"]+)"') +# Entries in the supported set may be symbolic, e.g. PaneChromeSettings.fooKey. +SYMBOL = re.compile(r"^([A-Z][A-Za-z0-9_]*)\.([A-Za-z0-9_]+)\s*,?$") + + +def _resolve_symbol(type_name, member): + """Find `static let = ""` in the file that DECLARES the type. + + Matching on "the file mentions the type name" picks the first file in + filesystem order that merely references it, which is both wrong and + machine-dependent. `settingsPath` is already declared by two different + types, so the collision class exists. A decoy that resolves to a shorter + path would silently widen the ancestor match below and hide real failures, + so an ambiguous resolution is reported rather than guessed at. + """ + declares = re.compile( + r"\b(?:enum|struct|class|extension|actor|protocol)\s+" + re.escape(type_name) + r"\b" + ) + pattern = re.compile( + r"static\s+let\s+" + re.escape(member) + r"\s*(?::\s*String\s*)?=\s*\"([^\"]+)\"" + ) + values = set() + for root in SOURCE_ROOTS: + for path in root.rglob("*.swift"): + text = path.read_text(encoding="utf-8", errors="replace") + if not declares.search(text): + continue + found = pattern.search(text) + if found: + values.add(found.group(1)) + if len(values) == 1: + return values.pop() + return None + + +def supported_paths(): + resolved, unresolved = set(), [] + for raw in SUPPORTED.read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if not line or line.startswith("//"): + continue + literal = STRING.search(line) + if literal: + resolved.add(literal.group(1)) + continue + symbol = SYMBOL.match(line) + if symbol: + value = _resolve_symbol(*symbol.groups()) + if value: + resolved.add(value) + else: + unresolved.append(line) + return resolved, unresolved + + +def advertised_paths(): + for path in sorted(UI_ROOT.rglob("*.swift")): + text = path.read_text(encoding="utf-8", errors="replace") + for match in REVIEW.finditer(text): + args = match.group(1) + # Skip non-literal forms such as `.json(catalog.app.foo.id)`; those + # name a catalog id that cannot be read without type information. + for value in STRING.findall(args): + line = text.count("\n", 0, match.start()) + 1 + yield value, path.relative_to(REPO_ROOT), line + + +# Rows advertising a path absent from the supported set when this guard was +# added. Each was checked against the parsers by hand: none of these five has a +# reader, so writing them into cmux.json genuinely does nothing. +# `cloud`, `computerUse` and `customSidebars` have no top-level case in the +# section dispatch at all, and the parsed `automation` section has no +# `codexIntegration` key. See the tracking issue. +# +# This list is NOT automatically ratcheted -- nothing compares it to a baseline, +# so a new failure could be parked here in the same change that introduces it. +# The staleness test below only reaps entries that have since become supported +# or are no longer advertised. Treat additions as needing review on their own +# merits. +KNOWN_UNSUPPORTED = frozenset({ + "automation.codexIntegration", + "cloud.beta.machines.enabled", + "computerUse.enabled", + "computerUse.showInMenuBar", + "customSidebars.renderer", +}) + + +class ConfigurationReviewPathsTests(unittest.TestCase): + def test_every_advertised_path_is_supported(self): + supported, unresolved = supported_paths() + self.assertTrue(supported, "parsed no supported paths; the guard would pass vacuously") + missing = [] + for value, rel, line in advertised_paths(): + if value in KNOWN_UNSUPPORTED: + continue + # Object-valued settings are listed at their root, and the store + # permits descendant paths beneath them (e.g. shortcuts.bindings). + parts = value.split(".") + ancestors = {".".join(parts[: i + 1]) for i in range(len(parts))} + if not (ancestors & supported): + missing.append(f"{rel}:{line} advertises {value!r}") + self.assertEqual( + missing, + [], + "settings rows advertise cmux.json paths the file store does not accept, " + "so writing them into cmux.json does nothing. Add each to " + "`supportedSettingsJSONPaths` and to the matching " + "`*SettingsFileMapping` in CmuxSettingsJSONPathSupport.swift.\n " + + "\n ".join(missing) + + ( + "\n(unresolved symbolic entries in the supported set: " + + ", ".join(unresolved) + + ")" + if unresolved + else "" + ), + ) + + + def test_known_unsupported_list_has_no_stale_entries(self): + """A path that became supported, or lost its row, must leave the list.""" + supported, _ = supported_paths() + advertised = {value for value, _, _ in advertised_paths()} + + def is_supported(path): + # Mirror the ancestor matching the main test uses. Checking exact + # membership instead would strand an entry that became supported + # via an ancestor, leaving it permanently un-reapable dead weight. + parts = path.split(".") + return bool( + {".".join(parts[: i + 1]) for i in range(len(parts))} & supported + ) + + stale = sorted( + path + for path in KNOWN_UNSUPPORTED + if path not in advertised or is_supported(path) + ) + self.assertEqual( + stale, + [], + "these paths are no longer unsupported-and-advertised, so delete them " + "from KNOWN_UNSUPPORTED: " + ", ".join(stale), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests_v2/test_terminal_paste_delivery.py b/tests_v2/test_terminal_paste_delivery.py new file mode 100644 index 000000000000..db03bca15681 --- /dev/null +++ b/tests_v2/test_terminal_paste_delivery.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +"""Verify native paste acknowledges literal delivery without submitting Return.""" +import json +import os +from pathlib import Path +import shlex +import sys +import tempfile +import time + +from cmux import cmux + + +def wait_for(predicate, description): + deadline = time.monotonic() + 15 + while time.monotonic() < deadline: + if predicate(): + return + time.sleep(0.05) + raise AssertionError(f"Timed out: {description}") + + +def main(): + socket_path = os.environ["CMUX_SOCKET_PATH"] + assert "12930-terminal-paste-delivery" in socket_path, "Use the isolated issue tag" + with tempfile.TemporaryDirectory(prefix="cmux-12930-") as directory: + root = Path(directory) + capture = root / "bytes" + ready = root / "ready" + recorder = root / "recorder.py" + recorder.write_text('''import os, tty +from pathlib import Path +tty.setraw(0) +os.write(1, b"\\x1b[?2004h") +Path(__file__).with_name("ready").touch() +with Path(__file__).with_name("bytes").open("ab", buffering=0) as output: + while True: + output.write(os.read(0, 4096)) +''') + evidence = [] + with cmux(socket_path) as client: + workspace = client._call("workspace.create", { + "initial_command": f"{shlex.quote(sys.executable)} {shlex.quote(str(recorder))}" + })["workspace_id"] + try: + client.select_workspace(workspace) + wait_for(ready.exists, "raw PTY recorder ready") + surface = client._call("surface.list", {"workspace_id": workspace})["surfaces"][0]["id"] + expected = b"" + for method in ("terminal.paste", "mobile.terminal.paste"): + text = "literal\n世界 🧪" + result = client._call(method, {"workspace_id": workspace, "surface_id": surface, + "text": text, "submit_key": "none"}) + expected += b"\x1b[200~" + text.encode() + b"\x1b[201~" + wait_for(lambda: capture.exists() and len(capture.read_bytes()) >= len(expected), "literal bytes") + actual = capture.read_bytes() + assert actual == expected, (actual.hex(), expected.hex()) + assert result.get("delivery") == "delivered", result + assert result["submitted"] is False, result + evidence.append({"method": method, "response": result, "bytes_hex": actual.hex()}) + client._call("surface.send_key", {"workspace_id": workspace, "surface_id": surface, "key": "enter"}) + expected += b"\r" + wait_for(lambda: len(capture.read_bytes()) >= len(expected), "separate Return") + assert capture.read_bytes() == expected + evidence.append({"separate_return_hex": capture.read_bytes().hex()}) + print(json.dumps(evidence, indent=2)) + finally: + client.close_workspace(workspace) + + +if __name__ == "__main__": + main() diff --git a/web/app/[locale]/dashboard/dashboard-account-menu.tsx b/web/app/[locale]/dashboard/dashboard-account-menu.tsx index e27352a7ac77..08c405b54a96 100644 --- a/web/app/[locale]/dashboard/dashboard-account-menu.tsx +++ b/web/app/[locale]/dashboard/dashboard-account-menu.tsx @@ -151,7 +151,7 @@ function TeamSubmenu({ }: { readonly teams: readonly DashboardCatalogTeam[]; readonly selected: DashboardCatalogTeam; - readonly onSelect: (team: DashboardCatalogTeam) => void; + readonly onSelect: (team: DashboardCatalogTeam) => void | Promise; }) { const t = useTranslations("dashboard.teamSwitcher"); return ( @@ -173,7 +173,7 @@ function TeamSubmenu({ value={selected.id} onValueChange={(value) => { const team = teams.find((candidate) => candidate.id === value); - if (team) onSelect(team); + if (team) void Promise.resolve(onSelect(team)).catch(() => undefined); }} > {teams.map((team) => ( diff --git a/web/app/[locale]/dashboard/dashboard-team-scope.ts b/web/app/[locale]/dashboard/dashboard-team-scope.ts index 2635ea711304..f3ebacf09064 100644 --- a/web/app/[locale]/dashboard/dashboard-team-scope.ts +++ b/web/app/[locale]/dashboard/dashboard-team-scope.ts @@ -1,9 +1,14 @@ "use client"; import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useRef } from "react"; import { useSearchParams } from "next/navigation"; import { usePathname, useRouter } from "@/i18n/navigation"; -import { persistCoderouterOrganizationScope } from "@/services/coderouter/organizationScope"; +import { + clearCoderouterOrganizationScope, + coderouterOrganizationFromCookieHeader, + persistCoderouterOrganizationScope, +} from "@/services/coderouter/organizationScope"; export type DashboardTeamCatalog = { readonly selectedTeamId: string | null; @@ -32,6 +37,12 @@ export type DashboardTeamScope = const CATALOG_TIMEOUT_MS = 10_000; +type ConfirmedTeamSwitchState = { + readonly catalog: DashboardTeamCatalog; + readonly cookieScope: string | null; + readonly search: string; +}; + /** * The dashboard-wide team scope. Stack Auth owns the selected team on the * server, so switching here changes what every dashboard surface shows @@ -42,6 +53,11 @@ export function useDashboardTeamScope(userId: string | null): DashboardTeamScope const pathname = usePathname(); const searchParams = useSearchParams(); const queryClient = useQueryClient(); + const activeSwitchId = useRef(0); + const nextSwitchId = useRef(0); + const pendingSwitches = useRef(0); + const confirmedSwitchState = useRef(null); + const switchPersistenceTail = useRef>(Promise.resolve()); const queryKey = ["dashboard-team-catalog", userId] as const; const { data, isPending } = useQuery({ queryKey, @@ -60,41 +76,129 @@ export function useDashboardTeamScope(userId: string | null): DashboardTeamScope const selected = selectedTeam(teams, data.selectedTeamId, searchParams.get("team")); const switchTeam = async (team: DashboardCatalogTeam) => { - if (team.id === selected.id) return; - const cancellation = new AbortController(); - const timeout = setTimeout(() => cancellation.abort(new Error("Team switch timed out")), CATALOG_TIMEOUT_MS); - let response: Response; - try { - response = await fetch("/api/subrouter/teams", { - method: "PATCH", - headers: { "content-type": "application/json", accept: "application/json" }, - body: JSON.stringify({ teamId: team.id }), - signal: cancellation.signal, - }); - } finally { - clearTimeout(timeout); + const currentCatalog = queryClient.getQueryData(queryKey) ?? data; + if ( + (pendingSwitches.current === 0 && team.id === selected.id) + || (pendingSwitches.current > 0 && currentCatalog.selectedTeamId === team.id) + ) { + return; } - if (!response.ok) throw new Error("Could not switch dashboard team"); - // Keep the legacy cookie in sync for older dashboard pages while the - // Stack Auth selected team remains the authority. - persistCoderouterOrganizationScope(userId, team.id); + + nextSwitchId.current += 1; + const operationId = nextSwitchId.current; + activeSwitchId.current = operationId; + + if (pendingSwitches.current === 0) { + confirmedSwitchState.current = { + catalog: currentCatalog, + cookieScope: coderouterOrganizationFromCookieHeader( + typeof document === "undefined" ? null : document.cookie, + userId, + ), + search: searchParams.toString(), + }; + } + pendingSwitches.current += 1; + + const optimisticSearch = new URLSearchParams(searchParams.toString()); queryClient.setQueryData( queryKey, (current) => current ? { ...current, selectedTeamId: team.id } : current, ); - if (searchParams.has("team")) { - // A deep-linked team in the URL would keep overriding the new scope. - const next = new URLSearchParams(searchParams.toString()); - next.delete("team"); - const query = next.toString(); - router.replace(query ? `${pathname}?${query}` : pathname); + persistCoderouterOrganizationScope(userId, team.id); + optimisticSearch.set("team", team.id); + router.replace(pathWithSearch(pathname, optimisticSearch)); + + const persistRequest = async () => { + const cancellation = new AbortController(); + const timeout = setTimeout( + () => cancellation.abort(new Error("Team switch timed out")), + CATALOG_TIMEOUT_MS, + ); + try { + const response = await fetch("/api/subrouter/teams", { + method: "PATCH", + headers: { "content-type": "application/json", accept: "application/json" }, + body: JSON.stringify({ teamId: team.id }), + signal: cancellation.signal, + }); + if (!response.ok) throw new Error("Could not switch dashboard team"); + } finally { + clearTimeout(timeout); + } + + const confirmed = confirmedSwitchState.current; + if (confirmed === null) { + throw new Error("Dashboard team switch confirmation state was lost"); + } + const confirmedSearch = new URLSearchParams(confirmed.search); + confirmedSearch.delete("team"); + const nextConfirmed: ConfirmedTeamSwitchState = { + catalog: { ...confirmed.catalog, selectedTeamId: team.id }, + cookieScope: team.id, + search: confirmedSearch.toString(), + }; + confirmedSwitchState.current = nextConfirmed; + return nextConfirmed; + }; + const persist = pendingSwitches.current === 1 + ? persistRequest() + : switchPersistenceTail.current.then(persistRequest); + switchPersistenceTail.current = persist.then( + () => undefined, + () => undefined, + ); + + const finish = () => { + pendingSwitches.current -= 1; + if (pendingSwitches.current === 0) { + confirmedSwitchState.current = null; + } + }; + + let confirmed: ConfirmedTeamSwitchState; + try { + confirmed = await persist; + } catch (error) { + if (activeSwitchId.current === operationId) { + const rollback = confirmedSwitchState.current; + if (rollback !== null) { + queryClient.setQueryData(queryKey, rollback.catalog); + if (rollback.cookieScope === null) { + clearCoderouterOrganizationScope(); + } else { + persistCoderouterOrganizationScope(userId, rollback.cookieScope); + } + router.replace( + pathWithSearch(pathname, new URLSearchParams(rollback.search)), + ); + } + activeSwitchId.current = 0; + } + finish(); + throw error; + } + + if (activeSwitchId.current === operationId) { + queryClient.setQueryData(queryKey, confirmed.catalog); + persistCoderouterOrganizationScope(userId, confirmed.cookieScope ?? team.id); + router.replace( + pathWithSearch(pathname, new URLSearchParams(confirmed.search)), + ); + activeSwitchId.current = 0; + router.refresh(); } - router.refresh(); + finish(); }; return { status: "ready", teams, selected, switchTeam }; } +function pathWithSearch(pathname: string, searchParams: URLSearchParams): string { + const query = searchParams.toString(); + return query ? `${pathname}?${query}` : pathname; +} + /** Teams the dashboard can show: route users and account-only managers. */ export function permittedTeams(catalog: DashboardTeamCatalog): readonly DashboardCatalogTeam[] { return catalog.teams.filter( diff --git a/web/app/api/coderouter/vm-usage/self/route.ts b/web/app/api/coderouter/vm-usage/self/route.ts index d072f10a2562..3b735868941c 100644 --- a/web/app/api/coderouter/vm-usage/self/route.ts +++ b/web/app/api/coderouter/vm-usage/self/route.ts @@ -52,7 +52,7 @@ async function handleGet(request: Request): Promise { ); } const identity = auth.identity; - if (identity.vmId === null) { + if (identity.vmId === null || identity.machine === "chatmux") { return Response.json( { error: "vm_bound_token_required", diff --git a/web/app/api/subrouter/teams/route.ts b/web/app/api/subrouter/teams/route.ts index ec572baf90fc..5e97f6adce2e 100644 --- a/web/app/api/subrouter/teams/route.ts +++ b/web/app/api/subrouter/teams/route.ts @@ -113,7 +113,7 @@ export async function organizationsGet(request: Request, if (request.headers.has(VM_ID_HEADER) || request.headers.has(ROUTE_TOKEN_HEADER)) { const auth = await authenticateRequestRouteToken(request); if (!auth.ok) return jsonResponse({ error: auth.reason }, 401); - if (!auth.identity.vmId) return jsonResponse({ error: "vm_bound_token_required" }, 403); + if (!auth.identity.vmId || auth.identity.machine === "chatmux") return jsonResponse({ error: "vm_bound_token_required" }, 403); return jsonResponse({ selectedTeamId: auth.identity.teamId, fixed: true, teams: [{ id: auth.identity.teamId, name: auth.identity.teamId, personal: auth.identity.teamId === auth.identity.stackUserId, permissions: { use: true, manageAccounts: false } }] }); diff --git a/web/app/api/vm/reflection/name/route.ts b/web/app/api/vm/reflection/name/route.ts new file mode 100644 index 000000000000..274fb6a88d40 --- /dev/null +++ b/web/app/api/vm/reflection/name/route.ts @@ -0,0 +1,24 @@ +import { coderouterControlRoute } from "@/services/coderouter/requestTelemetry"; +import { requireVmPrincipal, vmPrincipalFailureResponse } from "@/services/vms/vmPrincipal"; +import { reflectionMachineName } from "@/services/vms/reflection"; + +const JSON_HEADERS = { + "cache-control": "no-store", + "content-type": "application/json", +} as const; + +/** + * Boot-only identity endpoint. Prompt initialization needs the machine slug, + * not the full owner/peer reflection graph. Keeping this response small lets + * a fresh clone refresh `/etc/cmux/vm-name` without loading sibling machines. + */ +export const GET = coderouterControlRoute("vm_reflection_name", "/api/vm/reflection/name", async (request) => { + const auth = await requireVmPrincipal(request); + if (!auth.ok) return vmPrincipalFailureResponse(auth.reason); + const vm = auth.principal.vm; + return new Response(JSON.stringify({ + name: reflectionMachineName(vm), + vm_id: vm.id, + revision: vm.createdAt.getTime(), + }), { status: 200, headers: JSON_HEADERS }); +}); diff --git a/web/app/api/vm/route.ts b/web/app/api/vm/route.ts index ee4d8acf85cd..ac8696ef1081 100644 --- a/web/app/api/vm/route.ts +++ b/web/app/api/vm/route.ts @@ -3,6 +3,7 @@ import { normalizedDisplayName } from "../../../services/vms/displayName"; // provider credentials stay behind server-side ownership checks. import type { Span } from "@opentelemetry/api"; +import * as Effect from "effect/Effect"; import { preconnectCloudDb } from "../../../db/client"; import { preconnectFreestyle } from "../../../services/vms/drivers/freestyle"; import { @@ -292,6 +293,8 @@ export async function POST(request: Request): Promise { imageSize: imageSelection.size ?? undefined, modelPlane, timing, + // Keep the `vm.created` ledger write off New Machine's critical path. + deferAfterResponse: (work) => runAfterResponse(() => Effect.runPromise(work)), }), { request, onError: createErrorResponders(entitlements), @@ -310,6 +313,12 @@ export async function POST(request: Request): Promise { capabilities: vmCapabilitiesFor(created.provider), displayName: created.displayName, slug: created.slug, + // The private address and attach contract let the app dial the new + // machine's baked daemon directly. Without them, New Machine pays a + // fleet list re-read plus a whole POST /attach-endpoint round trip + // (~2 s measured) for data this response already had. + address: { ipv4: created.addressIpv4, ipv6: created.addressIpv6 }, + cmuxTuiContract: created.cmuxTuiContract, }); }, ); diff --git a/web/app/api/vm/self/route.ts b/web/app/api/vm/self/route.ts index aabb402ec4a4..02d5a5e6e632 100644 --- a/web/app/api/vm/self/route.ts +++ b/web/app/api/vm/self/route.ts @@ -29,8 +29,8 @@ export async function GET(request: Request): Promise { { status: 401, headers: JSON_HEADERS }, ); } - const { teamId, vmId } = auth.identity; - if (vmId === null) { + const { teamId, vmId, machine } = auth.identity; + if (vmId === null || machine === "chatmux") { return Response.json( { error: "vm_bound_token_required", diff --git a/web/scripts/build-devbox-freestyle.ts b/web/scripts/build-devbox-freestyle.ts index b98f54fa9f3f..cd9ac016d8e6 100644 --- a/web/scripts/build-devbox-freestyle.ts +++ b/web/scripts/build-devbox-freestyle.ts @@ -83,6 +83,10 @@ import { Freestyle } from "freestyle"; import { fileURLToPath } from "node:url"; import { VM_GUEST_MODEL_PLANE_ENV_PATH, renderVmGuestModelPlaneEnvFile, vmGuestModelPlaneEnv } from "../services/coderouter/vmGuestEnv"; +import { guestResourceReporterInstallCommand } from "../services/vms/guestResourceReporter"; +import { guestBrowserInstallCommand } from "../services/vms/guestBrowser"; +import { guestCliDistributionCommand } from "../services/vms/guestCliDistribution"; +import { GUEST_CMUX_SHIM, GUEST_CMUX_SHIM_PATH } from "../services/vms/guestCli"; import { CMUX_TUI_LAYOUT_MARKER_PATH, CMUX_TUI_SESSION, @@ -476,6 +480,17 @@ try { `${cmuxTuiPinCheckCommand(cmuxTuiSource)} && mkdir -p /etc/cmux && printf '%s %s\n' ${cmuxTuiSource.sha256} ${cmuxTuiSource.commit} > /etc/cmux/cmux-tui-pin && cat /etc/cmux/cmux-tui-pin`, ); + // The runtime VM path must not upload or install guest integration. These + // files are immutable image assets: create only allocates the image, while + // the boot supervisor starts the daemon and the reporter unit. + await step("guest-cli-directory", "mkdir -p /usr/local/libexec"); + await vm.fs.writeFile(GUEST_CMUX_SHIM_PATH, GUEST_CMUX_SHIM, { mode: 0o755 }); + await step( + "guest-cli-integration", + `mkdir -p /usr/local/libexec && ${guestBrowserInstallCommand()} && ${guestCliDistributionCommand()} && chmod 0755 ${GUEST_CMUX_SHIM_PATH} && test -x ${GUEST_CMUX_SHIM_PATH} && ${guestCliDistributionCommand(true)} && echo guest-cli-integration-ok`, + ); + await step("guest-resource-reporter", guestResourceReporterInstallCommand()); + // The install above also wrote the work user's Claude Code and Codex hooks // (cmux-tui agent hook install), so a Stop, permission request, or question // in either agent reaches the daemon journal and the owner's Mac as a @@ -535,11 +550,53 @@ try { "WantedBy=multi-user.target", ].join("\n"); await put("cmux-devbox-boot", "/usr/local/bin/cmux-devbox-boot", 0o755); + await put("cmux-prompt-sync", "/usr/local/bin/cmux-prompt-sync", 0o755); await vm.fs.writeFile("/etc/systemd/system/cmux-tui-daemon.service", `${service}\n`, { mode: 0o644 }); + await vm.fs.writeFile( + "/etc/systemd/system/cmux-prompt-sync.service", + [ + "[Unit]", + "Description=cmux Cloud prompt identity sync", + "After=network-online.target cmux-tui-daemon.service", + "Wants=network-online.target", + "", + "[Service]", + "Type=simple", + "ExecStart=/usr/local/bin/cmux-prompt-sync", + "Restart=on-failure", + "RestartSec=2", + "", + "[Install]", + "WantedBy=multi-user.target", + ].join("\n") + "\n", + { mode: 0o644 }, + ); + // Quiet resume. Every machine is a memory-snapshot clone whose monotonic + // clock jumps by the snapshot's age on resume, and the kernel would spend + // the clone's first second (the New Machine critical path) printing a + // workqueue-lockup report. The switch is runtime state, which the memory + // snapshot carries into every clone and derived size; the tmpfiles line + // re-applies it on a cold boot. Service watchdogs and housekeeping timers + // are handled by cmux-devbox-boot's park branch, right before the snapshot. + await step("snapshot-resume-dirs", "mkdir -p /etc/tmpfiles.d"); + await vm.fs.writeFile( + "/etc/tmpfiles.d/cmux-snapshot-resume.conf", + "# Clones resume with a monotonic clock jump; do not report a workqueue lockup.\nw- /sys/module/workqueue/parameters/watchdog_thresh - - - - 0\n", + { mode: 0o644 }, + ); + await step( + "snapshot-resume-quiet", + "{ [ ! -e /sys/module/workqueue/parameters/watchdog_thresh ] || echo 0 > /sys/module/workqueue/parameters/watchdog_thresh; } && " + + "echo snapshot-resume-quiet-ok", + ); await step( "cmux-tui-daemon-unit", "sh -n /usr/local/bin/cmux-devbox-boot && rm -f /etc/cmux/bake-instance-id && mkdir -p /etc/systemd/system/multi-user.target.wants && ln -sf /etc/systemd/system/cmux-tui-daemon.service /etc/systemd/system/multi-user.target.wants/cmux-tui-daemon.service && systemctl daemon-reload && systemctl enable cmux-tui-daemon && systemctl restart cmux-tui-daemon && systemctl is-active cmux-tui-daemon", ); + await step( + "cmux-prompt-sync-unit", + "python3 -m py_compile /usr/local/bin/cmux-prompt-sync && systemctl daemon-reload && systemctl enable cmux-prompt-sync && systemctl is-enabled cmux-prompt-sync", + ); // Prove the daemon contract on the builder: the supervisor started the // daemon on its own, the session answers, and the listener is dual-stack. await step( @@ -550,9 +607,25 @@ try { // WebSocket/Noise/RPC/PTY path before this machine can become a snapshot. await step("cmux-tui-ready", devboxWaitForDaemonCommand()); await step("cmux-tui-websocket-smoke", cmuxTuiWebsocketSmokeCommand()); + // Seed the durable first workspace and terminal while the daemon is already + // hot. A clone keeps this journaled layout, then cmux-prompt-sync clears the + // builder's rendered prompt and interrupts it after the clone name arrives. + // This removes workspace/terminal creation from the New Machine critical + // path while keeping the operation idempotent across a rebake. + await step( + "cmux-tui-first-terminal", + `(${cmuxTuiRunCommand(`--session ${CMUX_TUI_SESSION} --json terminal list`)} >/tmp/cmux-first-workspaces.json || :); ` + + `if ! jq -e '.. | objects | select(((.terminal_id? // .id?) | strings | startswith("term_")))' /tmp/cmux-first-workspaces.json >/dev/null 2>&1; then ` + + `${cmuxTuiRunCommand(`--session ${CMUX_TUI_SESSION} --json workspace create --name Cloud`)} >/dev/null && ` + + `${cmuxTuiRunCommand(`--session ${CMUX_TUI_SESSION} --json terminal list`)} >/tmp/cmux-first-workspaces.json; fi && ` + + `jq -e '.. | objects | select(((.terminal_id? // .id?) | strings | startswith("term_")))' /tmp/cmux-first-workspaces.json >/dev/null && echo cmux-tui-first-terminal-ok`, + ); + // Let the daemon, first PTY and desktop settle before the memory snapshot. + // Freestyle resumes the snapshot rather than replaying these startup steps. + await step("cmux-tui-settle-before-snapshot", "sleep 30"); // Park it (devboxParkDaemonCommand): the supervisor stops the daemon while - // the machine's id equals the recorded bake id, its identity and session - // state are wiped, and a clone (different id) starts fresh within one tick. + // the machine's id equals the recorded bake id. A clone rotates only the + // daemon authorization state and preserves this journaled first terminal. await step("cmux-tui-daemon-park", devboxParkDaemonCommand()); await step( diff --git a/web/scripts/cloud-vm/bench-private-link.ts b/web/scripts/cloud-vm/bench-private-link.ts index 175e0ec1c686..f30902ea3e07 100644 --- a/web/scripts/cloud-vm/bench-private-link.ts +++ b/web/scripts/cloud-vm/bench-private-link.ts @@ -38,7 +38,6 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { cleanupPrivateLinkResource as cleanup } from "../devbox-private-link-cleanup"; import { startPrivateLinkClient } from "../devbox-private-link-process"; -import { resolveCmuxTuiSource } from "../../services/vms/drivers/cmuxTuiDaemon"; import { FREESTYLE_NETWORK_FIREWALL_RULES, FreestyleProvider } from "../../services/vms/drivers/freestyle"; import { ProviderError } from "../../services/vms/drivers/types"; import type { GuestPromptIdentity } from "../../services/vms/guestPrompt"; @@ -450,7 +449,7 @@ function bench() { } // The production driver on the same bounded client, so its create, // attach and destroy requests settle within the polling deadline too. - const provider = new FreestyleProvider({ client: providerClient, resolveDaemonSource: resolveCmuxTuiSource }); + const provider = new FreestyleProvider({ client: providerClient }); const networking = provider.privateNetworking; const root = yield* Effect.acquireRelease( Effect.sync(() => mkdtempSync(path.join(tmpdir(), "cmux-bench-link-"))), diff --git a/web/scripts/cloud-vm/bench-raw-vm-create.ts b/web/scripts/cloud-vm/bench-raw-vm-create.ts new file mode 100644 index 000000000000..9c9cc8f7abdc --- /dev/null +++ b/web/scripts/cloud-vm/bench-raw-vm-create.ts @@ -0,0 +1,100 @@ +#!/usr/bin/env bun +/** + * The floor for New Machine: create one VM straight from a devbox snapshot + * with the Freestyle SDK, attached to an existing private network, and time + * until its cmux-tui daemon accepts TCP on 1337 through the Mac's WireGuard + * hub. No cmux backend, database, auth, billing, or app is involved. + * + * FREESTYLE_API_KEY=... bun scripts/cloud-vm/bench-raw-vm-create.ts \ + * --snapshot sh-... --vpc --hub [--runs 3] [--slug-prefix x] + * + * `--vpc` is the owner's network (read it from any of their VMs); `--hub` is + * the socket `vm.cmux_remote_info` returns as `wireguard_hub_socket`. + * Every VM this script creates is deleted before it exits. + */ +import { connect, isIPv4 } from "node:net"; +import { parseArgs } from "node:util"; +import { Freestyle } from "freestyle"; + +const { values } = parseArgs({ + options: { + snapshot: { type: "string" }, + vpc: { type: "string" }, + hub: { type: "string" }, + runs: { type: "string", default: "3" }, + "slug-prefix": { type: "string" }, + }, +}); +const snapshotId = values.snapshot, vpcId = values.vpc, hub = values.hub; +if (!snapshotId || !vpcId || !hub) throw new Error("--snapshot, --vpc and --hub are required"); +const apiKey = process.env.FREESTYLE_API_KEY?.trim(); +if (!apiKey) throw new Error("FREESTYLE_API_KEY is required"); +const fs = new Freestyle({ apiKey, baseUrl: process.env.FREESTYLE_API_URL?.trim() || undefined }); + +/** One SOCKS5 CONNECT through the hub; resolves true when the daemon accepts. */ +function probe(ip: string, timeoutMs = 400): Promise { + return new Promise((resolve) => { + const socket = connect(hub!); + let stage = 0; + const done = (ok: boolean) => { socket.destroy(); resolve(ok); }; + const timer = setTimeout(() => done(false), timeoutMs); + socket.on("error", () => { clearTimeout(timer); done(false); }); + socket.on("connect", () => socket.write(Buffer.from([5, 1, 0]))); + socket.on("data", (chunk) => { + if (stage === 0) { + stage = 1; + const address = isIPv4(ip) ? Buffer.from([1, ...ip.split(".").map(Number)]) : Buffer.alloc(0); + socket.write(Buffer.concat([Buffer.from([5, 1, 0]), address, Buffer.from([1337 >> 8, 1337 & 0xff])])); + } else { + clearTimeout(timer); + done(chunk.length >= 2 && chunk[1] === 0); + } + }); + }); +} + +/** Starts a new probe every 100 ms (early SYNs to a fresh VM are lost). */ +async function firstReachable(ip: string, deadlineMs = 30_000): Promise { + const started = performance.now(); + return new Promise((resolve) => { + let settled = false; + const tick = setInterval(() => { + if (performance.now() - started > deadlineMs) { clearInterval(tick); if (!settled) { settled = true; resolve(null); } return; } + const at = performance.now(); + void probe(ip).then((ok) => { + if (ok && !settled) { settled = true; clearInterval(tick); resolve(at); } + }); + }, 50); + }); +} + +const runs = Number(values.runs); +for (let run = 1; run <= runs; run++) { + const slug = values["slug-prefix"] ? `${values["slug-prefix"]}-${run}-${Date.now().toString(36)}` : undefined; + const t0 = performance.now(); + const { vm, vmId, data } = await fs.vms.create({ + snapshotId, + ...(slug ? { slug } : {}), + idleTimeoutSeconds: 600, + metadata: { cmux: "bench-raw" }, + firewall: { rules: [{ action: "allow", source: {}, destination: { public: true } }] }, + vpcs: [{ vpcId, ipv4: true, ipv6: true }], + }); + const t1 = performance.now(); + try { + const ip = (data.vpcs ?? []).map((n) => n.ipv4).find(Boolean); + if (!ip) throw new Error(`VM ${vmId} has no VPC IPv4`); + const reachableAt = await firstReachable(ip); + const created = Math.round(t1 - t0); + const reach = reachableAt === null ? "timeout" : `${Math.round(reachableAt - t1)} ms`; + const total = reachableAt === null ? "timeout" : `${Math.round(reachableAt - t0)} ms`; + let hostname = ""; + if (slug) { + const r = await vm.exec({ command: "curl -s -m 2 -H \"X-aws-ec2-metadata-token: $(curl -sf -m 2 -X PUT http://169.254.169.254/latest/api/token -H 'X-metadata-token-ttl-seconds: 60')\" http://169.254.169.254/latest/meta-data/hostname", timeoutMs: 10_000 }); + hostname = ` guest-metadata-hostname=${(r.stdout ?? "").trim()} slug=${slug}`; + } + console.log(`run ${run}: create ${created} ms, reachable +${reach} after create, total ${total} (${vmId} ${ip})${hostname}`); + } finally { + await vm.delete().catch((error) => console.error(`delete ${vmId} failed`, error)); + } +} diff --git a/web/scripts/cloud-vm/bench-vm-startup.mjs b/web/scripts/cloud-vm/bench-vm-startup.mjs index 937e6d9fc6d4..68e25745a091 100644 --- a/web/scripts/cloud-vm/bench-vm-startup.mjs +++ b/web/scripts/cloud-vm/bench-vm-startup.mjs @@ -50,7 +50,7 @@ const CREATE_TIMEOUT_MS = 630_000; const ATTACH_BUDGET_MS = 180_000; const requireFromWeb = createRequire(path.join(webDir, "package.json")); -const { StackServerApp } = await import(pathToFileURL(requireFromWeb.resolve("@stackframe/js")).href); +const { StackServerApp } = await import(pathToFileURL(requireFromWeb.resolve("@hexclave/js")).href); // ESM-only package (no require entry): resolved from this script's own tree. const { Freestyle, FreestyleApiError } = await import("freestyle"); diff --git a/web/scripts/verify-devbox-image.ts b/web/scripts/verify-devbox-image.ts index e504871a8e42..17cb0135e2f3 100644 --- a/web/scripts/verify-devbox-image.ts +++ b/web/scripts/verify-devbox-image.ts @@ -92,6 +92,11 @@ const CHECKS: readonly string[] = [ // the boot supervisor's announce loop is running on the booted machine. // `[b]oot` keeps pgrep from matching this check's own shell command line. "command -v arping && pgrep -f 'cmux-devbox-[b]oot' >/dev/null && grep -q 'announce_loop &' /usr/local/bin/cmux-devbox-boot && echo network-announce-ok", + // Quiet resume (cmux-devbox-boot park/re-arm, build-devbox-freestyle.ts + // "snapshot-resume-quiet"): this clone's resume killed no service by + // watchdog, fired no parked housekeeping timer, printed no workqueue + // lockup, and scheduled the delayed re-arm (or already ran it). + "! journalctl -b --no-pager 2>/dev/null | grep -qE 'Watchdog timeout|workqueue lockup|Starting (logrotate|man-db|dpkg-db-backup)\\.service' && { [ ! -e /sys/module/workqueue/parameters/watchdog_thresh ] || [ \"$(cat /sys/module/workqueue/parameters/watchdog_thresh)\" = 0 ]; } && { systemctl list-timers --all --no-pager | grep -q cmux-housekeeping-rearm || [ \"$(systemctl show -p ServiceWatchdogs --value)\" = yes ]; } && echo snapshot-resume-quiet-ok", // Chrome + managed policy + browser/computer-use drivers. "google-chrome-stable --version", "jq -e '.DefaultSearchProviderSearchURL | test(\"duckduckgo\")' /etc/opt/chrome/policies/managed/cmux.json >/dev/null && echo chrome-ddg-policy-ok", @@ -140,7 +145,7 @@ const INSTANCE_ID = DEVBOX_INSTANCE_ID_COMMAND; // cmux-remote keys per-session state by the base64url session name under its // default root state dir; the Noise static identity lives in auth/. const REMOTE_IDENTITY = `${DEVBOX_WORK_HOME}/.local/state/cmux/remote/sessions/${Buffer.from(CMUX_TUI_SESSION).toString("base64url")}/auth/identity.json`; -// cmux-tui's own per-machine secrets, regenerated on first start after the bake wiped them. +// cmux-tui's own per-machine secrets, regenerated on first start after the bake. const MACHINE_SECRETS = `${DEVBOX_WORK_HOME}/.local/state/cmux-tui/sessions/machine-id ${DEVBOX_WORK_HOME}/.local/state/cmux-tui/sessions/resource-effect-pepper`; const DAEMON_CHECKS: readonly string[] = [ // [s]tart: the pattern must not match the exec shell carrying this very command line. @@ -159,6 +164,7 @@ const DAEMON_CHECKS: readonly string[] = [ // The static model-plane env is baked; a shell with no boot env sources it. `test -s /etc/cmux/model-plane.env && grep -q "^export OPENAI_BASE_URL='https://" /etc/cmux/model-plane.env && ! grep -q crt_ /etc/cmux/model-plane.env && env -i HOME=/tmp/mp-verify bash -c '. /etc/cmux/agent-config.sh; printf %s "$OPENAI_BASE_URL"' | grep -q '^https://' && rm -rf /tmp/mp-verify && echo model-plane-env-baked`, "systemctl is-active cmux-tui-daemon >/dev/null && echo systemd-supervisor-active", + "test -x /usr/local/bin/cmux-prompt-sync && python3 -m py_compile /usr/local/bin/cmux-prompt-sync && systemctl is-enabled cmux-prompt-sync >/dev/null && echo prompt-sync-contract-ok", cmuxTuiWebsocketSmokeCommand(), ]; @@ -278,11 +284,10 @@ const FREESTYLE_BASE_CHECKS: readonly string[] = [ // real interactive logins as the work user print nothing from ble.sh or // the shell (a `bash -c` probe would not load ble.sh at all). `[ "$(find ${DEVBOX_WORK_HOME} -not -user ${DEVBOX_WORK_USER} | wc -l)" = 0 ] && echo home-owned-by-work-user`, - // ble.sh normally chooses /run/user//blesh when that session directory - // exists. Remove that transient runtime tree after startup, then run one - // more command in the same durable shell. The shell must stay clean because - // cmux terminals can outlive the desktop/session that created them. - `runtime_probe=$(mktemp -d /tmp/cmux-blesh-runtime-probe.XXXXXX) && chown ${DEVBOX_WORK_USER}:${DEVBOX_WORK_USER} "$runtime_probe" && sudo -n -u ${DEVBOX_WORK_USER} env -i HOME=${DEVBOX_WORK_HOME} USER=${DEVBOX_WORK_USER} TERM=xterm-256color XDG_RUNTIME_DIR="$runtime_probe" CMUX_BLESH_RUNTIME_SENTINEL="$runtime_probe/sentinel" PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin bash -c 'set -eu; tmux -L blesh-runtime-probe new-session -d -s login -x 120 -y 30; sleep 3; rm -rf "/tmp/cmux-blesh-runtime-$(id -u)/blesh"; tmux -L blesh-runtime-probe send-keys -t login "printf CMUX_BLESH_RUNTIME_OK > \\\"$CMUX_BLESH_RUNTIME_SENTINEL\\\"" Enter; sleep 1; tmux -L blesh-runtime-probe capture-pane -pt login >/dev/null; test -s "$CMUX_BLESH_RUNTIME_SENTINEL"; tmux -L blesh-runtime-probe kill-server' && test -s "$runtime_probe/sentinel" && rm -rf "$runtime_probe" && echo blesh-runtime-dir-removal-ok`, + // ble.sh uses a boot-scoped /tmp runtime root instead of the transient + // XDG runtime directory. Keep that root present while the durable shell + // runs, then prove the caller's XDG value remains independent. + `runtime_probe=$(mktemp -d /tmp/cmux-blesh-runtime-probe.XXXXXX) && chown ${DEVBOX_WORK_USER}:${DEVBOX_WORK_USER} "$runtime_probe" && sudo -n -u ${DEVBOX_WORK_USER} env -i HOME=${DEVBOX_WORK_HOME} USER=${DEVBOX_WORK_USER} TERM=xterm-256color XDG_RUNTIME_DIR="$runtime_probe" CMUX_BLESH_RUNTIME_SENTINEL="$runtime_probe/sentinel" PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin bash -c 'set -eu; tmux -L blesh-runtime-probe new-session -d -s login -x 120 -y 30; sleep 3; test -d "/tmp/cmux-blesh-runtime-$(id -u)/blesh"; tmux -L blesh-runtime-probe send-keys -t login "printf CMUX_BLESH_RUNTIME_OK > \\\"$CMUX_BLESH_RUNTIME_SENTINEL\\\"" Enter; sleep 1; tmux -L blesh-runtime-probe capture-pane -pt login >/dev/null; test -s "$CMUX_BLESH_RUNTIME_SENTINEL"; tmux -L blesh-runtime-probe kill-server' && test -s "$runtime_probe/sentinel" && rm -rf "$runtime_probe" && echo blesh-runtime-dir-isolated`, // Not cosmetic: cmux-tui refuses to store its Noise identity under a group- // or other-writable ancestor, and the daemon's state dir lives in this home. // Ubuntu's user-private-group umask (002) is what puts it there. diff --git a/web/services/coderouter/accountAccess.ts b/web/services/coderouter/accountAccess.ts index 457749d496d4..b98e9164056c 100644 --- a/web/services/coderouter/accountAccess.ts +++ b/web/services/coderouter/accountAccess.ts @@ -1,16 +1,24 @@ import { sql, type SQL } from "drizzle-orm"; /** A human can use shared accounts and their own private imports. A machine - * gets only its assigned pool, never its creator's personal account access. */ + * gets only its assigned pool, never its creator's personal account access. + * A chatmux machine (chatmuxVmToken.ts) has no pool: it gets exactly the + * accounts its team shares. */ export type CoderouterAccountAccess = | { readonly kind: "user"; readonly userId: string } - | { readonly kind: "vm"; readonly vmId: string; readonly poolId: string | null }; + | { readonly kind: "vm"; readonly vmId: string; readonly poolId: string | null } + | { readonly kind: "team-machine"; readonly teamId: string; readonly machineId: string }; export function accountAccessForIdentity(identity: { readonly stackUserId: string; readonly vmId: string | null; readonly poolId?: string | null; + readonly teamId?: string; + readonly machine?: "chatmux"; }): CoderouterAccountAccess { + if (identity.machine === "chatmux") { + return { kind: "team-machine", teamId: identity.teamId ?? "", machineId: identity.vmId ?? "" }; + } return identity.vmId === null ? { kind: "user", userId: identity.stackUserId } : { kind: "vm", vmId: identity.vmId, poolId: identity.poolId ?? null }; @@ -29,6 +37,11 @@ export function accountAccessPredicate( if (access.kind === "user") { return sql`(${account.visibility} = 'team' or ${account.createdBy} = ${access.userId})`; } + if (access.kind === "team-machine") { + // Only what the machine's own team shares; an empty team id matches nothing. + if (!access.teamId) return sql`false`; + return sql`(${account.visibility} = 'team' and ${account.teamId} = ${access.teamId})`; + } if (access.poolId === null) return sql`false`; // Personal scopes use the user id as their team id. Their owner’s private // accounts are intentionally usable by personal VMs, still behind the same @@ -52,6 +65,7 @@ export function accountAccessPredicate( /** The caller's session key is not a security namespace. */ export function scopedSessionKey(key: string | null, access?: CoderouterAccountAccess): string | null { if (!key || !access) return key; + if (access.kind === "team-machine") return JSON.stringify(["team-machine", access.machineId, key]); return JSON.stringify(access.kind === "vm" ? ["vm", access.vmId, access.poolId, key] : ["user", access.userId, key]); diff --git a/web/services/coderouter/chatmuxVmToken.ts b/web/services/coderouter/chatmuxVmToken.ts new file mode 100644 index 000000000000..a0a9fb4d5428 --- /dev/null +++ b/web/services/coderouter/chatmuxVmToken.ts @@ -0,0 +1,98 @@ +// chatmux VM tokens: short-lived ES256 JWTs that chatmux (chatmux.dev) signs +// for each of its Freestyle VMs with a key held in an HSM. The Freestyle edge +// injects the token into the VM's requests, so the guest never holds it. +// coderouter verifies it against chatmux's public JWKS; no coderouter database +// row exists for these machines. A chatmux machine may use only accounts its +// Hexclave team shares (visibility "team"), never anyone's private account. +// +// Off unless both CODEROUTER_CHATMUX_JWKS_URL and CODEROUTER_CHATMUX_ISSUERS +// are set. +import { createRemoteJWKSet, decodeProtectedHeader, jwtVerify, type JWTVerifyGetKey } from "jose"; + +export const CHATMUX_VM_AUTHORIZATION_HEADER = "x-chatmux-vm-authorization"; +export const CHATMUX_VM_AUDIENCE = "coderouter"; +/** chatmux signs for one hour and rewrites the edge rule hourly. */ +export const CHATMUX_VM_TOKEN_MAX_LIFETIME_SECONDS = 60 * 60; +const ROLES = new Set(["browser", "dev", "worker"]); + +export type ChatmuxVmClaims = { + /** `vm:`. */ + readonly sub: string; + readonly jti: string; + readonly team_id: string; + readonly owner_id: string; + readonly role: "browser" | "dev" | "worker"; + readonly iss: string; +}; + +type Config = { readonly keys: JWTVerifyGetKey; readonly issuers: ReadonlyArray }; + +let remote: { url: string; keys: JWTVerifyGetKey } | null = null; + +/** The configured key set and issuers, or null when chatmux tokens are off. */ +export function chatmuxConfig(env: Record = process.env): Config | null { + const url = env.CODEROUTER_CHATMUX_JWKS_URL?.trim(); + const issuers = (env.CODEROUTER_CHATMUX_ISSUERS ?? "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + if (!url || !issuers.length || !url.startsWith("https://")) return null; + // jose caches the set, refetches on an unknown kid, and rate-limits refetches. + if (remote?.url !== url) remote = { url, keys: createRemoteJWKSet(new URL(url), { cooldownDuration: 60_000 }) }; + return { keys: remote.keys, issuers }; +} + +function identifier(value: unknown): value is string { + return typeof value === "string" && value.length > 0 && value.length <= 256 && + ![...value].some((c) => c.charCodeAt(0) <= 0x20 || c.charCodeAt(0) === 0x7f || /\s/.test(c)); +} + +function validLifetime(iat: unknown, exp: unknown): boolean { + return typeof iat === "number" && typeof exp === "number" && exp > iat && + exp - iat <= CHATMUX_VM_TOKEN_MAX_LIFETIME_SECONDS; +} + +/** The typed claims of a verified payload, or null when one is missing or malformed. */ +function claimsFrom(payload: Record): ChatmuxVmClaims | null { + const { sub, jti, team_id, owner_id, role, iss } = payload; + if (!validLifetime(payload.iat, payload.exp)) return null; + if (![sub, jti, team_id, owner_id].every(identifier) || typeof iss !== "string") return null; + if (!(sub as string).startsWith("vm:") || typeof role !== "string" || !ROLES.has(role)) return null; + return { + sub: sub as string, + jti: jti as string, + team_id: team_id as string, + owner_id: owner_id as string, + role: role as ChatmuxVmClaims["role"], + iss, + }; +} + +/** + * Verifies a chatmux VM token locally (signature, issuer, audience, one-hour + * lifetime, required claims). No token, configuration, or JOSE error escapes. + */ +export async function verifyChatmuxVmToken( + token: string, + config: Config | null = chatmuxConfig(), + now = new Date(), +): Promise { + if (!config) return null; + try { + if (token.length > 4096 || !/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(token)) return null; + const header = decodeProtectedHeader(token); + if (header.alg !== "ES256" || typeof header.kid !== "string" || !/^[\w.-]{1,128}$/.test(header.kid)) return null; + const { payload } = await jwtVerify(token, config.keys, { + algorithms: ["ES256"], + issuer: [...config.issuers], + audience: CHATMUX_VM_AUDIENCE, + requiredClaims: ["sub", "jti", "team_id", "owner_id", "role", "iat", "exp"], + maxTokenAge: CHATMUX_VM_TOKEN_MAX_LIFETIME_SECONDS, + clockTolerance: 60, + currentDate: now, + }); + return claimsFrom(payload); + } catch { + return null; + } +} diff --git a/web/services/coderouter/requestContext.ts b/web/services/coderouter/requestContext.ts index 94c94ddee64b..8d9085928102 100644 --- a/web/services/coderouter/requestContext.ts +++ b/web/services/coderouter/requestContext.ts @@ -59,6 +59,10 @@ export async function resolveCoderouterControlContext( if (request.headers.has(VM_AUTHORIZATION_HEADER) || token?.startsWith("crt_") || request.headers.has(VM_ID_HEADER) || request.headers.has(ROUTE_TOKEN_HEADER)) { const auth = await authenticateRequestRouteToken(request); if (!auth.ok) return { ok: false, response: jsonResponse({ error: auth.reason }, 401) }; + // A chatmux machine may use its team's shared accounts, never manage them. + if (auth.identity.machine === "chatmux") { + return { ok: false, response: jsonResponse({ error: "chatmux_machine_not_allowed" }, 403) }; + } if (!auth.identity.vmId) { return { ok: false, response: jsonResponse({ error: "vm_bound_token_required" }, 403) }; } diff --git a/web/services/coderouter/requestTelemetry.ts b/web/services/coderouter/requestTelemetry.ts index 295467a8d696..605501e3a049 100644 --- a/web/services/coderouter/requestTelemetry.ts +++ b/web/services/coderouter/requestTelemetry.ts @@ -61,6 +61,7 @@ export type CoderouterSurface = | "claude_upstream" | "vm_usage" | "vm_reflection" + | "vm_reflection_name" | "analytics" | "health"; diff --git a/web/services/coderouter/routeTokenAuth.ts b/web/services/coderouter/routeTokenAuth.ts index 60a6e8a403da..382516795e5a 100644 --- a/web/services/coderouter/routeTokenAuth.ts +++ b/web/services/coderouter/routeTokenAuth.ts @@ -15,6 +15,7 @@ import { verifyVmAuthorization, } from "./vmAuthorization"; import { recordCoderouterIdentity, recordCoderouterSpan } from "./requestTelemetry"; +import { CHATMUX_VM_AUTHORIZATION_HEADER, verifyChatmuxVmToken } from "./chatmuxVmToken"; export const ROUTE_TOKEN_HEADER = "x-coderouter-route-token"; export const VM_ID_HEADER = "x-cmux-vm-id"; @@ -38,6 +39,8 @@ export type RouteTokenIdentity = { /** Opaque database id for a long-lived API key, or null for route tokens. */ readonly apiKeyId?: string | null; readonly poolId?: string | null; + /** A chatmux machine: team-shared accounts only (accountAccess.ts). */ + readonly machine?: "chatmux"; }; export type RouteTokenAuthFailure = @@ -97,10 +100,33 @@ export async function authenticateRequestRouteToken( return result; } +/** + * A chatmux VM token (chatmuxVmToken.ts). When its header is present it is + * the only credential considered: no fallback to another header, no database + * lookup, and a bad token fails closed. + */ +async function authenticateChatmuxMachine(request: Request): Promise { + const value = request.headers.get(CHATMUX_VM_AUTHORIZATION_HEADER)?.trim() ?? ""; + const token = /^Bearer[ \t]+([^\s,]+)$/i.exec(value)?.[1]; + const claims = token ? await verifyChatmuxVmToken(token) : null; + if (!token || !claims) return { ok: false, reason: "invalid_route_token" }; + return { + ok: true, + identity: { + teamId: claims.team_id, + stackUserId: claims.owner_id, + vmId: `chatmux:${claims.sub.slice("vm:".length)}`, + token, + machine: "chatmux", + }, + }; +} + async function authenticateUnobserved( request: Request, authenticate: Authenticate, ): Promise { + if (request.headers.has(CHATMUX_VM_AUTHORIZATION_HEADER)) return await authenticateChatmuxMachine(request); const signedHeader = request.headers.has(VM_AUTHORIZATION_HEADER); const token = routeTokenFromRequest(request); if (!token) return { ok: false, reason: signedHeader ? "invalid_route_token" : "missing_route_token" }; diff --git a/web/services/vms/drivers/freestyle.ts b/web/services/vms/drivers/freestyle.ts index eb2de578488c..fecfb4dc357f 100644 --- a/web/services/vms/drivers/freestyle.ts +++ b/web/services/vms/drivers/freestyle.ts @@ -4,20 +4,18 @@ import { type ResizeVmOptions, type TunnelData, type VmData, - type VmResources, type Vm, type VpcData, type SnapshotData, } from "freestyle"; -import { createHash, randomBytes } from "node:crypto"; +import { randomBytes } from "node:crypto"; import { isIP } from "node:net"; import { Effect } from "effect"; import { FreestyleResourceStatsReader } from "./freestyleResourceStatsReader"; import { announceFreestyleNetwork } from "./freestyleNetworkAnnouncement"; import { freestyleRequestFetch } from "./freestyleRequestTiming"; import { currentVmRequestContext } from "../requestContext"; -import { guestResourceReporterInstallCommand } from "../guestResourceReporter"; import { ProviderError, type AttachTransport, @@ -43,7 +41,6 @@ import { type VMResourceStatsResult, type VMStatus, } from "./types"; -import { PLAN_MACHINE_MEMORY_MB, vcpusForMemoryMb, vmDiskMb } from "../machineSpec"; import { DEVBOX_DESKTOP_NOVNC_PORT, DEVBOX_DESKTOP_START_SCRIPT, @@ -52,34 +49,9 @@ import { } from "../images/desktop"; import { recordSpanError, setSpanAttributes, withVmSpan } from "../telemetry"; import { parseSshPublicKey, scpPrepareCommand, SCP_KEY_TTL_SECONDS } from "./scp"; -import { guestCliDistributionCommand } from "../guestCliDistribution"; -import { GUEST_CMUX_SHIM, GUEST_CMUX_SHIM_PATH } from "../guestCli"; -import { guestBrowserInstallCommand, guestBrowserMimeReconcileCommand, guestBrowserReadyCommand } from "../guestBrowser"; -import { guestPromptInstallCommand, type GuestPromptIdentity } from "../guestPrompt"; import { - approveCmuxTuiEnrollment, - CMUX_TUI_ATTACH_BUNDLE_NOT_READY_EXIT, - CMUX_TUI_INSTALL_TIMEOUT_MS, CMUX_TUI_PORT, CMUX_TUI_SESSION, - CMUX_TUI_TRUSTED_CARRIER_ENV, - cmuxTuiAttachBundleCommand, - cmuxTuiDaemonBuild, - cmuxTuiDaemonCommand, - cmuxTuiAgentHooksInstallCommand, - cmuxTuiHooksReadyCommand, - cmuxTuiInstallCommand, - cmuxTuiPinnedManifestUrl, - cmuxTuiLayoutSelector, - cmuxTuiPinCheckCommand, - cmuxTuiRunCommand, - mintCmuxTuiInvitation, - parseCmuxTuiAttachBundle, - resolveCmuxTuiSource, - shellQuote, - waitForCmuxTuiReady, - type CmuxTuiInvoke, - type CmuxTuiSource, } from "./cmuxTuiDaemon"; // The Freestyle driver, on the public platform (api.freestyle.sh /v5, SDK @@ -107,9 +79,9 @@ import { // The daemon's Noise handshake encrypts and authenticates the session end to // end either way (carrier TLS is not required and the route token only feeds // the lease ledger). The daemon must bind dual-stack: the baked systemd unit -// sets CMUX_TUI_REMOTE_WS_BIND=[::]:1337 and the driver re-asserts it on heal — -// which is also what makes a VPC address reachable, since it is neither -// loopback nor the public NIC. +// sets CMUX_TUI_REMOTE_WS_BIND=[::]:1337, which is also what makes a VPC +// address reachable, since it is neither loopback nor the public NIC. The +// driver never re-asserts it; a wrong bind is an image bug, fixed by a rebake. // // Creates take NO ports field, NO create-time env, and NO systemd injection; // `firewall` is mandatory. The model-plane env is baked into the snapshot at @@ -118,12 +90,33 @@ import { // /etc/cmux/agent-config.sh sources it in every shell whatever user it runs as. // // Create runs no guest bootstrap. The devbox snapshot carries the pinned -// cmux-tui build and the cmux-tui-daemon systemd unit, and its supervisor -// (services/vms/images/devbox/cmux-devbox-boot) starts the daemon with a -// fresh identity as soon as the machine resumes, keyed on the platform -// instance id. Create is therefore `vms.create`, the grow-only resize, and one -// safe guest-adapter write; attach heals a daemon that is not yet, or no -// longer, listening. +// cmux-tui build, guest CLI integration, resource reporter, prompt sync, and +// cmux-tui-daemon systemd unit. Its supervisor starts the daemon with a fresh +// identity as soon as the machine resumes, keyed on the platform instance id. +// Create and trusted-carrier attach therefore use provider metadata and the +// immutable image contract, with no guest exec or filesystem upload. +// +// NO-WORK INVARIANT (read before adding a provider call to create, restore, +// resume, or openCmuxRemote). These paths are on the user's New Machine +// critical path, and every provider round trip here is paid on every create: +// - create: one `vms.create` with VPC and TLS rules inline. No resize (each +// size has its own snapshot), no `vm.exec`, no `vm.fs` write, no +// readiness poll, no second provider read. +// - restore/resume: no guest exec; the baked supervisor owns the daemon. +// - openCmuxRemote: built from the row's providerMetadata alone. No guest +// exec, no install, no "heal", no enrollment round trip. +// If the guest needs a new binary, file, hook, package, or setting, bake it +// into the devbox snapshot (web/scripts/build-devbox-freestyle.ts, the boot +// supervisor in services/vms/images/devbox/cmux-devbox-boot) and bump +// images/manifest.json. If it needs per-machine identity, derive it in the +// guest at boot from the platform instance id, or fetch it asynchronously +// from the guest (cmux-prompt-sync) without gating terminal access. Old +// images without `cmuxTuiContract: "snapshot-v2"` are refused on purpose; +// do not reintroduce create- or attach-time healing to support them. +// Explicit user operations (`exec`, `resize`, file push/pull) are separate +// and stay. The one sanctioned guest exec around create is the prompt-name +// push in workflows.ts (schedulePromptIdentityPush): display-only, run after +// the response, never awaited by create. Do not add anything else to it. // // The coderouter model plane is edge-injected: the create carries an inline // `tls` rule for the coderouter host whose transform overwrites `x-cmux-authorization` to every request the @@ -131,8 +124,8 @@ import { // installs its CA at boot; rules added after boot never reach a running // guest, so the rule must be inline. The baked env file holds only base // URLs and placeholder keys: no token is ever written into the guest, and -// create runs no exec and writes no file there (vms.create, the grow-only -// resize, and a delete on rollback are its only provider calls). Injection +// create runs no exec and writes no file there (vms.create and a delete on +// rollback are its only provider calls). Injection // becomes active 20-30 s after boot; the first agent request before that // reaches the origin without the header and is rejected, then succeeds. // @@ -176,12 +169,12 @@ const ROUTE_TOKEN_TTL_SECONDS = 12 * 60 * 60; const EDGE_DOMAIN = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/i; /** - * The seams tests replace: the SDK client and the cmux-tui manifest read. - * Production uses the env-configured client and the live manifest. + * The seam tests replace: the SDK client. There is deliberately no daemon + * manifest resolver here; the driver never installs or repairs cmux-tui (the + * snapshot owns it, see NO-WORK INVARIANT above). */ export type FreestyleProviderDependencies = { readonly client: (timeoutMs?: number) => Freestyle; - readonly resolveDaemonSource: typeof resolveCmuxTuiSource; }; /** @@ -527,93 +520,30 @@ export function mapFreestyleState(state: VmData["state"] | null | undefined): VM } /** - * Healthy = the daemon process is up AND something listens on 1337 in the v6 - * table (a dual-stack `[::]` bind; 0x0539 = 1337). A daemon bound 0.0.0.0 only - * appears in /proc/net/tcp, is unreachable at the public IPv6, and must be - * restarted under the dual-stack override. - */ -/** - * Is the installed binary the machine's pinned build? A baked image records - * the pin it was built with in /etc/cmux/cmux-tui-pin (` `), - * and that is the version contract for every machine from that snapshot: the - * heal reinstalls only a missing or corrupt binary, never one the live - * files.cmux.com manifest has since moved past (a new pin ships by rebake). - * Images without the file were installed from the live pin at create, so the - * live pin stays their reference. + * Tunnel creates finish in ~150 ms at the median and ~2.2 s at the slowest + * seen in production (30 days); 10 s leaves room while cutting a hung create + * well short of the 30 s route budget. */ -export function freestylePinCheckCommand(source: CmuxTuiSource): string { - return ( - "if [ -s /etc/cmux/cmux-tui-pin ]; then " + - `${cmuxTuiLayoutSelector()} && ` + - `test -x "$CMUX_TUI_BIN" && printf '%s %s\\n' "$(cut -d' ' -f1 /etc/cmux/cmux-tui-pin)" "$CMUX_TUI_BIN" | sha256sum -c >/dev/null 2>&1; ` + - `else ${cmuxTuiPinCheckCommand(source)}; fi` - ); -} - -/** How long the heal lets a baked supervisor bring the daemon up before restarting it. */ -const DAEMON_SETTLE_TIMEOUT_MS = 3_000; +const TUNNEL_CREATE_TIMEOUT_MS = 10_000; /** - * Healthy now, or healthy within the settle budget on an image whose - * supervisor binds the daemon to the instance id (it ships - * /etc/cmux/bake-instance-id) and is active. A machine attached right after - * create is inside the sub-second window before that supervisor has started - * the daemon; restarting the unit there costs a second and a half, waiting - * costs a few hundred milliseconds. Older images take the immediate check. + * A create that may have made the tunnel even though it did not return it: + * a slug conflict, a provider 5xx (a measured 503 still created the tunnel), + * or no answer at all (timeout, reset). A 4xx other than the conflict is a + * definite refusal, so it is not worth a recovery read. */ -export function freestyleDaemonSettledCommand(): string { - const healthy = freestyleDaemonHealthyCommand(); - const ticks = Math.floor(DAEMON_SETTLE_TIMEOUT_MS / 100); - return ( - "if [ -f /etc/cmux/bake-instance-id ] && systemctl is-active cmux-tui-daemon >/dev/null 2>&1; then " + - `for i in $(seq 1 ${ticks}); do { ${healthy}; } && exit 0; sleep 0.1; done; exit 1; ` + - `else ${healthy}; fi` - ); -} - -export function freestyleDaemonHealthyCommand(): string { - // [s]tart: pgrep -f would otherwise match the exec shell carrying this command line. - // On an image whose supervisor binds the daemon identity to the instance id - // (it ships /etc/cmux/bake-instance-id), the daemon is healthy only when the - // bound id is this machine's: a clone of a live machine briefly runs the - // source machine's daemon until the supervisor re-keys it, and an - // invitation minted from that daemon would name the wrong fingerprint. - return ( - "pgrep -f 'cmux-tui server [s]tart' >/dev/null 2>&1 && grep -qi ':0539 ' /proc/net/tcp6" + - " && { [ ! -f /etc/cmux/bake-instance-id ] || [ \"$(cat /etc/cmux/daemon-instance-id 2>/dev/null)\" = \"$(" + - "curl -sf -m 2 -H \"X-aws-ec2-metadata-token: $(curl -sf -m 2 -X PUT http://169.254.169.254/latest/api/token -H 'X-metadata-token-ttl-seconds: 60')\" http://169.254.169.254/latest/meta-data/instance-id" + - ")\" ]; }" - ); +export function tunnelCreateMayHaveSucceeded(err: unknown): boolean { + // Our own configuration failures (no credentials) never reached the provider. + if (err instanceof ProviderError) return false; + if (err instanceof FreestyleApiError) { + return (err.status === 409 && err.code === "CONFLICT") || err.status >= 500; + } + return true; } -const REMOTE_WS_BIND_OVERRIDE = - "/etc/systemd/system/cmux-tui-daemon.service.d/10-cmux-remote-ws-bind.conf"; - -/** - * (Re)start the daemon listening dual-stack. Under systemd (the baked - * cmux-tui-daemon unit), install a drop-in setting - * CMUX_TUI_REMOTE_WS_BIND=[::]:1337 — the env cmux-devbox-boot reads — then - * restart the unit, healing machines from bakes that predate the env default. - * Without systemd (or the unit), fall back to a direct daemon launch with the - * dual-stack bind. - */ -export function freestyleStartDaemonCommand(options?: { replaceExisting?: boolean }): string { - const replace = options?.replaceExisting === true; - // shellQuote, not a bare '…': the daemon command carries single quotes of - // its own (the layout breadcrumb's printf), which would end the string early. - const fallbackLaunch = `(setsid nohup sh -c ${shellQuote(cmuxTuiDaemonCommand(FREESTYLE_REMOTE_WS_BIND))} >>/tmp/cmux-tui-daemon.log 2>&1 &)`; - return [ - "if [ -d /run/systemd/system ] && [ -f /etc/systemd/system/cmux-tui-daemon.service ]; then", - `mkdir -p ${REMOTE_WS_BIND_OVERRIDE.replace(/\/[^/]+$/, "")};`, - `printf '[Service]\\nEnvironment=CMUX_TUI_REMOTE_WS_BIND=${FREESTYLE_REMOTE_WS_BIND}\\nEnvironment=${CMUX_TUI_TRUSTED_CARRIER_ENV}=1\\n' > ${REMOTE_WS_BIND_OVERRIDE};`, - "systemctl daemon-reload;", - "systemctl restart cmux-tui-daemon;", - "else", - replace - ? `pkill -f 'cmux-tui server [s]tart' >/dev/null 2>&1; sleep 1; ${fallbackLaunch};` - : `pgrep -f 'cmux-tui server [s]tart' >/dev/null 2>&1 || ${fallbackLaunch};`, - "fi", - ].join(" "); +function tunnelRecoveryReason(err: unknown): string { + if (!(err instanceof FreestyleApiError)) return "no_response"; + return err.status === 409 ? "conflict" : "server_error"; } function isNotFound(err: unknown): boolean { @@ -721,8 +651,10 @@ class FreestylePrivateNetworking implements VMPrivateNetworking { try { // clientPublicKey is always supplied, so the platform never mints or // holds a private key: the config comes back with a blank PrivateKey - // for the Mac to fill in from its own Keychain. - const data = await this.client().tunnels.create({ + // for the Mac to fill in from its own Keychain. The short timeout + // bounds a hung create (a measured 503 arrived after ~25 s); the + // recovery read below finds a tunnel the provider made anyway. + const data = await this.client(TUNNEL_CREATE_TIMEOUT_MS).tunnels.create({ slug: options.slug, displayName: options.displayName, clientPublicKey, @@ -732,9 +664,10 @@ class FreestylePrivateNetworking implements VMPrivateNetworking { setSpanAttributes(span, { "cmux.vm.tunnel.id": tunnel.id }); return { tunnel, created: true, rotated: false }; } catch (err) { - if (!(err instanceof FreestyleApiError && err.status === 409 && err.code === "CONFLICT")) { + if (!tunnelCreateMayHaveSucceeded(err)) { throw new ProviderError("freestyle", `createTunnel(${options.slug})`, err); } + span.setAttribute("cmux.vm.tunnel.recovery_reason", tunnelRecoveryReason(err)); // A duplicate create may reuse only the exact same client identity. // Key rotation belongs to explicit enrollment of an existing row; // recovery must never evict a concurrent client's working key. @@ -862,10 +795,6 @@ class FreestylePrivateNetworking implements VMPrivateNetworking { } } -function errorMessage(err: unknown): string { - return err instanceof Error ? err.message : String(err); -} - function spanAttributes(vmId: string, operation: string, extra: Record = {}) { return { "cmux.vm.provider": "freestyle", @@ -917,7 +846,6 @@ export class FreestyleProvider implements VMProvider { constructor( private readonly deps: FreestyleProviderDependencies = { client: freestyleClient, - resolveDaemonSource: resolveCmuxTuiSource, }, ) { this.privateNetworking = new FreestylePrivateNetworking(this.deps.client); @@ -986,9 +914,13 @@ export class FreestyleProvider implements VMProvider { // clone boot; attach performs the strict announcement before // handing out the private daemon route. if (networkId) await this.announcePrivateAddresses(vm, data, { validateOnly: true }); + // Every production create resolves to a size-specific snapshot. The + // snapshot owns the guest CLI, browser integration, resource + // reporter, prompt templates, hooks, and daemon. A create must only + // allocate that immutable image and attach its account network. + // Per-machine prompt identity is refreshed asynchronously by the + // boot contract; no guest exec or filesystem upload belongs here. if (options.imageSize) { - // One snapshot per size: the machine already boots at the shape - // that was sold, so nothing is read back and nothing is grown. setSpanAttributes(span, { "cmux.vm.image_size": options.imageSize.name, "cmux.vm.resources.cpu": options.imageSize.cpu, @@ -997,17 +929,13 @@ export class FreestyleProvider implements VMProvider { "cmux.vm.resize.requested": false, }); } else { - // A size-less image boots at its snapshot's resources and only a - // grow-only resize raises them. Size first so the machine the - // daemon comes up on is the one that was sold. - await this.growToRequestedSize(fs, vm, vmId, options.memoryMb, span, data.resources); + setSpanAttributes(span, { + "cmux.vm.resources.cpu": data.resources?.cpu ?? 0, + "cmux.vm.resources.memory_mb": data.resources?.memory ?? 0, + "cmux.vm.resources.storage_mb": data.resources?.storage ?? 0, + "cmux.vm.resize.requested": false, + }); } - // The baked supervisor is already bringing the daemon up; the only - // per-machine input it needs is the model-plane env file. - - // The in-VM shim is a separate convenience layer over the baked - // daemon and is installed idempotently for agents and peer links. - await this.installGuestCli(vm, vmId, options.promptIdentity); // The baked supervisor announces the VPC interface on clone boot // and every 30 seconds. Waiting for a second guest-side `ip` probe // here made create pay a redundant network round trip and turned @@ -1036,6 +964,7 @@ export class FreestyleProvider implements VMProvider { // placed, never where to dial it. providerMetadata: { ...(options.providerMetadata ?? {}), + cmuxTuiContract: "snapshot-v2", ...(networkId ? { networkId } : {}), ...(freestyleNetworkAddressMetadata(data)), }, @@ -1047,35 +976,6 @@ export class FreestyleProvider implements VMProvider { ); } - /** - * Grow the VM to the requested memory (the plan machine when the caller - * sent none), the vCPUs that memory implies, and the plan disk. Freestyle - * resize is grow-only, so only larger dimensions are sent; a snapshot that - * already carries the size is a no-op. - */ - private async growToRequestedSize( - fs: Freestyle, - vm: Vm, - vmId: string, - memoryMb: number | undefined, - span: Parameters[0], - // The create response already describes the machine; a caller without - // it (an older row being re-sized) pays one status read instead. - currentResources?: VmResources, - ): Promise { - const current = currentResources ?? (await fs.vms.get(vmId)).resources; - const target = freestyleTargetResources(memoryMb ?? PLAN_MACHINE_MEMORY_MB); - const request = freestyleResizeRequest(current, target); - setSpanAttributes(span, { - "cmux.vm.resources.cpu": target.cpu, - "cmux.vm.resources.memory_mb": target.memory, - "cmux.vm.resources.storage_mb": target.storage, - "cmux.vm.resize.requested": request !== null, - }); - if (!request) return; - await vm.resize(request); - } - async destroy(vmId: string): Promise { return withVmSpan( "cmux.vm.provider.destroy", @@ -1179,13 +1079,9 @@ export class FreestyleProvider implements VMProvider { const status = mapFreestyleState(data.state); setSpanAttributes(span, { "cmux.vm.provider_state": data.state, "cmux.vm.status": status }); // A memory-preserving pause keeps the daemon; a cold boot (the VM had - // stopped) relies on the baked systemd unit. Heal best-effort so the - // first attach doesn't race the unit; attach re-verifies anyway. - try { - await this.ensureCmuxTuiRunning(vm, vmId); - } catch (healErr) { - recordSpanError(span, healErr); - } + // stopped) relies on the baked systemd unit. Resume does no guest + // work (see NO-WORK INVARIANT at the top of this file). The client's + // link retry covers the short window before the unit is listening. return { provider: "freestyle" as const, providerVmId: data.id, @@ -1213,7 +1109,6 @@ export class FreestyleProvider implements VMProvider { try { const fs = this.deps.client(timeoutMs + EXEC_OVERHEAD_TIMEOUT_MS); const vm = fs.vms.ref(vmId); - await this.ensureGuestCli(vm, vmId); const r = await vm.exec({ command, timeoutMs, linuxUser: GUEST_LINUX_USER }); // statusCode is null when the guest killed the command at its timeout. const exitCode = r.statusCode ?? 124; @@ -1386,16 +1281,11 @@ export class FreestyleProvider implements VMProvider { "cmux.vm.id": vmId, "cmux.vm.network.private": !!networkId, }); - // The snapshot carries the installed binary and a persisted - - // model-plane file with placeholders only. The guest adapter is a - // required artifact, so install it before returning; daemon healing - // remains best-effort for a transient resume race. The new machine's - // edge rule is supplied inline, so its route is still fail-closed. + // The snapshot carries the daemon, guest integration, reporter, and + // prompt sync contract. Restore has the same no-guest-bootstrap + // invariant as fresh create. try { - await this.installGuestCli(vm, vmId); - await this.ensureCmuxTuiRunning(vm, vmId, false).catch(() => undefined); - await this.announcePrivateAddresses(vm, data); + if (networkId) await this.announcePrivateAddresses(vm, data, { validateOnly: true }); } catch (err) { await vm.delete().catch((cleanupErr) => { console.error(`[freestyle] restore rollback failed; VM ${vmId} may be orphaned`, cleanupErr); @@ -1410,6 +1300,7 @@ export class FreestyleProvider implements VMProvider { createdAt: Date.now(), providerMetadata: { ...(options?.providerMetadata ?? {}), + cmuxTuiContract: "snapshot-v2", ...(networkId ? { networkId, ...freestyleNetworkAddressMetadata(data) } : {}), }, }; @@ -1425,94 +1316,27 @@ export class FreestyleProvider implements VMProvider { "cmux.vm.provider.open_cmux_remote", "tunnel", spanAttributes(vmId, "open_cmux_remote"), - // oxlint-disable-next-line complexity -- Attach healing must preserve readiness, enrollment, and route-token ordering. async (span) => { try { - const fs = this.deps.client(CMUX_TUI_INSTALL_TIMEOUT_MS + EXEC_OVERHEAD_TIMEOUT_MS); - const vm = fs.vms.ref(vmId); - // The row already holds the private addresses from create; only a - // public-ingress machine needs the provider read for its IPv6. + // Attach never runs guest work (NO-WORK INVARIANT at the top of this + // file). A snapshot-v2 row carries its private addresses, so attach + // is pure metadata. A row from before this contract was recorded + // still gets the same answer: every image baked since 2026-09-06 + // (#12042) serves the trusted listener, and an older one fails at + // connect instead of being healed here. Only a row that never + // recorded its addresses pays one provider read; the workflow then + // persists them, so it happens once per machine. const persisted = freestyleRouteAddressesFromMetadata(options?.providerMetadata); - const data = persisted ?? await vm.data(); - const route = freestyleCmuxRemoteRoute(data, vmId); - await this.announcePrivateAddresses(vm, data); - - span.setAttribute("cmux.vm.network.private", (data.vpcs ?? data.networks ?? []).length > 0); - span.setAttribute("cmux.vm.route.source", persisted ? "row" : "provider"); - // Direct-IPv6 carries no URL token; this one exists only for the - // lease ledger. The daemon's Noise enrollment is the session gate — - // the same trust model as E2B's public proxy route. + const routeAddresses = persisted ?? await this.deps.client().vms.ref(vmId).data(); + span.setAttribute("cmux.vm.cmux_tui_contract", String(options?.providerMetadata?.cmuxTuiContract ?? "none")); + const route = freestyleCmuxRemoteRoute(routeAddresses, vmId); const token = `cmux-freestyle-route-${randomBytes(32).toString("hex")}`; const expiresAtUnix = Math.floor(Date.now() / 1000) + ROUTE_TOKEN_TTL_SECONDS; - // One guest exec: readiness gate, daemon build, enrolled devices, and - // an invitation unless the caller is enrolled. Exit 3 means the daemon - // was not ready inside the settle budget; heal, then run it again. - const fingerprint = options?.deviceFingerprint; - const promptSetup = options?.promptIdentity ? `${guestPromptInstallCommand(options.promptIdentity)} && ` : ""; - let bundleResult = await this.execResult( - vm, - promptSetup + cmuxTuiAttachBundleCommand({ readyGate: freestyleDaemonSettledCommand(), deviceFingerprint: fingerprint }), - DAEMON_SETTLE_TIMEOUT_MS + EXEC_OVERHEAD_TIMEOUT_MS + EXEC_DEFAULT_TIMEOUT_MS, - ); - let healed = false; - if (!bundleResult || bundleResult.exitCode === CMUX_TUI_ATTACH_BUNDLE_NOT_READY_EXIT) { - healed = true; - await this.ensureCmuxTuiRunning(vm, vmId); - bundleResult = await this.execResult(vm, promptSetup + cmuxTuiAttachBundleCommand({ deviceFingerprint: fingerprint })); - } - if (!healed && bundleResult?.exitCode === 0) { - // The settled daemon's CLI files, agent hooks, and systemd reporter - // own separate paths. Prepare them concurrently, retaining the CLI - // gate and waiting for every side effect before returning an error. - const [cli] = await Promise.allSettled([ - this.ensureGuestCli(vm, vmId, false), - this.ensureAgentHooks(vm, vmId), - this.ensureResourceReporter(vm, vmId), - ]); - if (cli.status === "rejected") throw cli.reason; - } - if (!bundleResult || bundleResult.exitCode !== 0) { - throw new ProviderError( - "freestyle", - `cmux-tui attach bundle in ${vmId} failed (exit ${bundleResult?.exitCode ?? "n/a"}): ${(bundleResult?.stderr || bundleResult?.stdout || "").slice(0, 500)}`, - ); - } - let bundle = parseCmuxTuiAttachBundle(bundleResult.stdout, "freestyle", vmId, fingerprint); - if (!bundle.trustedCarrier) { - // A healthy daemon from an older image can still lack the trusted - // listener. Install/restart the pinned daemon before retrying. - const source = await this.deps.resolveDaemonSource("freestyle"); - const pinned = await this.execResult(vm, freestylePinCheckCommand(source)); - if (pinned?.exitCode !== 0) { - await this.execOrThrow(vm, vmId, cmuxTuiInstallCommand(source), CMUX_TUI_INSTALL_TIMEOUT_MS); - } - await this.execOrThrow(vm, vmId, freestyleStartDaemonCommand({ replaceExisting: true }), 60_000); - await waitForCmuxTuiReady(this.cmuxTuiInvoke(vm), "freestyle", vmId); - bundleResult = await this.execResult(vm, cmuxTuiAttachBundleCommand({ deviceFingerprint: fingerprint })); - if (!bundleResult || bundleResult.exitCode !== 0) { - throw new ProviderError("freestyle", `cmux-tui attach bundle retry in ${vmId} failed`); - } - bundle = parseCmuxTuiAttachBundle(bundleResult.stdout, "freestyle", vmId, fingerprint); - if (!bundle.trustedCarrier) { - throw new ProviderError( - "freestyle", - `cmux-tui daemon in ${vmId} still refuses the trusted listener after the pinned build was installed and restarted`, - ); - } - healed = true; - } - span.setAttribute("cmux.vm.cmux_remote.healed", healed); - const invoke = this.cmuxTuiInvoke(vm); - const enrolled = bundle.enrolled; - let invitation: CmuxRemoteEndpoint["invitation"] = bundle.invitation ?? undefined; - if (!bundle.trustedCarrier && !enrolled && !invitation) { - // The shell's substring check and the JSON parse disagreed (a - // revoked device with the same fingerprint): mint separately. - invitation = await mintCmuxTuiInvitation(invoke, "freestyle", vmId); - } - span.setAttribute("cmux.vm.cmux_remote.invited", !enrolled); - const daemonBuild = bundle.daemonBuild ?? await cmuxTuiDaemonBuild(invoke); - const addresses = freestyleNetworkAddressMetadata(data); + const addresses = freestyleNetworkAddressMetadata(routeAddresses); + span.setAttribute("cmux.vm.network.private", (routeAddresses.vpcs ?? routeAddresses.networks ?? []).length > 0); + span.setAttribute("cmux.vm.route.source", persisted ? "row" : "provider"); + span.setAttribute("cmux.vm.cmux_remote.healed", false); + span.setAttribute("cmux.vm.cmux_remote.invited", false); const networkAddresses = { ...(addresses.networkIpv4 ? { ipv4: addresses.networkIpv4 } : {}), ...(addresses.networkIpv6 ? { ipv6: addresses.networkIpv6 } : {}), @@ -1523,9 +1347,7 @@ export class FreestyleProvider implements VMProvider { token, expiresAtUnix, session: CMUX_TUI_SESSION, - trustedCarrier: bundle.trustedCarrier, - ...(daemonBuild ? { daemonBuild } : {}), - ...(invitation ? { invitation } : {}), + trustedCarrier: true, ...(Object.keys(networkAddresses).length ? { networkAddresses } : {}), }; } catch (err) { @@ -1556,21 +1378,8 @@ export class FreestyleProvider implements VMProvider { options?: CmuxRemoteApprovalOptions, ): Promise { void options; - return withVmSpan( - "cmux.vm.provider.approve_cmux_remote_enrollment", - "provider", - spanAttributes(vmId, "approve_cmux_remote_enrollment"), - async () => { - try { - const vm = this.deps.client().vms.ref(vmId); - return await approveCmuxTuiEnrollment(this.cmuxTuiInvoke(vm), "freestyle", vmId, invitationId); - } catch (err) { - throw err instanceof ProviderError - ? err - : new ProviderError("freestyle", `approveCmuxRemoteEnrollment(${vmId}) failed`, err); - } - }, - ); + void invitationId; + throw new ProviderError("freestyle", `VM ${vmId} uses trusted-carrier attach and has no enrollment approval path`); } /** @@ -1644,129 +1453,6 @@ export class FreestyleProvider implements VMProvider { ); } - /** - * Attach-time heal: a daemon that is running AND listening dual-stack is - * left alone; anything else is repaired, reinstalling first when the binary - * is missing (a pre-bake image) or superseded by a manifest pin change. On a - * freshly resumed machine this also covers the sub-second window before the - * baked supervisor has started the daemon. The dual-stack check matters - * because a machine from an older bake boots the daemon on 0.0.0.0, which - * the public-IPv6 route cannot reach. - */ - private async ensureCmuxTuiRunning(vm: Vm, vmId: string, installGuestCli = true): Promise { - // Keep the shim present even when the baked daemon is already healthy. - if (installGuestCli) await this.installGuestCli(vm, vmId); - const healthy = await this.execResult(vm, freestyleDaemonSettledCommand(), DAEMON_SETTLE_TIMEOUT_MS + EXEC_OVERHEAD_TIMEOUT_MS); - if (healthy?.exitCode === 0) { - await this.ensureAgentHooks(vm, vmId); - return; - } - const source = await this.deps.resolveDaemonSource("freestyle"); - const pinned = await this.execResult(vm, freestylePinCheckCommand(source)); - if (pinned?.exitCode !== 0) { - await this.execOrThrow(vm, vmId, cmuxTuiInstallCommand(source), CMUX_TUI_INSTALL_TIMEOUT_MS) - .catch((err: unknown) => { - throw new ProviderError("freestyle", `cmux-tui install in ${vmId} failed: ${errorMessage(err)}`); - }); - } - await this.execOrThrow(vm, vmId, freestyleStartDaemonCommand(), 60_000); - await waitForCmuxTuiReady(this.cmuxTuiInvoke(vm), "freestyle", vmId); - // A repaired daemon whose binary was still pinned skipped the install - // (and with it the hooks); a resumed machine lands here while its - // supervisor re-keys the daemon. Same idempotent check as the healthy path. - await this.ensureAgentHooks(vm, vmId); - } - - /** - * A healthy daemon from a bake or create that predates hook installation - * has no Claude Code / Codex hooks, so its agents never post turn-completed - * or approval notifications. Install them for the daemon's own commit (the - * pin file the bake wrote, else the live pin the create used), the helper - * beside the binary so the two never disagree in generation. The daemon - * keeps running: it already exports CMUX_TUI_HOOK into every pane, and - * agents read hooks at their next launch. - */ - private async ensureAgentHooks(vm: Vm, vmId: string): Promise { - // Best effort throughout: a hook failure is logged and never costs the - // attach or the heal that called it. - try { - await this.installAgentHooks(vm, vmId); - } catch (err) { - console.warn(`[freestyle] ${vmId}: agent hooks not installed: ${errorMessage(err)}`); - } - } - - private async installAgentHooks(vm: Vm, vmId: string): Promise { - const ready = await this.execResult(vm, cmuxTuiHooksReadyCommand()); - if (ready?.exitCode === 0) return; - const pin = await this.execResult(vm, "cut -d' ' -f2 /etc/cmux/cmux-tui-pin 2>/dev/null"); - const commit = pin?.exitCode === 0 ? pin.stdout.trim() : ""; - // A pinned build published before the helper shipped throws here: the - // daemon is left as it is rather than paired with a helper from another - // generation. - const source = /^[0-9a-f]{40}$/.test(commit) - ? await this.deps.resolveDaemonSource("freestyle", cmuxTuiPinnedManifestUrl(commit)) - : await this.deps.resolveDaemonSource("freestyle"); - await this.execOrThrow(vm, vmId, cmuxTuiAgentHooksInstallCommand(source), CMUX_TUI_INSTALL_TIMEOUT_MS); - } - - /** Advisory telemetry must not prevent the machine or its CLI from working. */ - private async ensureResourceReporter(vm: Vm, vmId: string): Promise { - try { - await this.execOrThrow(vm, vmId, guestResourceReporterInstallCommand(), 5_000); - } catch (error) { - console.warn(`[freestyle] ${vmId}: resource reporter not installed: ${errorMessage(error)}`); - } - } - - private async ensureGuestCli(vm: Vm, vmId: string, installReporter = true): Promise { - const expected = createHash("sha256").update(GUEST_CMUX_SHIM).digest("hex"); - const current = await this.execResult(vm, `test "$(sha256sum '${GUEST_CMUX_SHIM_PATH}' 2>/dev/null | cut -d ' ' -f 1)" = '${expected}' && ${guestBrowserReadyCommand} && ${guestCliDistributionCommand(true)}`); - if (current?.exitCode === 0) { - await this.execResult(vm, guestBrowserMimeReconcileCommand); - return; - } - if (installReporter) await this.installGuestCli(vm, vmId); - else await this.installGuestCliFiles(vm, vmId); - } - - /** Separate guest paths may initialize together; rollback waits for both to settle. */ - private async installGuestCli(vm: Vm, vmId: string, promptIdentity?: GuestPromptIdentity): Promise { - const [cli] = await Promise.allSettled([ - this.installGuestCliFiles(vm, vmId, promptIdentity), - this.ensureResourceReporter(vm, vmId), - ]); - if (cli.status === "rejected") throw cli.reason; - } - - /** - * Installs the in-VM `cmux` shim with an upload-then-rename. The temporary - * path avoids following a pre-existing `/usr/local/bin/cmux` symlink and the - * filesystem endpoint avoids a shell command-line limit silently dropping - * the adapter on older images; create/attach callers treat a failed install - * as a failed heal. - */ - private async installGuestCliFiles(vm: Vm, vmId: string, promptIdentity?: GuestPromptIdentity): Promise { - const temporaryPath = `${GUEST_CMUX_SHIM_PATH}.tmp-${randomBytes(12).toString("hex")}`; - try { - await this.execOrThrow(vm, vmId, "mkdir -p /usr/local/libexec", 5_000); - await vm.fs.writeTextFile(temporaryPath, GUEST_CMUX_SHIM, { mode: 0o755 }); - const result = await vm.exec({ - command: `${guestBrowserInstallCommand()} && chmod 0755 '${temporaryPath}' && mv -f '${temporaryPath}' '${GUEST_CMUX_SHIM_PATH}' && ${guestCliDistributionCommand()}` - + (promptIdentity ? ` && ${guestPromptInstallCommand(promptIdentity)}` : ""), - timeoutMs: 90_000, - linuxUser: GUEST_LINUX_USER, - }); - const exitCode = result.statusCode ?? 124; - if (exitCode !== 0) { - throw new Error(`guest cmux shim install exited ${exitCode}`); - } - } catch (error) { - await vm.fs.remove(temporaryPath).catch(() => undefined); - throw error; - } - } - private async execResult(vm: Vm, command: string, timeoutMs = EXEC_DEFAULT_TIMEOUT_MS): Promise { try { const r = await vm.exec({ command, timeoutMs, linuxUser: GUEST_LINUX_USER }); @@ -1776,47 +1462,5 @@ export class FreestyleProvider implements VMProvider { } } - private async execOrThrow(vm: Vm, vmId: string, command: string, timeoutMs: number): Promise { - const r = await vm.exec({ command, timeoutMs, linuxUser: GUEST_LINUX_USER }); - const exitCode = r.statusCode ?? 124; - if (exitCode !== 0) { - throw new Error(`exec in ${vmId} exited ${exitCode}: ${(r.stderr ?? r.stdout ?? "").trim().slice(0, 500)}`); - } - return { exitCode, stdout: r.stdout ?? "", stderr: r.stderr ?? "" }; - } - - private cmuxTuiInvoke(vm: Vm): CmuxTuiInvoke { - return async (args, timeoutMs) => { - const r = await this.execResult(vm, cmuxTuiRunCommand(args), timeoutMs ?? EXEC_DEFAULT_TIMEOUT_MS); - return r ?? { exitCode: 124, stdout: "", stderr: "exec failed" }; - }; - } } -/** The resources a machine of `memoryMb` is sold with (see entitlements.ts). */ -export function freestyleTargetResources( - memoryMb: number, - env: Record = process.env, -): VmResources { - return { - cpu: vcpusForMemoryMb(memoryMb), - memory: memoryMb, - storage: vmDiskMb(env), - }; -} - -/** - * The grow-only resize that takes `current` to `target`, or null when nothing - * needs to grow. Shrinks are never requested: Freestyle rejects them, and a - * snapshot restored at a larger size keeps what it had. - */ -export function freestyleResizeRequest( - current: VmResources, - target: VmResources, -): ResizeVmOptions | null { - const request: ResizeVmOptions = {}; - if (target.cpu > current.cpu) request.cpu = target.cpu; - if (target.memory > current.memory) request.memory = target.memory; - if (target.storage > current.storage) request.storage = target.storage; - return Object.keys(request).length > 0 ? request : null; -} diff --git a/web/services/vms/images/devbox/cmux-devbox-boot b/web/services/vms/images/devbox/cmux-devbox-boot index ad2be1f66166..135bce3b77c0 100644 --- a/web/services/vms/images/devbox/cmux-devbox-boot +++ b/web/services/vms/images/devbox/cmux-devbox-boot @@ -16,7 +16,7 @@ # sudo), so every terminal pane is a non-root shell and coding agents start. # The command picks that layout on the machine; a machine from an image baked # before the work user existed keeps a root daemon and its /root state. This -# script reads the same selection so the state it wipes on a clone is the +# script reads the same selection so the state it refreshes on a clone is the # state the daemon actually writes. # # Per-machine daemon identity. A Freestyle snapshot is a memory image: every @@ -29,13 +29,23 @@ # is bound to that id, cloud-init style, and re-checked every tick: # * /etc/cmux/daemon-instance-id names the machine whose identity the # state dir holds. A different id means "this is a clone": a running -# daemon (another machine's identity) is stopped, the remote state is -# wiped, and the daemon starts with a fresh identity bound to this id. +# daemon (another machine's identity) is stopped, its authorization state +# is replaced, and the daemon starts with a fresh identity bound to this +# id. The journaled workspace and first terminal survive the identity turn. # * /etc/cmux/bake-instance-id is the machine a snapshot is being taken # from. While it matches, the daemon is kept parked so the snapshot never # carries a live identity. The bake and derive scripts write it right # before they snapshot; a clone has a different id and starts its daemon # within one tick of resume. +# +# Clone detection speed is New Machine latency. A snapshot is always taken +# while this loop is parked, so every clone resumes inside the parked branch. +# That branch therefore ticks every PARKED_TICK (50 ms) instead of 1 s, and +# a clone is noticed within ~50 ms plus one metadata read. Only the builder +# (for the seconds before its snapshot) and a clone's first tick pay the fast +# cadence; a bound machine ticks once a second as before. Do not raise +# PARKED_TICK or move work in front of the announce without measuring New +# Machine end to end: the owner's Mac is already dialing while this runs. # Without a metadata service (containers) the id is empty and the daemon # simply runs, keeping whatever identity the state dir already holds. # @@ -58,10 +68,24 @@ # measurement). announce_network sends a gratuitous ARP burst from every # global IPv4 address; it runs detached when a clone is detected and every # 30 s for the life of the supervisor so an idle machine never ages out of -# the fabric's table. The command line is devboxNetworkAnnounceCommand() in +# the fabric's table. On a clone the announce is the FIRST action, before +# the daemon, SSH, or state work: arping -U puts its first frame on the wire +# at once, and until that frame the Mac's SYNs are dropped by the fabric. +# The command line is devboxNetworkAnnounceCommand() in # network.ts; vm-devbox-identity.test.ts pins the equality. The attach path # announces on its own (freestyleNetworkAnnouncement.ts). # +# Resume housekeeping. The guest's realtime AND monotonic clocks jump by the +# snapshot's age on resume, so every due timer (logrotate, man-db, fstrim, +# dpkg-db-backup, tmpfiles-clean, apt) would fire in the clone's first second +# and compete with the daemon start, and systemd would kill every service +# whose watchdog "missed" a ping during the jump (logind, udevd, journald). +# The parked branch stops those timers and switches service watchdogs off +# (runtime state, so the memory snapshot carries it; a bake-time switch is +# lost when a later step re-executes systemd). A clone re-arms both +# HOUSEKEEPING_DELAY after it is bound (systemd-run), so timers still run and +# watchdogs still guard services, just off the critical path. +# # CMUX_TUI_REMOTE_WS_BIND overrides the listener bind. Default: the IPv4 # wildcard. Freestyle sets [::]:1337 (its systemd unit / a driver drop-in) # because private Freestyle networks can assign either address family; a @@ -89,6 +113,9 @@ DESKTOP_DISPLAY=:1 BOUND_INSTANCE_FILE=/etc/cmux/daemon-instance-id BAKE_INSTANCE_FILE=/etc/cmux/bake-instance-id METADATA=http://169.254.169.254 +PARKED_TICK=0.05 +HOUSEKEEPING_DELAY=10min +HOUSEKEEPING_TIMERS="apt-daily.timer apt-daily-upgrade.timer dpkg-db-backup.timer e2scrub_all.timer fstrim.timer logrotate.timer man-db.timer motd-news.timer systemd-tmpfiles-clean.timer" # Selects the daemon's user, home and binary; keep identical to # cmuxTuiLayoutSelector() in web/services/vms/drivers/cmuxTuiDaemon.ts. @@ -109,6 +136,7 @@ cmux_tui_layout daemon_pid="" desktop_pid="" +parked="" start_desktop() { [ -x "$DESKTOP_BOOT" ] || return 0 @@ -132,7 +160,10 @@ rekey_ssh_host() { command -v ssh-keygen >/dev/null 2>&1 || return 0 staging=$(mktemp -d /etc/ssh/.cmux-rekey.XXXXXX) || { echo "cmux-devbox-boot: could not stage new ssh host keys; keeping the existing ones" >&2; return 1; } mkdir -p "$staging/etc/ssh" - if ! ssh-keygen -A -f "$staging" >/dev/null 2>&1 || [ -z "$(ls "$staging"/etc/ssh/ssh_host_*_key 2>/dev/null)" ]; then + # Lowest CPU and I/O priority: this runs beside the daemon's first start. + low=""; command -v nice >/dev/null 2>&1 && low="nice -n 19" + command -v ionice >/dev/null 2>&1 && low="$low ionice -c 3" + if ! $low ssh-keygen -A -f "$staging" >/dev/null 2>&1 || [ -z "$(ls "$staging"/etc/ssh/ssh_host_*_key 2>/dev/null)" ]; then rm -rf "$staging" echo "cmux-devbox-boot: ssh host key generation failed; keeping the existing keys" >&2 return 1 @@ -149,6 +180,23 @@ announce_network() { command -v arping >/dev/null 2>&1 && ip -o -4 addr show scope global 2>/dev/null | { while read -r _ dev _ cidr _; do case "$dev" in lo|docker*|veth*|br-*|virbr*) continue;; esac; case "$cidr" in 169.254.*) continue;; esac; arping -U -c 2 -w 2 -I "$dev" "${cidr%/*}" >/dev/null 2>&1 & done; wait; }; true } +# A derive clone schedules a re-arm and is then parked for its own snapshot; +# the pending transient timer must not ride into that snapshot, where the +# clock jump would fire it on the next clone's first second. +park_housekeeping() { + [ -d /run/systemd/system ] || return 0 + # shellcheck disable=SC2086 + systemctl stop cmux-housekeeping-rearm.timer cmux-housekeeping-rearm.service $HOUSEKEEPING_TIMERS >/dev/null 2>&1 || true + systemd-analyze service-watchdogs no >/dev/null 2>&1 || true +} + +rearm_housekeeping() { + [ -d /run/systemd/system ] || return 0 + systemctl stop cmux-housekeeping-rearm.timer cmux-housekeeping-rearm.service >/dev/null 2>&1 || true + systemd-run --quiet --no-block --collect --unit=cmux-housekeeping-rearm --on-active="$HOUSEKEEPING_DELAY" \ + /bin/sh -c "systemd-analyze service-watchdogs yes; systemctl start $HOUSEKEEPING_TIMERS" >/dev/null 2>&1 || true +} + announce_loop() { while true; do announce_network; sleep 30; done } @@ -163,9 +211,19 @@ stop_daemon() { daemon_pid="" } +# Start the daemon (command identical to cmuxTuiDaemonCommand()). +# --remote-ws-trusted-carrier: the listener is reachable only inside the +# owner's private network, whose members are all authorized, so every link +# gets carrier authentication with no device enrollment. +start_daemon() { + (if id -u cmux >/dev/null 2>&1 && command -v setpriv >/dev/null 2>&1 && setpriv --reuid=cmux --regid=cmux --init-groups test -w /home/cmux 2>/dev/null && setpriv --reuid=cmux --regid=cmux --init-groups sudo -n true >/dev/null 2>&1; then CMUX_TUI_USER=cmux; CMUX_TUI_HOME=/home/cmux; CMUX_TUI_LAYOUT=user; else CMUX_TUI_USER=root; CMUX_TUI_HOME=/root; CMUX_TUI_LAYOUT=root; fi; CMUX_TUI_BIN="$CMUX_TUI_HOME/.cmux/bin/cmux-tui"; { mkdir -p /etc/cmux 2>/dev/null; printf '%s\n' "$CMUX_TUI_LAYOUT" > /etc/cmux/daemon-layout; } 2>/dev/null; cd "$CMUX_TUI_HOME" && if [ "$CMUX_TUI_USER" = root ]; then exec env HOME="$CMUX_TUI_HOME" TERM=xterm-256color TERM_PROGRAM=ghostty TERM_PROGRAM_VERSION="$(cat /etc/cmux/ghostty-version 2>/dev/null)" "$CMUX_TUI_BIN" server start --session cloud --remote-ws "${CMUX_TUI_REMOTE_WS_BIND:-0.0.0.0:1337}" --remote-ws-insecure-bind --remote-ws-trusted-carrier; else exec setpriv --reuid="$CMUX_TUI_USER" --regid="$CMUX_TUI_USER" --init-groups env HOME="$CMUX_TUI_HOME" USER="$CMUX_TUI_USER" LOGNAME="$CMUX_TUI_USER" SHELL=/bin/bash TERM=xterm-256color TERM_PROGRAM=ghostty TERM_PROGRAM_VERSION="$(cat /etc/cmux/ghostty-version 2>/dev/null)" "$CMUX_TUI_BIN" server start --session cloud --remote-ws "${CMUX_TUI_REMOTE_WS_BIND:-0.0.0.0:1337}" --remote-ws-insecure-bind --remote-ws-trusted-carrier; fi) & + daemon_pid=$! +} + announce_loop & while true; do + tick=1 start_desktop # A driver can install the binary after boot, and it installs into the home # this same selection names, so re-read the layout until one appears. @@ -174,23 +232,45 @@ while true; do id=$(instance_id 2>/dev/null) || id="" if [ -n "$id" ] && [ -f "$BAKE_INSTANCE_FILE" ] && [ "$id" = "$(cat "$BAKE_INSTANCE_FILE")" ]; then stop_daemon # the machine being snapshotted: parked until the snapshot is taken + [ -n "$parked" ] || { park_housekeeping; parked=1; } + tick=$PARKED_TICK + elif [ -z "$id" ] && [ -n "$parked" ]; then + # A clone's first metadata read can fail while the platform finishes + # restoring it. Stay parked and retry on the fast tick; starting now + # would run an unbound daemon that the next tick must restart. + tick=$PARKED_TICK else if [ -n "$id" ] && [ "$id" != "$(cat "$BOUND_INSTANCE_FILE" 2>/dev/null)" ]; then - stop_daemon - rm -rf "$REMOTE_STATE_DIR" - ( rekey_ssh_host & ) + # Announce first: the fabric drops the Mac's SYNs until it sees a frame. ( announce_network & ) + stop_daemon + # Keep the journaled session graph and first terminal from the warm + # snapshot. Only auth/connection material belongs to the builder and + # must be regenerated for this clone. Running PTYs are recreated by + # cmux-tui when it restores the journal; prompt-sync clears the resumed + # bytes and sends Ctrl-C once the new machine name is published. + find "$REMOTE_STATE_DIR/sessions" -mindepth 2 -maxdepth 2 -type d -name auth -prune -exec rm -rf {} + 2>/dev/null || true + rm -rf "$REMOTE_STATE_DIR/connections" 2>/dev/null || true mkdir -p /etc/cmux && printf '%s\n' "$id" > "$BOUND_INSTANCE_FILE" + # Start the daemon before anything else that needs CPU: a New Machine + # waits on this listener, and the clone has only a couple of vCPUs. + # Host re-keying (RSA generation is most of a second of CPU), prompt + # sync (a Python start), and the timer re-arm all used to run first or + # beside it and slowed the listener by ~0.2 s. Do not move them back. + start_daemon + # The prompt sync is a clone-local oneshot. A memory snapshot can + # carry an already-active systemd unit, so explicitly restart it after + # the fresh instance identity is bound. Older images simply have no + # unit and continue without this optional cosmetic feature. + systemctl --no-block restart cmux-prompt-sync.service >/dev/null 2>&1 || true + ( rekey_ssh_host & ) + [ -n "$parked" ] && { rearm_housekeeping; parked=""; } fi if [ -z "$daemon_pid" ] || ! kill -0 "$daemon_pid" 2>/dev/null; then [ -n "$daemon_pid" ] && wait "$daemon_pid" 2>/dev/null - # --remote-ws-trusted-carrier: the listener is reachable only inside the - # owner's private network, whose members are all authorized, so every - # link gets carrier authentication with no device enrollment. - (if id -u cmux >/dev/null 2>&1 && command -v setpriv >/dev/null 2>&1 && setpriv --reuid=cmux --regid=cmux --init-groups test -w /home/cmux 2>/dev/null && setpriv --reuid=cmux --regid=cmux --init-groups sudo -n true >/dev/null 2>&1; then CMUX_TUI_USER=cmux; CMUX_TUI_HOME=/home/cmux; CMUX_TUI_LAYOUT=user; else CMUX_TUI_USER=root; CMUX_TUI_HOME=/root; CMUX_TUI_LAYOUT=root; fi; CMUX_TUI_BIN="$CMUX_TUI_HOME/.cmux/bin/cmux-tui"; { mkdir -p /etc/cmux 2>/dev/null; printf '%s\n' "$CMUX_TUI_LAYOUT" > /etc/cmux/daemon-layout; } 2>/dev/null; cd "$CMUX_TUI_HOME" && if [ "$CMUX_TUI_USER" = root ]; then exec env HOME="$CMUX_TUI_HOME" TERM=xterm-256color TERM_PROGRAM=ghostty TERM_PROGRAM_VERSION="$(cat /etc/cmux/ghostty-version 2>/dev/null)" "$CMUX_TUI_BIN" server start --session cloud --remote-ws "${CMUX_TUI_REMOTE_WS_BIND:-0.0.0.0:1337}" --remote-ws-insecure-bind --remote-ws-trusted-carrier; else exec setpriv --reuid="$CMUX_TUI_USER" --regid="$CMUX_TUI_USER" --init-groups env HOME="$CMUX_TUI_HOME" USER="$CMUX_TUI_USER" LOGNAME="$CMUX_TUI_USER" SHELL=/bin/bash TERM=xterm-256color TERM_PROGRAM=ghostty TERM_PROGRAM_VERSION="$(cat /etc/cmux/ghostty-version 2>/dev/null)" "$CMUX_TUI_BIN" server start --session cloud --remote-ws "${CMUX_TUI_REMOTE_WS_BIND:-0.0.0.0:1337}" --remote-ws-insecure-bind --remote-ws-trusted-carrier; fi) & - daemon_pid=$! + start_daemon fi fi fi - sleep 1 + sleep "$tick" done diff --git a/web/services/vms/images/devbox/cmux-prompt-sync b/web/services/vms/images/devbox/cmux-prompt-sync new file mode 100644 index 000000000000..28822386a84d --- /dev/null +++ b/web/services/vms/images/devbox/cmux-prompt-sync @@ -0,0 +1,217 @@ +#!/usr/bin/env python3 +"""Refresh the local prompt name without blocking the daemon or its terminals.""" +import argparse +import fcntl +import json +import os +from pathlib import Path +import re +import tempfile +import time +import subprocess +import threading +import urllib.request + +NAME = re.compile(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?") + + +def publish(directory, payload): + name = payload.get("name") + machine = payload.get("vm_id") + if not isinstance(name, str) or NAME.fullmatch(name) is None: + raise ValueError("invalid machine name") + if not isinstance(machine, str) or not machine: + raise ValueError("missing machine identity") + directory.mkdir(parents=True, exist_ok=True) + # The rename writer uses this same lock. A stopped clone worker cannot + # interleave partial writes with a subsequent worker or rename request. + with (directory / ".prompt-lock").open("a") as lock: + fcntl.flock(lock, fcntl.LOCK_EX) + target = directory / "vm-name" + value = name + "\n" + if target.is_file() and not target.is_symlink() and target.read_text() == value: + return False + fd, temporary = tempfile.mkstemp(prefix=".prompt-", dir=directory) + try: + with os.fdopen(fd, "w") as stream: + stream.write(value) + os.fchmod(stream.fileno(), 0o644) + os.replace(temporary, target) + finally: + if os.path.lexists(temporary): + os.unlink(temporary) + return True + + +def fetch_name(url): + request = urllib.request.Request(url, headers={ + "Authorization": "Bearer cmux-vm-edge-placeholder", + }) + with urllib.request.urlopen(request, timeout=5) as response: + body = response.read(4097) + if len(body) > 4096: + raise ValueError("identity response too large") + payload = json.loads(body) + if not isinstance(payload, dict): + raise ValueError("invalid identity response") + return payload + + +def tui_layout(): + user = "cmux" + home = "/home/cmux" + try: + import pwd + pwd.getpwnam(user) + if not os.access(home, os.W_OK) or subprocess.run( + ["setpriv", "--reuid=cmux", "--regid=cmux", "--init-groups", "sudo", "-n", "true"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=2, + check=False, + ).returncode != 0: + raise OSError("cmux work user is not usable") + except (KeyError, OSError, subprocess.SubprocessError): + user = "root" + home = "/root" + return user, home, os.environ.get("CMUX_TUI_BIN", f"{home}/.cmux/bin/cmux-tui") + + +def tui(*args): + user, home, binary = tui_layout() + environment = { + **os.environ, + "HOME": home, + "TERM": "xterm-256color", + "TERM_PROGRAM": "ghostty", + } + command = [binary, "--session", "cloud", *args] + if user == "root": + return subprocess.run(command, capture_output=True, text=True, timeout=10, check=False, env=environment) + environment.update({"USER": user, "LOGNAME": user, "SHELL": "/bin/bash"}) + return subprocess.run( + ["setpriv", f"--reuid={user}", f"--regid={user}", "--init-groups", "env", *[f"{key}={value}" for key, value in environment.items()], *command], + capture_output=True, + text=True, + timeout=10, + check=False, + ) + + +def terminal_id(value): + for item in walk(value): + if not isinstance(item, dict): + continue + candidate = item.get("terminal_id") or item.get("id") + if isinstance(candidate, str) and candidate.startswith("term_"): + return candidate + return None + + +def find_existing_terminal(): + result = tui("terminal", "list", "--json") + if result.returncode != 0: + return None + try: + return terminal_id(json.loads(result.stdout)) + except (ValueError, TypeError): + return None + + +def seed_terminal(ready, directory): + marker = directory / "first-terminal.json" + terminal = None + try: + if marker.is_file(): + terminal = terminal_id(json.loads(marker.read_text())) + except (OSError, ValueError, TypeError): + terminal = None + for _ in range(60): + if terminal: + break + terminal = find_existing_terminal() + if not terminal: + result = tui("workspace", "create", "--name", "Cloud", "--json") + if result.returncode == 0: + try: + value = json.loads(result.stdout) + terminal = terminal_id(value) + except (OSError, ValueError, TypeError): + pass + if not terminal: + terminal = find_existing_terminal() + if terminal: + marker.write_text(json.dumps({"terminal_id": terminal}) + "\n") + if not terminal: + time.sleep(1) + if terminal and ready.wait(timeout=60): + # The snapshot can resume with the builder's prompt already rendered. + # Clear that bytes/history and interrupt it after the clone identity is + # published. These two calls are deliberately detached from the name + # refresh loop, so New Machine never waits on them. + tui("terminal", terminal, "history", "clear", "--quiet") + tui("terminal", terminal, "keys", "ctrl+c", "--quiet") + + +# The bake leaves this default in vm-name. A clone's real name arrives either +# from the reflection fetch below or from the control plane writing vm-name +# directly (the same writer a rename uses, run once after create). The latter +# is the only path that reaches a private dev backend, so a name that appears +# in the file must also trigger the one-time prompt refresh. +BAKED_NAME = "cmux" + + +def watch_local_name(directory, ready, timeout=600.0): + target = directory / "vm-name" + deadline = time.monotonic() + timeout + while not ready.is_set() and time.monotonic() < deadline: + try: + value = target.read_text().strip() + except OSError: + value = "" + if value != BAKED_NAME and NAME.fullmatch(value) is not None: + ready.set() + return + time.sleep(0.2) + + +def walk(value): + if isinstance(value, dict): + yield value + for child in value.values(): + yield from walk(child) + elif isinstance(value, list): + for child in value: + yield from walk(child) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--directory", type=Path, default=Path("/etc/cmux")) + parser.add_argument("--url", default="https://reflection.cmux.internal/name") + parser.add_argument("--once", action="store_true") + args = parser.parse_args() + ready = threading.Event() + threading.Thread(target=seed_terminal, args=(ready, args.directory), daemon=True).start() + if not args.once: + threading.Thread(target=watch_local_name, args=(args.directory, ready), daemon=True).start() + delay = 1 + while True: + try: + publish(args.directory, fetch_name(args.url)) + ready.set() + delay = 1 + if args.once: + return + # Display-only state; no network request runs from a shell prompt. + time.sleep(30) + except (OSError, ValueError): + if args.once: + raise + # Never publish a fallback as success or clear an existing name. + time.sleep(delay) + delay = min(delay * 2, 8) + + +if __name__ == "__main__": + main() diff --git a/web/services/vms/images/manifest.json b/web/services/vms/images/manifest.json index 750a0561ce4b..40caae55bb30 100644 --- a/web/services/vms/images/manifest.json +++ b/web/services/vms/images/manifest.json @@ -10010,20 +10010,2941 @@ }, { "provider": "freestyle", - "version": "freestyle-cmux-devbox-20260921-100253-sm", - "imageId": "sh-04169f8c29494697a860707022da83c2", + "version": "freestyle-cmux-herdr-startup-desktop-v1-sm", + "imageId": "sh-82630fe8e8ec40d9be352c92ab9519a6", "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", "kind": "desktop", "cmuxdRemoteCommit": "none-cmux-tui", - "repoCommit": "f7195972d553cce04c7f0339271c19e4bfecf75b", + "repoCommit": "f77ad520773240fbf8d1654c89848e5f325b6f9a", "epoch": "2026-09-10-r2", "devboxSource": { "layers": "desktop", - "digest": "3ddf459ba4d35a40c30a91ebc58236ad2f451cfa04da5abbbaaedfdf1f508cbc", + "digest": "d2af9b50e868bc15c40782f99d2f723ff29d79e6773dd283b14ddc7ca2966b41", "schema": 2 }, - "builtAt": "2026-09-21T10:07:01.297Z", - "builderScriptVersion": "a340e3615002cb2a685587753fbf8a8ddaf25a9067d5d14b6e58b01cfb07cc49", + "builtAt": "2026-09-21T13:10:00.661Z", + "builderScriptVersion": "97f949aa605205ea3d175c8a19131433b1673b2d7ca66869674e32c2b6ff9173", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Promoted from an existing freestyle image by promote-devbox-image.ts --image. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-82630fe8e8ec40d9be352c92ab9519a6, md=sh-1fe252f12a2a490d877c0d184a3b6463, xl=sh-91c855780b2845f2b72a8c804c6fed78, lg=sh-d170b55bf022483ca27d698fa845a92c, lgx=sh-44ae721cb30e478a87a1d4c3454ea58b, 2xl=sh-aa2a0622609440f180f319d18c65357c.", + "defaultForKind": false, + "size": { + "name": "sm", + "cpu": 2, + "memoryMb": 4096, + "storageMb": 16384, + "freestyleBase": "freestyle/ubuntu-sm" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-herdr-startup-desktop-v1-md", + "imageId": "sh-1fe252f12a2a490d877c0d184a3b6463", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "desktop", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "f77ad520773240fbf8d1654c89848e5f325b6f9a", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "d2af9b50e868bc15c40782f99d2f723ff29d79e6773dd283b14ddc7ca2966b41", + "schema": 2 + }, + "builtAt": "2026-09-21T13:10:00.661Z", + "builderScriptVersion": "97f949aa605205ea3d175c8a19131433b1673b2d7ca66869674e32c2b6ff9173", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Promoted from an existing freestyle image by promote-devbox-image.ts --image. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-82630fe8e8ec40d9be352c92ab9519a6, md=sh-1fe252f12a2a490d877c0d184a3b6463, xl=sh-91c855780b2845f2b72a8c804c6fed78, lg=sh-d170b55bf022483ca27d698fa845a92c, lgx=sh-44ae721cb30e478a87a1d4c3454ea58b, 2xl=sh-aa2a0622609440f180f319d18c65357c.", + "defaultForKind": false, + "size": { + "name": "md", + "cpu": 4, + "memoryMb": 8192, + "storageMb": 32768, + "freestyleBase": "freestyle/ubuntu" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-herdr-startup-desktop-v1-lg", + "imageId": "sh-d170b55bf022483ca27d698fa845a92c", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "desktop", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "f77ad520773240fbf8d1654c89848e5f325b6f9a", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "d2af9b50e868bc15c40782f99d2f723ff29d79e6773dd283b14ddc7ca2966b41", + "schema": 2 + }, + "builtAt": "2026-09-21T13:10:00.661Z", + "builderScriptVersion": "97f949aa605205ea3d175c8a19131433b1673b2d7ca66869674e32c2b6ff9173", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Promoted from an existing freestyle image by promote-devbox-image.ts --image. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-82630fe8e8ec40d9be352c92ab9519a6, md=sh-1fe252f12a2a490d877c0d184a3b6463, xl=sh-91c855780b2845f2b72a8c804c6fed78, lg=sh-d170b55bf022483ca27d698fa845a92c, lgx=sh-44ae721cb30e478a87a1d4c3454ea58b, 2xl=sh-aa2a0622609440f180f319d18c65357c.", + "defaultForKind": false, + "size": { + "name": "lg", + "cpu": 8, + "memoryMb": 16384, + "storageMb": 65536, + "freestyleBase": "freestyle/ubuntu-lg" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-herdr-startup-desktop-v1-lgx", + "imageId": "sh-44ae721cb30e478a87a1d4c3454ea58b", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "desktop", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "f77ad520773240fbf8d1654c89848e5f325b6f9a", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "d2af9b50e868bc15c40782f99d2f723ff29d79e6773dd283b14ddc7ca2966b41", + "schema": 2 + }, + "builtAt": "2026-09-21T13:10:00.661Z", + "builderScriptVersion": "97f949aa605205ea3d175c8a19131433b1673b2d7ca66869674e32c2b6ff9173", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Promoted from an existing freestyle image by promote-devbox-image.ts --image. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-82630fe8e8ec40d9be352c92ab9519a6, md=sh-1fe252f12a2a490d877c0d184a3b6463, xl=sh-91c855780b2845f2b72a8c804c6fed78, lg=sh-d170b55bf022483ca27d698fa845a92c, lgx=sh-44ae721cb30e478a87a1d4c3454ea58b, 2xl=sh-aa2a0622609440f180f319d18c65357c.", + "defaultForKind": false, + "size": { + "name": "lgx", + "cpu": 12, + "memoryMb": 24576, + "storageMb": 98304 + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-herdr-startup-desktop-v1-xl", + "imageId": "sh-91c855780b2845f2b72a8c804c6fed78", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "desktop", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "f77ad520773240fbf8d1654c89848e5f325b6f9a", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "d2af9b50e868bc15c40782f99d2f723ff29d79e6773dd283b14ddc7ca2966b41", + "schema": 2 + }, + "builtAt": "2026-09-21T13:10:00.661Z", + "builderScriptVersion": "97f949aa605205ea3d175c8a19131433b1673b2d7ca66869674e32c2b6ff9173", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Promoted from an existing freestyle image by promote-devbox-image.ts --image. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-82630fe8e8ec40d9be352c92ab9519a6, md=sh-1fe252f12a2a490d877c0d184a3b6463, xl=sh-91c855780b2845f2b72a8c804c6fed78, lg=sh-d170b55bf022483ca27d698fa845a92c, lgx=sh-44ae721cb30e478a87a1d4c3454ea58b, 2xl=sh-aa2a0622609440f180f319d18c65357c.", + "defaultForKind": false, + "size": { + "name": "xl", + "cpu": 16, + "memoryMb": 32768, + "storageMb": 131072, + "freestyleBase": "freestyle/ubuntu-xl" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-herdr-startup-desktop-v1-2xl", + "imageId": "sh-aa2a0622609440f180f319d18c65357c", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "desktop", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "f77ad520773240fbf8d1654c89848e5f325b6f9a", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "d2af9b50e868bc15c40782f99d2f723ff29d79e6773dd283b14ddc7ca2966b41", + "schema": 2 + }, + "builtAt": "2026-09-21T13:10:00.661Z", + "builderScriptVersion": "97f949aa605205ea3d175c8a19131433b1673b2d7ca66869674e32c2b6ff9173", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Promoted from an existing freestyle image by promote-devbox-image.ts --image. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-82630fe8e8ec40d9be352c92ab9519a6, md=sh-1fe252f12a2a490d877c0d184a3b6463, xl=sh-91c855780b2845f2b72a8c804c6fed78, lg=sh-d170b55bf022483ca27d698fa845a92c, lgx=sh-44ae721cb30e478a87a1d4c3454ea58b, 2xl=sh-aa2a0622609440f180f319d18c65357c.", + "defaultForKind": false, + "size": { + "name": "2xl", + "cpu": 32, + "memoryMb": 65536, + "storageMb": 131072, + "freestyleBase": "freestyle/ubuntu-2xl" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-herdr-startup-desktop-v1-sm-base", + "imageId": "sh-82630fe8e8ec40d9be352c92ab9519a6", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "base", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "f77ad520773240fbf8d1654c89848e5f325b6f9a", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "d2af9b50e868bc15c40782f99d2f723ff29d79e6773dd283b14ddc7ca2966b41", + "schema": 2 + }, + "builtAt": "2026-09-21T13:10:00.661Z", + "builderScriptVersion": "97f949aa605205ea3d175c8a19131433b1673b2d7ca66869674e32c2b6ff9173", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Promoted from an existing freestyle image by promote-devbox-image.ts --image. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-82630fe8e8ec40d9be352c92ab9519a6, md=sh-1fe252f12a2a490d877c0d184a3b6463, xl=sh-91c855780b2845f2b72a8c804c6fed78, lg=sh-d170b55bf022483ca27d698fa845a92c, lgx=sh-44ae721cb30e478a87a1d4c3454ea58b, 2xl=sh-aa2a0622609440f180f319d18c65357c.", + "defaultForKind": false, + "size": { + "name": "sm", + "cpu": 2, + "memoryMb": 4096, + "storageMb": 16384, + "freestyleBase": "freestyle/ubuntu-sm" + }, + "defaultForLocalDev": false + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-herdr-startup-desktop-v1-md-base", + "imageId": "sh-1fe252f12a2a490d877c0d184a3b6463", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "base", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "f77ad520773240fbf8d1654c89848e5f325b6f9a", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "d2af9b50e868bc15c40782f99d2f723ff29d79e6773dd283b14ddc7ca2966b41", + "schema": 2 + }, + "builtAt": "2026-09-21T13:10:00.661Z", + "builderScriptVersion": "97f949aa605205ea3d175c8a19131433b1673b2d7ca66869674e32c2b6ff9173", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Promoted from an existing freestyle image by promote-devbox-image.ts --image. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-82630fe8e8ec40d9be352c92ab9519a6, md=sh-1fe252f12a2a490d877c0d184a3b6463, xl=sh-91c855780b2845f2b72a8c804c6fed78, lg=sh-d170b55bf022483ca27d698fa845a92c, lgx=sh-44ae721cb30e478a87a1d4c3454ea58b, 2xl=sh-aa2a0622609440f180f319d18c65357c.", + "defaultForKind": false, + "size": { + "name": "md", + "cpu": 4, + "memoryMb": 8192, + "storageMb": 32768, + "freestyleBase": "freestyle/ubuntu" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-herdr-startup-desktop-v1-lg-base", + "imageId": "sh-d170b55bf022483ca27d698fa845a92c", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "base", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "f77ad520773240fbf8d1654c89848e5f325b6f9a", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "d2af9b50e868bc15c40782f99d2f723ff29d79e6773dd283b14ddc7ca2966b41", + "schema": 2 + }, + "builtAt": "2026-09-21T13:10:00.661Z", + "builderScriptVersion": "97f949aa605205ea3d175c8a19131433b1673b2d7ca66869674e32c2b6ff9173", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Promoted from an existing freestyle image by promote-devbox-image.ts --image. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-82630fe8e8ec40d9be352c92ab9519a6, md=sh-1fe252f12a2a490d877c0d184a3b6463, xl=sh-91c855780b2845f2b72a8c804c6fed78, lg=sh-d170b55bf022483ca27d698fa845a92c, lgx=sh-44ae721cb30e478a87a1d4c3454ea58b, 2xl=sh-aa2a0622609440f180f319d18c65357c.", + "defaultForKind": false, + "size": { + "name": "lg", + "cpu": 8, + "memoryMb": 16384, + "storageMb": 65536, + "freestyleBase": "freestyle/ubuntu-lg" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-herdr-startup-desktop-v1-lgx-base", + "imageId": "sh-44ae721cb30e478a87a1d4c3454ea58b", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "base", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "f77ad520773240fbf8d1654c89848e5f325b6f9a", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "d2af9b50e868bc15c40782f99d2f723ff29d79e6773dd283b14ddc7ca2966b41", + "schema": 2 + }, + "builtAt": "2026-09-21T13:10:00.661Z", + "builderScriptVersion": "97f949aa605205ea3d175c8a19131433b1673b2d7ca66869674e32c2b6ff9173", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Promoted from an existing freestyle image by promote-devbox-image.ts --image. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-82630fe8e8ec40d9be352c92ab9519a6, md=sh-1fe252f12a2a490d877c0d184a3b6463, xl=sh-91c855780b2845f2b72a8c804c6fed78, lg=sh-d170b55bf022483ca27d698fa845a92c, lgx=sh-44ae721cb30e478a87a1d4c3454ea58b, 2xl=sh-aa2a0622609440f180f319d18c65357c.", + "defaultForKind": false, + "size": { + "name": "lgx", + "cpu": 12, + "memoryMb": 24576, + "storageMb": 98304 + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-herdr-startup-desktop-v1-xl-base", + "imageId": "sh-91c855780b2845f2b72a8c804c6fed78", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "base", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "f77ad520773240fbf8d1654c89848e5f325b6f9a", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "d2af9b50e868bc15c40782f99d2f723ff29d79e6773dd283b14ddc7ca2966b41", + "schema": 2 + }, + "builtAt": "2026-09-21T13:10:00.661Z", + "builderScriptVersion": "97f949aa605205ea3d175c8a19131433b1673b2d7ca66869674e32c2b6ff9173", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Promoted from an existing freestyle image by promote-devbox-image.ts --image. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-82630fe8e8ec40d9be352c92ab9519a6, md=sh-1fe252f12a2a490d877c0d184a3b6463, xl=sh-91c855780b2845f2b72a8c804c6fed78, lg=sh-d170b55bf022483ca27d698fa845a92c, lgx=sh-44ae721cb30e478a87a1d4c3454ea58b, 2xl=sh-aa2a0622609440f180f319d18c65357c.", + "defaultForKind": false, + "size": { + "name": "xl", + "cpu": 16, + "memoryMb": 32768, + "storageMb": 131072, + "freestyleBase": "freestyle/ubuntu-xl" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-herdr-startup-desktop-v1-2xl-base", + "imageId": "sh-aa2a0622609440f180f319d18c65357c", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "base", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "f77ad520773240fbf8d1654c89848e5f325b6f9a", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "d2af9b50e868bc15c40782f99d2f723ff29d79e6773dd283b14ddc7ca2966b41", + "schema": 2 + }, + "builtAt": "2026-09-21T13:10:00.661Z", + "builderScriptVersion": "97f949aa605205ea3d175c8a19131433b1673b2d7ca66869674e32c2b6ff9173", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Promoted from an existing freestyle image by promote-devbox-image.ts --image. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-82630fe8e8ec40d9be352c92ab9519a6, md=sh-1fe252f12a2a490d877c0d184a3b6463, xl=sh-91c855780b2845f2b72a8c804c6fed78, lg=sh-d170b55bf022483ca27d698fa845a92c, lgx=sh-44ae721cb30e478a87a1d4c3454ea58b, 2xl=sh-aa2a0622609440f180f319d18c65357c.", + "defaultForKind": false, + "size": { + "name": "2xl", + "cpu": 32, + "memoryMb": 65536, + "storageMb": 131072, + "freestyleBase": "freestyle/ubuntu-2xl" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-herdr-exact-desktop-v1-sm", + "imageId": "sh-a74374bdcd254be396fc2325c49744bf", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "desktop", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "1e30ef64222b10543c7d0209edde9714a4300764", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "d2af9b50e868bc15c40782f99d2f723ff29d79e6773dd283b14ddc7ca2966b41", + "schema": 2 + }, + "builtAt": "2026-09-21T14:03:43.707Z", + "builderScriptVersion": "97f949aa605205ea3d175c8a19131433b1673b2d7ca66869674e32c2b6ff9173", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Promoted from an existing freestyle image by promote-devbox-image.ts --image. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-a74374bdcd254be396fc2325c49744bf, md=sh-94cf8732ad124e8f82dd3a2ed40dc26c, lg=sh-9f833208157d477f8053ea7ea3be9d9f, lgx=sh-bb121933c3a744c084244953cbbb99e2, xl=sh-db86202fb4404837b2cac6bef587c28b, 2xl=sh-84b51ea64475455eb4618346ec945ae2.", + "defaultForKind": false, + "size": { + "name": "sm", + "cpu": 2, + "memoryMb": 4096, + "storageMb": 16384, + "freestyleBase": "freestyle/ubuntu-sm" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-herdr-exact-desktop-v1-md", + "imageId": "sh-94cf8732ad124e8f82dd3a2ed40dc26c", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "desktop", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "1e30ef64222b10543c7d0209edde9714a4300764", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "d2af9b50e868bc15c40782f99d2f723ff29d79e6773dd283b14ddc7ca2966b41", + "schema": 2 + }, + "builtAt": "2026-09-21T14:03:43.707Z", + "builderScriptVersion": "97f949aa605205ea3d175c8a19131433b1673b2d7ca66869674e32c2b6ff9173", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Promoted from an existing freestyle image by promote-devbox-image.ts --image. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-a74374bdcd254be396fc2325c49744bf, md=sh-94cf8732ad124e8f82dd3a2ed40dc26c, lg=sh-9f833208157d477f8053ea7ea3be9d9f, lgx=sh-bb121933c3a744c084244953cbbb99e2, xl=sh-db86202fb4404837b2cac6bef587c28b, 2xl=sh-84b51ea64475455eb4618346ec945ae2.", + "defaultForKind": false, + "size": { + "name": "md", + "cpu": 4, + "memoryMb": 8192, + "storageMb": 32768, + "freestyleBase": "freestyle/ubuntu" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-herdr-exact-desktop-v1-lg", + "imageId": "sh-9f833208157d477f8053ea7ea3be9d9f", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "desktop", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "1e30ef64222b10543c7d0209edde9714a4300764", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "d2af9b50e868bc15c40782f99d2f723ff29d79e6773dd283b14ddc7ca2966b41", + "schema": 2 + }, + "builtAt": "2026-09-21T14:03:43.707Z", + "builderScriptVersion": "97f949aa605205ea3d175c8a19131433b1673b2d7ca66869674e32c2b6ff9173", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Promoted from an existing freestyle image by promote-devbox-image.ts --image. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-a74374bdcd254be396fc2325c49744bf, md=sh-94cf8732ad124e8f82dd3a2ed40dc26c, lg=sh-9f833208157d477f8053ea7ea3be9d9f, lgx=sh-bb121933c3a744c084244953cbbb99e2, xl=sh-db86202fb4404837b2cac6bef587c28b, 2xl=sh-84b51ea64475455eb4618346ec945ae2.", + "defaultForKind": false, + "size": { + "name": "lg", + "cpu": 8, + "memoryMb": 16384, + "storageMb": 65536, + "freestyleBase": "freestyle/ubuntu-lg" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-herdr-exact-desktop-v1-lgx", + "imageId": "sh-bb121933c3a744c084244953cbbb99e2", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "desktop", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "1e30ef64222b10543c7d0209edde9714a4300764", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "d2af9b50e868bc15c40782f99d2f723ff29d79e6773dd283b14ddc7ca2966b41", + "schema": 2 + }, + "builtAt": "2026-09-21T14:03:43.707Z", + "builderScriptVersion": "97f949aa605205ea3d175c8a19131433b1673b2d7ca66869674e32c2b6ff9173", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Promoted from an existing freestyle image by promote-devbox-image.ts --image. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-a74374bdcd254be396fc2325c49744bf, md=sh-94cf8732ad124e8f82dd3a2ed40dc26c, lg=sh-9f833208157d477f8053ea7ea3be9d9f, lgx=sh-bb121933c3a744c084244953cbbb99e2, xl=sh-db86202fb4404837b2cac6bef587c28b, 2xl=sh-84b51ea64475455eb4618346ec945ae2.", + "defaultForKind": false, + "size": { + "name": "lgx", + "cpu": 12, + "memoryMb": 24576, + "storageMb": 98304 + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-herdr-exact-desktop-v1-xl", + "imageId": "sh-db86202fb4404837b2cac6bef587c28b", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "desktop", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "1e30ef64222b10543c7d0209edde9714a4300764", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "d2af9b50e868bc15c40782f99d2f723ff29d79e6773dd283b14ddc7ca2966b41", + "schema": 2 + }, + "builtAt": "2026-09-21T14:03:43.707Z", + "builderScriptVersion": "97f949aa605205ea3d175c8a19131433b1673b2d7ca66869674e32c2b6ff9173", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Promoted from an existing freestyle image by promote-devbox-image.ts --image. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-a74374bdcd254be396fc2325c49744bf, md=sh-94cf8732ad124e8f82dd3a2ed40dc26c, lg=sh-9f833208157d477f8053ea7ea3be9d9f, lgx=sh-bb121933c3a744c084244953cbbb99e2, xl=sh-db86202fb4404837b2cac6bef587c28b, 2xl=sh-84b51ea64475455eb4618346ec945ae2.", + "defaultForKind": false, + "size": { + "name": "xl", + "cpu": 16, + "memoryMb": 32768, + "storageMb": 131072, + "freestyleBase": "freestyle/ubuntu-xl" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-herdr-exact-desktop-v1-2xl", + "imageId": "sh-84b51ea64475455eb4618346ec945ae2", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "desktop", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "1e30ef64222b10543c7d0209edde9714a4300764", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "d2af9b50e868bc15c40782f99d2f723ff29d79e6773dd283b14ddc7ca2966b41", + "schema": 2 + }, + "builtAt": "2026-09-21T14:03:43.707Z", + "builderScriptVersion": "97f949aa605205ea3d175c8a19131433b1673b2d7ca66869674e32c2b6ff9173", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Promoted from an existing freestyle image by promote-devbox-image.ts --image. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-a74374bdcd254be396fc2325c49744bf, md=sh-94cf8732ad124e8f82dd3a2ed40dc26c, lg=sh-9f833208157d477f8053ea7ea3be9d9f, lgx=sh-bb121933c3a744c084244953cbbb99e2, xl=sh-db86202fb4404837b2cac6bef587c28b, 2xl=sh-84b51ea64475455eb4618346ec945ae2.", + "defaultForKind": false, + "size": { + "name": "2xl", + "cpu": 32, + "memoryMb": 65536, + "storageMb": 131072, + "freestyleBase": "freestyle/ubuntu-2xl" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-herdr-exact-desktop-v1-sm-base", + "imageId": "sh-a74374bdcd254be396fc2325c49744bf", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "base", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "1e30ef64222b10543c7d0209edde9714a4300764", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "d2af9b50e868bc15c40782f99d2f723ff29d79e6773dd283b14ddc7ca2966b41", + "schema": 2 + }, + "builtAt": "2026-09-21T14:03:43.707Z", + "builderScriptVersion": "97f949aa605205ea3d175c8a19131433b1673b2d7ca66869674e32c2b6ff9173", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Promoted from an existing freestyle image by promote-devbox-image.ts --image. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-a74374bdcd254be396fc2325c49744bf, md=sh-94cf8732ad124e8f82dd3a2ed40dc26c, lg=sh-9f833208157d477f8053ea7ea3be9d9f, lgx=sh-bb121933c3a744c084244953cbbb99e2, xl=sh-db86202fb4404837b2cac6bef587c28b, 2xl=sh-84b51ea64475455eb4618346ec945ae2.", + "defaultForKind": false, + "size": { + "name": "sm", + "cpu": 2, + "memoryMb": 4096, + "storageMb": 16384, + "freestyleBase": "freestyle/ubuntu-sm" + }, + "defaultForLocalDev": false + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-herdr-exact-desktop-v1-md-base", + "imageId": "sh-94cf8732ad124e8f82dd3a2ed40dc26c", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "base", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "1e30ef64222b10543c7d0209edde9714a4300764", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "d2af9b50e868bc15c40782f99d2f723ff29d79e6773dd283b14ddc7ca2966b41", + "schema": 2 + }, + "builtAt": "2026-09-21T14:03:43.707Z", + "builderScriptVersion": "97f949aa605205ea3d175c8a19131433b1673b2d7ca66869674e32c2b6ff9173", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Promoted from an existing freestyle image by promote-devbox-image.ts --image. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-a74374bdcd254be396fc2325c49744bf, md=sh-94cf8732ad124e8f82dd3a2ed40dc26c, lg=sh-9f833208157d477f8053ea7ea3be9d9f, lgx=sh-bb121933c3a744c084244953cbbb99e2, xl=sh-db86202fb4404837b2cac6bef587c28b, 2xl=sh-84b51ea64475455eb4618346ec945ae2.", + "defaultForKind": false, + "size": { + "name": "md", + "cpu": 4, + "memoryMb": 8192, + "storageMb": 32768, + "freestyleBase": "freestyle/ubuntu" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-herdr-exact-desktop-v1-lg-base", + "imageId": "sh-9f833208157d477f8053ea7ea3be9d9f", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "base", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "1e30ef64222b10543c7d0209edde9714a4300764", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "d2af9b50e868bc15c40782f99d2f723ff29d79e6773dd283b14ddc7ca2966b41", + "schema": 2 + }, + "builtAt": "2026-09-21T14:03:43.707Z", + "builderScriptVersion": "97f949aa605205ea3d175c8a19131433b1673b2d7ca66869674e32c2b6ff9173", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Promoted from an existing freestyle image by promote-devbox-image.ts --image. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-a74374bdcd254be396fc2325c49744bf, md=sh-94cf8732ad124e8f82dd3a2ed40dc26c, lg=sh-9f833208157d477f8053ea7ea3be9d9f, lgx=sh-bb121933c3a744c084244953cbbb99e2, xl=sh-db86202fb4404837b2cac6bef587c28b, 2xl=sh-84b51ea64475455eb4618346ec945ae2.", + "defaultForKind": false, + "size": { + "name": "lg", + "cpu": 8, + "memoryMb": 16384, + "storageMb": 65536, + "freestyleBase": "freestyle/ubuntu-lg" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-herdr-exact-desktop-v1-lgx-base", + "imageId": "sh-bb121933c3a744c084244953cbbb99e2", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "base", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "1e30ef64222b10543c7d0209edde9714a4300764", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "d2af9b50e868bc15c40782f99d2f723ff29d79e6773dd283b14ddc7ca2966b41", + "schema": 2 + }, + "builtAt": "2026-09-21T14:03:43.707Z", + "builderScriptVersion": "97f949aa605205ea3d175c8a19131433b1673b2d7ca66869674e32c2b6ff9173", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Promoted from an existing freestyle image by promote-devbox-image.ts --image. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-a74374bdcd254be396fc2325c49744bf, md=sh-94cf8732ad124e8f82dd3a2ed40dc26c, lg=sh-9f833208157d477f8053ea7ea3be9d9f, lgx=sh-bb121933c3a744c084244953cbbb99e2, xl=sh-db86202fb4404837b2cac6bef587c28b, 2xl=sh-84b51ea64475455eb4618346ec945ae2.", + "defaultForKind": false, + "size": { + "name": "lgx", + "cpu": 12, + "memoryMb": 24576, + "storageMb": 98304 + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-herdr-exact-desktop-v1-xl-base", + "imageId": "sh-db86202fb4404837b2cac6bef587c28b", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "base", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "1e30ef64222b10543c7d0209edde9714a4300764", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "d2af9b50e868bc15c40782f99d2f723ff29d79e6773dd283b14ddc7ca2966b41", + "schema": 2 + }, + "builtAt": "2026-09-21T14:03:43.707Z", + "builderScriptVersion": "97f949aa605205ea3d175c8a19131433b1673b2d7ca66869674e32c2b6ff9173", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Promoted from an existing freestyle image by promote-devbox-image.ts --image. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-a74374bdcd254be396fc2325c49744bf, md=sh-94cf8732ad124e8f82dd3a2ed40dc26c, lg=sh-9f833208157d477f8053ea7ea3be9d9f, lgx=sh-bb121933c3a744c084244953cbbb99e2, xl=sh-db86202fb4404837b2cac6bef587c28b, 2xl=sh-84b51ea64475455eb4618346ec945ae2.", + "defaultForKind": false, + "size": { + "name": "xl", + "cpu": 16, + "memoryMb": 32768, + "storageMb": 131072, + "freestyleBase": "freestyle/ubuntu-xl" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-herdr-exact-desktop-v1-2xl-base", + "imageId": "sh-84b51ea64475455eb4618346ec945ae2", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "base", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "1e30ef64222b10543c7d0209edde9714a4300764", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "d2af9b50e868bc15c40782f99d2f723ff29d79e6773dd283b14ddc7ca2966b41", + "schema": 2 + }, + "builtAt": "2026-09-21T14:03:43.707Z", + "builderScriptVersion": "97f949aa605205ea3d175c8a19131433b1673b2d7ca66869674e32c2b6ff9173", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Promoted from an existing freestyle image by promote-devbox-image.ts --image. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-a74374bdcd254be396fc2325c49744bf, md=sh-94cf8732ad124e8f82dd3a2ed40dc26c, lg=sh-9f833208157d477f8053ea7ea3be9d9f, lgx=sh-bb121933c3a744c084244953cbbb99e2, xl=sh-db86202fb4404837b2cac6bef587c28b, 2xl=sh-84b51ea64475455eb4618346ec945ae2.", + "defaultForKind": false, + "size": { + "name": "2xl", + "cpu": 32, + "memoryMb": 65536, + "storageMb": 131072, + "freestyleBase": "freestyle/ubuntu-2xl" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-herdr-exact-warm-v4-sm", + "imageId": "sh-1de3d78c7dfe43188bb67674fbf0fc56", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "desktop", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "47a08763f32f9457a008140486c06765f026e202", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "ac80f2d0c4901ed999a4ff0abb1780d3a086dc9d29841a783b800429ff25c8a1", + "schema": 2 + }, + "builtAt": "2026-09-21T15:10:25.541Z", + "builderScriptVersion": "3db946e7c5e3e764c1a11416e908f99ddeaff1123916b25ec37f3c6dbbe34bef", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Promoted from an existing freestyle image by promote-devbox-image.ts --image. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-1de3d78c7dfe43188bb67674fbf0fc56, md=sh-8c49faec2e9542148e87a9154441ba2e, lg=sh-93dac6ceeaff47d69bdce376b7401190, lgx=sh-560734e8887f40f3821d6c5c1c539440, xl=sh-687e0ad8e9c742208cd8d30250766c99, 2xl=sh-a8f553bbb53b43f7a4d9e3a84ba79ce5.", + "defaultForKind": false, + "size": { + "name": "sm", + "cpu": 2, + "memoryMb": 4096, + "storageMb": 16384, + "freestyleBase": "freestyle/ubuntu-sm" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-herdr-exact-warm-v4-md", + "imageId": "sh-8c49faec2e9542148e87a9154441ba2e", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "desktop", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "47a08763f32f9457a008140486c06765f026e202", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "ac80f2d0c4901ed999a4ff0abb1780d3a086dc9d29841a783b800429ff25c8a1", + "schema": 2 + }, + "builtAt": "2026-09-21T15:10:25.541Z", + "builderScriptVersion": "3db946e7c5e3e764c1a11416e908f99ddeaff1123916b25ec37f3c6dbbe34bef", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Promoted from an existing freestyle image by promote-devbox-image.ts --image. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-1de3d78c7dfe43188bb67674fbf0fc56, md=sh-8c49faec2e9542148e87a9154441ba2e, lg=sh-93dac6ceeaff47d69bdce376b7401190, lgx=sh-560734e8887f40f3821d6c5c1c539440, xl=sh-687e0ad8e9c742208cd8d30250766c99, 2xl=sh-a8f553bbb53b43f7a4d9e3a84ba79ce5.", + "defaultForKind": false, + "size": { + "name": "md", + "cpu": 4, + "memoryMb": 8192, + "storageMb": 32768, + "freestyleBase": "freestyle/ubuntu" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-herdr-exact-warm-v4-lg", + "imageId": "sh-93dac6ceeaff47d69bdce376b7401190", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "desktop", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "47a08763f32f9457a008140486c06765f026e202", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "ac80f2d0c4901ed999a4ff0abb1780d3a086dc9d29841a783b800429ff25c8a1", + "schema": 2 + }, + "builtAt": "2026-09-21T15:10:25.541Z", + "builderScriptVersion": "3db946e7c5e3e764c1a11416e908f99ddeaff1123916b25ec37f3c6dbbe34bef", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Promoted from an existing freestyle image by promote-devbox-image.ts --image. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-1de3d78c7dfe43188bb67674fbf0fc56, md=sh-8c49faec2e9542148e87a9154441ba2e, lg=sh-93dac6ceeaff47d69bdce376b7401190, lgx=sh-560734e8887f40f3821d6c5c1c539440, xl=sh-687e0ad8e9c742208cd8d30250766c99, 2xl=sh-a8f553bbb53b43f7a4d9e3a84ba79ce5.", + "defaultForKind": false, + "size": { + "name": "lg", + "cpu": 8, + "memoryMb": 16384, + "storageMb": 65536, + "freestyleBase": "freestyle/ubuntu-lg" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-herdr-exact-warm-v4-lgx", + "imageId": "sh-560734e8887f40f3821d6c5c1c539440", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "desktop", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "47a08763f32f9457a008140486c06765f026e202", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "ac80f2d0c4901ed999a4ff0abb1780d3a086dc9d29841a783b800429ff25c8a1", + "schema": 2 + }, + "builtAt": "2026-09-21T15:10:25.541Z", + "builderScriptVersion": "3db946e7c5e3e764c1a11416e908f99ddeaff1123916b25ec37f3c6dbbe34bef", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Promoted from an existing freestyle image by promote-devbox-image.ts --image. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-1de3d78c7dfe43188bb67674fbf0fc56, md=sh-8c49faec2e9542148e87a9154441ba2e, lg=sh-93dac6ceeaff47d69bdce376b7401190, lgx=sh-560734e8887f40f3821d6c5c1c539440, xl=sh-687e0ad8e9c742208cd8d30250766c99, 2xl=sh-a8f553bbb53b43f7a4d9e3a84ba79ce5.", + "defaultForKind": false, + "size": { + "name": "lgx", + "cpu": 12, + "memoryMb": 24576, + "storageMb": 98304 + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-herdr-exact-warm-v4-xl", + "imageId": "sh-687e0ad8e9c742208cd8d30250766c99", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "desktop", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "47a08763f32f9457a008140486c06765f026e202", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "ac80f2d0c4901ed999a4ff0abb1780d3a086dc9d29841a783b800429ff25c8a1", + "schema": 2 + }, + "builtAt": "2026-09-21T15:10:25.541Z", + "builderScriptVersion": "3db946e7c5e3e764c1a11416e908f99ddeaff1123916b25ec37f3c6dbbe34bef", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Promoted from an existing freestyle image by promote-devbox-image.ts --image. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-1de3d78c7dfe43188bb67674fbf0fc56, md=sh-8c49faec2e9542148e87a9154441ba2e, lg=sh-93dac6ceeaff47d69bdce376b7401190, lgx=sh-560734e8887f40f3821d6c5c1c539440, xl=sh-687e0ad8e9c742208cd8d30250766c99, 2xl=sh-a8f553bbb53b43f7a4d9e3a84ba79ce5.", + "defaultForKind": false, + "size": { + "name": "xl", + "cpu": 16, + "memoryMb": 32768, + "storageMb": 131072, + "freestyleBase": "freestyle/ubuntu-xl" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-herdr-exact-warm-v4-2xl", + "imageId": "sh-a8f553bbb53b43f7a4d9e3a84ba79ce5", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "desktop", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "47a08763f32f9457a008140486c06765f026e202", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "ac80f2d0c4901ed999a4ff0abb1780d3a086dc9d29841a783b800429ff25c8a1", + "schema": 2 + }, + "builtAt": "2026-09-21T15:10:25.541Z", + "builderScriptVersion": "3db946e7c5e3e764c1a11416e908f99ddeaff1123916b25ec37f3c6dbbe34bef", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Promoted from an existing freestyle image by promote-devbox-image.ts --image. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-1de3d78c7dfe43188bb67674fbf0fc56, md=sh-8c49faec2e9542148e87a9154441ba2e, lg=sh-93dac6ceeaff47d69bdce376b7401190, lgx=sh-560734e8887f40f3821d6c5c1c539440, xl=sh-687e0ad8e9c742208cd8d30250766c99, 2xl=sh-a8f553bbb53b43f7a4d9e3a84ba79ce5.", + "defaultForKind": false, + "size": { + "name": "2xl", + "cpu": 32, + "memoryMb": 65536, + "storageMb": 131072, + "freestyleBase": "freestyle/ubuntu-2xl" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-herdr-exact-warm-v4-sm-base", + "imageId": "sh-1de3d78c7dfe43188bb67674fbf0fc56", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "base", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "47a08763f32f9457a008140486c06765f026e202", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "ac80f2d0c4901ed999a4ff0abb1780d3a086dc9d29841a783b800429ff25c8a1", + "schema": 2 + }, + "builtAt": "2026-09-21T15:10:25.541Z", + "builderScriptVersion": "3db946e7c5e3e764c1a11416e908f99ddeaff1123916b25ec37f3c6dbbe34bef", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Promoted from an existing freestyle image by promote-devbox-image.ts --image. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-1de3d78c7dfe43188bb67674fbf0fc56, md=sh-8c49faec2e9542148e87a9154441ba2e, lg=sh-93dac6ceeaff47d69bdce376b7401190, lgx=sh-560734e8887f40f3821d6c5c1c539440, xl=sh-687e0ad8e9c742208cd8d30250766c99, 2xl=sh-a8f553bbb53b43f7a4d9e3a84ba79ce5.", + "defaultForKind": false, + "size": { + "name": "sm", + "cpu": 2, + "memoryMb": 4096, + "storageMb": 16384, + "freestyleBase": "freestyle/ubuntu-sm" + }, + "defaultForLocalDev": false + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-herdr-exact-warm-v4-md-base", + "imageId": "sh-8c49faec2e9542148e87a9154441ba2e", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "base", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "47a08763f32f9457a008140486c06765f026e202", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "ac80f2d0c4901ed999a4ff0abb1780d3a086dc9d29841a783b800429ff25c8a1", + "schema": 2 + }, + "builtAt": "2026-09-21T15:10:25.541Z", + "builderScriptVersion": "3db946e7c5e3e764c1a11416e908f99ddeaff1123916b25ec37f3c6dbbe34bef", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Promoted from an existing freestyle image by promote-devbox-image.ts --image. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-1de3d78c7dfe43188bb67674fbf0fc56, md=sh-8c49faec2e9542148e87a9154441ba2e, lg=sh-93dac6ceeaff47d69bdce376b7401190, lgx=sh-560734e8887f40f3821d6c5c1c539440, xl=sh-687e0ad8e9c742208cd8d30250766c99, 2xl=sh-a8f553bbb53b43f7a4d9e3a84ba79ce5.", + "defaultForKind": false, + "size": { + "name": "md", + "cpu": 4, + "memoryMb": 8192, + "storageMb": 32768, + "freestyleBase": "freestyle/ubuntu" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-herdr-exact-warm-v4-lg-base", + "imageId": "sh-93dac6ceeaff47d69bdce376b7401190", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "base", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "47a08763f32f9457a008140486c06765f026e202", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "ac80f2d0c4901ed999a4ff0abb1780d3a086dc9d29841a783b800429ff25c8a1", + "schema": 2 + }, + "builtAt": "2026-09-21T15:10:25.541Z", + "builderScriptVersion": "3db946e7c5e3e764c1a11416e908f99ddeaff1123916b25ec37f3c6dbbe34bef", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Promoted from an existing freestyle image by promote-devbox-image.ts --image. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-1de3d78c7dfe43188bb67674fbf0fc56, md=sh-8c49faec2e9542148e87a9154441ba2e, lg=sh-93dac6ceeaff47d69bdce376b7401190, lgx=sh-560734e8887f40f3821d6c5c1c539440, xl=sh-687e0ad8e9c742208cd8d30250766c99, 2xl=sh-a8f553bbb53b43f7a4d9e3a84ba79ce5.", + "defaultForKind": false, + "size": { + "name": "lg", + "cpu": 8, + "memoryMb": 16384, + "storageMb": 65536, + "freestyleBase": "freestyle/ubuntu-lg" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-herdr-exact-warm-v4-lgx-base", + "imageId": "sh-560734e8887f40f3821d6c5c1c539440", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "base", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "47a08763f32f9457a008140486c06765f026e202", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "ac80f2d0c4901ed999a4ff0abb1780d3a086dc9d29841a783b800429ff25c8a1", + "schema": 2 + }, + "builtAt": "2026-09-21T15:10:25.541Z", + "builderScriptVersion": "3db946e7c5e3e764c1a11416e908f99ddeaff1123916b25ec37f3c6dbbe34bef", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Promoted from an existing freestyle image by promote-devbox-image.ts --image. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-1de3d78c7dfe43188bb67674fbf0fc56, md=sh-8c49faec2e9542148e87a9154441ba2e, lg=sh-93dac6ceeaff47d69bdce376b7401190, lgx=sh-560734e8887f40f3821d6c5c1c539440, xl=sh-687e0ad8e9c742208cd8d30250766c99, 2xl=sh-a8f553bbb53b43f7a4d9e3a84ba79ce5.", + "defaultForKind": false, + "size": { + "name": "lgx", + "cpu": 12, + "memoryMb": 24576, + "storageMb": 98304 + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-herdr-exact-warm-v4-xl-base", + "imageId": "sh-687e0ad8e9c742208cd8d30250766c99", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "base", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "47a08763f32f9457a008140486c06765f026e202", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "ac80f2d0c4901ed999a4ff0abb1780d3a086dc9d29841a783b800429ff25c8a1", + "schema": 2 + }, + "builtAt": "2026-09-21T15:10:25.541Z", + "builderScriptVersion": "3db946e7c5e3e764c1a11416e908f99ddeaff1123916b25ec37f3c6dbbe34bef", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Promoted from an existing freestyle image by promote-devbox-image.ts --image. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-1de3d78c7dfe43188bb67674fbf0fc56, md=sh-8c49faec2e9542148e87a9154441ba2e, lg=sh-93dac6ceeaff47d69bdce376b7401190, lgx=sh-560734e8887f40f3821d6c5c1c539440, xl=sh-687e0ad8e9c742208cd8d30250766c99, 2xl=sh-a8f553bbb53b43f7a4d9e3a84ba79ce5.", + "defaultForKind": false, + "size": { + "name": "xl", + "cpu": 16, + "memoryMb": 32768, + "storageMb": 131072, + "freestyleBase": "freestyle/ubuntu-xl" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-herdr-exact-warm-v4-2xl-base", + "imageId": "sh-a8f553bbb53b43f7a4d9e3a84ba79ce5", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "base", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "47a08763f32f9457a008140486c06765f026e202", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "ac80f2d0c4901ed999a4ff0abb1780d3a086dc9d29841a783b800429ff25c8a1", + "schema": 2 + }, + "builtAt": "2026-09-21T15:10:25.541Z", + "builderScriptVersion": "3db946e7c5e3e764c1a11416e908f99ddeaff1123916b25ec37f3c6dbbe34bef", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Promoted from an existing freestyle image by promote-devbox-image.ts --image. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-1de3d78c7dfe43188bb67674fbf0fc56, md=sh-8c49faec2e9542148e87a9154441ba2e, lg=sh-93dac6ceeaff47d69bdce376b7401190, lgx=sh-560734e8887f40f3821d6c5c1c539440, xl=sh-687e0ad8e9c742208cd8d30250766c99, 2xl=sh-a8f553bbb53b43f7a4d9e3a84ba79ce5.", + "defaultForKind": false, + "size": { + "name": "2xl", + "cpu": 32, + "memoryMb": 65536, + "storageMb": 131072, + "freestyleBase": "freestyle/ubuntu-2xl" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-herdr-exact-warm-v7-sm", + "imageId": "sh-1b1df26553d142b1b5849c902ff17031", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "desktop", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "77bef31c06a27a5cd325508abf461550fab0b940", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "ac80f2d0c4901ed999a4ff0abb1780d3a086dc9d29841a783b800429ff25c8a1", + "schema": 2 + }, + "builtAt": "2026-09-21T15:47:16.668Z", + "builderScriptVersion": "3db946e7c5e3e764c1a11416e908f99ddeaff1123916b25ec37f3c6dbbe34bef", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Promoted from an existing freestyle image by promote-devbox-image.ts --image. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-1b1df26553d142b1b5849c902ff17031, md=sh-008b4f592cc04ada9c3598962e596ce5, lg=sh-1954cfdb25be4ea8986d3c3cfcd7e3ec, lgx=sh-a75fd74aac3547858e4dbeadd6d5a04b, xl=sh-4f6b45f358be40129b51d9ffcf264b8f, 2xl=sh-ecc65d9210a84975876744c5447fd05a.", + "defaultForKind": false, + "size": { + "name": "sm", + "cpu": 2, + "memoryMb": 4096, + "storageMb": 16384, + "freestyleBase": "freestyle/ubuntu-sm" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-herdr-exact-warm-v7-md", + "imageId": "sh-008b4f592cc04ada9c3598962e596ce5", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "desktop", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "77bef31c06a27a5cd325508abf461550fab0b940", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "ac80f2d0c4901ed999a4ff0abb1780d3a086dc9d29841a783b800429ff25c8a1", + "schema": 2 + }, + "builtAt": "2026-09-21T15:47:16.668Z", + "builderScriptVersion": "3db946e7c5e3e764c1a11416e908f99ddeaff1123916b25ec37f3c6dbbe34bef", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Promoted from an existing freestyle image by promote-devbox-image.ts --image. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-1b1df26553d142b1b5849c902ff17031, md=sh-008b4f592cc04ada9c3598962e596ce5, lg=sh-1954cfdb25be4ea8986d3c3cfcd7e3ec, lgx=sh-a75fd74aac3547858e4dbeadd6d5a04b, xl=sh-4f6b45f358be40129b51d9ffcf264b8f, 2xl=sh-ecc65d9210a84975876744c5447fd05a.", + "defaultForKind": false, + "size": { + "name": "md", + "cpu": 4, + "memoryMb": 8192, + "storageMb": 32768, + "freestyleBase": "freestyle/ubuntu" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-herdr-exact-warm-v7-lg", + "imageId": "sh-1954cfdb25be4ea8986d3c3cfcd7e3ec", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "desktop", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "77bef31c06a27a5cd325508abf461550fab0b940", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "ac80f2d0c4901ed999a4ff0abb1780d3a086dc9d29841a783b800429ff25c8a1", + "schema": 2 + }, + "builtAt": "2026-09-21T15:47:16.668Z", + "builderScriptVersion": "3db946e7c5e3e764c1a11416e908f99ddeaff1123916b25ec37f3c6dbbe34bef", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Promoted from an existing freestyle image by promote-devbox-image.ts --image. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-1b1df26553d142b1b5849c902ff17031, md=sh-008b4f592cc04ada9c3598962e596ce5, lg=sh-1954cfdb25be4ea8986d3c3cfcd7e3ec, lgx=sh-a75fd74aac3547858e4dbeadd6d5a04b, xl=sh-4f6b45f358be40129b51d9ffcf264b8f, 2xl=sh-ecc65d9210a84975876744c5447fd05a.", + "defaultForKind": false, + "size": { + "name": "lg", + "cpu": 8, + "memoryMb": 16384, + "storageMb": 65536, + "freestyleBase": "freestyle/ubuntu-lg" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-herdr-exact-warm-v7-lgx", + "imageId": "sh-a75fd74aac3547858e4dbeadd6d5a04b", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "desktop", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "77bef31c06a27a5cd325508abf461550fab0b940", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "ac80f2d0c4901ed999a4ff0abb1780d3a086dc9d29841a783b800429ff25c8a1", + "schema": 2 + }, + "builtAt": "2026-09-21T15:47:16.668Z", + "builderScriptVersion": "3db946e7c5e3e764c1a11416e908f99ddeaff1123916b25ec37f3c6dbbe34bef", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Promoted from an existing freestyle image by promote-devbox-image.ts --image. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-1b1df26553d142b1b5849c902ff17031, md=sh-008b4f592cc04ada9c3598962e596ce5, lg=sh-1954cfdb25be4ea8986d3c3cfcd7e3ec, lgx=sh-a75fd74aac3547858e4dbeadd6d5a04b, xl=sh-4f6b45f358be40129b51d9ffcf264b8f, 2xl=sh-ecc65d9210a84975876744c5447fd05a.", + "defaultForKind": false, + "size": { + "name": "lgx", + "cpu": 12, + "memoryMb": 24576, + "storageMb": 98304 + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-herdr-exact-warm-v7-xl", + "imageId": "sh-4f6b45f358be40129b51d9ffcf264b8f", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "desktop", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "77bef31c06a27a5cd325508abf461550fab0b940", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "ac80f2d0c4901ed999a4ff0abb1780d3a086dc9d29841a783b800429ff25c8a1", + "schema": 2 + }, + "builtAt": "2026-09-21T15:47:16.668Z", + "builderScriptVersion": "3db946e7c5e3e764c1a11416e908f99ddeaff1123916b25ec37f3c6dbbe34bef", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Promoted from an existing freestyle image by promote-devbox-image.ts --image. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-1b1df26553d142b1b5849c902ff17031, md=sh-008b4f592cc04ada9c3598962e596ce5, lg=sh-1954cfdb25be4ea8986d3c3cfcd7e3ec, lgx=sh-a75fd74aac3547858e4dbeadd6d5a04b, xl=sh-4f6b45f358be40129b51d9ffcf264b8f, 2xl=sh-ecc65d9210a84975876744c5447fd05a.", + "defaultForKind": false, + "size": { + "name": "xl", + "cpu": 16, + "memoryMb": 32768, + "storageMb": 131072, + "freestyleBase": "freestyle/ubuntu-xl" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-herdr-exact-warm-v7-2xl", + "imageId": "sh-ecc65d9210a84975876744c5447fd05a", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "desktop", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "77bef31c06a27a5cd325508abf461550fab0b940", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "ac80f2d0c4901ed999a4ff0abb1780d3a086dc9d29841a783b800429ff25c8a1", + "schema": 2 + }, + "builtAt": "2026-09-21T15:47:16.668Z", + "builderScriptVersion": "3db946e7c5e3e764c1a11416e908f99ddeaff1123916b25ec37f3c6dbbe34bef", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Promoted from an existing freestyle image by promote-devbox-image.ts --image. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-1b1df26553d142b1b5849c902ff17031, md=sh-008b4f592cc04ada9c3598962e596ce5, lg=sh-1954cfdb25be4ea8986d3c3cfcd7e3ec, lgx=sh-a75fd74aac3547858e4dbeadd6d5a04b, xl=sh-4f6b45f358be40129b51d9ffcf264b8f, 2xl=sh-ecc65d9210a84975876744c5447fd05a.", + "defaultForKind": false, + "size": { + "name": "2xl", + "cpu": 32, + "memoryMb": 65536, + "storageMb": 131072, + "freestyleBase": "freestyle/ubuntu-2xl" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-herdr-exact-warm-v7-sm-base", + "imageId": "sh-1b1df26553d142b1b5849c902ff17031", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "base", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "77bef31c06a27a5cd325508abf461550fab0b940", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "ac80f2d0c4901ed999a4ff0abb1780d3a086dc9d29841a783b800429ff25c8a1", + "schema": 2 + }, + "builtAt": "2026-09-21T15:47:16.668Z", + "builderScriptVersion": "3db946e7c5e3e764c1a11416e908f99ddeaff1123916b25ec37f3c6dbbe34bef", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Promoted from an existing freestyle image by promote-devbox-image.ts --image. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-1b1df26553d142b1b5849c902ff17031, md=sh-008b4f592cc04ada9c3598962e596ce5, lg=sh-1954cfdb25be4ea8986d3c3cfcd7e3ec, lgx=sh-a75fd74aac3547858e4dbeadd6d5a04b, xl=sh-4f6b45f358be40129b51d9ffcf264b8f, 2xl=sh-ecc65d9210a84975876744c5447fd05a.", + "defaultForKind": false, + "size": { + "name": "sm", + "cpu": 2, + "memoryMb": 4096, + "storageMb": 16384, + "freestyleBase": "freestyle/ubuntu-sm" + }, + "defaultForLocalDev": false + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-herdr-exact-warm-v7-md-base", + "imageId": "sh-008b4f592cc04ada9c3598962e596ce5", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "base", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "77bef31c06a27a5cd325508abf461550fab0b940", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "ac80f2d0c4901ed999a4ff0abb1780d3a086dc9d29841a783b800429ff25c8a1", + "schema": 2 + }, + "builtAt": "2026-09-21T15:47:16.668Z", + "builderScriptVersion": "3db946e7c5e3e764c1a11416e908f99ddeaff1123916b25ec37f3c6dbbe34bef", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Promoted from an existing freestyle image by promote-devbox-image.ts --image. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-1b1df26553d142b1b5849c902ff17031, md=sh-008b4f592cc04ada9c3598962e596ce5, lg=sh-1954cfdb25be4ea8986d3c3cfcd7e3ec, lgx=sh-a75fd74aac3547858e4dbeadd6d5a04b, xl=sh-4f6b45f358be40129b51d9ffcf264b8f, 2xl=sh-ecc65d9210a84975876744c5447fd05a.", + "defaultForKind": false, + "size": { + "name": "md", + "cpu": 4, + "memoryMb": 8192, + "storageMb": 32768, + "freestyleBase": "freestyle/ubuntu" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-herdr-exact-warm-v7-lg-base", + "imageId": "sh-1954cfdb25be4ea8986d3c3cfcd7e3ec", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "base", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "77bef31c06a27a5cd325508abf461550fab0b940", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "ac80f2d0c4901ed999a4ff0abb1780d3a086dc9d29841a783b800429ff25c8a1", + "schema": 2 + }, + "builtAt": "2026-09-21T15:47:16.668Z", + "builderScriptVersion": "3db946e7c5e3e764c1a11416e908f99ddeaff1123916b25ec37f3c6dbbe34bef", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Promoted from an existing freestyle image by promote-devbox-image.ts --image. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-1b1df26553d142b1b5849c902ff17031, md=sh-008b4f592cc04ada9c3598962e596ce5, lg=sh-1954cfdb25be4ea8986d3c3cfcd7e3ec, lgx=sh-a75fd74aac3547858e4dbeadd6d5a04b, xl=sh-4f6b45f358be40129b51d9ffcf264b8f, 2xl=sh-ecc65d9210a84975876744c5447fd05a.", + "defaultForKind": false, + "size": { + "name": "lg", + "cpu": 8, + "memoryMb": 16384, + "storageMb": 65536, + "freestyleBase": "freestyle/ubuntu-lg" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-herdr-exact-warm-v7-lgx-base", + "imageId": "sh-a75fd74aac3547858e4dbeadd6d5a04b", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "base", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "77bef31c06a27a5cd325508abf461550fab0b940", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "ac80f2d0c4901ed999a4ff0abb1780d3a086dc9d29841a783b800429ff25c8a1", + "schema": 2 + }, + "builtAt": "2026-09-21T15:47:16.668Z", + "builderScriptVersion": "3db946e7c5e3e764c1a11416e908f99ddeaff1123916b25ec37f3c6dbbe34bef", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Promoted from an existing freestyle image by promote-devbox-image.ts --image. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-1b1df26553d142b1b5849c902ff17031, md=sh-008b4f592cc04ada9c3598962e596ce5, lg=sh-1954cfdb25be4ea8986d3c3cfcd7e3ec, lgx=sh-a75fd74aac3547858e4dbeadd6d5a04b, xl=sh-4f6b45f358be40129b51d9ffcf264b8f, 2xl=sh-ecc65d9210a84975876744c5447fd05a.", + "defaultForKind": false, + "size": { + "name": "lgx", + "cpu": 12, + "memoryMb": 24576, + "storageMb": 98304 + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-herdr-exact-warm-v7-xl-base", + "imageId": "sh-4f6b45f358be40129b51d9ffcf264b8f", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "base", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "77bef31c06a27a5cd325508abf461550fab0b940", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "ac80f2d0c4901ed999a4ff0abb1780d3a086dc9d29841a783b800429ff25c8a1", + "schema": 2 + }, + "builtAt": "2026-09-21T15:47:16.668Z", + "builderScriptVersion": "3db946e7c5e3e764c1a11416e908f99ddeaff1123916b25ec37f3c6dbbe34bef", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Promoted from an existing freestyle image by promote-devbox-image.ts --image. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-1b1df26553d142b1b5849c902ff17031, md=sh-008b4f592cc04ada9c3598962e596ce5, lg=sh-1954cfdb25be4ea8986d3c3cfcd7e3ec, lgx=sh-a75fd74aac3547858e4dbeadd6d5a04b, xl=sh-4f6b45f358be40129b51d9ffcf264b8f, 2xl=sh-ecc65d9210a84975876744c5447fd05a.", + "defaultForKind": false, + "size": { + "name": "xl", + "cpu": 16, + "memoryMb": 32768, + "storageMb": 131072, + "freestyleBase": "freestyle/ubuntu-xl" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-herdr-exact-warm-v7-2xl-base", + "imageId": "sh-ecc65d9210a84975876744c5447fd05a", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "base", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "77bef31c06a27a5cd325508abf461550fab0b940", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "ac80f2d0c4901ed999a4ff0abb1780d3a086dc9d29841a783b800429ff25c8a1", + "schema": 2 + }, + "builtAt": "2026-09-21T15:47:16.668Z", + "builderScriptVersion": "3db946e7c5e3e764c1a11416e908f99ddeaff1123916b25ec37f3c6dbbe34bef", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Promoted from an existing freestyle image by promote-devbox-image.ts --image. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-1b1df26553d142b1b5849c902ff17031, md=sh-008b4f592cc04ada9c3598962e596ce5, lg=sh-1954cfdb25be4ea8986d3c3cfcd7e3ec, lgx=sh-a75fd74aac3547858e4dbeadd6d5a04b, xl=sh-4f6b45f358be40129b51d9ffcf264b8f, 2xl=sh-ecc65d9210a84975876744c5447fd05a.", + "defaultForKind": false, + "size": { + "name": "2xl", + "cpu": 32, + "memoryMb": 65536, + "storageMb": 131072, + "freestyleBase": "freestyle/ubuntu-2xl" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-herdr-exact-warm-v8-sm", + "imageId": "sh-9d9765dc5061405fbb81d213f2e27976", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "desktop", + "cmuxdRemoteCommit": "none-cmux-tui", + "cmuxTuiCommit": "01dc721bcac54383e263877bd201973c11054d8d", + "cmuxTuiSha256": "7c3468cf53016dec8af94b620a4d4807a4d53ad003567cadc1def6f637dfb42a", + "repoCommit": "e7b14069a301a68145374155b3996411d774ede0", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "ac80f2d0c4901ed999a4ff0abb1780d3a086dc9d29841a783b800429ff25c8a1", + "schema": 2 + }, + "builtAt": "2026-09-21T16:02:22.055Z", + "builderScriptVersion": "3db946e7c5e3e764c1a11416e908f99ddeaff1123916b25ec37f3c6dbbe34bef", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Promoted from an existing freestyle image by promote-devbox-image.ts --image. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-9d9765dc5061405fbb81d213f2e27976, md=sh-cd6421a24f154b5183ce33cea40b7f9c, lgx=sh-0be5cff195134943b9c471ae1921bb0d, xl=sh-f72a8c4801794b69b56cb9ee0af4f07e, lg=sh-bdb4c1455bc748a4b56e0bbd4079305a, 2xl=sh-800e5f572db5453d906036f78764e83f.", + "defaultForKind": false, + "size": { + "name": "sm", + "cpu": 2, + "memoryMb": 4096, + "storageMb": 16384, + "freestyleBase": "freestyle/ubuntu-sm" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-herdr-exact-warm-v8-md", + "imageId": "sh-cd6421a24f154b5183ce33cea40b7f9c", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "desktop", + "cmuxdRemoteCommit": "none-cmux-tui", + "cmuxTuiCommit": "01dc721bcac54383e263877bd201973c11054d8d", + "cmuxTuiSha256": "7c3468cf53016dec8af94b620a4d4807a4d53ad003567cadc1def6f637dfb42a", + "repoCommit": "e7b14069a301a68145374155b3996411d774ede0", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "ac80f2d0c4901ed999a4ff0abb1780d3a086dc9d29841a783b800429ff25c8a1", + "schema": 2 + }, + "builtAt": "2026-09-21T16:02:22.055Z", + "builderScriptVersion": "3db946e7c5e3e764c1a11416e908f99ddeaff1123916b25ec37f3c6dbbe34bef", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Promoted from an existing freestyle image by promote-devbox-image.ts --image. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-9d9765dc5061405fbb81d213f2e27976, md=sh-cd6421a24f154b5183ce33cea40b7f9c, lgx=sh-0be5cff195134943b9c471ae1921bb0d, xl=sh-f72a8c4801794b69b56cb9ee0af4f07e, lg=sh-bdb4c1455bc748a4b56e0bbd4079305a, 2xl=sh-800e5f572db5453d906036f78764e83f.", + "defaultForKind": false, + "size": { + "name": "md", + "cpu": 4, + "memoryMb": 8192, + "storageMb": 32768, + "freestyleBase": "freestyle/ubuntu" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-herdr-exact-warm-v8-lg", + "imageId": "sh-bdb4c1455bc748a4b56e0bbd4079305a", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "desktop", + "cmuxdRemoteCommit": "none-cmux-tui", + "cmuxTuiCommit": "01dc721bcac54383e263877bd201973c11054d8d", + "cmuxTuiSha256": "7c3468cf53016dec8af94b620a4d4807a4d53ad003567cadc1def6f637dfb42a", + "repoCommit": "e7b14069a301a68145374155b3996411d774ede0", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "ac80f2d0c4901ed999a4ff0abb1780d3a086dc9d29841a783b800429ff25c8a1", + "schema": 2 + }, + "builtAt": "2026-09-21T16:02:22.055Z", + "builderScriptVersion": "3db946e7c5e3e764c1a11416e908f99ddeaff1123916b25ec37f3c6dbbe34bef", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Promoted from an existing freestyle image by promote-devbox-image.ts --image. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-9d9765dc5061405fbb81d213f2e27976, md=sh-cd6421a24f154b5183ce33cea40b7f9c, lgx=sh-0be5cff195134943b9c471ae1921bb0d, xl=sh-f72a8c4801794b69b56cb9ee0af4f07e, lg=sh-bdb4c1455bc748a4b56e0bbd4079305a, 2xl=sh-800e5f572db5453d906036f78764e83f.", + "defaultForKind": false, + "size": { + "name": "lg", + "cpu": 8, + "memoryMb": 16384, + "storageMb": 65536, + "freestyleBase": "freestyle/ubuntu-lg" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-herdr-exact-warm-v8-lgx", + "imageId": "sh-0be5cff195134943b9c471ae1921bb0d", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "desktop", + "cmuxdRemoteCommit": "none-cmux-tui", + "cmuxTuiCommit": "01dc721bcac54383e263877bd201973c11054d8d", + "cmuxTuiSha256": "7c3468cf53016dec8af94b620a4d4807a4d53ad003567cadc1def6f637dfb42a", + "repoCommit": "e7b14069a301a68145374155b3996411d774ede0", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "ac80f2d0c4901ed999a4ff0abb1780d3a086dc9d29841a783b800429ff25c8a1", + "schema": 2 + }, + "builtAt": "2026-09-21T16:02:22.055Z", + "builderScriptVersion": "3db946e7c5e3e764c1a11416e908f99ddeaff1123916b25ec37f3c6dbbe34bef", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Promoted from an existing freestyle image by promote-devbox-image.ts --image. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-9d9765dc5061405fbb81d213f2e27976, md=sh-cd6421a24f154b5183ce33cea40b7f9c, lgx=sh-0be5cff195134943b9c471ae1921bb0d, xl=sh-f72a8c4801794b69b56cb9ee0af4f07e, lg=sh-bdb4c1455bc748a4b56e0bbd4079305a, 2xl=sh-800e5f572db5453d906036f78764e83f.", + "defaultForKind": false, + "size": { + "name": "lgx", + "cpu": 12, + "memoryMb": 24576, + "storageMb": 98304 + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-herdr-exact-warm-v8-xl", + "imageId": "sh-f72a8c4801794b69b56cb9ee0af4f07e", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "desktop", + "cmuxdRemoteCommit": "none-cmux-tui", + "cmuxTuiCommit": "01dc721bcac54383e263877bd201973c11054d8d", + "cmuxTuiSha256": "7c3468cf53016dec8af94b620a4d4807a4d53ad003567cadc1def6f637dfb42a", + "repoCommit": "e7b14069a301a68145374155b3996411d774ede0", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "ac80f2d0c4901ed999a4ff0abb1780d3a086dc9d29841a783b800429ff25c8a1", + "schema": 2 + }, + "builtAt": "2026-09-21T16:02:22.055Z", + "builderScriptVersion": "3db946e7c5e3e764c1a11416e908f99ddeaff1123916b25ec37f3c6dbbe34bef", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Promoted from an existing freestyle image by promote-devbox-image.ts --image. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-9d9765dc5061405fbb81d213f2e27976, md=sh-cd6421a24f154b5183ce33cea40b7f9c, lgx=sh-0be5cff195134943b9c471ae1921bb0d, xl=sh-f72a8c4801794b69b56cb9ee0af4f07e, lg=sh-bdb4c1455bc748a4b56e0bbd4079305a, 2xl=sh-800e5f572db5453d906036f78764e83f.", + "defaultForKind": false, + "size": { + "name": "xl", + "cpu": 16, + "memoryMb": 32768, + "storageMb": 131072, + "freestyleBase": "freestyle/ubuntu-xl" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-herdr-exact-warm-v8-2xl", + "imageId": "sh-800e5f572db5453d906036f78764e83f", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "desktop", + "cmuxdRemoteCommit": "none-cmux-tui", + "cmuxTuiCommit": "01dc721bcac54383e263877bd201973c11054d8d", + "cmuxTuiSha256": "7c3468cf53016dec8af94b620a4d4807a4d53ad003567cadc1def6f637dfb42a", + "repoCommit": "e7b14069a301a68145374155b3996411d774ede0", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "ac80f2d0c4901ed999a4ff0abb1780d3a086dc9d29841a783b800429ff25c8a1", + "schema": 2 + }, + "builtAt": "2026-09-21T16:02:22.055Z", + "builderScriptVersion": "3db946e7c5e3e764c1a11416e908f99ddeaff1123916b25ec37f3c6dbbe34bef", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Promoted from an existing freestyle image by promote-devbox-image.ts --image. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-9d9765dc5061405fbb81d213f2e27976, md=sh-cd6421a24f154b5183ce33cea40b7f9c, lgx=sh-0be5cff195134943b9c471ae1921bb0d, xl=sh-f72a8c4801794b69b56cb9ee0af4f07e, lg=sh-bdb4c1455bc748a4b56e0bbd4079305a, 2xl=sh-800e5f572db5453d906036f78764e83f.", + "defaultForKind": false, + "size": { + "name": "2xl", + "cpu": 32, + "memoryMb": 65536, + "storageMb": 131072, + "freestyleBase": "freestyle/ubuntu-2xl" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-herdr-exact-warm-v8-sm-base", + "imageId": "sh-9d9765dc5061405fbb81d213f2e27976", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "base", + "cmuxdRemoteCommit": "none-cmux-tui", + "cmuxTuiCommit": "01dc721bcac54383e263877bd201973c11054d8d", + "cmuxTuiSha256": "7c3468cf53016dec8af94b620a4d4807a4d53ad003567cadc1def6f637dfb42a", + "repoCommit": "e7b14069a301a68145374155b3996411d774ede0", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "ac80f2d0c4901ed999a4ff0abb1780d3a086dc9d29841a783b800429ff25c8a1", + "schema": 2 + }, + "builtAt": "2026-09-21T16:02:22.055Z", + "builderScriptVersion": "3db946e7c5e3e764c1a11416e908f99ddeaff1123916b25ec37f3c6dbbe34bef", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Promoted from an existing freestyle image by promote-devbox-image.ts --image. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-9d9765dc5061405fbb81d213f2e27976, md=sh-cd6421a24f154b5183ce33cea40b7f9c, lgx=sh-0be5cff195134943b9c471ae1921bb0d, xl=sh-f72a8c4801794b69b56cb9ee0af4f07e, lg=sh-bdb4c1455bc748a4b56e0bbd4079305a, 2xl=sh-800e5f572db5453d906036f78764e83f.", + "defaultForKind": false, + "size": { + "name": "sm", + "cpu": 2, + "memoryMb": 4096, + "storageMb": 16384, + "freestyleBase": "freestyle/ubuntu-sm" + }, + "defaultForLocalDev": false + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-herdr-exact-warm-v8-md-base", + "imageId": "sh-cd6421a24f154b5183ce33cea40b7f9c", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "base", + "cmuxdRemoteCommit": "none-cmux-tui", + "cmuxTuiCommit": "01dc721bcac54383e263877bd201973c11054d8d", + "cmuxTuiSha256": "7c3468cf53016dec8af94b620a4d4807a4d53ad003567cadc1def6f637dfb42a", + "repoCommit": "e7b14069a301a68145374155b3996411d774ede0", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "ac80f2d0c4901ed999a4ff0abb1780d3a086dc9d29841a783b800429ff25c8a1", + "schema": 2 + }, + "builtAt": "2026-09-21T16:02:22.055Z", + "builderScriptVersion": "3db946e7c5e3e764c1a11416e908f99ddeaff1123916b25ec37f3c6dbbe34bef", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Promoted from an existing freestyle image by promote-devbox-image.ts --image. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-9d9765dc5061405fbb81d213f2e27976, md=sh-cd6421a24f154b5183ce33cea40b7f9c, lgx=sh-0be5cff195134943b9c471ae1921bb0d, xl=sh-f72a8c4801794b69b56cb9ee0af4f07e, lg=sh-bdb4c1455bc748a4b56e0bbd4079305a, 2xl=sh-800e5f572db5453d906036f78764e83f.", + "defaultForKind": false, + "size": { + "name": "md", + "cpu": 4, + "memoryMb": 8192, + "storageMb": 32768, + "freestyleBase": "freestyle/ubuntu" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-herdr-exact-warm-v8-lg-base", + "imageId": "sh-bdb4c1455bc748a4b56e0bbd4079305a", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "base", + "cmuxdRemoteCommit": "none-cmux-tui", + "cmuxTuiCommit": "01dc721bcac54383e263877bd201973c11054d8d", + "cmuxTuiSha256": "7c3468cf53016dec8af94b620a4d4807a4d53ad003567cadc1def6f637dfb42a", + "repoCommit": "e7b14069a301a68145374155b3996411d774ede0", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "ac80f2d0c4901ed999a4ff0abb1780d3a086dc9d29841a783b800429ff25c8a1", + "schema": 2 + }, + "builtAt": "2026-09-21T16:02:22.055Z", + "builderScriptVersion": "3db946e7c5e3e764c1a11416e908f99ddeaff1123916b25ec37f3c6dbbe34bef", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Promoted from an existing freestyle image by promote-devbox-image.ts --image. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-9d9765dc5061405fbb81d213f2e27976, md=sh-cd6421a24f154b5183ce33cea40b7f9c, lgx=sh-0be5cff195134943b9c471ae1921bb0d, xl=sh-f72a8c4801794b69b56cb9ee0af4f07e, lg=sh-bdb4c1455bc748a4b56e0bbd4079305a, 2xl=sh-800e5f572db5453d906036f78764e83f.", + "defaultForKind": false, + "size": { + "name": "lg", + "cpu": 8, + "memoryMb": 16384, + "storageMb": 65536, + "freestyleBase": "freestyle/ubuntu-lg" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-herdr-exact-warm-v8-lgx-base", + "imageId": "sh-0be5cff195134943b9c471ae1921bb0d", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "base", + "cmuxdRemoteCommit": "none-cmux-tui", + "cmuxTuiCommit": "01dc721bcac54383e263877bd201973c11054d8d", + "cmuxTuiSha256": "7c3468cf53016dec8af94b620a4d4807a4d53ad003567cadc1def6f637dfb42a", + "repoCommit": "e7b14069a301a68145374155b3996411d774ede0", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "ac80f2d0c4901ed999a4ff0abb1780d3a086dc9d29841a783b800429ff25c8a1", + "schema": 2 + }, + "builtAt": "2026-09-21T16:02:22.055Z", + "builderScriptVersion": "3db946e7c5e3e764c1a11416e908f99ddeaff1123916b25ec37f3c6dbbe34bef", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Promoted from an existing freestyle image by promote-devbox-image.ts --image. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-9d9765dc5061405fbb81d213f2e27976, md=sh-cd6421a24f154b5183ce33cea40b7f9c, lgx=sh-0be5cff195134943b9c471ae1921bb0d, xl=sh-f72a8c4801794b69b56cb9ee0af4f07e, lg=sh-bdb4c1455bc748a4b56e0bbd4079305a, 2xl=sh-800e5f572db5453d906036f78764e83f.", + "defaultForKind": false, + "size": { + "name": "lgx", + "cpu": 12, + "memoryMb": 24576, + "storageMb": 98304 + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-herdr-exact-warm-v8-xl-base", + "imageId": "sh-f72a8c4801794b69b56cb9ee0af4f07e", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "base", + "cmuxdRemoteCommit": "none-cmux-tui", + "cmuxTuiCommit": "01dc721bcac54383e263877bd201973c11054d8d", + "cmuxTuiSha256": "7c3468cf53016dec8af94b620a4d4807a4d53ad003567cadc1def6f637dfb42a", + "repoCommit": "e7b14069a301a68145374155b3996411d774ede0", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "ac80f2d0c4901ed999a4ff0abb1780d3a086dc9d29841a783b800429ff25c8a1", + "schema": 2 + }, + "builtAt": "2026-09-21T16:02:22.055Z", + "builderScriptVersion": "3db946e7c5e3e764c1a11416e908f99ddeaff1123916b25ec37f3c6dbbe34bef", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Promoted from an existing freestyle image by promote-devbox-image.ts --image. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-9d9765dc5061405fbb81d213f2e27976, md=sh-cd6421a24f154b5183ce33cea40b7f9c, lgx=sh-0be5cff195134943b9c471ae1921bb0d, xl=sh-f72a8c4801794b69b56cb9ee0af4f07e, lg=sh-bdb4c1455bc748a4b56e0bbd4079305a, 2xl=sh-800e5f572db5453d906036f78764e83f.", + "defaultForKind": false, + "size": { + "name": "xl", + "cpu": 16, + "memoryMb": 32768, + "storageMb": 131072, + "freestyleBase": "freestyle/ubuntu-xl" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-herdr-exact-warm-v8-2xl-base", + "imageId": "sh-800e5f572db5453d906036f78764e83f", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "base", + "cmuxdRemoteCommit": "none-cmux-tui", + "cmuxTuiCommit": "01dc721bcac54383e263877bd201973c11054d8d", + "cmuxTuiSha256": "7c3468cf53016dec8af94b620a4d4807a4d53ad003567cadc1def6f637dfb42a", + "repoCommit": "e7b14069a301a68145374155b3996411d774ede0", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "ac80f2d0c4901ed999a4ff0abb1780d3a086dc9d29841a783b800429ff25c8a1", + "schema": 2 + }, + "builtAt": "2026-09-21T16:02:22.055Z", + "builderScriptVersion": "3db946e7c5e3e764c1a11416e908f99ddeaff1123916b25ec37f3c6dbbe34bef", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Promoted from an existing freestyle image by promote-devbox-image.ts --image. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-9d9765dc5061405fbb81d213f2e27976, md=sh-cd6421a24f154b5183ce33cea40b7f9c, lgx=sh-0be5cff195134943b9c471ae1921bb0d, xl=sh-f72a8c4801794b69b56cb9ee0af4f07e, lg=sh-bdb4c1455bc748a4b56e0bbd4079305a, 2xl=sh-800e5f572db5453d906036f78764e83f.", + "defaultForKind": false, + "size": { + "name": "2xl", + "cpu": 32, + "memoryMb": 65536, + "storageMb": 131072, + "freestyleBase": "freestyle/ubuntu-2xl" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-herdr-exact-warm-v9-sm", + "imageId": "sh-02e3745002e1461289c6b736f841689f", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "desktop", + "cmuxdRemoteCommit": "none-cmux-tui", + "cmuxTuiCommit": "01dc721bcac54383e263877bd201973c11054d8d", + "cmuxTuiSha256": "7c3468cf53016dec8af94b620a4d4807a4d53ad003567cadc1def6f637dfb42a", + "repoCommit": "79367d7a60d4f10f8ca99dac1f5b635d300b56ba", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "eb9c038a541e3d404d8d61f37bc9b4c63ca2bdc811a58b7dbcab5ed4995d9387", + "schema": 2 + }, + "builtAt": "2026-09-21T17:11:14.558Z", + "builderScriptVersion": "3db946e7c5e3e764c1a11416e908f99ddeaff1123916b25ec37f3c6dbbe34bef", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Promoted from an existing freestyle image by promote-devbox-image.ts --image. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-02e3745002e1461289c6b736f841689f, md=sh-90a7cd501b8a459787e4e091beeb5abb, lg=sh-7cdbea162eba482b96b2f3f30939e1e9, lgx=sh-36d1ee683cd3467b843e9da20986c97e, xl=sh-d80a6981192544789bc749ed9b03b16a, 2xl=sh-7d734a0957fe4f1882bad0f09285fca6.", + "defaultForKind": false, + "size": { + "name": "sm", + "cpu": 2, + "memoryMb": 4096, + "storageMb": 16384, + "freestyleBase": "freestyle/ubuntu-sm" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-herdr-exact-warm-v9-md", + "imageId": "sh-90a7cd501b8a459787e4e091beeb5abb", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "desktop", + "cmuxdRemoteCommit": "none-cmux-tui", + "cmuxTuiCommit": "01dc721bcac54383e263877bd201973c11054d8d", + "cmuxTuiSha256": "7c3468cf53016dec8af94b620a4d4807a4d53ad003567cadc1def6f637dfb42a", + "repoCommit": "79367d7a60d4f10f8ca99dac1f5b635d300b56ba", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "eb9c038a541e3d404d8d61f37bc9b4c63ca2bdc811a58b7dbcab5ed4995d9387", + "schema": 2 + }, + "builtAt": "2026-09-21T17:11:14.558Z", + "builderScriptVersion": "3db946e7c5e3e764c1a11416e908f99ddeaff1123916b25ec37f3c6dbbe34bef", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Promoted from an existing freestyle image by promote-devbox-image.ts --image. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-02e3745002e1461289c6b736f841689f, md=sh-90a7cd501b8a459787e4e091beeb5abb, lg=sh-7cdbea162eba482b96b2f3f30939e1e9, lgx=sh-36d1ee683cd3467b843e9da20986c97e, xl=sh-d80a6981192544789bc749ed9b03b16a, 2xl=sh-7d734a0957fe4f1882bad0f09285fca6.", + "defaultForKind": false, + "size": { + "name": "md", + "cpu": 4, + "memoryMb": 8192, + "storageMb": 32768, + "freestyleBase": "freestyle/ubuntu" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-herdr-exact-warm-v9-lg", + "imageId": "sh-7cdbea162eba482b96b2f3f30939e1e9", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "desktop", + "cmuxdRemoteCommit": "none-cmux-tui", + "cmuxTuiCommit": "01dc721bcac54383e263877bd201973c11054d8d", + "cmuxTuiSha256": "7c3468cf53016dec8af94b620a4d4807a4d53ad003567cadc1def6f637dfb42a", + "repoCommit": "79367d7a60d4f10f8ca99dac1f5b635d300b56ba", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "eb9c038a541e3d404d8d61f37bc9b4c63ca2bdc811a58b7dbcab5ed4995d9387", + "schema": 2 + }, + "builtAt": "2026-09-21T17:11:14.558Z", + "builderScriptVersion": "3db946e7c5e3e764c1a11416e908f99ddeaff1123916b25ec37f3c6dbbe34bef", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Promoted from an existing freestyle image by promote-devbox-image.ts --image. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-02e3745002e1461289c6b736f841689f, md=sh-90a7cd501b8a459787e4e091beeb5abb, lg=sh-7cdbea162eba482b96b2f3f30939e1e9, lgx=sh-36d1ee683cd3467b843e9da20986c97e, xl=sh-d80a6981192544789bc749ed9b03b16a, 2xl=sh-7d734a0957fe4f1882bad0f09285fca6.", + "defaultForKind": false, + "size": { + "name": "lg", + "cpu": 8, + "memoryMb": 16384, + "storageMb": 65536, + "freestyleBase": "freestyle/ubuntu-lg" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-herdr-exact-warm-v9-lgx", + "imageId": "sh-36d1ee683cd3467b843e9da20986c97e", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "desktop", + "cmuxdRemoteCommit": "none-cmux-tui", + "cmuxTuiCommit": "01dc721bcac54383e263877bd201973c11054d8d", + "cmuxTuiSha256": "7c3468cf53016dec8af94b620a4d4807a4d53ad003567cadc1def6f637dfb42a", + "repoCommit": "79367d7a60d4f10f8ca99dac1f5b635d300b56ba", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "eb9c038a541e3d404d8d61f37bc9b4c63ca2bdc811a58b7dbcab5ed4995d9387", + "schema": 2 + }, + "builtAt": "2026-09-21T17:11:14.558Z", + "builderScriptVersion": "3db946e7c5e3e764c1a11416e908f99ddeaff1123916b25ec37f3c6dbbe34bef", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Promoted from an existing freestyle image by promote-devbox-image.ts --image. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-02e3745002e1461289c6b736f841689f, md=sh-90a7cd501b8a459787e4e091beeb5abb, lg=sh-7cdbea162eba482b96b2f3f30939e1e9, lgx=sh-36d1ee683cd3467b843e9da20986c97e, xl=sh-d80a6981192544789bc749ed9b03b16a, 2xl=sh-7d734a0957fe4f1882bad0f09285fca6.", + "defaultForKind": false, + "size": { + "name": "lgx", + "cpu": 12, + "memoryMb": 24576, + "storageMb": 98304 + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-herdr-exact-warm-v9-xl", + "imageId": "sh-d80a6981192544789bc749ed9b03b16a", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "desktop", + "cmuxdRemoteCommit": "none-cmux-tui", + "cmuxTuiCommit": "01dc721bcac54383e263877bd201973c11054d8d", + "cmuxTuiSha256": "7c3468cf53016dec8af94b620a4d4807a4d53ad003567cadc1def6f637dfb42a", + "repoCommit": "79367d7a60d4f10f8ca99dac1f5b635d300b56ba", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "eb9c038a541e3d404d8d61f37bc9b4c63ca2bdc811a58b7dbcab5ed4995d9387", + "schema": 2 + }, + "builtAt": "2026-09-21T17:11:14.558Z", + "builderScriptVersion": "3db946e7c5e3e764c1a11416e908f99ddeaff1123916b25ec37f3c6dbbe34bef", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Promoted from an existing freestyle image by promote-devbox-image.ts --image. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-02e3745002e1461289c6b736f841689f, md=sh-90a7cd501b8a459787e4e091beeb5abb, lg=sh-7cdbea162eba482b96b2f3f30939e1e9, lgx=sh-36d1ee683cd3467b843e9da20986c97e, xl=sh-d80a6981192544789bc749ed9b03b16a, 2xl=sh-7d734a0957fe4f1882bad0f09285fca6.", + "defaultForKind": false, + "size": { + "name": "xl", + "cpu": 16, + "memoryMb": 32768, + "storageMb": 131072, + "freestyleBase": "freestyle/ubuntu-xl" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-herdr-exact-warm-v9-2xl", + "imageId": "sh-7d734a0957fe4f1882bad0f09285fca6", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "desktop", + "cmuxdRemoteCommit": "none-cmux-tui", + "cmuxTuiCommit": "01dc721bcac54383e263877bd201973c11054d8d", + "cmuxTuiSha256": "7c3468cf53016dec8af94b620a4d4807a4d53ad003567cadc1def6f637dfb42a", + "repoCommit": "79367d7a60d4f10f8ca99dac1f5b635d300b56ba", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "eb9c038a541e3d404d8d61f37bc9b4c63ca2bdc811a58b7dbcab5ed4995d9387", + "schema": 2 + }, + "builtAt": "2026-09-21T17:11:14.558Z", + "builderScriptVersion": "3db946e7c5e3e764c1a11416e908f99ddeaff1123916b25ec37f3c6dbbe34bef", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Promoted from an existing freestyle image by promote-devbox-image.ts --image. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-02e3745002e1461289c6b736f841689f, md=sh-90a7cd501b8a459787e4e091beeb5abb, lg=sh-7cdbea162eba482b96b2f3f30939e1e9, lgx=sh-36d1ee683cd3467b843e9da20986c97e, xl=sh-d80a6981192544789bc749ed9b03b16a, 2xl=sh-7d734a0957fe4f1882bad0f09285fca6.", + "defaultForKind": false, + "size": { + "name": "2xl", + "cpu": 32, + "memoryMb": 65536, + "storageMb": 131072, + "freestyleBase": "freestyle/ubuntu-2xl" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-herdr-exact-warm-v9-sm-base", + "imageId": "sh-02e3745002e1461289c6b736f841689f", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "base", + "cmuxdRemoteCommit": "none-cmux-tui", + "cmuxTuiCommit": "01dc721bcac54383e263877bd201973c11054d8d", + "cmuxTuiSha256": "7c3468cf53016dec8af94b620a4d4807a4d53ad003567cadc1def6f637dfb42a", + "repoCommit": "79367d7a60d4f10f8ca99dac1f5b635d300b56ba", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "eb9c038a541e3d404d8d61f37bc9b4c63ca2bdc811a58b7dbcab5ed4995d9387", + "schema": 2 + }, + "builtAt": "2026-09-21T17:11:14.558Z", + "builderScriptVersion": "3db946e7c5e3e764c1a11416e908f99ddeaff1123916b25ec37f3c6dbbe34bef", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Promoted from an existing freestyle image by promote-devbox-image.ts --image. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-02e3745002e1461289c6b736f841689f, md=sh-90a7cd501b8a459787e4e091beeb5abb, lg=sh-7cdbea162eba482b96b2f3f30939e1e9, lgx=sh-36d1ee683cd3467b843e9da20986c97e, xl=sh-d80a6981192544789bc749ed9b03b16a, 2xl=sh-7d734a0957fe4f1882bad0f09285fca6.", + "defaultForKind": false, + "size": { + "name": "sm", + "cpu": 2, + "memoryMb": 4096, + "storageMb": 16384, + "freestyleBase": "freestyle/ubuntu-sm" + }, + "defaultForLocalDev": false + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-herdr-exact-warm-v9-md-base", + "imageId": "sh-90a7cd501b8a459787e4e091beeb5abb", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "base", + "cmuxdRemoteCommit": "none-cmux-tui", + "cmuxTuiCommit": "01dc721bcac54383e263877bd201973c11054d8d", + "cmuxTuiSha256": "7c3468cf53016dec8af94b620a4d4807a4d53ad003567cadc1def6f637dfb42a", + "repoCommit": "79367d7a60d4f10f8ca99dac1f5b635d300b56ba", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "eb9c038a541e3d404d8d61f37bc9b4c63ca2bdc811a58b7dbcab5ed4995d9387", + "schema": 2 + }, + "builtAt": "2026-09-21T17:11:14.558Z", + "builderScriptVersion": "3db946e7c5e3e764c1a11416e908f99ddeaff1123916b25ec37f3c6dbbe34bef", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Promoted from an existing freestyle image by promote-devbox-image.ts --image. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-02e3745002e1461289c6b736f841689f, md=sh-90a7cd501b8a459787e4e091beeb5abb, lg=sh-7cdbea162eba482b96b2f3f30939e1e9, lgx=sh-36d1ee683cd3467b843e9da20986c97e, xl=sh-d80a6981192544789bc749ed9b03b16a, 2xl=sh-7d734a0957fe4f1882bad0f09285fca6.", + "defaultForKind": false, + "size": { + "name": "md", + "cpu": 4, + "memoryMb": 8192, + "storageMb": 32768, + "freestyleBase": "freestyle/ubuntu" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-herdr-exact-warm-v9-lg-base", + "imageId": "sh-7cdbea162eba482b96b2f3f30939e1e9", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "base", + "cmuxdRemoteCommit": "none-cmux-tui", + "cmuxTuiCommit": "01dc721bcac54383e263877bd201973c11054d8d", + "cmuxTuiSha256": "7c3468cf53016dec8af94b620a4d4807a4d53ad003567cadc1def6f637dfb42a", + "repoCommit": "79367d7a60d4f10f8ca99dac1f5b635d300b56ba", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "eb9c038a541e3d404d8d61f37bc9b4c63ca2bdc811a58b7dbcab5ed4995d9387", + "schema": 2 + }, + "builtAt": "2026-09-21T17:11:14.558Z", + "builderScriptVersion": "3db946e7c5e3e764c1a11416e908f99ddeaff1123916b25ec37f3c6dbbe34bef", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Promoted from an existing freestyle image by promote-devbox-image.ts --image. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-02e3745002e1461289c6b736f841689f, md=sh-90a7cd501b8a459787e4e091beeb5abb, lg=sh-7cdbea162eba482b96b2f3f30939e1e9, lgx=sh-36d1ee683cd3467b843e9da20986c97e, xl=sh-d80a6981192544789bc749ed9b03b16a, 2xl=sh-7d734a0957fe4f1882bad0f09285fca6.", + "defaultForKind": false, + "size": { + "name": "lg", + "cpu": 8, + "memoryMb": 16384, + "storageMb": 65536, + "freestyleBase": "freestyle/ubuntu-lg" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-herdr-exact-warm-v9-lgx-base", + "imageId": "sh-36d1ee683cd3467b843e9da20986c97e", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "base", + "cmuxdRemoteCommit": "none-cmux-tui", + "cmuxTuiCommit": "01dc721bcac54383e263877bd201973c11054d8d", + "cmuxTuiSha256": "7c3468cf53016dec8af94b620a4d4807a4d53ad003567cadc1def6f637dfb42a", + "repoCommit": "79367d7a60d4f10f8ca99dac1f5b635d300b56ba", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "eb9c038a541e3d404d8d61f37bc9b4c63ca2bdc811a58b7dbcab5ed4995d9387", + "schema": 2 + }, + "builtAt": "2026-09-21T17:11:14.558Z", + "builderScriptVersion": "3db946e7c5e3e764c1a11416e908f99ddeaff1123916b25ec37f3c6dbbe34bef", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Promoted from an existing freestyle image by promote-devbox-image.ts --image. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-02e3745002e1461289c6b736f841689f, md=sh-90a7cd501b8a459787e4e091beeb5abb, lg=sh-7cdbea162eba482b96b2f3f30939e1e9, lgx=sh-36d1ee683cd3467b843e9da20986c97e, xl=sh-d80a6981192544789bc749ed9b03b16a, 2xl=sh-7d734a0957fe4f1882bad0f09285fca6.", + "defaultForKind": false, + "size": { + "name": "lgx", + "cpu": 12, + "memoryMb": 24576, + "storageMb": 98304 + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-herdr-exact-warm-v9-xl-base", + "imageId": "sh-d80a6981192544789bc749ed9b03b16a", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "base", + "cmuxdRemoteCommit": "none-cmux-tui", + "cmuxTuiCommit": "01dc721bcac54383e263877bd201973c11054d8d", + "cmuxTuiSha256": "7c3468cf53016dec8af94b620a4d4807a4d53ad003567cadc1def6f637dfb42a", + "repoCommit": "79367d7a60d4f10f8ca99dac1f5b635d300b56ba", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "eb9c038a541e3d404d8d61f37bc9b4c63ca2bdc811a58b7dbcab5ed4995d9387", + "schema": 2 + }, + "builtAt": "2026-09-21T17:11:14.558Z", + "builderScriptVersion": "3db946e7c5e3e764c1a11416e908f99ddeaff1123916b25ec37f3c6dbbe34bef", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Promoted from an existing freestyle image by promote-devbox-image.ts --image. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-02e3745002e1461289c6b736f841689f, md=sh-90a7cd501b8a459787e4e091beeb5abb, lg=sh-7cdbea162eba482b96b2f3f30939e1e9, lgx=sh-36d1ee683cd3467b843e9da20986c97e, xl=sh-d80a6981192544789bc749ed9b03b16a, 2xl=sh-7d734a0957fe4f1882bad0f09285fca6.", + "defaultForKind": false, + "size": { + "name": "xl", + "cpu": 16, + "memoryMb": 32768, + "storageMb": 131072, + "freestyleBase": "freestyle/ubuntu-xl" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-herdr-exact-warm-v9-2xl-base", + "imageId": "sh-7d734a0957fe4f1882bad0f09285fca6", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "base", + "cmuxdRemoteCommit": "none-cmux-tui", + "cmuxTuiCommit": "01dc721bcac54383e263877bd201973c11054d8d", + "cmuxTuiSha256": "7c3468cf53016dec8af94b620a4d4807a4d53ad003567cadc1def6f637dfb42a", + "repoCommit": "79367d7a60d4f10f8ca99dac1f5b635d300b56ba", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "eb9c038a541e3d404d8d61f37bc9b4c63ca2bdc811a58b7dbcab5ed4995d9387", + "schema": 2 + }, + "builtAt": "2026-09-21T17:11:14.558Z", + "builderScriptVersion": "3db946e7c5e3e764c1a11416e908f99ddeaff1123916b25ec37f3c6dbbe34bef", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Promoted from an existing freestyle image by promote-devbox-image.ts --image. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-02e3745002e1461289c6b736f841689f, md=sh-90a7cd501b8a459787e4e091beeb5abb, lg=sh-7cdbea162eba482b96b2f3f30939e1e9, lgx=sh-36d1ee683cd3467b843e9da20986c97e, xl=sh-d80a6981192544789bc749ed9b03b16a, 2xl=sh-7d734a0957fe4f1882bad0f09285fca6.", + "defaultForKind": false, + "size": { + "name": "2xl", + "cpu": 32, + "memoryMb": 65536, + "storageMb": 131072, + "freestyleBase": "freestyle/ubuntu-2xl" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-devbox-pr13299-fastboot3-sm", + "imageId": "sh-20bbac71904a4e5390df2bddba435289", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "desktop", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "866ea3fab4d985443ae3ef5196ef84a634cf1ef2", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "a3aeed279c6ba4e76525eebd3c6e75ba05d4a0a8fed8812860b9857fd130786c", + "schema": 2 + }, + "builtAt": "2026-09-23T00:36:22.647Z", + "builderScriptVersion": "a4aa29b672f02d72c9751b491fd5cd5ac89fa171a5407910ac8eca4b00b3776a", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 01dc721bca, identity bound to the instance id, no create-time bootstrap. Validated 2026-09-23 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-20bbac71904a4e5390df2bddba435289, md=sh-d79ad2905f9d49879fa2417c95af3edc, lg=sh-656d88528e4d4a8bbd6638cb4396a1e7, lgx=sh-c9e69465669b4af8a25adfea23a5371c, xl=sh-9fa6b6698d544a50b392e6ba7a9c9cbe, 2xl=sh-2f261d7128654ca4aa96e99813ffa672.", + "cmuxTuiCommit": "01dc721bcac54383e263877bd201973c11054d8d", + "cmuxTuiSha256": "7c3468cf53016dec8af94b620a4d4807a4d53ad003567cadc1def6f637dfb42a", + "defaultForKind": false, + "size": { + "name": "sm", + "cpu": 2, + "memoryMb": 4096, + "storageMb": 16384, + "freestyleBase": "freestyle/ubuntu-sm" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-devbox-pr13299-fastboot3-md", + "imageId": "sh-d79ad2905f9d49879fa2417c95af3edc", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "desktop", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "866ea3fab4d985443ae3ef5196ef84a634cf1ef2", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "a3aeed279c6ba4e76525eebd3c6e75ba05d4a0a8fed8812860b9857fd130786c", + "schema": 2 + }, + "builtAt": "2026-09-23T00:36:22.647Z", + "builderScriptVersion": "a4aa29b672f02d72c9751b491fd5cd5ac89fa171a5407910ac8eca4b00b3776a", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 01dc721bca, identity bound to the instance id, no create-time bootstrap. Validated 2026-09-23 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-20bbac71904a4e5390df2bddba435289, md=sh-d79ad2905f9d49879fa2417c95af3edc, lg=sh-656d88528e4d4a8bbd6638cb4396a1e7, lgx=sh-c9e69465669b4af8a25adfea23a5371c, xl=sh-9fa6b6698d544a50b392e6ba7a9c9cbe, 2xl=sh-2f261d7128654ca4aa96e99813ffa672.", + "cmuxTuiCommit": "01dc721bcac54383e263877bd201973c11054d8d", + "cmuxTuiSha256": "7c3468cf53016dec8af94b620a4d4807a4d53ad003567cadc1def6f637dfb42a", + "defaultForKind": false, + "size": { + "name": "md", + "cpu": 4, + "memoryMb": 8192, + "storageMb": 32768, + "freestyleBase": "freestyle/ubuntu" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-devbox-pr13299-fastboot3-lg", + "imageId": "sh-656d88528e4d4a8bbd6638cb4396a1e7", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "desktop", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "866ea3fab4d985443ae3ef5196ef84a634cf1ef2", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "a3aeed279c6ba4e76525eebd3c6e75ba05d4a0a8fed8812860b9857fd130786c", + "schema": 2 + }, + "builtAt": "2026-09-23T00:36:22.647Z", + "builderScriptVersion": "a4aa29b672f02d72c9751b491fd5cd5ac89fa171a5407910ac8eca4b00b3776a", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 01dc721bca, identity bound to the instance id, no create-time bootstrap. Validated 2026-09-23 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-20bbac71904a4e5390df2bddba435289, md=sh-d79ad2905f9d49879fa2417c95af3edc, lg=sh-656d88528e4d4a8bbd6638cb4396a1e7, lgx=sh-c9e69465669b4af8a25adfea23a5371c, xl=sh-9fa6b6698d544a50b392e6ba7a9c9cbe, 2xl=sh-2f261d7128654ca4aa96e99813ffa672.", + "cmuxTuiCommit": "01dc721bcac54383e263877bd201973c11054d8d", + "cmuxTuiSha256": "7c3468cf53016dec8af94b620a4d4807a4d53ad003567cadc1def6f637dfb42a", + "defaultForKind": false, + "size": { + "name": "lg", + "cpu": 8, + "memoryMb": 16384, + "storageMb": 65536, + "freestyleBase": "freestyle/ubuntu-lg" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-devbox-pr13299-fastboot3-lgx", + "imageId": "sh-c9e69465669b4af8a25adfea23a5371c", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "desktop", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "866ea3fab4d985443ae3ef5196ef84a634cf1ef2", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "a3aeed279c6ba4e76525eebd3c6e75ba05d4a0a8fed8812860b9857fd130786c", + "schema": 2 + }, + "builtAt": "2026-09-23T00:36:22.647Z", + "builderScriptVersion": "a4aa29b672f02d72c9751b491fd5cd5ac89fa171a5407910ac8eca4b00b3776a", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 01dc721bca, identity bound to the instance id, no create-time bootstrap. Validated 2026-09-23 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-20bbac71904a4e5390df2bddba435289, md=sh-d79ad2905f9d49879fa2417c95af3edc, lg=sh-656d88528e4d4a8bbd6638cb4396a1e7, lgx=sh-c9e69465669b4af8a25adfea23a5371c, xl=sh-9fa6b6698d544a50b392e6ba7a9c9cbe, 2xl=sh-2f261d7128654ca4aa96e99813ffa672.", + "cmuxTuiCommit": "01dc721bcac54383e263877bd201973c11054d8d", + "cmuxTuiSha256": "7c3468cf53016dec8af94b620a4d4807a4d53ad003567cadc1def6f637dfb42a", + "defaultForKind": false, + "size": { + "name": "lgx", + "cpu": 12, + "memoryMb": 24576, + "storageMb": 98304 + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-devbox-pr13299-fastboot3-xl", + "imageId": "sh-9fa6b6698d544a50b392e6ba7a9c9cbe", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "desktop", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "866ea3fab4d985443ae3ef5196ef84a634cf1ef2", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "a3aeed279c6ba4e76525eebd3c6e75ba05d4a0a8fed8812860b9857fd130786c", + "schema": 2 + }, + "builtAt": "2026-09-23T00:36:22.647Z", + "builderScriptVersion": "a4aa29b672f02d72c9751b491fd5cd5ac89fa171a5407910ac8eca4b00b3776a", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 01dc721bca, identity bound to the instance id, no create-time bootstrap. Validated 2026-09-23 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-20bbac71904a4e5390df2bddba435289, md=sh-d79ad2905f9d49879fa2417c95af3edc, lg=sh-656d88528e4d4a8bbd6638cb4396a1e7, lgx=sh-c9e69465669b4af8a25adfea23a5371c, xl=sh-9fa6b6698d544a50b392e6ba7a9c9cbe, 2xl=sh-2f261d7128654ca4aa96e99813ffa672.", + "cmuxTuiCommit": "01dc721bcac54383e263877bd201973c11054d8d", + "cmuxTuiSha256": "7c3468cf53016dec8af94b620a4d4807a4d53ad003567cadc1def6f637dfb42a", + "defaultForKind": false, + "size": { + "name": "xl", + "cpu": 16, + "memoryMb": 32768, + "storageMb": 131072, + "freestyleBase": "freestyle/ubuntu-xl" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-devbox-pr13299-fastboot3-2xl", + "imageId": "sh-2f261d7128654ca4aa96e99813ffa672", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "desktop", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "866ea3fab4d985443ae3ef5196ef84a634cf1ef2", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "a3aeed279c6ba4e76525eebd3c6e75ba05d4a0a8fed8812860b9857fd130786c", + "schema": 2 + }, + "builtAt": "2026-09-23T00:36:22.647Z", + "builderScriptVersion": "a4aa29b672f02d72c9751b491fd5cd5ac89fa171a5407910ac8eca4b00b3776a", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 01dc721bca, identity bound to the instance id, no create-time bootstrap. Validated 2026-09-23 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-20bbac71904a4e5390df2bddba435289, md=sh-d79ad2905f9d49879fa2417c95af3edc, lg=sh-656d88528e4d4a8bbd6638cb4396a1e7, lgx=sh-c9e69465669b4af8a25adfea23a5371c, xl=sh-9fa6b6698d544a50b392e6ba7a9c9cbe, 2xl=sh-2f261d7128654ca4aa96e99813ffa672.", + "cmuxTuiCommit": "01dc721bcac54383e263877bd201973c11054d8d", + "cmuxTuiSha256": "7c3468cf53016dec8af94b620a4d4807a4d53ad003567cadc1def6f637dfb42a", + "defaultForKind": false, + "size": { + "name": "2xl", + "cpu": 32, + "memoryMb": 65536, + "storageMb": 131072, + "freestyleBase": "freestyle/ubuntu-2xl" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-devbox-pr13299-fastboot3-sm-base", + "imageId": "sh-20bbac71904a4e5390df2bddba435289", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "base", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "866ea3fab4d985443ae3ef5196ef84a634cf1ef2", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "a3aeed279c6ba4e76525eebd3c6e75ba05d4a0a8fed8812860b9857fd130786c", + "schema": 2 + }, + "builtAt": "2026-09-23T00:36:22.647Z", + "builderScriptVersion": "a4aa29b672f02d72c9751b491fd5cd5ac89fa171a5407910ac8eca4b00b3776a", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 01dc721bca, identity bound to the instance id, no create-time bootstrap. Validated 2026-09-23 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-20bbac71904a4e5390df2bddba435289, md=sh-d79ad2905f9d49879fa2417c95af3edc, lg=sh-656d88528e4d4a8bbd6638cb4396a1e7, lgx=sh-c9e69465669b4af8a25adfea23a5371c, xl=sh-9fa6b6698d544a50b392e6ba7a9c9cbe, 2xl=sh-2f261d7128654ca4aa96e99813ffa672.", + "cmuxTuiCommit": "01dc721bcac54383e263877bd201973c11054d8d", + "cmuxTuiSha256": "7c3468cf53016dec8af94b620a4d4807a4d53ad003567cadc1def6f637dfb42a", + "defaultForKind": false, + "size": { + "name": "sm", + "cpu": 2, + "memoryMb": 4096, + "storageMb": 16384, + "freestyleBase": "freestyle/ubuntu-sm" + }, + "defaultForLocalDev": false + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-devbox-pr13299-fastboot3-md-base", + "imageId": "sh-d79ad2905f9d49879fa2417c95af3edc", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "base", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "866ea3fab4d985443ae3ef5196ef84a634cf1ef2", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "a3aeed279c6ba4e76525eebd3c6e75ba05d4a0a8fed8812860b9857fd130786c", + "schema": 2 + }, + "builtAt": "2026-09-23T00:36:22.647Z", + "builderScriptVersion": "a4aa29b672f02d72c9751b491fd5cd5ac89fa171a5407910ac8eca4b00b3776a", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 01dc721bca, identity bound to the instance id, no create-time bootstrap. Validated 2026-09-23 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-20bbac71904a4e5390df2bddba435289, md=sh-d79ad2905f9d49879fa2417c95af3edc, lg=sh-656d88528e4d4a8bbd6638cb4396a1e7, lgx=sh-c9e69465669b4af8a25adfea23a5371c, xl=sh-9fa6b6698d544a50b392e6ba7a9c9cbe, 2xl=sh-2f261d7128654ca4aa96e99813ffa672.", + "cmuxTuiCommit": "01dc721bcac54383e263877bd201973c11054d8d", + "cmuxTuiSha256": "7c3468cf53016dec8af94b620a4d4807a4d53ad003567cadc1def6f637dfb42a", + "defaultForKind": false, + "size": { + "name": "md", + "cpu": 4, + "memoryMb": 8192, + "storageMb": 32768, + "freestyleBase": "freestyle/ubuntu" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-devbox-pr13299-fastboot3-lg-base", + "imageId": "sh-656d88528e4d4a8bbd6638cb4396a1e7", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "base", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "866ea3fab4d985443ae3ef5196ef84a634cf1ef2", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "a3aeed279c6ba4e76525eebd3c6e75ba05d4a0a8fed8812860b9857fd130786c", + "schema": 2 + }, + "builtAt": "2026-09-23T00:36:22.647Z", + "builderScriptVersion": "a4aa29b672f02d72c9751b491fd5cd5ac89fa171a5407910ac8eca4b00b3776a", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 01dc721bca, identity bound to the instance id, no create-time bootstrap. Validated 2026-09-23 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-20bbac71904a4e5390df2bddba435289, md=sh-d79ad2905f9d49879fa2417c95af3edc, lg=sh-656d88528e4d4a8bbd6638cb4396a1e7, lgx=sh-c9e69465669b4af8a25adfea23a5371c, xl=sh-9fa6b6698d544a50b392e6ba7a9c9cbe, 2xl=sh-2f261d7128654ca4aa96e99813ffa672.", + "cmuxTuiCommit": "01dc721bcac54383e263877bd201973c11054d8d", + "cmuxTuiSha256": "7c3468cf53016dec8af94b620a4d4807a4d53ad003567cadc1def6f637dfb42a", + "defaultForKind": false, + "size": { + "name": "lg", + "cpu": 8, + "memoryMb": 16384, + "storageMb": 65536, + "freestyleBase": "freestyle/ubuntu-lg" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-devbox-pr13299-fastboot3-lgx-base", + "imageId": "sh-c9e69465669b4af8a25adfea23a5371c", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "base", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "866ea3fab4d985443ae3ef5196ef84a634cf1ef2", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "a3aeed279c6ba4e76525eebd3c6e75ba05d4a0a8fed8812860b9857fd130786c", + "schema": 2 + }, + "builtAt": "2026-09-23T00:36:22.647Z", + "builderScriptVersion": "a4aa29b672f02d72c9751b491fd5cd5ac89fa171a5407910ac8eca4b00b3776a", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 01dc721bca, identity bound to the instance id, no create-time bootstrap. Validated 2026-09-23 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-20bbac71904a4e5390df2bddba435289, md=sh-d79ad2905f9d49879fa2417c95af3edc, lg=sh-656d88528e4d4a8bbd6638cb4396a1e7, lgx=sh-c9e69465669b4af8a25adfea23a5371c, xl=sh-9fa6b6698d544a50b392e6ba7a9c9cbe, 2xl=sh-2f261d7128654ca4aa96e99813ffa672.", + "cmuxTuiCommit": "01dc721bcac54383e263877bd201973c11054d8d", + "cmuxTuiSha256": "7c3468cf53016dec8af94b620a4d4807a4d53ad003567cadc1def6f637dfb42a", + "defaultForKind": false, + "size": { + "name": "lgx", + "cpu": 12, + "memoryMb": 24576, + "storageMb": 98304 + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-devbox-pr13299-fastboot3-xl-base", + "imageId": "sh-9fa6b6698d544a50b392e6ba7a9c9cbe", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "base", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "866ea3fab4d985443ae3ef5196ef84a634cf1ef2", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "a3aeed279c6ba4e76525eebd3c6e75ba05d4a0a8fed8812860b9857fd130786c", + "schema": 2 + }, + "builtAt": "2026-09-23T00:36:22.647Z", + "builderScriptVersion": "a4aa29b672f02d72c9751b491fd5cd5ac89fa171a5407910ac8eca4b00b3776a", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 01dc721bca, identity bound to the instance id, no create-time bootstrap. Validated 2026-09-23 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-20bbac71904a4e5390df2bddba435289, md=sh-d79ad2905f9d49879fa2417c95af3edc, lg=sh-656d88528e4d4a8bbd6638cb4396a1e7, lgx=sh-c9e69465669b4af8a25adfea23a5371c, xl=sh-9fa6b6698d544a50b392e6ba7a9c9cbe, 2xl=sh-2f261d7128654ca4aa96e99813ffa672.", + "cmuxTuiCommit": "01dc721bcac54383e263877bd201973c11054d8d", + "cmuxTuiSha256": "7c3468cf53016dec8af94b620a4d4807a4d53ad003567cadc1def6f637dfb42a", + "defaultForKind": false, + "size": { + "name": "xl", + "cpu": 16, + "memoryMb": 32768, + "storageMb": 131072, + "freestyleBase": "freestyle/ubuntu-xl" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-devbox-pr13299-fastboot3-2xl-base", + "imageId": "sh-2f261d7128654ca4aa96e99813ffa672", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "base", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "866ea3fab4d985443ae3ef5196ef84a634cf1ef2", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "a3aeed279c6ba4e76525eebd3c6e75ba05d4a0a8fed8812860b9857fd130786c", + "schema": 2 + }, + "builtAt": "2026-09-23T00:36:22.647Z", + "builderScriptVersion": "a4aa29b672f02d72c9751b491fd5cd5ac89fa171a5407910ac8eca4b00b3776a", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-10-r2 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 01dc721bca, identity bound to the instance id, no create-time bootstrap. Validated 2026-09-23 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-20bbac71904a4e5390df2bddba435289, md=sh-d79ad2905f9d49879fa2417c95af3edc, lg=sh-656d88528e4d4a8bbd6638cb4396a1e7, lgx=sh-c9e69465669b4af8a25adfea23a5371c, xl=sh-9fa6b6698d544a50b392e6ba7a9c9cbe, 2xl=sh-2f261d7128654ca4aa96e99813ffa672.", + "cmuxTuiCommit": "01dc721bcac54383e263877bd201973c11054d8d", + "cmuxTuiSha256": "7c3468cf53016dec8af94b620a4d4807a4d53ad003567cadc1def6f637dfb42a", + "defaultForKind": false, + "size": { + "name": "2xl", + "cpu": 32, + "memoryMb": 65536, + "storageMb": 131072, + "freestyleBase": "freestyle/ubuntu-2xl" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-devbox-pr13299-fastboot5-sm", + "imageId": "sh-1e33a1c0b7cd4171b07a9c541595c87a", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "desktop", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "e1ccd5cf2ea431d90c67b2000fdc03454a09947d", + "epoch": "2026-09-10-r2", + "devboxSource": { + "layers": "desktop", + "digest": "acdc46a12fd49a88212c4c5e04b0e288d31c72252d43d470595f1c92794f4ad3", + "schema": 2 + }, + "builtAt": "2026-09-23T05:05:21.976Z", + "builderScriptVersion": "a4aa29b672f02d72c9751b491fd5cd5ac89fa171a5407910ac8eca4b00b3776a", "agentToolResolvedVersions": { "@anthropic-ai/claude-code": "2.1.267", "@openai/codex": "0.154.0", @@ -10032,9 +12953,9 @@ "agent-browser": "0.37.1" }, "validationStatus": "passed", - "notes": "cmux devbox epoch 2026-09-10-r2 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 0e0ac43d99, identity bound to the instance id, no create-time bootstrap. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-04169f8c29494697a860707022da83c2, md=sh-a8c2d2ec221648f984c2d3a71bb05e41, lg=sh-6da2d45cbbf740689c4834edc630636d, lgx=sh-4eb0d36abc2b46359ff0fba0cd16765b, xl=sh-8f920876adca4edea91ba19b9e6ba6dd, 2xl=sh-a9a958b56bc04f7c9d380ccfbf9f8b80.", - "cmuxTuiCommit": "0e0ac43d9909ba5268ecc10b4cec77fe57fa2a10", - "cmuxTuiSha256": "e8d761e70ff9c8fb4164599be821e9ce71b70c3f12e0455660077007d85efa96", + "notes": "cmux devbox epoch 2026-09-10-r2 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 01dc721bca, identity bound to the instance id, no create-time bootstrap. Validated 2026-09-23 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-1e33a1c0b7cd4171b07a9c541595c87a, md=sh-a4aa28ba0ea04915ba4c20ab382bed5c, lg=sh-fc219d31ad674c56adfd0469ddefc84c, lgx=sh-84d6b280d4894187991cee8c70f81e44, xl=sh-1c54adaacc3b498383d8ebbf23d3bdb5, 2xl=sh-fb2c4371f3f343c8900e22db34dd9629.", + "cmuxTuiCommit": "01dc721bcac54383e263877bd201973c11054d8d", + "cmuxTuiSha256": "7c3468cf53016dec8af94b620a4d4807a4d53ad003567cadc1def6f637dfb42a", "defaultForKind": false, "size": { "name": "sm", @@ -10046,20 +12967,20 @@ }, { "provider": "freestyle", - "version": "freestyle-cmux-devbox-20260921-100253-md", - "imageId": "sh-a8c2d2ec221648f984c2d3a71bb05e41", + "version": "freestyle-cmux-devbox-pr13299-fastboot5-md", + "imageId": "sh-a4aa28ba0ea04915ba4c20ab382bed5c", "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", "kind": "desktop", "cmuxdRemoteCommit": "none-cmux-tui", - "repoCommit": "f7195972d553cce04c7f0339271c19e4bfecf75b", + "repoCommit": "e1ccd5cf2ea431d90c67b2000fdc03454a09947d", "epoch": "2026-09-10-r2", "devboxSource": { "layers": "desktop", - "digest": "3ddf459ba4d35a40c30a91ebc58236ad2f451cfa04da5abbbaaedfdf1f508cbc", + "digest": "acdc46a12fd49a88212c4c5e04b0e288d31c72252d43d470595f1c92794f4ad3", "schema": 2 }, - "builtAt": "2026-09-21T10:07:01.297Z", - "builderScriptVersion": "a340e3615002cb2a685587753fbf8a8ddaf25a9067d5d14b6e58b01cfb07cc49", + "builtAt": "2026-09-23T05:05:21.976Z", + "builderScriptVersion": "a4aa29b672f02d72c9751b491fd5cd5ac89fa171a5407910ac8eca4b00b3776a", "agentToolResolvedVersions": { "@anthropic-ai/claude-code": "2.1.267", "@openai/codex": "0.154.0", @@ -10068,9 +12989,9 @@ "agent-browser": "0.37.1" }, "validationStatus": "passed", - "notes": "cmux devbox epoch 2026-09-10-r2 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 0e0ac43d99, identity bound to the instance id, no create-time bootstrap. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-04169f8c29494697a860707022da83c2, md=sh-a8c2d2ec221648f984c2d3a71bb05e41, lg=sh-6da2d45cbbf740689c4834edc630636d, lgx=sh-4eb0d36abc2b46359ff0fba0cd16765b, xl=sh-8f920876adca4edea91ba19b9e6ba6dd, 2xl=sh-a9a958b56bc04f7c9d380ccfbf9f8b80.", - "cmuxTuiCommit": "0e0ac43d9909ba5268ecc10b4cec77fe57fa2a10", - "cmuxTuiSha256": "e8d761e70ff9c8fb4164599be821e9ce71b70c3f12e0455660077007d85efa96", + "notes": "cmux devbox epoch 2026-09-10-r2 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 01dc721bca, identity bound to the instance id, no create-time bootstrap. Validated 2026-09-23 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-1e33a1c0b7cd4171b07a9c541595c87a, md=sh-a4aa28ba0ea04915ba4c20ab382bed5c, lg=sh-fc219d31ad674c56adfd0469ddefc84c, lgx=sh-84d6b280d4894187991cee8c70f81e44, xl=sh-1c54adaacc3b498383d8ebbf23d3bdb5, 2xl=sh-fb2c4371f3f343c8900e22db34dd9629.", + "cmuxTuiCommit": "01dc721bcac54383e263877bd201973c11054d8d", + "cmuxTuiSha256": "7c3468cf53016dec8af94b620a4d4807a4d53ad003567cadc1def6f637dfb42a", "defaultForKind": false, "size": { "name": "md", @@ -10082,20 +13003,20 @@ }, { "provider": "freestyle", - "version": "freestyle-cmux-devbox-20260921-100253-lg", - "imageId": "sh-6da2d45cbbf740689c4834edc630636d", + "version": "freestyle-cmux-devbox-pr13299-fastboot5-lg", + "imageId": "sh-fc219d31ad674c56adfd0469ddefc84c", "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", "kind": "desktop", "cmuxdRemoteCommit": "none-cmux-tui", - "repoCommit": "f7195972d553cce04c7f0339271c19e4bfecf75b", + "repoCommit": "e1ccd5cf2ea431d90c67b2000fdc03454a09947d", "epoch": "2026-09-10-r2", "devboxSource": { "layers": "desktop", - "digest": "3ddf459ba4d35a40c30a91ebc58236ad2f451cfa04da5abbbaaedfdf1f508cbc", + "digest": "acdc46a12fd49a88212c4c5e04b0e288d31c72252d43d470595f1c92794f4ad3", "schema": 2 }, - "builtAt": "2026-09-21T10:07:01.297Z", - "builderScriptVersion": "a340e3615002cb2a685587753fbf8a8ddaf25a9067d5d14b6e58b01cfb07cc49", + "builtAt": "2026-09-23T05:05:21.976Z", + "builderScriptVersion": "a4aa29b672f02d72c9751b491fd5cd5ac89fa171a5407910ac8eca4b00b3776a", "agentToolResolvedVersions": { "@anthropic-ai/claude-code": "2.1.267", "@openai/codex": "0.154.0", @@ -10104,9 +13025,9 @@ "agent-browser": "0.37.1" }, "validationStatus": "passed", - "notes": "cmux devbox epoch 2026-09-10-r2 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 0e0ac43d99, identity bound to the instance id, no create-time bootstrap. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-04169f8c29494697a860707022da83c2, md=sh-a8c2d2ec221648f984c2d3a71bb05e41, lg=sh-6da2d45cbbf740689c4834edc630636d, lgx=sh-4eb0d36abc2b46359ff0fba0cd16765b, xl=sh-8f920876adca4edea91ba19b9e6ba6dd, 2xl=sh-a9a958b56bc04f7c9d380ccfbf9f8b80.", - "cmuxTuiCommit": "0e0ac43d9909ba5268ecc10b4cec77fe57fa2a10", - "cmuxTuiSha256": "e8d761e70ff9c8fb4164599be821e9ce71b70c3f12e0455660077007d85efa96", + "notes": "cmux devbox epoch 2026-09-10-r2 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 01dc721bca, identity bound to the instance id, no create-time bootstrap. Validated 2026-09-23 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-1e33a1c0b7cd4171b07a9c541595c87a, md=sh-a4aa28ba0ea04915ba4c20ab382bed5c, lg=sh-fc219d31ad674c56adfd0469ddefc84c, lgx=sh-84d6b280d4894187991cee8c70f81e44, xl=sh-1c54adaacc3b498383d8ebbf23d3bdb5, 2xl=sh-fb2c4371f3f343c8900e22db34dd9629.", + "cmuxTuiCommit": "01dc721bcac54383e263877bd201973c11054d8d", + "cmuxTuiSha256": "7c3468cf53016dec8af94b620a4d4807a4d53ad003567cadc1def6f637dfb42a", "defaultForKind": false, "size": { "name": "lg", @@ -10118,20 +13039,20 @@ }, { "provider": "freestyle", - "version": "freestyle-cmux-devbox-20260921-100253-lgx", - "imageId": "sh-4eb0d36abc2b46359ff0fba0cd16765b", + "version": "freestyle-cmux-devbox-pr13299-fastboot5-lgx", + "imageId": "sh-84d6b280d4894187991cee8c70f81e44", "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", "kind": "desktop", "cmuxdRemoteCommit": "none-cmux-tui", - "repoCommit": "f7195972d553cce04c7f0339271c19e4bfecf75b", + "repoCommit": "e1ccd5cf2ea431d90c67b2000fdc03454a09947d", "epoch": "2026-09-10-r2", "devboxSource": { "layers": "desktop", - "digest": "3ddf459ba4d35a40c30a91ebc58236ad2f451cfa04da5abbbaaedfdf1f508cbc", + "digest": "acdc46a12fd49a88212c4c5e04b0e288d31c72252d43d470595f1c92794f4ad3", "schema": 2 }, - "builtAt": "2026-09-21T10:07:01.297Z", - "builderScriptVersion": "a340e3615002cb2a685587753fbf8a8ddaf25a9067d5d14b6e58b01cfb07cc49", + "builtAt": "2026-09-23T05:05:21.976Z", + "builderScriptVersion": "a4aa29b672f02d72c9751b491fd5cd5ac89fa171a5407910ac8eca4b00b3776a", "agentToolResolvedVersions": { "@anthropic-ai/claude-code": "2.1.267", "@openai/codex": "0.154.0", @@ -10140,9 +13061,9 @@ "agent-browser": "0.37.1" }, "validationStatus": "passed", - "notes": "cmux devbox epoch 2026-09-10-r2 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 0e0ac43d99, identity bound to the instance id, no create-time bootstrap. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-04169f8c29494697a860707022da83c2, md=sh-a8c2d2ec221648f984c2d3a71bb05e41, lg=sh-6da2d45cbbf740689c4834edc630636d, lgx=sh-4eb0d36abc2b46359ff0fba0cd16765b, xl=sh-8f920876adca4edea91ba19b9e6ba6dd, 2xl=sh-a9a958b56bc04f7c9d380ccfbf9f8b80.", - "cmuxTuiCommit": "0e0ac43d9909ba5268ecc10b4cec77fe57fa2a10", - "cmuxTuiSha256": "e8d761e70ff9c8fb4164599be821e9ce71b70c3f12e0455660077007d85efa96", + "notes": "cmux devbox epoch 2026-09-10-r2 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 01dc721bca, identity bound to the instance id, no create-time bootstrap. Validated 2026-09-23 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-1e33a1c0b7cd4171b07a9c541595c87a, md=sh-a4aa28ba0ea04915ba4c20ab382bed5c, lg=sh-fc219d31ad674c56adfd0469ddefc84c, lgx=sh-84d6b280d4894187991cee8c70f81e44, xl=sh-1c54adaacc3b498383d8ebbf23d3bdb5, 2xl=sh-fb2c4371f3f343c8900e22db34dd9629.", + "cmuxTuiCommit": "01dc721bcac54383e263877bd201973c11054d8d", + "cmuxTuiSha256": "7c3468cf53016dec8af94b620a4d4807a4d53ad003567cadc1def6f637dfb42a", "defaultForKind": false, "size": { "name": "lgx", @@ -10153,20 +13074,20 @@ }, { "provider": "freestyle", - "version": "freestyle-cmux-devbox-20260921-100253-xl", - "imageId": "sh-8f920876adca4edea91ba19b9e6ba6dd", + "version": "freestyle-cmux-devbox-pr13299-fastboot5-xl", + "imageId": "sh-1c54adaacc3b498383d8ebbf23d3bdb5", "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", "kind": "desktop", "cmuxdRemoteCommit": "none-cmux-tui", - "repoCommit": "f7195972d553cce04c7f0339271c19e4bfecf75b", + "repoCommit": "e1ccd5cf2ea431d90c67b2000fdc03454a09947d", "epoch": "2026-09-10-r2", "devboxSource": { "layers": "desktop", - "digest": "3ddf459ba4d35a40c30a91ebc58236ad2f451cfa04da5abbbaaedfdf1f508cbc", + "digest": "acdc46a12fd49a88212c4c5e04b0e288d31c72252d43d470595f1c92794f4ad3", "schema": 2 }, - "builtAt": "2026-09-21T10:07:01.297Z", - "builderScriptVersion": "a340e3615002cb2a685587753fbf8a8ddaf25a9067d5d14b6e58b01cfb07cc49", + "builtAt": "2026-09-23T05:05:21.976Z", + "builderScriptVersion": "a4aa29b672f02d72c9751b491fd5cd5ac89fa171a5407910ac8eca4b00b3776a", "agentToolResolvedVersions": { "@anthropic-ai/claude-code": "2.1.267", "@openai/codex": "0.154.0", @@ -10175,9 +13096,9 @@ "agent-browser": "0.37.1" }, "validationStatus": "passed", - "notes": "cmux devbox epoch 2026-09-10-r2 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 0e0ac43d99, identity bound to the instance id, no create-time bootstrap. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-04169f8c29494697a860707022da83c2, md=sh-a8c2d2ec221648f984c2d3a71bb05e41, lg=sh-6da2d45cbbf740689c4834edc630636d, lgx=sh-4eb0d36abc2b46359ff0fba0cd16765b, xl=sh-8f920876adca4edea91ba19b9e6ba6dd, 2xl=sh-a9a958b56bc04f7c9d380ccfbf9f8b80.", - "cmuxTuiCommit": "0e0ac43d9909ba5268ecc10b4cec77fe57fa2a10", - "cmuxTuiSha256": "e8d761e70ff9c8fb4164599be821e9ce71b70c3f12e0455660077007d85efa96", + "notes": "cmux devbox epoch 2026-09-10-r2 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 01dc721bca, identity bound to the instance id, no create-time bootstrap. Validated 2026-09-23 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-1e33a1c0b7cd4171b07a9c541595c87a, md=sh-a4aa28ba0ea04915ba4c20ab382bed5c, lg=sh-fc219d31ad674c56adfd0469ddefc84c, lgx=sh-84d6b280d4894187991cee8c70f81e44, xl=sh-1c54adaacc3b498383d8ebbf23d3bdb5, 2xl=sh-fb2c4371f3f343c8900e22db34dd9629.", + "cmuxTuiCommit": "01dc721bcac54383e263877bd201973c11054d8d", + "cmuxTuiSha256": "7c3468cf53016dec8af94b620a4d4807a4d53ad003567cadc1def6f637dfb42a", "defaultForKind": false, "size": { "name": "xl", @@ -10189,20 +13110,20 @@ }, { "provider": "freestyle", - "version": "freestyle-cmux-devbox-20260921-100253-2xl", - "imageId": "sh-a9a958b56bc04f7c9d380ccfbf9f8b80", + "version": "freestyle-cmux-devbox-pr13299-fastboot5-2xl", + "imageId": "sh-fb2c4371f3f343c8900e22db34dd9629", "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", "kind": "desktop", "cmuxdRemoteCommit": "none-cmux-tui", - "repoCommit": "f7195972d553cce04c7f0339271c19e4bfecf75b", + "repoCommit": "e1ccd5cf2ea431d90c67b2000fdc03454a09947d", "epoch": "2026-09-10-r2", "devboxSource": { "layers": "desktop", - "digest": "3ddf459ba4d35a40c30a91ebc58236ad2f451cfa04da5abbbaaedfdf1f508cbc", + "digest": "acdc46a12fd49a88212c4c5e04b0e288d31c72252d43d470595f1c92794f4ad3", "schema": 2 }, - "builtAt": "2026-09-21T10:07:01.297Z", - "builderScriptVersion": "a340e3615002cb2a685587753fbf8a8ddaf25a9067d5d14b6e58b01cfb07cc49", + "builtAt": "2026-09-23T05:05:21.976Z", + "builderScriptVersion": "a4aa29b672f02d72c9751b491fd5cd5ac89fa171a5407910ac8eca4b00b3776a", "agentToolResolvedVersions": { "@anthropic-ai/claude-code": "2.1.267", "@openai/codex": "0.154.0", @@ -10211,9 +13132,9 @@ "agent-browser": "0.37.1" }, "validationStatus": "passed", - "notes": "cmux devbox epoch 2026-09-10-r2 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 0e0ac43d99, identity bound to the instance id, no create-time bootstrap. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-04169f8c29494697a860707022da83c2, md=sh-a8c2d2ec221648f984c2d3a71bb05e41, lg=sh-6da2d45cbbf740689c4834edc630636d, lgx=sh-4eb0d36abc2b46359ff0fba0cd16765b, xl=sh-8f920876adca4edea91ba19b9e6ba6dd, 2xl=sh-a9a958b56bc04f7c9d380ccfbf9f8b80.", - "cmuxTuiCommit": "0e0ac43d9909ba5268ecc10b4cec77fe57fa2a10", - "cmuxTuiSha256": "e8d761e70ff9c8fb4164599be821e9ce71b70c3f12e0455660077007d85efa96", + "notes": "cmux devbox epoch 2026-09-10-r2 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 01dc721bca, identity bound to the instance id, no create-time bootstrap. Validated 2026-09-23 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-1e33a1c0b7cd4171b07a9c541595c87a, md=sh-a4aa28ba0ea04915ba4c20ab382bed5c, lg=sh-fc219d31ad674c56adfd0469ddefc84c, lgx=sh-84d6b280d4894187991cee8c70f81e44, xl=sh-1c54adaacc3b498383d8ebbf23d3bdb5, 2xl=sh-fb2c4371f3f343c8900e22db34dd9629.", + "cmuxTuiCommit": "01dc721bcac54383e263877bd201973c11054d8d", + "cmuxTuiSha256": "7c3468cf53016dec8af94b620a4d4807a4d53ad003567cadc1def6f637dfb42a", "defaultForKind": false, "size": { "name": "2xl", @@ -10225,20 +13146,20 @@ }, { "provider": "freestyle", - "version": "freestyle-cmux-devbox-20260921-100253-sm-base", - "imageId": "sh-04169f8c29494697a860707022da83c2", + "version": "freestyle-cmux-devbox-pr13299-fastboot5-sm-base", + "imageId": "sh-1e33a1c0b7cd4171b07a9c541595c87a", "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", "kind": "base", "cmuxdRemoteCommit": "none-cmux-tui", - "repoCommit": "f7195972d553cce04c7f0339271c19e4bfecf75b", + "repoCommit": "e1ccd5cf2ea431d90c67b2000fdc03454a09947d", "epoch": "2026-09-10-r2", "devboxSource": { "layers": "desktop", - "digest": "3ddf459ba4d35a40c30a91ebc58236ad2f451cfa04da5abbbaaedfdf1f508cbc", + "digest": "acdc46a12fd49a88212c4c5e04b0e288d31c72252d43d470595f1c92794f4ad3", "schema": 2 }, - "builtAt": "2026-09-21T10:07:01.297Z", - "builderScriptVersion": "a340e3615002cb2a685587753fbf8a8ddaf25a9067d5d14b6e58b01cfb07cc49", + "builtAt": "2026-09-23T05:05:21.976Z", + "builderScriptVersion": "a4aa29b672f02d72c9751b491fd5cd5ac89fa171a5407910ac8eca4b00b3776a", "agentToolResolvedVersions": { "@anthropic-ai/claude-code": "2.1.267", "@openai/codex": "0.154.0", @@ -10247,9 +13168,9 @@ "agent-browser": "0.37.1" }, "validationStatus": "passed", - "notes": "cmux devbox epoch 2026-09-10-r2 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 0e0ac43d99, identity bound to the instance id, no create-time bootstrap. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-04169f8c29494697a860707022da83c2, md=sh-a8c2d2ec221648f984c2d3a71bb05e41, lg=sh-6da2d45cbbf740689c4834edc630636d, lgx=sh-4eb0d36abc2b46359ff0fba0cd16765b, xl=sh-8f920876adca4edea91ba19b9e6ba6dd, 2xl=sh-a9a958b56bc04f7c9d380ccfbf9f8b80.", - "cmuxTuiCommit": "0e0ac43d9909ba5268ecc10b4cec77fe57fa2a10", - "cmuxTuiSha256": "e8d761e70ff9c8fb4164599be821e9ce71b70c3f12e0455660077007d85efa96", + "notes": "cmux devbox epoch 2026-09-10-r2 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 01dc721bca, identity bound to the instance id, no create-time bootstrap. Validated 2026-09-23 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-1e33a1c0b7cd4171b07a9c541595c87a, md=sh-a4aa28ba0ea04915ba4c20ab382bed5c, lg=sh-fc219d31ad674c56adfd0469ddefc84c, lgx=sh-84d6b280d4894187991cee8c70f81e44, xl=sh-1c54adaacc3b498383d8ebbf23d3bdb5, 2xl=sh-fb2c4371f3f343c8900e22db34dd9629.", + "cmuxTuiCommit": "01dc721bcac54383e263877bd201973c11054d8d", + "cmuxTuiSha256": "7c3468cf53016dec8af94b620a4d4807a4d53ad003567cadc1def6f637dfb42a", "defaultForKind": false, "size": { "name": "sm", @@ -10262,20 +13183,20 @@ }, { "provider": "freestyle", - "version": "freestyle-cmux-devbox-20260921-100253-md-base", - "imageId": "sh-a8c2d2ec221648f984c2d3a71bb05e41", + "version": "freestyle-cmux-devbox-pr13299-fastboot5-md-base", + "imageId": "sh-a4aa28ba0ea04915ba4c20ab382bed5c", "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", "kind": "base", "cmuxdRemoteCommit": "none-cmux-tui", - "repoCommit": "f7195972d553cce04c7f0339271c19e4bfecf75b", + "repoCommit": "e1ccd5cf2ea431d90c67b2000fdc03454a09947d", "epoch": "2026-09-10-r2", "devboxSource": { "layers": "desktop", - "digest": "3ddf459ba4d35a40c30a91ebc58236ad2f451cfa04da5abbbaaedfdf1f508cbc", + "digest": "acdc46a12fd49a88212c4c5e04b0e288d31c72252d43d470595f1c92794f4ad3", "schema": 2 }, - "builtAt": "2026-09-21T10:07:01.297Z", - "builderScriptVersion": "a340e3615002cb2a685587753fbf8a8ddaf25a9067d5d14b6e58b01cfb07cc49", + "builtAt": "2026-09-23T05:05:21.976Z", + "builderScriptVersion": "a4aa29b672f02d72c9751b491fd5cd5ac89fa171a5407910ac8eca4b00b3776a", "agentToolResolvedVersions": { "@anthropic-ai/claude-code": "2.1.267", "@openai/codex": "0.154.0", @@ -10284,9 +13205,9 @@ "agent-browser": "0.37.1" }, "validationStatus": "passed", - "notes": "cmux devbox epoch 2026-09-10-r2 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 0e0ac43d99, identity bound to the instance id, no create-time bootstrap. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-04169f8c29494697a860707022da83c2, md=sh-a8c2d2ec221648f984c2d3a71bb05e41, lg=sh-6da2d45cbbf740689c4834edc630636d, lgx=sh-4eb0d36abc2b46359ff0fba0cd16765b, xl=sh-8f920876adca4edea91ba19b9e6ba6dd, 2xl=sh-a9a958b56bc04f7c9d380ccfbf9f8b80.", - "cmuxTuiCommit": "0e0ac43d9909ba5268ecc10b4cec77fe57fa2a10", - "cmuxTuiSha256": "e8d761e70ff9c8fb4164599be821e9ce71b70c3f12e0455660077007d85efa96", + "notes": "cmux devbox epoch 2026-09-10-r2 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 01dc721bca, identity bound to the instance id, no create-time bootstrap. Validated 2026-09-23 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-1e33a1c0b7cd4171b07a9c541595c87a, md=sh-a4aa28ba0ea04915ba4c20ab382bed5c, lg=sh-fc219d31ad674c56adfd0469ddefc84c, lgx=sh-84d6b280d4894187991cee8c70f81e44, xl=sh-1c54adaacc3b498383d8ebbf23d3bdb5, 2xl=sh-fb2c4371f3f343c8900e22db34dd9629.", + "cmuxTuiCommit": "01dc721bcac54383e263877bd201973c11054d8d", + "cmuxTuiSha256": "7c3468cf53016dec8af94b620a4d4807a4d53ad003567cadc1def6f637dfb42a", "defaultForKind": false, "size": { "name": "md", @@ -10298,20 +13219,20 @@ }, { "provider": "freestyle", - "version": "freestyle-cmux-devbox-20260921-100253-lg-base", - "imageId": "sh-6da2d45cbbf740689c4834edc630636d", + "version": "freestyle-cmux-devbox-pr13299-fastboot5-lg-base", + "imageId": "sh-fc219d31ad674c56adfd0469ddefc84c", "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", "kind": "base", "cmuxdRemoteCommit": "none-cmux-tui", - "repoCommit": "f7195972d553cce04c7f0339271c19e4bfecf75b", + "repoCommit": "e1ccd5cf2ea431d90c67b2000fdc03454a09947d", "epoch": "2026-09-10-r2", "devboxSource": { "layers": "desktop", - "digest": "3ddf459ba4d35a40c30a91ebc58236ad2f451cfa04da5abbbaaedfdf1f508cbc", + "digest": "acdc46a12fd49a88212c4c5e04b0e288d31c72252d43d470595f1c92794f4ad3", "schema": 2 }, - "builtAt": "2026-09-21T10:07:01.297Z", - "builderScriptVersion": "a340e3615002cb2a685587753fbf8a8ddaf25a9067d5d14b6e58b01cfb07cc49", + "builtAt": "2026-09-23T05:05:21.976Z", + "builderScriptVersion": "a4aa29b672f02d72c9751b491fd5cd5ac89fa171a5407910ac8eca4b00b3776a", "agentToolResolvedVersions": { "@anthropic-ai/claude-code": "2.1.267", "@openai/codex": "0.154.0", @@ -10320,9 +13241,9 @@ "agent-browser": "0.37.1" }, "validationStatus": "passed", - "notes": "cmux devbox epoch 2026-09-10-r2 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 0e0ac43d99, identity bound to the instance id, no create-time bootstrap. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-04169f8c29494697a860707022da83c2, md=sh-a8c2d2ec221648f984c2d3a71bb05e41, lg=sh-6da2d45cbbf740689c4834edc630636d, lgx=sh-4eb0d36abc2b46359ff0fba0cd16765b, xl=sh-8f920876adca4edea91ba19b9e6ba6dd, 2xl=sh-a9a958b56bc04f7c9d380ccfbf9f8b80.", - "cmuxTuiCommit": "0e0ac43d9909ba5268ecc10b4cec77fe57fa2a10", - "cmuxTuiSha256": "e8d761e70ff9c8fb4164599be821e9ce71b70c3f12e0455660077007d85efa96", + "notes": "cmux devbox epoch 2026-09-10-r2 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 01dc721bca, identity bound to the instance id, no create-time bootstrap. Validated 2026-09-23 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-1e33a1c0b7cd4171b07a9c541595c87a, md=sh-a4aa28ba0ea04915ba4c20ab382bed5c, lg=sh-fc219d31ad674c56adfd0469ddefc84c, lgx=sh-84d6b280d4894187991cee8c70f81e44, xl=sh-1c54adaacc3b498383d8ebbf23d3bdb5, 2xl=sh-fb2c4371f3f343c8900e22db34dd9629.", + "cmuxTuiCommit": "01dc721bcac54383e263877bd201973c11054d8d", + "cmuxTuiSha256": "7c3468cf53016dec8af94b620a4d4807a4d53ad003567cadc1def6f637dfb42a", "defaultForKind": false, "size": { "name": "lg", @@ -10334,20 +13255,20 @@ }, { "provider": "freestyle", - "version": "freestyle-cmux-devbox-20260921-100253-lgx-base", - "imageId": "sh-4eb0d36abc2b46359ff0fba0cd16765b", + "version": "freestyle-cmux-devbox-pr13299-fastboot5-lgx-base", + "imageId": "sh-84d6b280d4894187991cee8c70f81e44", "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", "kind": "base", "cmuxdRemoteCommit": "none-cmux-tui", - "repoCommit": "f7195972d553cce04c7f0339271c19e4bfecf75b", + "repoCommit": "e1ccd5cf2ea431d90c67b2000fdc03454a09947d", "epoch": "2026-09-10-r2", "devboxSource": { "layers": "desktop", - "digest": "3ddf459ba4d35a40c30a91ebc58236ad2f451cfa04da5abbbaaedfdf1f508cbc", + "digest": "acdc46a12fd49a88212c4c5e04b0e288d31c72252d43d470595f1c92794f4ad3", "schema": 2 }, - "builtAt": "2026-09-21T10:07:01.297Z", - "builderScriptVersion": "a340e3615002cb2a685587753fbf8a8ddaf25a9067d5d14b6e58b01cfb07cc49", + "builtAt": "2026-09-23T05:05:21.976Z", + "builderScriptVersion": "a4aa29b672f02d72c9751b491fd5cd5ac89fa171a5407910ac8eca4b00b3776a", "agentToolResolvedVersions": { "@anthropic-ai/claude-code": "2.1.267", "@openai/codex": "0.154.0", @@ -10356,9 +13277,9 @@ "agent-browser": "0.37.1" }, "validationStatus": "passed", - "notes": "cmux devbox epoch 2026-09-10-r2 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 0e0ac43d99, identity bound to the instance id, no create-time bootstrap. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-04169f8c29494697a860707022da83c2, md=sh-a8c2d2ec221648f984c2d3a71bb05e41, lg=sh-6da2d45cbbf740689c4834edc630636d, lgx=sh-4eb0d36abc2b46359ff0fba0cd16765b, xl=sh-8f920876adca4edea91ba19b9e6ba6dd, 2xl=sh-a9a958b56bc04f7c9d380ccfbf9f8b80.", - "cmuxTuiCommit": "0e0ac43d9909ba5268ecc10b4cec77fe57fa2a10", - "cmuxTuiSha256": "e8d761e70ff9c8fb4164599be821e9ce71b70c3f12e0455660077007d85efa96", + "notes": "cmux devbox epoch 2026-09-10-r2 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 01dc721bca, identity bound to the instance id, no create-time bootstrap. Validated 2026-09-23 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-1e33a1c0b7cd4171b07a9c541595c87a, md=sh-a4aa28ba0ea04915ba4c20ab382bed5c, lg=sh-fc219d31ad674c56adfd0469ddefc84c, lgx=sh-84d6b280d4894187991cee8c70f81e44, xl=sh-1c54adaacc3b498383d8ebbf23d3bdb5, 2xl=sh-fb2c4371f3f343c8900e22db34dd9629.", + "cmuxTuiCommit": "01dc721bcac54383e263877bd201973c11054d8d", + "cmuxTuiSha256": "7c3468cf53016dec8af94b620a4d4807a4d53ad003567cadc1def6f637dfb42a", "defaultForKind": false, "size": { "name": "lgx", @@ -10369,20 +13290,20 @@ }, { "provider": "freestyle", - "version": "freestyle-cmux-devbox-20260921-100253-xl-base", - "imageId": "sh-8f920876adca4edea91ba19b9e6ba6dd", + "version": "freestyle-cmux-devbox-pr13299-fastboot5-xl-base", + "imageId": "sh-1c54adaacc3b498383d8ebbf23d3bdb5", "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", "kind": "base", "cmuxdRemoteCommit": "none-cmux-tui", - "repoCommit": "f7195972d553cce04c7f0339271c19e4bfecf75b", + "repoCommit": "e1ccd5cf2ea431d90c67b2000fdc03454a09947d", "epoch": "2026-09-10-r2", "devboxSource": { "layers": "desktop", - "digest": "3ddf459ba4d35a40c30a91ebc58236ad2f451cfa04da5abbbaaedfdf1f508cbc", + "digest": "acdc46a12fd49a88212c4c5e04b0e288d31c72252d43d470595f1c92794f4ad3", "schema": 2 }, - "builtAt": "2026-09-21T10:07:01.297Z", - "builderScriptVersion": "a340e3615002cb2a685587753fbf8a8ddaf25a9067d5d14b6e58b01cfb07cc49", + "builtAt": "2026-09-23T05:05:21.976Z", + "builderScriptVersion": "a4aa29b672f02d72c9751b491fd5cd5ac89fa171a5407910ac8eca4b00b3776a", "agentToolResolvedVersions": { "@anthropic-ai/claude-code": "2.1.267", "@openai/codex": "0.154.0", @@ -10391,9 +13312,9 @@ "agent-browser": "0.37.1" }, "validationStatus": "passed", - "notes": "cmux devbox epoch 2026-09-10-r2 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 0e0ac43d99, identity bound to the instance id, no create-time bootstrap. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-04169f8c29494697a860707022da83c2, md=sh-a8c2d2ec221648f984c2d3a71bb05e41, lg=sh-6da2d45cbbf740689c4834edc630636d, lgx=sh-4eb0d36abc2b46359ff0fba0cd16765b, xl=sh-8f920876adca4edea91ba19b9e6ba6dd, 2xl=sh-a9a958b56bc04f7c9d380ccfbf9f8b80.", - "cmuxTuiCommit": "0e0ac43d9909ba5268ecc10b4cec77fe57fa2a10", - "cmuxTuiSha256": "e8d761e70ff9c8fb4164599be821e9ce71b70c3f12e0455660077007d85efa96", + "notes": "cmux devbox epoch 2026-09-10-r2 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 01dc721bca, identity bound to the instance id, no create-time bootstrap. Validated 2026-09-23 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-1e33a1c0b7cd4171b07a9c541595c87a, md=sh-a4aa28ba0ea04915ba4c20ab382bed5c, lg=sh-fc219d31ad674c56adfd0469ddefc84c, lgx=sh-84d6b280d4894187991cee8c70f81e44, xl=sh-1c54adaacc3b498383d8ebbf23d3bdb5, 2xl=sh-fb2c4371f3f343c8900e22db34dd9629.", + "cmuxTuiCommit": "01dc721bcac54383e263877bd201973c11054d8d", + "cmuxTuiSha256": "7c3468cf53016dec8af94b620a4d4807a4d53ad003567cadc1def6f637dfb42a", "defaultForKind": false, "size": { "name": "xl", @@ -10405,20 +13326,20 @@ }, { "provider": "freestyle", - "version": "freestyle-cmux-devbox-20260921-100253-2xl-base", - "imageId": "sh-a9a958b56bc04f7c9d380ccfbf9f8b80", + "version": "freestyle-cmux-devbox-pr13299-fastboot5-2xl-base", + "imageId": "sh-fb2c4371f3f343c8900e22db34dd9629", "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", "kind": "base", "cmuxdRemoteCommit": "none-cmux-tui", - "repoCommit": "f7195972d553cce04c7f0339271c19e4bfecf75b", + "repoCommit": "e1ccd5cf2ea431d90c67b2000fdc03454a09947d", "epoch": "2026-09-10-r2", "devboxSource": { "layers": "desktop", - "digest": "3ddf459ba4d35a40c30a91ebc58236ad2f451cfa04da5abbbaaedfdf1f508cbc", + "digest": "acdc46a12fd49a88212c4c5e04b0e288d31c72252d43d470595f1c92794f4ad3", "schema": 2 }, - "builtAt": "2026-09-21T10:07:01.297Z", - "builderScriptVersion": "a340e3615002cb2a685587753fbf8a8ddaf25a9067d5d14b6e58b01cfb07cc49", + "builtAt": "2026-09-23T05:05:21.976Z", + "builderScriptVersion": "a4aa29b672f02d72c9751b491fd5cd5ac89fa171a5407910ac8eca4b00b3776a", "agentToolResolvedVersions": { "@anthropic-ai/claude-code": "2.1.267", "@openai/codex": "0.154.0", @@ -10427,9 +13348,9 @@ "agent-browser": "0.37.1" }, "validationStatus": "passed", - "notes": "cmux devbox epoch 2026-09-10-r2 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 0e0ac43d99, identity bound to the instance id, no create-time bootstrap. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-04169f8c29494697a860707022da83c2, md=sh-a8c2d2ec221648f984c2d3a71bb05e41, lg=sh-6da2d45cbbf740689c4834edc630636d, lgx=sh-4eb0d36abc2b46359ff0fba0cd16765b, xl=sh-8f920876adca4edea91ba19b9e6ba6dd, 2xl=sh-a9a958b56bc04f7c9d380ccfbf9f8b80.", - "cmuxTuiCommit": "0e0ac43d9909ba5268ecc10b4cec77fe57fa2a10", - "cmuxTuiSha256": "e8d761e70ff9c8fb4164599be821e9ce71b70c3f12e0455660077007d85efa96", + "notes": "cmux devbox epoch 2026-09-10-r2 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 01dc721bca, identity bound to the instance id, no create-time bootstrap. Validated 2026-09-23 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-1e33a1c0b7cd4171b07a9c541595c87a, md=sh-a4aa28ba0ea04915ba4c20ab382bed5c, lg=sh-fc219d31ad674c56adfd0469ddefc84c, lgx=sh-84d6b280d4894187991cee8c70f81e44, xl=sh-1c54adaacc3b498383d8ebbf23d3bdb5, 2xl=sh-fb2c4371f3f343c8900e22db34dd9629.", + "cmuxTuiCommit": "01dc721bcac54383e263877bd201973c11054d8d", + "cmuxTuiSha256": "7c3468cf53016dec8af94b620a4d4807a4d53ad003567cadc1def6f637dfb42a", "defaultForKind": false, "size": { "name": "2xl", @@ -10441,20 +13362,20 @@ }, { "provider": "freestyle", - "version": "freestyle-cmux-devbox-20260921-102554-sm", - "imageId": "sh-e2b0a76aa0f343119bf5fc9afefa88df", + "version": "freestyle-cmux-devbox-pr13299-fastboot6-sm", + "imageId": "sh-3792b42d04df4525b2541a6b705c27b1", "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", "kind": "desktop", "cmuxdRemoteCommit": "none-cmux-tui", - "repoCommit": "58bb2439cc7e105af24352f82c50b349ce752f89", + "repoCommit": "8291e529d7f27fea3b580c851648f5a8dc01a27b", "epoch": "2026-09-10-r2", "devboxSource": { "layers": "desktop", - "digest": "9d2903316ead45416e55709bb095a3da5ff547067080b021f51489e7fadd93f1", + "digest": "acdc46a12fd49a88212c4c5e04b0e288d31c72252d43d470595f1c92794f4ad3", "schema": 2 }, - "builtAt": "2026-09-21T10:29:45.312Z", - "builderScriptVersion": "a340e3615002cb2a685587753fbf8a8ddaf25a9067d5d14b6e58b01cfb07cc49", + "builtAt": "2026-09-23T10:00:32.703Z", + "builderScriptVersion": "a4aa29b672f02d72c9751b491fd5cd5ac89fa171a5407910ac8eca4b00b3776a", "agentToolResolvedVersions": { "@anthropic-ai/claude-code": "2.1.267", "@openai/codex": "0.154.0", @@ -10463,9 +13384,9 @@ "agent-browser": "0.37.1" }, "validationStatus": "passed", - "notes": "cmux devbox epoch 2026-09-10-r2 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 0e0ac43d99, identity bound to the instance id, no create-time bootstrap. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-e2b0a76aa0f343119bf5fc9afefa88df, md=sh-367fa91262564869b6fd014219cc8d8a, lgx=sh-4a796c5630d246f0897a95cc54a96499, lg=sh-847c68063c2244f39d9dbada244f2ec0, xl=sh-20423b02aeba4133bc4917f782d976bf, 2xl=sh-7e44b33c25c140c7a7842e79a45a7310.", - "cmuxTuiCommit": "0e0ac43d9909ba5268ecc10b4cec77fe57fa2a10", - "cmuxTuiSha256": "e8d761e70ff9c8fb4164599be821e9ce71b70c3f12e0455660077007d85efa96", + "notes": "cmux devbox epoch 2026-09-10-r2 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 01dc721bca, identity bound to the instance id, no create-time bootstrap. Validated 2026-09-23 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-3792b42d04df4525b2541a6b705c27b1, lg=sh-52ac4212fb7343a3a1a04f062e6dcfdb, md=sh-03412524e43b4ed4b6f53886edac7156, lgx=sh-fda010eabcbb446ba69b7f2705cc62db, xl=sh-cd15cab1d39a4fca8abce4627e86112e, 2xl=sh-f9834276babb4a76b27f72c735d81af0.", + "cmuxTuiCommit": "01dc721bcac54383e263877bd201973c11054d8d", + "cmuxTuiSha256": "7c3468cf53016dec8af94b620a4d4807a4d53ad003567cadc1def6f637dfb42a", "defaultForKind": true, "size": { "name": "sm", @@ -10477,20 +13398,20 @@ }, { "provider": "freestyle", - "version": "freestyle-cmux-devbox-20260921-102554-md", - "imageId": "sh-367fa91262564869b6fd014219cc8d8a", + "version": "freestyle-cmux-devbox-pr13299-fastboot6-md", + "imageId": "sh-03412524e43b4ed4b6f53886edac7156", "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", "kind": "desktop", "cmuxdRemoteCommit": "none-cmux-tui", - "repoCommit": "58bb2439cc7e105af24352f82c50b349ce752f89", + "repoCommit": "8291e529d7f27fea3b580c851648f5a8dc01a27b", "epoch": "2026-09-10-r2", "devboxSource": { "layers": "desktop", - "digest": "9d2903316ead45416e55709bb095a3da5ff547067080b021f51489e7fadd93f1", + "digest": "acdc46a12fd49a88212c4c5e04b0e288d31c72252d43d470595f1c92794f4ad3", "schema": 2 }, - "builtAt": "2026-09-21T10:29:45.312Z", - "builderScriptVersion": "a340e3615002cb2a685587753fbf8a8ddaf25a9067d5d14b6e58b01cfb07cc49", + "builtAt": "2026-09-23T10:00:32.703Z", + "builderScriptVersion": "a4aa29b672f02d72c9751b491fd5cd5ac89fa171a5407910ac8eca4b00b3776a", "agentToolResolvedVersions": { "@anthropic-ai/claude-code": "2.1.267", "@openai/codex": "0.154.0", @@ -10499,9 +13420,9 @@ "agent-browser": "0.37.1" }, "validationStatus": "passed", - "notes": "cmux devbox epoch 2026-09-10-r2 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 0e0ac43d99, identity bound to the instance id, no create-time bootstrap. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-e2b0a76aa0f343119bf5fc9afefa88df, md=sh-367fa91262564869b6fd014219cc8d8a, lgx=sh-4a796c5630d246f0897a95cc54a96499, lg=sh-847c68063c2244f39d9dbada244f2ec0, xl=sh-20423b02aeba4133bc4917f782d976bf, 2xl=sh-7e44b33c25c140c7a7842e79a45a7310.", - "cmuxTuiCommit": "0e0ac43d9909ba5268ecc10b4cec77fe57fa2a10", - "cmuxTuiSha256": "e8d761e70ff9c8fb4164599be821e9ce71b70c3f12e0455660077007d85efa96", + "notes": "cmux devbox epoch 2026-09-10-r2 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 01dc721bca, identity bound to the instance id, no create-time bootstrap. Validated 2026-09-23 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-3792b42d04df4525b2541a6b705c27b1, lg=sh-52ac4212fb7343a3a1a04f062e6dcfdb, md=sh-03412524e43b4ed4b6f53886edac7156, lgx=sh-fda010eabcbb446ba69b7f2705cc62db, xl=sh-cd15cab1d39a4fca8abce4627e86112e, 2xl=sh-f9834276babb4a76b27f72c735d81af0.", + "cmuxTuiCommit": "01dc721bcac54383e263877bd201973c11054d8d", + "cmuxTuiSha256": "7c3468cf53016dec8af94b620a4d4807a4d53ad003567cadc1def6f637dfb42a", "defaultForKind": true, "size": { "name": "md", @@ -10513,20 +13434,20 @@ }, { "provider": "freestyle", - "version": "freestyle-cmux-devbox-20260921-102554-lg", - "imageId": "sh-847c68063c2244f39d9dbada244f2ec0", + "version": "freestyle-cmux-devbox-pr13299-fastboot6-lg", + "imageId": "sh-52ac4212fb7343a3a1a04f062e6dcfdb", "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", "kind": "desktop", "cmuxdRemoteCommit": "none-cmux-tui", - "repoCommit": "58bb2439cc7e105af24352f82c50b349ce752f89", + "repoCommit": "8291e529d7f27fea3b580c851648f5a8dc01a27b", "epoch": "2026-09-10-r2", "devboxSource": { "layers": "desktop", - "digest": "9d2903316ead45416e55709bb095a3da5ff547067080b021f51489e7fadd93f1", + "digest": "acdc46a12fd49a88212c4c5e04b0e288d31c72252d43d470595f1c92794f4ad3", "schema": 2 }, - "builtAt": "2026-09-21T10:29:45.312Z", - "builderScriptVersion": "a340e3615002cb2a685587753fbf8a8ddaf25a9067d5d14b6e58b01cfb07cc49", + "builtAt": "2026-09-23T10:00:32.703Z", + "builderScriptVersion": "a4aa29b672f02d72c9751b491fd5cd5ac89fa171a5407910ac8eca4b00b3776a", "agentToolResolvedVersions": { "@anthropic-ai/claude-code": "2.1.267", "@openai/codex": "0.154.0", @@ -10535,9 +13456,9 @@ "agent-browser": "0.37.1" }, "validationStatus": "passed", - "notes": "cmux devbox epoch 2026-09-10-r2 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 0e0ac43d99, identity bound to the instance id, no create-time bootstrap. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-e2b0a76aa0f343119bf5fc9afefa88df, md=sh-367fa91262564869b6fd014219cc8d8a, lgx=sh-4a796c5630d246f0897a95cc54a96499, lg=sh-847c68063c2244f39d9dbada244f2ec0, xl=sh-20423b02aeba4133bc4917f782d976bf, 2xl=sh-7e44b33c25c140c7a7842e79a45a7310.", - "cmuxTuiCommit": "0e0ac43d9909ba5268ecc10b4cec77fe57fa2a10", - "cmuxTuiSha256": "e8d761e70ff9c8fb4164599be821e9ce71b70c3f12e0455660077007d85efa96", + "notes": "cmux devbox epoch 2026-09-10-r2 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 01dc721bca, identity bound to the instance id, no create-time bootstrap. Validated 2026-09-23 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-3792b42d04df4525b2541a6b705c27b1, lg=sh-52ac4212fb7343a3a1a04f062e6dcfdb, md=sh-03412524e43b4ed4b6f53886edac7156, lgx=sh-fda010eabcbb446ba69b7f2705cc62db, xl=sh-cd15cab1d39a4fca8abce4627e86112e, 2xl=sh-f9834276babb4a76b27f72c735d81af0.", + "cmuxTuiCommit": "01dc721bcac54383e263877bd201973c11054d8d", + "cmuxTuiSha256": "7c3468cf53016dec8af94b620a4d4807a4d53ad003567cadc1def6f637dfb42a", "defaultForKind": true, "size": { "name": "lg", @@ -10549,20 +13470,20 @@ }, { "provider": "freestyle", - "version": "freestyle-cmux-devbox-20260921-102554-lgx", - "imageId": "sh-4a796c5630d246f0897a95cc54a96499", + "version": "freestyle-cmux-devbox-pr13299-fastboot6-lgx", + "imageId": "sh-fda010eabcbb446ba69b7f2705cc62db", "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", "kind": "desktop", "cmuxdRemoteCommit": "none-cmux-tui", - "repoCommit": "58bb2439cc7e105af24352f82c50b349ce752f89", + "repoCommit": "8291e529d7f27fea3b580c851648f5a8dc01a27b", "epoch": "2026-09-10-r2", "devboxSource": { "layers": "desktop", - "digest": "9d2903316ead45416e55709bb095a3da5ff547067080b021f51489e7fadd93f1", + "digest": "acdc46a12fd49a88212c4c5e04b0e288d31c72252d43d470595f1c92794f4ad3", "schema": 2 }, - "builtAt": "2026-09-21T10:29:45.312Z", - "builderScriptVersion": "a340e3615002cb2a685587753fbf8a8ddaf25a9067d5d14b6e58b01cfb07cc49", + "builtAt": "2026-09-23T10:00:32.703Z", + "builderScriptVersion": "a4aa29b672f02d72c9751b491fd5cd5ac89fa171a5407910ac8eca4b00b3776a", "agentToolResolvedVersions": { "@anthropic-ai/claude-code": "2.1.267", "@openai/codex": "0.154.0", @@ -10571,9 +13492,9 @@ "agent-browser": "0.37.1" }, "validationStatus": "passed", - "notes": "cmux devbox epoch 2026-09-10-r2 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 0e0ac43d99, identity bound to the instance id, no create-time bootstrap. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-e2b0a76aa0f343119bf5fc9afefa88df, md=sh-367fa91262564869b6fd014219cc8d8a, lgx=sh-4a796c5630d246f0897a95cc54a96499, lg=sh-847c68063c2244f39d9dbada244f2ec0, xl=sh-20423b02aeba4133bc4917f782d976bf, 2xl=sh-7e44b33c25c140c7a7842e79a45a7310.", - "cmuxTuiCommit": "0e0ac43d9909ba5268ecc10b4cec77fe57fa2a10", - "cmuxTuiSha256": "e8d761e70ff9c8fb4164599be821e9ce71b70c3f12e0455660077007d85efa96", + "notes": "cmux devbox epoch 2026-09-10-r2 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 01dc721bca, identity bound to the instance id, no create-time bootstrap. Validated 2026-09-23 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-3792b42d04df4525b2541a6b705c27b1, lg=sh-52ac4212fb7343a3a1a04f062e6dcfdb, md=sh-03412524e43b4ed4b6f53886edac7156, lgx=sh-fda010eabcbb446ba69b7f2705cc62db, xl=sh-cd15cab1d39a4fca8abce4627e86112e, 2xl=sh-f9834276babb4a76b27f72c735d81af0.", + "cmuxTuiCommit": "01dc721bcac54383e263877bd201973c11054d8d", + "cmuxTuiSha256": "7c3468cf53016dec8af94b620a4d4807a4d53ad003567cadc1def6f637dfb42a", "defaultForKind": true, "size": { "name": "lgx", @@ -10584,20 +13505,20 @@ }, { "provider": "freestyle", - "version": "freestyle-cmux-devbox-20260921-102554-xl", - "imageId": "sh-20423b02aeba4133bc4917f782d976bf", + "version": "freestyle-cmux-devbox-pr13299-fastboot6-xl", + "imageId": "sh-cd15cab1d39a4fca8abce4627e86112e", "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", "kind": "desktop", "cmuxdRemoteCommit": "none-cmux-tui", - "repoCommit": "58bb2439cc7e105af24352f82c50b349ce752f89", + "repoCommit": "8291e529d7f27fea3b580c851648f5a8dc01a27b", "epoch": "2026-09-10-r2", "devboxSource": { "layers": "desktop", - "digest": "9d2903316ead45416e55709bb095a3da5ff547067080b021f51489e7fadd93f1", + "digest": "acdc46a12fd49a88212c4c5e04b0e288d31c72252d43d470595f1c92794f4ad3", "schema": 2 }, - "builtAt": "2026-09-21T10:29:45.312Z", - "builderScriptVersion": "a340e3615002cb2a685587753fbf8a8ddaf25a9067d5d14b6e58b01cfb07cc49", + "builtAt": "2026-09-23T10:00:32.703Z", + "builderScriptVersion": "a4aa29b672f02d72c9751b491fd5cd5ac89fa171a5407910ac8eca4b00b3776a", "agentToolResolvedVersions": { "@anthropic-ai/claude-code": "2.1.267", "@openai/codex": "0.154.0", @@ -10606,9 +13527,9 @@ "agent-browser": "0.37.1" }, "validationStatus": "passed", - "notes": "cmux devbox epoch 2026-09-10-r2 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 0e0ac43d99, identity bound to the instance id, no create-time bootstrap. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-e2b0a76aa0f343119bf5fc9afefa88df, md=sh-367fa91262564869b6fd014219cc8d8a, lgx=sh-4a796c5630d246f0897a95cc54a96499, lg=sh-847c68063c2244f39d9dbada244f2ec0, xl=sh-20423b02aeba4133bc4917f782d976bf, 2xl=sh-7e44b33c25c140c7a7842e79a45a7310.", - "cmuxTuiCommit": "0e0ac43d9909ba5268ecc10b4cec77fe57fa2a10", - "cmuxTuiSha256": "e8d761e70ff9c8fb4164599be821e9ce71b70c3f12e0455660077007d85efa96", + "notes": "cmux devbox epoch 2026-09-10-r2 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 01dc721bca, identity bound to the instance id, no create-time bootstrap. Validated 2026-09-23 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-3792b42d04df4525b2541a6b705c27b1, lg=sh-52ac4212fb7343a3a1a04f062e6dcfdb, md=sh-03412524e43b4ed4b6f53886edac7156, lgx=sh-fda010eabcbb446ba69b7f2705cc62db, xl=sh-cd15cab1d39a4fca8abce4627e86112e, 2xl=sh-f9834276babb4a76b27f72c735d81af0.", + "cmuxTuiCommit": "01dc721bcac54383e263877bd201973c11054d8d", + "cmuxTuiSha256": "7c3468cf53016dec8af94b620a4d4807a4d53ad003567cadc1def6f637dfb42a", "defaultForKind": true, "size": { "name": "xl", @@ -10620,20 +13541,20 @@ }, { "provider": "freestyle", - "version": "freestyle-cmux-devbox-20260921-102554-2xl", - "imageId": "sh-7e44b33c25c140c7a7842e79a45a7310", + "version": "freestyle-cmux-devbox-pr13299-fastboot6-2xl", + "imageId": "sh-f9834276babb4a76b27f72c735d81af0", "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", "kind": "desktop", "cmuxdRemoteCommit": "none-cmux-tui", - "repoCommit": "58bb2439cc7e105af24352f82c50b349ce752f89", + "repoCommit": "8291e529d7f27fea3b580c851648f5a8dc01a27b", "epoch": "2026-09-10-r2", "devboxSource": { "layers": "desktop", - "digest": "9d2903316ead45416e55709bb095a3da5ff547067080b021f51489e7fadd93f1", + "digest": "acdc46a12fd49a88212c4c5e04b0e288d31c72252d43d470595f1c92794f4ad3", "schema": 2 }, - "builtAt": "2026-09-21T10:29:45.312Z", - "builderScriptVersion": "a340e3615002cb2a685587753fbf8a8ddaf25a9067d5d14b6e58b01cfb07cc49", + "builtAt": "2026-09-23T10:00:32.703Z", + "builderScriptVersion": "a4aa29b672f02d72c9751b491fd5cd5ac89fa171a5407910ac8eca4b00b3776a", "agentToolResolvedVersions": { "@anthropic-ai/claude-code": "2.1.267", "@openai/codex": "0.154.0", @@ -10642,9 +13563,9 @@ "agent-browser": "0.37.1" }, "validationStatus": "passed", - "notes": "cmux devbox epoch 2026-09-10-r2 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 0e0ac43d99, identity bound to the instance id, no create-time bootstrap. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-e2b0a76aa0f343119bf5fc9afefa88df, md=sh-367fa91262564869b6fd014219cc8d8a, lgx=sh-4a796c5630d246f0897a95cc54a96499, lg=sh-847c68063c2244f39d9dbada244f2ec0, xl=sh-20423b02aeba4133bc4917f782d976bf, 2xl=sh-7e44b33c25c140c7a7842e79a45a7310.", - "cmuxTuiCommit": "0e0ac43d9909ba5268ecc10b4cec77fe57fa2a10", - "cmuxTuiSha256": "e8d761e70ff9c8fb4164599be821e9ce71b70c3f12e0455660077007d85efa96", + "notes": "cmux devbox epoch 2026-09-10-r2 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 01dc721bca, identity bound to the instance id, no create-time bootstrap. Validated 2026-09-23 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-3792b42d04df4525b2541a6b705c27b1, lg=sh-52ac4212fb7343a3a1a04f062e6dcfdb, md=sh-03412524e43b4ed4b6f53886edac7156, lgx=sh-fda010eabcbb446ba69b7f2705cc62db, xl=sh-cd15cab1d39a4fca8abce4627e86112e, 2xl=sh-f9834276babb4a76b27f72c735d81af0.", + "cmuxTuiCommit": "01dc721bcac54383e263877bd201973c11054d8d", + "cmuxTuiSha256": "7c3468cf53016dec8af94b620a4d4807a4d53ad003567cadc1def6f637dfb42a", "defaultForKind": true, "size": { "name": "2xl", @@ -10656,20 +13577,20 @@ }, { "provider": "freestyle", - "version": "freestyle-cmux-devbox-20260921-102554-sm-base", - "imageId": "sh-e2b0a76aa0f343119bf5fc9afefa88df", + "version": "freestyle-cmux-devbox-pr13299-fastboot6-sm-base", + "imageId": "sh-3792b42d04df4525b2541a6b705c27b1", "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", "kind": "base", "cmuxdRemoteCommit": "none-cmux-tui", - "repoCommit": "58bb2439cc7e105af24352f82c50b349ce752f89", + "repoCommit": "8291e529d7f27fea3b580c851648f5a8dc01a27b", "epoch": "2026-09-10-r2", "devboxSource": { "layers": "desktop", - "digest": "9d2903316ead45416e55709bb095a3da5ff547067080b021f51489e7fadd93f1", + "digest": "acdc46a12fd49a88212c4c5e04b0e288d31c72252d43d470595f1c92794f4ad3", "schema": 2 }, - "builtAt": "2026-09-21T10:29:45.312Z", - "builderScriptVersion": "a340e3615002cb2a685587753fbf8a8ddaf25a9067d5d14b6e58b01cfb07cc49", + "builtAt": "2026-09-23T10:00:32.703Z", + "builderScriptVersion": "a4aa29b672f02d72c9751b491fd5cd5ac89fa171a5407910ac8eca4b00b3776a", "agentToolResolvedVersions": { "@anthropic-ai/claude-code": "2.1.267", "@openai/codex": "0.154.0", @@ -10678,9 +13599,9 @@ "agent-browser": "0.37.1" }, "validationStatus": "passed", - "notes": "cmux devbox epoch 2026-09-10-r2 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 0e0ac43d99, identity bound to the instance id, no create-time bootstrap. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-e2b0a76aa0f343119bf5fc9afefa88df, md=sh-367fa91262564869b6fd014219cc8d8a, lgx=sh-4a796c5630d246f0897a95cc54a96499, lg=sh-847c68063c2244f39d9dbada244f2ec0, xl=sh-20423b02aeba4133bc4917f782d976bf, 2xl=sh-7e44b33c25c140c7a7842e79a45a7310.", - "cmuxTuiCommit": "0e0ac43d9909ba5268ecc10b4cec77fe57fa2a10", - "cmuxTuiSha256": "e8d761e70ff9c8fb4164599be821e9ce71b70c3f12e0455660077007d85efa96", + "notes": "cmux devbox epoch 2026-09-10-r2 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 01dc721bca, identity bound to the instance id, no create-time bootstrap. Validated 2026-09-23 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-3792b42d04df4525b2541a6b705c27b1, lg=sh-52ac4212fb7343a3a1a04f062e6dcfdb, md=sh-03412524e43b4ed4b6f53886edac7156, lgx=sh-fda010eabcbb446ba69b7f2705cc62db, xl=sh-cd15cab1d39a4fca8abce4627e86112e, 2xl=sh-f9834276babb4a76b27f72c735d81af0.", + "cmuxTuiCommit": "01dc721bcac54383e263877bd201973c11054d8d", + "cmuxTuiSha256": "7c3468cf53016dec8af94b620a4d4807a4d53ad003567cadc1def6f637dfb42a", "defaultForKind": true, "size": { "name": "sm", @@ -10693,20 +13614,20 @@ }, { "provider": "freestyle", - "version": "freestyle-cmux-devbox-20260921-102554-md-base", - "imageId": "sh-367fa91262564869b6fd014219cc8d8a", + "version": "freestyle-cmux-devbox-pr13299-fastboot6-md-base", + "imageId": "sh-03412524e43b4ed4b6f53886edac7156", "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", "kind": "base", "cmuxdRemoteCommit": "none-cmux-tui", - "repoCommit": "58bb2439cc7e105af24352f82c50b349ce752f89", + "repoCommit": "8291e529d7f27fea3b580c851648f5a8dc01a27b", "epoch": "2026-09-10-r2", "devboxSource": { "layers": "desktop", - "digest": "9d2903316ead45416e55709bb095a3da5ff547067080b021f51489e7fadd93f1", + "digest": "acdc46a12fd49a88212c4c5e04b0e288d31c72252d43d470595f1c92794f4ad3", "schema": 2 }, - "builtAt": "2026-09-21T10:29:45.312Z", - "builderScriptVersion": "a340e3615002cb2a685587753fbf8a8ddaf25a9067d5d14b6e58b01cfb07cc49", + "builtAt": "2026-09-23T10:00:32.703Z", + "builderScriptVersion": "a4aa29b672f02d72c9751b491fd5cd5ac89fa171a5407910ac8eca4b00b3776a", "agentToolResolvedVersions": { "@anthropic-ai/claude-code": "2.1.267", "@openai/codex": "0.154.0", @@ -10715,9 +13636,9 @@ "agent-browser": "0.37.1" }, "validationStatus": "passed", - "notes": "cmux devbox epoch 2026-09-10-r2 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 0e0ac43d99, identity bound to the instance id, no create-time bootstrap. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-e2b0a76aa0f343119bf5fc9afefa88df, md=sh-367fa91262564869b6fd014219cc8d8a, lgx=sh-4a796c5630d246f0897a95cc54a96499, lg=sh-847c68063c2244f39d9dbada244f2ec0, xl=sh-20423b02aeba4133bc4917f782d976bf, 2xl=sh-7e44b33c25c140c7a7842e79a45a7310.", - "cmuxTuiCommit": "0e0ac43d9909ba5268ecc10b4cec77fe57fa2a10", - "cmuxTuiSha256": "e8d761e70ff9c8fb4164599be821e9ce71b70c3f12e0455660077007d85efa96", + "notes": "cmux devbox epoch 2026-09-10-r2 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 01dc721bca, identity bound to the instance id, no create-time bootstrap. Validated 2026-09-23 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-3792b42d04df4525b2541a6b705c27b1, lg=sh-52ac4212fb7343a3a1a04f062e6dcfdb, md=sh-03412524e43b4ed4b6f53886edac7156, lgx=sh-fda010eabcbb446ba69b7f2705cc62db, xl=sh-cd15cab1d39a4fca8abce4627e86112e, 2xl=sh-f9834276babb4a76b27f72c735d81af0.", + "cmuxTuiCommit": "01dc721bcac54383e263877bd201973c11054d8d", + "cmuxTuiSha256": "7c3468cf53016dec8af94b620a4d4807a4d53ad003567cadc1def6f637dfb42a", "defaultForKind": true, "size": { "name": "md", @@ -10729,20 +13650,20 @@ }, { "provider": "freestyle", - "version": "freestyle-cmux-devbox-20260921-102554-lg-base", - "imageId": "sh-847c68063c2244f39d9dbada244f2ec0", + "version": "freestyle-cmux-devbox-pr13299-fastboot6-lg-base", + "imageId": "sh-52ac4212fb7343a3a1a04f062e6dcfdb", "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", "kind": "base", "cmuxdRemoteCommit": "none-cmux-tui", - "repoCommit": "58bb2439cc7e105af24352f82c50b349ce752f89", + "repoCommit": "8291e529d7f27fea3b580c851648f5a8dc01a27b", "epoch": "2026-09-10-r2", "devboxSource": { "layers": "desktop", - "digest": "9d2903316ead45416e55709bb095a3da5ff547067080b021f51489e7fadd93f1", + "digest": "acdc46a12fd49a88212c4c5e04b0e288d31c72252d43d470595f1c92794f4ad3", "schema": 2 }, - "builtAt": "2026-09-21T10:29:45.312Z", - "builderScriptVersion": "a340e3615002cb2a685587753fbf8a8ddaf25a9067d5d14b6e58b01cfb07cc49", + "builtAt": "2026-09-23T10:00:32.703Z", + "builderScriptVersion": "a4aa29b672f02d72c9751b491fd5cd5ac89fa171a5407910ac8eca4b00b3776a", "agentToolResolvedVersions": { "@anthropic-ai/claude-code": "2.1.267", "@openai/codex": "0.154.0", @@ -10751,9 +13672,9 @@ "agent-browser": "0.37.1" }, "validationStatus": "passed", - "notes": "cmux devbox epoch 2026-09-10-r2 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 0e0ac43d99, identity bound to the instance id, no create-time bootstrap. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-e2b0a76aa0f343119bf5fc9afefa88df, md=sh-367fa91262564869b6fd014219cc8d8a, lgx=sh-4a796c5630d246f0897a95cc54a96499, lg=sh-847c68063c2244f39d9dbada244f2ec0, xl=sh-20423b02aeba4133bc4917f782d976bf, 2xl=sh-7e44b33c25c140c7a7842e79a45a7310.", - "cmuxTuiCommit": "0e0ac43d9909ba5268ecc10b4cec77fe57fa2a10", - "cmuxTuiSha256": "e8d761e70ff9c8fb4164599be821e9ce71b70c3f12e0455660077007d85efa96", + "notes": "cmux devbox epoch 2026-09-10-r2 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 01dc721bca, identity bound to the instance id, no create-time bootstrap. Validated 2026-09-23 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-3792b42d04df4525b2541a6b705c27b1, lg=sh-52ac4212fb7343a3a1a04f062e6dcfdb, md=sh-03412524e43b4ed4b6f53886edac7156, lgx=sh-fda010eabcbb446ba69b7f2705cc62db, xl=sh-cd15cab1d39a4fca8abce4627e86112e, 2xl=sh-f9834276babb4a76b27f72c735d81af0.", + "cmuxTuiCommit": "01dc721bcac54383e263877bd201973c11054d8d", + "cmuxTuiSha256": "7c3468cf53016dec8af94b620a4d4807a4d53ad003567cadc1def6f637dfb42a", "defaultForKind": true, "size": { "name": "lg", @@ -10765,20 +13686,20 @@ }, { "provider": "freestyle", - "version": "freestyle-cmux-devbox-20260921-102554-lgx-base", - "imageId": "sh-4a796c5630d246f0897a95cc54a96499", + "version": "freestyle-cmux-devbox-pr13299-fastboot6-lgx-base", + "imageId": "sh-fda010eabcbb446ba69b7f2705cc62db", "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", "kind": "base", "cmuxdRemoteCommit": "none-cmux-tui", - "repoCommit": "58bb2439cc7e105af24352f82c50b349ce752f89", + "repoCommit": "8291e529d7f27fea3b580c851648f5a8dc01a27b", "epoch": "2026-09-10-r2", "devboxSource": { "layers": "desktop", - "digest": "9d2903316ead45416e55709bb095a3da5ff547067080b021f51489e7fadd93f1", + "digest": "acdc46a12fd49a88212c4c5e04b0e288d31c72252d43d470595f1c92794f4ad3", "schema": 2 }, - "builtAt": "2026-09-21T10:29:45.312Z", - "builderScriptVersion": "a340e3615002cb2a685587753fbf8a8ddaf25a9067d5d14b6e58b01cfb07cc49", + "builtAt": "2026-09-23T10:00:32.703Z", + "builderScriptVersion": "a4aa29b672f02d72c9751b491fd5cd5ac89fa171a5407910ac8eca4b00b3776a", "agentToolResolvedVersions": { "@anthropic-ai/claude-code": "2.1.267", "@openai/codex": "0.154.0", @@ -10787,9 +13708,9 @@ "agent-browser": "0.37.1" }, "validationStatus": "passed", - "notes": "cmux devbox epoch 2026-09-10-r2 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 0e0ac43d99, identity bound to the instance id, no create-time bootstrap. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-e2b0a76aa0f343119bf5fc9afefa88df, md=sh-367fa91262564869b6fd014219cc8d8a, lgx=sh-4a796c5630d246f0897a95cc54a96499, lg=sh-847c68063c2244f39d9dbada244f2ec0, xl=sh-20423b02aeba4133bc4917f782d976bf, 2xl=sh-7e44b33c25c140c7a7842e79a45a7310.", - "cmuxTuiCommit": "0e0ac43d9909ba5268ecc10b4cec77fe57fa2a10", - "cmuxTuiSha256": "e8d761e70ff9c8fb4164599be821e9ce71b70c3f12e0455660077007d85efa96", + "notes": "cmux devbox epoch 2026-09-10-r2 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 01dc721bca, identity bound to the instance id, no create-time bootstrap. Validated 2026-09-23 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-3792b42d04df4525b2541a6b705c27b1, lg=sh-52ac4212fb7343a3a1a04f062e6dcfdb, md=sh-03412524e43b4ed4b6f53886edac7156, lgx=sh-fda010eabcbb446ba69b7f2705cc62db, xl=sh-cd15cab1d39a4fca8abce4627e86112e, 2xl=sh-f9834276babb4a76b27f72c735d81af0.", + "cmuxTuiCommit": "01dc721bcac54383e263877bd201973c11054d8d", + "cmuxTuiSha256": "7c3468cf53016dec8af94b620a4d4807a4d53ad003567cadc1def6f637dfb42a", "defaultForKind": true, "size": { "name": "lgx", @@ -10800,20 +13721,20 @@ }, { "provider": "freestyle", - "version": "freestyle-cmux-devbox-20260921-102554-xl-base", - "imageId": "sh-20423b02aeba4133bc4917f782d976bf", + "version": "freestyle-cmux-devbox-pr13299-fastboot6-xl-base", + "imageId": "sh-cd15cab1d39a4fca8abce4627e86112e", "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", "kind": "base", "cmuxdRemoteCommit": "none-cmux-tui", - "repoCommit": "58bb2439cc7e105af24352f82c50b349ce752f89", + "repoCommit": "8291e529d7f27fea3b580c851648f5a8dc01a27b", "epoch": "2026-09-10-r2", "devboxSource": { "layers": "desktop", - "digest": "9d2903316ead45416e55709bb095a3da5ff547067080b021f51489e7fadd93f1", + "digest": "acdc46a12fd49a88212c4c5e04b0e288d31c72252d43d470595f1c92794f4ad3", "schema": 2 }, - "builtAt": "2026-09-21T10:29:45.312Z", - "builderScriptVersion": "a340e3615002cb2a685587753fbf8a8ddaf25a9067d5d14b6e58b01cfb07cc49", + "builtAt": "2026-09-23T10:00:32.703Z", + "builderScriptVersion": "a4aa29b672f02d72c9751b491fd5cd5ac89fa171a5407910ac8eca4b00b3776a", "agentToolResolvedVersions": { "@anthropic-ai/claude-code": "2.1.267", "@openai/codex": "0.154.0", @@ -10822,9 +13743,9 @@ "agent-browser": "0.37.1" }, "validationStatus": "passed", - "notes": "cmux devbox epoch 2026-09-10-r2 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 0e0ac43d99, identity bound to the instance id, no create-time bootstrap. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-e2b0a76aa0f343119bf5fc9afefa88df, md=sh-367fa91262564869b6fd014219cc8d8a, lgx=sh-4a796c5630d246f0897a95cc54a96499, lg=sh-847c68063c2244f39d9dbada244f2ec0, xl=sh-20423b02aeba4133bc4917f782d976bf, 2xl=sh-7e44b33c25c140c7a7842e79a45a7310.", - "cmuxTuiCommit": "0e0ac43d9909ba5268ecc10b4cec77fe57fa2a10", - "cmuxTuiSha256": "e8d761e70ff9c8fb4164599be821e9ce71b70c3f12e0455660077007d85efa96", + "notes": "cmux devbox epoch 2026-09-10-r2 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 01dc721bca, identity bound to the instance id, no create-time bootstrap. Validated 2026-09-23 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-3792b42d04df4525b2541a6b705c27b1, lg=sh-52ac4212fb7343a3a1a04f062e6dcfdb, md=sh-03412524e43b4ed4b6f53886edac7156, lgx=sh-fda010eabcbb446ba69b7f2705cc62db, xl=sh-cd15cab1d39a4fca8abce4627e86112e, 2xl=sh-f9834276babb4a76b27f72c735d81af0.", + "cmuxTuiCommit": "01dc721bcac54383e263877bd201973c11054d8d", + "cmuxTuiSha256": "7c3468cf53016dec8af94b620a4d4807a4d53ad003567cadc1def6f637dfb42a", "defaultForKind": true, "size": { "name": "xl", @@ -10836,20 +13757,20 @@ }, { "provider": "freestyle", - "version": "freestyle-cmux-devbox-20260921-102554-2xl-base", - "imageId": "sh-7e44b33c25c140c7a7842e79a45a7310", + "version": "freestyle-cmux-devbox-pr13299-fastboot6-2xl-base", + "imageId": "sh-f9834276babb4a76b27f72c735d81af0", "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", "kind": "base", "cmuxdRemoteCommit": "none-cmux-tui", - "repoCommit": "58bb2439cc7e105af24352f82c50b349ce752f89", + "repoCommit": "8291e529d7f27fea3b580c851648f5a8dc01a27b", "epoch": "2026-09-10-r2", "devboxSource": { "layers": "desktop", - "digest": "9d2903316ead45416e55709bb095a3da5ff547067080b021f51489e7fadd93f1", + "digest": "acdc46a12fd49a88212c4c5e04b0e288d31c72252d43d470595f1c92794f4ad3", "schema": 2 }, - "builtAt": "2026-09-21T10:29:45.312Z", - "builderScriptVersion": "a340e3615002cb2a685587753fbf8a8ddaf25a9067d5d14b6e58b01cfb07cc49", + "builtAt": "2026-09-23T10:00:32.703Z", + "builderScriptVersion": "a4aa29b672f02d72c9751b491fd5cd5ac89fa171a5407910ac8eca4b00b3776a", "agentToolResolvedVersions": { "@anthropic-ai/claude-code": "2.1.267", "@openai/codex": "0.154.0", @@ -10858,9 +13779,9 @@ "agent-browser": "0.37.1" }, "validationStatus": "passed", - "notes": "cmux devbox epoch 2026-09-10-r2 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 0e0ac43d99, identity bound to the instance id, no create-time bootstrap. Validated 2026-09-21 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-e2b0a76aa0f343119bf5fc9afefa88df, md=sh-367fa91262564869b6fd014219cc8d8a, lgx=sh-4a796c5630d246f0897a95cc54a96499, lg=sh-847c68063c2244f39d9dbada244f2ec0, xl=sh-20423b02aeba4133bc4917f782d976bf, 2xl=sh-7e44b33c25c140c7a7842e79a45a7310.", - "cmuxTuiCommit": "0e0ac43d9909ba5268ecc10b4cec77fe57fa2a10", - "cmuxTuiSha256": "e8d761e70ff9c8fb4164599be821e9ce71b70c3f12e0455660077007d85efa96", + "notes": "cmux devbox epoch 2026-09-10-r2 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 01dc721bca, identity bound to the instance id, no create-time bootstrap. Validated 2026-09-23 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-3792b42d04df4525b2541a6b705c27b1, lg=sh-52ac4212fb7343a3a1a04f062e6dcfdb, md=sh-03412524e43b4ed4b6f53886edac7156, lgx=sh-fda010eabcbb446ba69b7f2705cc62db, xl=sh-cd15cab1d39a4fca8abce4627e86112e, 2xl=sh-f9834276babb4a76b27f72c735d81af0.", + "cmuxTuiCommit": "01dc721bcac54383e263877bd201973c11054d8d", + "cmuxTuiSha256": "7c3468cf53016dec8af94b620a4d4807a4d53ad003567cadc1def6f637dfb42a", "defaultForKind": true, "size": { "name": "2xl", diff --git a/web/services/vms/vmPrincipal.ts b/web/services/vms/vmPrincipal.ts index c26d589e5bca..90618e210563 100644 --- a/web/services/vms/vmPrincipal.ts +++ b/web/services/vms/vmPrincipal.ts @@ -98,7 +98,8 @@ export async function requireVmPrincipal( : await authenticateRequestRouteToken(request); if (!auth.ok) return { ok: false, reason: auth.reason }; const identity = auth.identity; - if (identity.vmId === null) return { ok: false, reason: "vm_bound_token_required" }; + // A chatmux machine is not a Cloud VM; it has no cloud_vms row. + if (identity.vmId === null || identity.machine === "chatmux") return { ok: false, reason: "vm_bound_token_required" }; const row = await dependencies.loadVm(identity.vmId); if (!row) return { ok: false, reason: "vm_not_found" }; if (!vmPrincipalOwns(row, identity)) return { ok: false, reason: "vm_owner_mismatch" }; diff --git a/web/services/vms/workflows.ts b/web/services/vms/workflows.ts index 5e207ca027b2..ae530ae15197 100644 --- a/web/services/vms/workflows.ts +++ b/web/services/vms/workflows.ts @@ -9,6 +9,7 @@ import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Either from "effect/Either"; import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; import * as ManagedRuntime from "effect/ManagedRuntime"; import * as Option from "effect/Option"; import { eq } from "drizzle-orm"; @@ -112,6 +113,7 @@ import { type CloudVmRow, type VmRepositoryShape, type VmResizeReservation, + type VmUsageEventInput, } from "./repository"; import { measureVmEffect, type VmTimingSink } from "./timings"; import { guestPromptInstallCommand, vmPromptIdentity } from "./guestPrompt"; @@ -159,6 +161,13 @@ export type VmEntry = { /** The machine's address on its owner's private network, when it has one. */ readonly addressIpv4: string | null; readonly addressIpv6: string | null; + /** + * The image's cmux-tui attach contract (`"snapshot-v2"`: baked daemon with + * the trusted private-network listener). With a private address, it is + * everything a client needs to dial the daemon, so the create response can + * carry it and New Machine skips the separate attach request. + */ + readonly cmuxTuiContract: string | null; }; export type BaseVmEntry = VmEntry & { @@ -579,6 +588,13 @@ type CreateVmInput = { */ readonly modelPlane?: VmModelPlaneProvisioner; readonly timing?: VmTimingSink; + /** + * Runs best-effort work after the response has been sent (the route passes + * `runAfterResponse`). createVm uses it only for the `vm.created` ledger + * row, which is written after the machine is already usable and whose + * failure is already ignored. Without it the row is written inline. + */ + readonly deferAfterResponse?: (work: Effect.Effect) => void; }; function createVmBeginInput(input: CreateVmInput): CreateVmInput { @@ -655,7 +671,15 @@ export function createVm(input: CreateVmInput): Effect.Effect failed/created + // order and the row is written before the response leaves. + const requestedEvents = yield* Effect.fork( + recordCreateRequestedEvents(repo, input, create.vm, creditReservation), + ); + const awaitRequestedEvents = Fiber.join(requestedEvents); const materials = yield* measureVmEffect( input.timing, @@ -663,7 +687,7 @@ export function createVm(input: CreateVmInput): Effect.Effect - Effect.all([ + awaitRequestedEvents.pipe(Effect.andThen(Effect.all([ refundCredit(billing, repo, create.vm, creditReservation), repo.markCreateFailed({ id: create.vm.id, @@ -684,7 +708,7 @@ export function createVm(input: CreateVmInput): Effect.Effect Effect.void)) + ], { discard: true })), Effect.catchAll(() => Effect.void)) ), ); @@ -712,7 +736,7 @@ export function createVm(input: CreateVmInput): Effect.Effect - Effect.all([ + awaitRequestedEvents.pipe(Effect.andThen(Effect.all([ revokeModelPlane(input.modelPlane, create.vm.id), refundCredit(billing, repo, create.vm, creditReservation), repo.markCreateFailed({ @@ -734,7 +758,7 @@ export function createVm(input: CreateVmInput): Effect.Effect Effect.void)) + ], { discard: true })), Effect.catchAll(() => Effect.void)) ), ); @@ -751,6 +775,7 @@ export function createVm(input: CreateVmInput): Effect.Effect Effect.gen(function* () { + yield* awaitRequestedEvents; yield* rollbackProviderCreate(providers, input.provider, handle); yield* revokeModelPlane(input.modelPlane, create.vm.id); yield* refundCredit(billing, repo, create.vm, creditReservation); @@ -771,12 +796,59 @@ export function createVm(input: CreateVmInput): Effect.Effect Effect.void)), + ); + } else { + yield* recordCreateSuccessEvents(repo, input, running); + } + yield* schedulePromptIdentityPush(providers, running, input.deferAfterResponse); return vmEntryFromRow(running); }); } +/** + * Publishes a new machine's prompt name (`cmux@`) into the guest once, + * after the create response, with the same command a rename uses. + * + * The guest also pulls its name from https://reflection.cmux.internal/name + * through the Freestyle edge, but the edge can only reach a public origin: a + * private backend (every tailnet dev stack) never answers it, so dev machines + * kept the baked `cmux@cmux`. This push is the path that works everywhere. + * It is never on New Machine's critical path (NO-WORK INVARIANT in + * drivers/freestyle.ts): it runs after the response when the route provides + * the hook, detached otherwise, and a failure leaves reflection to publish + * the name. cmux-prompt-sync redraws the prompt when the name file changes. + */ +function schedulePromptIdentityPush( + providers: VmProviderGatewayShape, + row: CloudVmRow, + defer: ((work: Effect.Effect) => void) | undefined, +): Effect.Effect { + const providerVmId = row.providerVmId; + if (!providerVmId || row.status !== "running") return Effect.void; + const push = Effect.suspend(() => + providers.exec(row.provider, providerVmId, guestPromptInstallCommand(vmPromptIdentity(row)), { + timeoutMs: 10_000, + providerMetadata: row.providerMetadata, + }) + ).pipe( + Effect.flatMap((result) => result.exitCode === 0 ? Effect.void : Effect.fail(new Error(`prompt push exited ${result.exitCode}`))), + Effect.catchAllCause((cause) => Effect.logWarning("Cloud prompt push deferred to reflection", { vmId: row.id, cause })), + ); + if (defer) { + defer(push); + return Effect.void; + } + return Effect.asVoid(Effect.forkDaemon(push)); +} + /** * Runs the injected model-plane provisioning for a row that now exists. The * gateway rejects with VmModelPlaneError; anything else is treated as @@ -1071,6 +1143,7 @@ function finishBaseCreate( ); yield* recordCreateSuccessEvents(repo, { ...input, idempotencyKey, origin: "base" }, running); + yield* schedulePromptIdentityPush(providers, running, undefined); yield* repo.recordUsageEvent({ userId: input.userId, billingTeamId: input.billingTeamId, @@ -4058,44 +4131,50 @@ function recordCreateRequestedEvents( export type VmCreateOrigin = "create" | "restore" | "fork" | "base"; +type CreateSuccessEventInput = { + readonly idempotencyKey?: string; + readonly timing?: VmTimingSink; + readonly origin?: VmCreateOrigin; + readonly memoryMb?: number; + readonly persistentHome?: boolean; + readonly perMachineHome?: boolean; + readonly imageSize?: CreateOptions["imageSize"]; +}; + +function createSuccessUsageEvents(input: CreateSuccessEventInput, running: CloudVmRow): VmUsageEventInput[] { + return [ + { + userId: running.userId, + billingTeamId: running.billingTeamId, + billingPlanId: running.billingPlanId, + vmId: running.id, + eventType: "vm.created", + provider: running.provider, + imageId: running.imageId, + metadata: { + idempotencyKeySet: !!input.idempotencyKey, + imageVersion: running.imageVersion, + // Machine shape and origin, so analytics can size the fleet by plan + // and tell a fresh create from a restore, fork or base open. + origin: input.origin ?? "create", + ...(input.memoryMb !== undefined ? { memoryMb: input.memoryMb } : {}), + ...(input.imageSize ? { imageSize: input.imageSize.name } : {}), + ...(input.persistentHome !== undefined ? { persistentHome: input.persistentHome } : {}), + ...(input.perMachineHome !== undefined ? { perMachineHome: input.perMachineHome } : {}), + }, + }, + ]; +} + function recordCreateSuccessEvents( repo: VmRepositoryShape, - input: { - readonly idempotencyKey?: string; - readonly timing?: VmTimingSink; - readonly origin?: VmCreateOrigin; - readonly memoryMb?: number; - readonly persistentHome?: boolean; - readonly perMachineHome?: boolean; - readonly imageSize?: CreateOptions["imageSize"]; - }, + input: CreateSuccessEventInput, running: CloudVmRow, ) { return measureVmEffect( input.timing, "usage_events", - repo.recordUsageEvents([ - { - userId: running.userId, - billingTeamId: running.billingTeamId, - billingPlanId: running.billingPlanId, - vmId: running.id, - eventType: "vm.created", - provider: running.provider, - imageId: running.imageId, - metadata: { - idempotencyKeySet: !!input.idempotencyKey, - imageVersion: running.imageVersion, - // Machine shape and origin, so analytics can size the fleet by plan - // and tell a fresh create from a restore, fork or base open. - origin: input.origin ?? "create", - ...(input.memoryMb !== undefined ? { memoryMb: input.memoryMb } : {}), - ...(input.imageSize ? { imageSize: input.imageSize.name } : {}), - ...(input.persistentHome !== undefined ? { persistentHome: input.persistentHome } : {}), - ...(input.perMachineHome !== undefined ? { perMachineHome: input.perMachineHome } : {}), - }, - }, - ]).pipe(Effect.catchAll(() => Effect.void)), + repo.recordUsageEvents(createSuccessUsageEvents(input, running)).pipe(Effect.catchAll(() => Effect.void)), ); } @@ -4277,6 +4356,7 @@ function vmEntryFromRow(row: CloudVmRow): VmEntry { slug: row.slug ?? null, addressIpv4: typeof addressIpv4 === "string" && addressIpv4 ? addressIpv4 : null, addressIpv6: typeof addressIpv6 === "string" && addressIpv6 ? addressIpv6 : null, + cmuxTuiContract: typeof metadata["cmuxTuiContract"] === "string" ? metadata["cmuxTuiContract"] : null, }; } diff --git a/web/tests/coderouter-chatmux-vm-token.test.ts b/web/tests/coderouter-chatmux-vm-token.test.ts new file mode 100644 index 000000000000..af5a88c8e392 --- /dev/null +++ b/web/tests/coderouter-chatmux-vm-token.test.ts @@ -0,0 +1,184 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { createLocalJWKSet, exportJWK, generateKeyPair, SignJWT } from "jose"; +import { sql } from "drizzle-orm"; +import { PgDialect } from "drizzle-orm/pg-core"; +import { + CHATMUX_VM_AUTHORIZATION_HEADER, + CHATMUX_VM_TOKEN_MAX_LIFETIME_SECONDS, + chatmuxConfig, + verifyChatmuxVmToken, +} from "../services/coderouter/chatmuxVmToken"; +import { authenticateRequestRouteToken } from "../services/coderouter/routeTokenAuth"; +import { resolveCoderouterControlContext } from "../services/coderouter/requestContext"; +import { requireVmPrincipal } from "../services/vms/vmPrincipal"; +import { + accountAccessForIdentity, + accountAccessPredicate, + scopedSessionKey, +} from "../services/coderouter/accountAccess"; + +const ISSUER = "https://chatmux.dev"; +const now = Math.floor(Date.now() / 1000); +/** The verifier's clock, pinned to the tokens' issue time. */ +const at = new Date(now * 1000); +const signing = await generateKeyPair("ES256"); +const other = await generateKeyPair("ES256"); +const publicJwk = { ...(await exportJWK(signing.publicKey)), kid: "hsm-1", alg: "ES256", use: "sig" }; +const config = { keys: createLocalJWKSet({ keys: [publicJwk] }), issuers: [ISSUER] }; + +const claims = { sub: "vm:vm-123", jti: "tok-1", team_id: "team-a", owner_id: "user-1", role: "dev" }; +async function token( + over: Record = {}, + header: Record = {}, + key = signing.privateKey, +) { + return await new SignJWT({ iss: ISSUER, aud: ["coderouter", "chatmux"], iat: now, exp: now + 3600, ...claims, ...over }) + .setProtectedHeader({ alg: "ES256", kid: "hsm-1", typ: "JWT", ...header }) + .sign(key); +} + +describe("chatmux VM tokens", () => { + test("accepts a token signed by the chatmux key with the expected claims", async () => { + expect(await verifyChatmuxVmToken(await token(), config, at)).toEqual({ ...claims, iss: ISSUER } as never); + }); + + test("rejects other keys, issuers, audiences, lifetimes, roles, and missing claims", async () => { + const bad = [ + await token({}, {}, other.privateKey), + await token({ iss: "https://evil.example" }), + await token({ aud: "chatmux" }), + await token({ exp: now - 120 }), + await token({ iat: now - 10, exp: now - 10 + CHATMUX_VM_TOKEN_MAX_LIFETIME_SECONDS + 60 }), + await token({ role: "admin" }), + await token({ sub: "user-1" }), + await token({ team_id: "" }), + await token({ owner_id: undefined }), + await token({ jti: undefined }), + await token({}, { kid: "unknown" }), + "a.b.c", + "x".repeat(5000), + ]; + for (const t of bad) expect(await verifyChatmuxVmToken(t, config, at)).toBe(null); + }); + + test("is off unless both a https JWKS address and issuers are configured", () => { + expect(chatmuxConfig({})).toBe(null); + expect(chatmuxConfig({ CODEROUTER_CHATMUX_JWKS_URL: "https://chatmux.dev/.well-known/chatmux-vm-jwks.json" })).toBe(null); + expect(chatmuxConfig({ CODEROUTER_CHATMUX_JWKS_URL: "http://chatmux.dev/jwks", CODEROUTER_CHATMUX_ISSUERS: ISSUER })).toBe(null); + expect( + chatmuxConfig({ + CODEROUTER_CHATMUX_JWKS_URL: "https://chatmux.dev/.well-known/chatmux-vm-jwks.json", + CODEROUTER_CHATMUX_ISSUERS: `${ISSUER}, https://preview.example`, + })?.issuers, + ).toEqual([ISSUER, "https://preview.example"]); + }); +}); + +describe("chatmux machines in request authentication", () => { + const originalFetch = globalThis.fetch; + const env = { ...process.env }; + afterEach(() => { + globalThis.fetch = originalFetch; + process.env = { ...env }; + }); + + function serveJwks() { + process.env.CODEROUTER_CHATMUX_JWKS_URL = "https://jwks.test/.well-known/chatmux-vm-jwks.json"; + process.env.CODEROUTER_CHATMUX_ISSUERS = ISSUER; + globalThis.fetch = (async () => + new Response(JSON.stringify({ keys: [publicJwk] }), { + headers: { "content-type": "application/json" }, + })) as unknown as typeof fetch; + } + + const request = (value: string, extra: Record = {}) => + new Request("https://coderouter.test/v1/models", { + headers: { [CHATMUX_VM_AUTHORIZATION_HEADER]: value, ...extra }, + }); + + test("a valid token is the whole identity: no database lookup, team-shared access", async () => { + serveJwks(); + let lookups = 0; + const result = await authenticateRequestRouteToken(request(`Bearer ${await token()}`), async () => { + lookups++; + return null; + }); + expect(lookups).toBe(0); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect([result.identity.teamId, result.identity.stackUserId, result.identity.vmId, result.identity.machine]) + .toEqual(["team-a", "user-1", "chatmux:vm-123", "chatmux"]); + expect(accountAccessForIdentity(result.identity)).toEqual({ + kind: "team-machine", + teamId: "team-a", + machineId: "chatmux:vm-123", + }); + }); + + test("a bad chatmux token fails closed and never falls back to another credential", async () => { + serveJwks(); + for (const value of ["", "Bearer", "Basic x", `Bearer ${await token({}, {}, other.privateKey)}`]) { + let lookups = 0; + const result = await authenticateRequestRouteToken( + request(value, { authorization: "Bearer crt_valid_cli", "x-coderouter-route-token": "crt_valid_cli" }), + async () => { + lookups++; + return { teamId: "t", stackUserId: "u" }; + }, + ); + expect(result).toEqual({ ok: false, reason: "invalid_route_token" }); + expect(lookups).toBe(0); + } + }); + + test("a chatmux machine cannot manage accounts, even with a route-token header added", async () => { + serveJwks(); + const resolved = await resolveCoderouterControlContext( + request(`Bearer ${await token()}`, { "x-coderouter-route-token": "crt_anything" }), + ); + expect(resolved.ok).toBe(false); + if (resolved.ok) return; + expect(resolved.response.status).toBe(403); + expect(await resolved.response.json()).toEqual({ error: "chatmux_machine_not_allowed" }); + }); + + test("a chatmux machine is not a Cloud VM principal", async () => { + serveJwks(); + let loads = 0; + const result = await requireVmPrincipal(request(`Bearer ${await token()}`), { + loadVm: async () => { + loads++; + return null; + }, + }); + expect(result).toEqual({ ok: false, reason: "vm_bound_token_required" }); + expect(loads).toBe(0); + }); +}); + +describe("team-machine account access", () => { + const dialect = new PgDialect(); + const account = { id: sql`a.id`, teamId: sql`a.team_id`, visibility: sql`a.visibility`, createdBy: sql`a.created_by` }; + + test("allows only accounts its team shares, never a private account", () => { + const q = dialect.sqlToQuery( + accountAccessPredicate(account, "native", { kind: "team-machine", teamId: "team-a", machineId: "m" }), + ); + expect(q.sql).toBe("(a.visibility = 'team' and a.team_id = $1)"); + expect(q.params).toEqual(["team-a"]); + }); + + test("an empty team id matches nothing", () => { + const q = dialect.sqlToQuery( + accountAccessPredicate(account, "native", { kind: "team-machine", teamId: "", machineId: "m" }), + ); + expect(q.sql).toBe("false"); + }); + + test("session keys of different machines never collide", () => { + const a = scopedSessionKey("k", { kind: "team-machine", teamId: "t", machineId: "chatmux:vm-1" }); + const b = scopedSessionKey("k", { kind: "team-machine", teamId: "t", machineId: "chatmux:vm-2" }); + expect(a).not.toBe(b); + expect(a).toBe(JSON.stringify(["team-machine", "chatmux:vm-1", "k"])); + }); +}); diff --git a/web/tests/dashboard-team-scope.test.tsx b/web/tests/dashboard-team-scope.test.tsx index 9d4f74ef7bf8..2f4ca8a55f22 100644 --- a/web/tests/dashboard-team-scope.test.tsx +++ b/web/tests/dashboard-team-scope.test.tsx @@ -1,6 +1,10 @@ import { beforeEach, describe, expect, mock, spyOn, test } from "bun:test"; import { renderToStaticMarkup } from "react-dom/server"; +import type { DashboardTeamScope } from "../app/[locale]/dashboard/dashboard-team-scope"; + +type ReadyTeamScope = Extract; + type Catalog = { selectedTeamId: string | null; teams: Array<{ @@ -14,30 +18,61 @@ type Catalog = { let catalog: Catalog | undefined; let pending = false; let searchTeam: string | null = null; +let legacyCookieScope: string | null = "team-2"; +const queryData = new Map(); +const routerReplace = mock(() => undefined); +const routerRefresh = mock(() => undefined); + +function queryKey(value: readonly unknown[]): string { + return JSON.stringify(value); +} + +const queryClient = { + getQueryData: (key: readonly unknown[]) => queryData.get(queryKey(key)), + setQueryData: (key: readonly unknown[], update: unknown) => { + const keyString = queryKey(key); + const current = queryData.get(keyString); + queryData.set(keyString, typeof update === "function" ? update(current) : update); + }, +}; mock.module("@tanstack/react-query", () => ({ useQuery: () => ({ data: catalog, isPending: pending }), - useQueryClient: () => ({ setQueryData: () => undefined }), + useQueryClient: () => queryClient, })); mock.module("next/navigation", () => ({ useSearchParams: () => ({ get: (name: string) => (name === "team" ? searchTeam : null), has: (name: string) => name === "team" && searchTeam !== null, + toString: () => searchTeam ? `team=${encodeURIComponent(searchTeam)}` : "", }), })); mock.module("@/i18n/navigation", () => ({ usePathname: () => "/dashboard/coderouter", - useRouter: () => ({ replace: () => undefined, refresh: () => undefined }), + useRouter: () => ({ replace: routerReplace, refresh: routerRefresh }), +})); + +mock.module("@/services/coderouter/organizationScope", () => ({ + clearCoderouterOrganizationScope: () => { + legacyCookieScope = null; + }, + coderouterOrganizationFromCookieHeader: () => legacyCookieScope, + persistCoderouterOrganizationScope: (_userId: string, organizationId: string) => { + legacyCookieScope = organizationId; + }, })); const { useDashboardTeamScope, parseTeamCatalog, selectedTeam, permittedTeams } = await import( "../app/[locale]/dashboard/dashboard-team-scope" ); +let probedScope: DashboardTeamScope | undefined; + function Probe({ userId }: { userId: string | null }) { const scope = useDashboardTeamScope(userId); + probedScope = scope; return (
       {scope.status === "ready"
@@ -45,6 +80,22 @@ function Probe({ userId }: { userId: string | null }) {
         : ""}
     
); +} + +// Read the probe through a function so control flow analysis keeps the +// declared type. Assigning `undefined` below narrows `probedScope` for the +// rest of the function, and the Probe render reassigns it through a closure +// that the analysis cannot see, which would otherwise leave it `never`. +function takeProbedScope(): DashboardTeamScope | undefined { + return probedScope; +} + +function renderReadyScope(): ReadyTeamScope { + probedScope = undefined; + renderToStaticMarkup(); + const scope = takeProbedScope(); + if (!scope || scope.status !== "ready") throw new Error("Expected a ready team scope"); + return scope; } const twoTeams: Catalog = { @@ -76,6 +127,11 @@ describe("dashboard team scope", () => { catalog = twoTeams; pending = false; searchTeam = null; + legacyCookieScope = "team-2"; + queryData.clear(); + queryData.set(queryKey(["dashboard-team-catalog", "user-1"]), twoTeams); + routerReplace.mockClear(); + routerRefresh.mockClear(); }); test("exposes the persisted team as current and only permitted teams", () => { @@ -147,8 +203,7 @@ describe("dashboard team scope", () => { signal?.addEventListener("abort", () => reject(signal?.reason), { once: true }); })) as typeof fetch; try { - const scope = useDashboardTeamScope("user-1"); - if (scope.status !== "ready") throw new Error("Expected a ready team scope"); + const scope = renderReadyScope(); const switching = scope.switchTeam(twoTeams.teams[0]!); expect(signal).toBeInstanceOf(AbortSignal); expect(expire).toBeDefined(); @@ -162,4 +217,201 @@ describe("dashboard team scope", () => { clear.mockRestore(); } }); + + test("updates the selected team and dashboard scope before the server responds", async () => { + const originalFetch = globalThis.fetch; + let resolveFetch: ((response: Response) => void) | undefined; + globalThis.fetch = (() => new Promise((resolve) => { + resolveFetch = resolve; + })) as typeof fetch; + try { + const scope = renderReadyScope(); + + const switching = scope.switchTeam(twoTeams.teams[0]!); + + expect(queryData.get(queryKey(["dashboard-team-catalog", "user-1"]))).toMatchObject({ + selectedTeamId: "user-1", + }); + expect(routerReplace).toHaveBeenCalledWith("/dashboard/coderouter?team=user-1"); + expect(routerRefresh).not.toHaveBeenCalled(); + + resolveFetch!(new Response(null, { status: 204 })); + await switching; + + expect(routerReplace).toHaveBeenLastCalledWith("/dashboard/coderouter"); + expect(routerRefresh).toHaveBeenCalledTimes(1); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("an older failed switch cannot roll back a newer optimistic switch", async () => { + const originalFetch = globalThis.fetch; + const extendedCatalog: Catalog = { + ...twoTeams, + teams: [ + ...twoTeams.teams, + { + id: "team-4", + name: "Other", + personal: false, + permissions: { use: true, manageAccounts: false }, + }, + ], + }; + catalog = extendedCatalog; + queryData.set(queryKey(["dashboard-team-catalog", "user-1"]), extendedCatalog); + const resolvers: Array<(response: Response) => void> = []; + globalThis.fetch = (() => new Promise((resolve) => { + resolvers.push(resolve); + })) as typeof fetch; + try { + const scope = renderReadyScope(); + const first = scope.switchTeam(extendedCatalog.teams[0]!); + const second = scope.switchTeam(extendedCatalog.teams[3]!); + + expect(legacyCookieScope).toBe("team-4"); + expect(routerReplace).toHaveBeenLastCalledWith("/dashboard/coderouter?team=team-4"); + + resolvers[0]!(new Response(null, { status: 500 })); + await expect(first).rejects.toThrow("Could not switch dashboard team"); + await Promise.resolve(); + expect(resolvers).toHaveLength(2); + expect(queryData.get(queryKey(["dashboard-team-catalog", "user-1"]))).toMatchObject({ + selectedTeamId: "team-4", + }); + expect(legacyCookieScope).toBe("team-4"); + expect(routerReplace).toHaveBeenLastCalledWith("/dashboard/coderouter?team=team-4"); + + resolvers[1]!(new Response(null, { status: 204 })); + await second; + expect(routerReplace).toHaveBeenLastCalledWith("/dashboard/coderouter"); + expect(routerRefresh).toHaveBeenCalledTimes(1); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("two overlapping failures restore the last confirmed team", async () => { + const originalFetch = globalThis.fetch; + const extendedCatalog: Catalog = { + ...twoTeams, + teams: [ + ...twoTeams.teams, + { + id: "team-4", + name: "Other", + personal: false, + permissions: { use: true, manageAccounts: false }, + }, + ], + }; + catalog = extendedCatalog; + queryData.set(queryKey(["dashboard-team-catalog", "user-1"]), extendedCatalog); + const resolvers: Array<(response: Response) => void> = []; + globalThis.fetch = (() => new Promise((resolve) => { + resolvers.push(resolve); + })) as typeof fetch; + try { + const scope = renderReadyScope(); + const first = scope.switchTeam(extendedCatalog.teams[0]!); + const second = scope.switchTeam(extendedCatalog.teams[3]!); + + expect(queryData.get(queryKey(["dashboard-team-catalog", "user-1"]))).toMatchObject({ + selectedTeamId: "team-4", + }); + expect(legacyCookieScope).toBe("team-4"); + expect(resolvers).toHaveLength(1); + + resolvers[0]!(new Response(null, { status: 500 })); + await expect(first).rejects.toThrow("Could not switch dashboard team"); + await Promise.resolve(); + expect(resolvers).toHaveLength(2); + expect(queryData.get(queryKey(["dashboard-team-catalog", "user-1"]))).toMatchObject({ + selectedTeamId: "team-4", + }); + + resolvers[1]!(new Response(null, { status: 500 })); + await expect(second).rejects.toThrow("Could not switch dashboard team"); + + expect(queryData.get(queryKey(["dashboard-team-catalog", "user-1"]))).toMatchObject({ + selectedTeamId: "team-2", + }); + expect(legacyCookieScope).toBe("team-2"); + expect(routerReplace).toHaveBeenLastCalledWith("/dashboard/coderouter"); + expect(routerRefresh).not.toHaveBeenCalled(); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("a rapid switch back to the confirmed team supersedes the pending optimistic team", async () => { + const originalFetch = globalThis.fetch; + const resolvers: Array<(response: Response) => void> = []; + globalThis.fetch = (() => new Promise((resolve) => { + resolvers.push(resolve); + })) as typeof fetch; + try { + const scope = renderReadyScope(); + const away = scope.switchTeam(twoTeams.teams[0]!); + const back = scope.switchTeam(twoTeams.teams[1]!); + + expect(queryData.get(queryKey(["dashboard-team-catalog", "user-1"]))).toMatchObject({ + selectedTeamId: "team-2", + }); + expect(legacyCookieScope).toBe("team-2"); + + resolvers[0]!(new Response(null, { status: 204 })); + await away; + await Promise.resolve(); + expect(resolvers).toHaveLength(2); + + resolvers[1]!(new Response(null, { status: 204 })); + await back; + + expect(queryData.get(queryKey(["dashboard-team-catalog", "user-1"]))).toMatchObject({ + selectedTeamId: "team-2", + }); + expect(legacyCookieScope).toBe("team-2"); + expect(routerReplace).toHaveBeenLastCalledWith("/dashboard/coderouter"); + expect(routerRefresh).toHaveBeenCalledTimes(1); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("rollback restores the pre-switch legacy cookie instead of the URL-selected team", async () => { + searchTeam = "user-1"; + legacyCookieScope = "team-2"; + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => new Response(null, { status: 500 })) as typeof fetch; + try { + const scope = renderReadyScope(); + await expect(scope.switchTeam(twoTeams.teams[1]!)).rejects.toThrow( + "Could not switch dashboard team", + ); + expect(legacyCookieScope).toBe("team-2"); + expect(routerReplace).toHaveBeenLastCalledWith("/dashboard/coderouter?team=user-1"); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("rolls back the optimistic scope when the server rejects the switch", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => new Response(null, { status: 500 })) as typeof fetch; + try { + const scope = renderReadyScope(); + + await expect(scope.switchTeam(twoTeams.teams[0]!)).rejects.toThrow("Could not switch dashboard team"); + + expect(queryData.get(queryKey(["dashboard-team-catalog", "user-1"]))).toMatchObject({ + selectedTeamId: "team-2", + }); + expect(routerReplace).toHaveBeenLastCalledWith("/dashboard/coderouter"); + expect(routerRefresh).not.toHaveBeenCalled(); + } finally { + globalThis.fetch = originalFetch; + } + }); }); diff --git a/web/tests/freestyle-cloud-shell-repair.test.ts b/web/tests/freestyle-cloud-shell-repair.test.ts index 811a09c8a6d3..573f18d5094d 100644 --- a/web/tests/freestyle-cloud-shell-repair.test.ts +++ b/web/tests/freestyle-cloud-shell-repair.test.ts @@ -5,11 +5,6 @@ import { cmuxTuiInstallCommand, cmuxTuiPinCheckCommand, } from "../services/vms/drivers/cmuxTuiDaemon"; -import { - freestyleDaemonHealthyCommand, - freestyleDaemonSettledCommand, - freestyleStartDaemonCommand, -} from "../services/vms/drivers/freestyle"; const SOURCE = { url: "https://files.cmux.com/cmux-tui/test/cmux-tui-x86_64-unknown-linux-musl", @@ -38,52 +33,7 @@ describe("Freestyle Cloud VM daemon repair", () => { expect(daemon).not.toContain("cmuxd-remote"); }); - test("health checks require the managed daemon and its dual-stack listener", () => { - // Attach right after create lands in the supervisor's start window: on a - // baked image the heal waits up to the settle budget before restarting. - const settled = freestyleDaemonSettledCommand(); - expect(settled).toContain("if [ -f /etc/cmux/bake-instance-id ] && systemctl is-active cmux-tui-daemon"); - expect(settled).toContain("for i in $(seq 1 30); do {"); - expect(settled).toContain("sleep 0.1"); - expect(settled).toContain(`else ${freestyleDaemonHealthyCommand()}; fi`); - const healthy = freestyleDaemonHealthyCommand(); - // [s]tart keeps the pattern from matching the exec shell that carries it. - expect(healthy).toContain("pgrep -f 'cmux-tui server [s]tart' >/dev/null 2>&1 && grep -qi ':0539 ' /proc/net/tcp6"); - // Instance-binding images: healthy also means bound to this machine's id. - expect(healthy).toContain("[ ! -f /etc/cmux/bake-instance-id ] ||"); - expect(healthy).toContain("/etc/cmux/daemon-instance-id"); - expect(healthy).toContain("/latest/meta-data/instance-id"); - }); - - test("health and repair require the dual-stack Freestyle listener", () => { - const health = freestyleDaemonHealthyCommand(); - expect(health).toContain("pgrep -f 'cmux-tui server [s]tart' >/dev/null 2>&1 && grep -qi ':0539 ' /proc/net/tcp6"); - // Instance-binding images: health must also bind the daemon to this machine. - expect(health).toContain("[ ! -f /etc/cmux/bake-instance-id ] ||"); - expect(health).toContain("/etc/cmux/daemon-instance-id"); - expect(health).toContain("/latest/meta-data/instance-id"); - - const start = freestyleStartDaemonCommand(); - expect(start).toContain("cmux-tui-daemon.service"); - expect(start).toContain("Environment=CMUX_TUI_REMOTE_WS_BIND=[::]:1337"); - // Machines healed in place get trusted mode through the same drop-in; the - // daemon reads the env, so the baked launch line need not carry the flag. - expect(start).toContain("Environment=CMUX_TUI_REMOTE_WS_TRUSTED_CARRIER=1"); - expect(start).toContain("systemctl daemon-reload"); - expect(start).toContain("systemctl restart cmux-tui-daemon"); - expect(start).toContain("--remote-ws [::]:1337"); - - // The default launcher keeps a daemon that already runs; the trusted-listener - // heal must replace it, or installing the pinned binary changes nothing for - // the live process and the retried bundle still reports an untrusted daemon. - expect(start).toContain("pgrep -f 'cmux-tui server [s]tart' >/dev/null 2>&1 ||"); - expect(start).not.toContain("pkill"); - const heal = freestyleStartDaemonCommand({ replaceExisting: true }); - expect(heal).toContain("systemctl restart cmux-tui-daemon"); - expect(heal).toContain("pkill -f 'cmux-tui server [s]tart'"); - expect(heal).not.toContain("pgrep -f 'cmux-tui server [s]tart' >/dev/null 2>&1 ||"); - expect(heal).toContain("--remote-ws-trusted-carrier"); - + test("pin check verifies the managed binary digest", () => { const pinCheck = cmuxTuiPinCheckCommand(SOURCE); expect(pinCheck).toContain(SOURCE.sha256); expect(pinCheck).toContain("sha256sum -c"); diff --git a/web/tests/freestyle-network-announcement.test.ts b/web/tests/freestyle-network-announcement.test.ts index 73b3a60f42e7..34824975410a 100644 --- a/web/tests/freestyle-network-announcement.test.ts +++ b/web/tests/freestyle-network-announcement.test.ts @@ -75,7 +75,6 @@ describe("Freestyle private network readiness", () => { } } as unknown as Freestyle; const provider = new FreestyleProvider({ client: () => client, - resolveDaemonSource: async () => { throw new Error("No daemon install is needed"); }, }); const allocation = operation === "create" @@ -84,9 +83,7 @@ describe("Freestyle private network readiness", () => { if (hasAddresses) { await allocation; events.push("published"); - expect(events).toEqual(operation === "create" - ? ["allocated", "published"] - : ["allocated", "guest-network", "published"]); + expect(events).toEqual(["allocated", "published"]); } else { await expect(allocation).rejects.toThrow(); expect(events).toEqual(["allocated", "delete"]); @@ -116,7 +113,6 @@ describe("Freestyle private network readiness", () => { } } as unknown as Freestyle; const provider = new FreestyleProvider({ client: () => client, - resolveDaemonSource: async () => { throw new Error("No daemon install is needed"); }, }); const handle = await provider.create({ image: "sh-fixture", network: { id: "vpc-fixture" } }); diff --git a/web/tests/vm-create-critical-path.test.ts b/web/tests/vm-create-critical-path.test.ts new file mode 100644 index 000000000000..723c13c417da --- /dev/null +++ b/web/tests/vm-create-critical-path.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, test } from "bun:test"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { VmBillingGateway, noOpVmBillingGateway } from "../services/vms/billingGateway"; +import { VmProviderOperationError } from "../services/vms/errors"; +import { VmProviderGateway, type VmProviderGatewayShape } from "../services/vms/providerGateway"; +import { VmRepository, type CloudVmRow, type VmRepositoryShape } from "../services/vms/repository"; +import { createVm } from "../services/vms/workflows"; + +// New Machine's create latency: ledger writes that do not gate the machine +// must not sit in front of the provider call or the response, and moving +// them must not reorder or drop ledger rows. + +const ROW_ID = "00000000-0000-4000-8000-00000000fa57"; +type UsageEvent = Parameters[0]; + +function row(): CloudVmRow { + const now = new Date(); + return { + id: ROW_ID, + userId: "user-fast", + billingTeamId: "team-fast", + billingPlanId: "pro", + provider: "freestyle", + providerVmId: null, + displayName: null, + slug: null, + imageId: "snapshot-test", + imageVersion: null, + status: "provisioning", + idempotencyKey: null, + createdAt: now, + updatedAt: now, + destroyedAt: null, + failureCode: null, + failureMessage: null, + providerMetadata: {}, + ownerTeamId: "team-fast", + coderouterPoolId: null, + }; +} + +function fakeRepo(input: { + readonly events: UsageEvent[]; + readonly writeGate?: (events: readonly UsageEvent[]) => Effect.Effect; +}): VmRepositoryShape { + const vm = row(); + const now = new Date(); + const record = (events: readonly UsageEvent[]) => + (input.writeGate?.(events) ?? Effect.void).pipe(Effect.andThen(Effect.sync(() => { + input.events.push(...events); + }))); + return { + beginCreate: () => Effect.succeed({ inserted: true, vm }), + claimBillingGrant: () => Effect.succeed({ kind: "already_claimed" }), + recordUsageEvent: (event: UsageEvent) => record([event]), + recordUsageEvents: (events: readonly UsageEvent[]) => record(events), + markCreateFailed: () => Effect.void, + markCreateRunning: (update: { providerVmId: string; image: string }) => + Effect.succeed({ ...vm, status: "running", providerVmId: update.providerVmId, imageId: update.image }), + activeLimitCandidates: () => Effect.succeed([]), + findNetwork: () => Effect.succeed({ + id: "00000000-0000-4000-8000-00000000c10d", + userId: vm.userId, + provider: "freestyle", + providerNetworkId: "network-fast", + slug: "fast", + cidr: null, + cidrV6: null, + createdAt: now, + updatedAt: now, + }), + upsertNetwork: () => Effect.die("the owner network already exists"), + } as unknown as VmRepositoryShape; +} + +function fakeProviders(input: { + readonly onCreate?: () => Effect.Effect; + readonly fail?: boolean; + readonly execs?: string[]; +}): VmProviderGatewayShape { + return { + exec: (_provider: string, _vmId: string, command: string) => Effect.sync(() => { + input.execs?.push(command); + return { exitCode: 0, stdout: "", stderr: "" }; + }), + create: () => + (input.onCreate?.() ?? Effect.void).pipe(Effect.andThen(input.fail + ? Effect.fail(new VmProviderOperationError({ provider: "freestyle", operation: "create", cause: new Error("boom") })) + : Effect.succeed({ + provider: "freestyle" as const, + providerVmId: "provider-vm-fast", + status: "running" as const, + image: "snapshot-test", + createdAt: Date.now(), + }))), + destroy: () => Effect.void, + supportsPrivateNetworking: () => true, + ensureNetwork: () => Effect.die("the owner network already exists"), + } as unknown as VmProviderGatewayShape; +} + +function layer(repo: VmRepositoryShape, providers: VmProviderGatewayShape) { + return Layer.mergeAll( + Layer.succeed(VmRepository, repo), + Layer.succeed(VmProviderGateway, providers), + Layer.succeed(VmBillingGateway, noOpVmBillingGateway()), + ); +} + +const createInput = { + userId: "user-fast", + billingCustomerType: "team" as const, + billingTeamId: "team-fast", + billingPlanId: "pro", + maxActiveVms: null, + provider: "freestyle" as const, + image: "snapshot-test", +}; + +const types = (events: readonly UsageEvent[]) => events.map((event) => event.eventType); + +describe("createVm critical path", () => { + test("the provider call starts while the requested-events write is still in flight, and create waits for it", async () => { + const events: UsageEvent[] = []; + const program = Effect.gen(function* () { + const releaseRequested = yield* Deferred.make(); + const providerStarted = yield* Deferred.make(); + const repo = fakeRepo({ + events, + writeGate: (batch) => batch.some((event) => event.eventType === "vm.create.requested") + ? Deferred.await(releaseRequested) + : Effect.void, + }); + const providers = fakeProviders({ onCreate: () => Deferred.succeed(providerStarted, undefined) }); + const fiber = yield* Effect.fork(createVm(createInput).pipe(Effect.provide(layer(repo, providers)))); + // Deadlocks (and times out) if the provider call still waits on the write. + yield* Deferred.await(providerStarted); + expect(types(events)).toEqual([]); + yield* Deferred.succeed(releaseRequested, undefined); + return yield* fiber.await; + }); + const exit = await Effect.runPromise(program); + expect(exit._tag).toBe("Success"); + expect(types(events)).toEqual(["vm.create.requested", "vm.created"]); + }); + + test("with an after-response hook, vm.created is written after create returns", async () => { + const events: UsageEvent[] = []; + const deferred: Effect.Effect[] = []; + const created = await Effect.runPromise( + createVm({ ...createInput, deferAfterResponse: (work) => deferred.push(work) }).pipe( + Effect.provide(layer(fakeRepo({ events }), fakeProviders({}))), + ), + ); + expect(created.providerVmId).toBe("provider-vm-fast"); + expect(types(events)).toEqual(["vm.create.requested"]); + // vm.created and the prompt name push both run after the response. + expect(deferred).toHaveLength(2); + for (const work of deferred) await Effect.runPromise(work); + expect(types(events)).toEqual(["vm.create.requested", "vm.created"]); + }); + + test("the prompt name is pushed into the guest only after the response", async () => { + const execs: string[] = []; + const deferred: Effect.Effect[] = []; + await Effect.runPromise( + createVm({ ...createInput, deferAfterResponse: (work) => deferred.push(work) }).pipe( + Effect.provide(layer(fakeRepo({ events: [] }), fakeProviders({ execs }))), + ), + ); + // Nothing runs in the guest before create returns. + expect(execs).toEqual([]); + for (const work of deferred) await Effect.runPromise(work); + expect(execs).toHaveLength(1); + expect(execs[0]).toContain("vm-name"); + }); + + test("a provider failure records its failure row after the requested row", async () => { + const events: UsageEvent[] = []; + const deferred: Effect.Effect[] = []; + const program = Effect.gen(function* () { + const releaseRequested = yield* Deferred.make(); + const repo = fakeRepo({ + events, + writeGate: (batch) => batch.some((event) => event.eventType === "vm.create.requested") + ? Deferred.await(releaseRequested) + : Effect.void, + }); + // The provider fails while the requested write is still held open. + const providers = fakeProviders({ fail: true, onCreate: () => Effect.fork(Effect.sleep("20 millis").pipe(Effect.andThen(Deferred.succeed(releaseRequested, undefined)))).pipe(Effect.asVoid) }); + return yield* createVm({ ...createInput, deferAfterResponse: (work) => deferred.push(work) }).pipe( + Effect.provide(layer(repo, providers)), + Effect.flip, + ); + }); + const error = await Effect.runPromise(program); + expect(error).toBeInstanceOf(VmProviderOperationError); + expect(types(events)).toEqual(["vm.create.requested", "vm.create.failed"]); + expect(deferred).toHaveLength(0); + }); +}); diff --git a/web/tests/vm-devbox-identity.test.ts b/web/tests/vm-devbox-identity.test.ts index 0f3cb961f58f..792a0a365d20 100644 --- a/web/tests/vm-devbox-identity.test.ts +++ b/web/tests/vm-devbox-identity.test.ts @@ -156,12 +156,17 @@ describe("devbox identity contract (services/vms/images/identity.ts)", () => { // Detached: a subshell backgrounds the job and exits, so the loop never // waits on it, the daemon starts in the same tick, and no zombie is left. expect(devboxBoot).toContain("( rekey_ssh_host & )"); - const wipe = devboxBoot.indexOf('rm -rf "$REMOTE_STATE_DIR"'); + const stateRefresh = devboxBoot.indexOf('find "$REMOTE_STATE_DIR/sessions"'); const rekey = devboxBoot.indexOf("( rekey_ssh_host & )"); const bound = devboxBoot.indexOf(`printf '%s\\n' "$id" > "$BOUND_INSTANCE_FILE"`); - expect(wipe).toBeGreaterThan(-1); - expect(rekey).toBeGreaterThan(wipe); - expect(bound).toBeGreaterThan(rekey); + const daemonStart = devboxBoot.indexOf("start_daemon", bound); + expect(stateRefresh).toBeGreaterThan(-1); + expect(bound).toBeGreaterThan(stateRefresh); + // The daemon starts before key generation competes for the clone's CPU, + // and key generation runs at the lowest CPU and I/O priority. + expect(daemonStart).toBeGreaterThan(bound); + expect(rekey).toBeGreaterThan(daemonStart); + expect(devboxBoot).toContain('low="nice -n 19"'); }); }); @@ -228,12 +233,43 @@ describe("devbox private-network announce (services/vms/images/network.ts)", () expect(devboxBoot).toContain("announce_loop() {\n while true; do announce_network; sleep 30; done\n}"); expect(devboxBoot.indexOf("\nannounce_loop &\n")).toBeGreaterThan(-1); expect(devboxBoot.indexOf("\nannounce_loop &\n")).toBeLessThan(devboxBoot.indexOf("\nwhile true; do\n")); - // On a clone: detached, right after the SSH rekey, before the machine is bound. - const rekey = devboxBoot.indexOf("( rekey_ssh_host & )"); + // On a clone: the very first action, detached, before the daemon stop, + // the state refresh, the SSH rekey, and the bind. The Mac is already + // dialing; the fabric drops its SYNs until this frame goes out. + const cloneBranch = devboxBoot.indexOf('if [ -n "$id" ] && [ "$id" != "$(cat "$BOUND_INSTANCE_FILE" 2>/dev/null)" ]; then'); const announce = devboxBoot.indexOf("( announce_network & )"); + const stop = devboxBoot.indexOf("stop_daemon", cloneBranch); + const rekey = devboxBoot.indexOf("( rekey_ssh_host & )"); + const bound = devboxBoot.indexOf(`printf '%s\\n' "$id" > "$BOUND_INSTANCE_FILE"`); + expect(cloneBranch).toBeGreaterThan(-1); + expect(announce).toBeGreaterThan(cloneBranch); + expect(stop).toBeGreaterThan(announce); + expect(bound).toBeGreaterThan(stop); + expect(rekey).toBeGreaterThan(bound); + }); + + test("a parked supervisor ticks fast so a clone is noticed within ~50 ms of resume", () => { + expect(devboxBoot).toContain("PARKED_TICK=0.05"); + expect(devboxBoot).toContain('sleep "$tick"'); + // The parked branch and the failed-first-read branch keep the fast tick; + // a bound machine goes back to one second. + expect(devboxBoot.match(/tick=\$PARKED_TICK/g)?.length).toBe(2); + expect(devboxBoot).toContain(" tick=1\n"); + expect(devboxBoot).toContain('elif [ -z "$id" ] && [ -n "$parked" ]; then'); + }); + + test("resume housekeeping timers are parked with the daemon and re-armed off the critical path", () => { + for (const timer of ["logrotate.timer", "man-db.timer", "fstrim.timer", "dpkg-db-backup.timer", "systemd-tmpfiles-clean.timer", "apt-daily.timer"]) { + expect(devboxBoot).toContain(timer); + } + expect(devboxBoot).toContain("systemctl stop cmux-housekeeping-rearm.timer cmux-housekeeping-rearm.service $HOUSEKEEPING_TIMERS"); + // Service watchdogs are runtime state: off while parked (so the clock jump + // kills nothing on resume), back on with the delayed re-arm. + expect(devboxBoot).toContain(" systemd-analyze service-watchdogs no >/dev/null 2>&1 || true\n"); + expect(devboxBoot).toContain('--on-active="$HOUSEKEEPING_DELAY"'); + expect(devboxBoot).toContain('/bin/sh -c "systemd-analyze service-watchdogs yes; systemctl start $HOUSEKEEPING_TIMERS"'); const bound = devboxBoot.indexOf(`printf '%s\\n' "$id" > "$BOUND_INSTANCE_FILE"`); - expect(announce).toBeGreaterThan(rekey); - expect(bound).toBeGreaterThan(announce); + expect(devboxBoot.indexOf('[ -n "$parked" ] && { rearm_housekeeping; parked=""; }')).toBeGreaterThan(bound); }); test("the image installs arping and verify proves the announce loop on a booted machine", () => { diff --git a/web/tests/vm-devbox-image.test.ts b/web/tests/vm-devbox-image.test.ts index da55b9a9c0f0..405e2a3fd965 100644 --- a/web/tests/vm-devbox-image.test.ts +++ b/web/tests/vm-devbox-image.test.ts @@ -115,6 +115,7 @@ describe("devbox image template", () => { "cmux-devbox-boot", "cmux-motd", "cmux-opencode", + "cmux-prompt-sync", "cmux-prompt.bash", "cmux-terminfo.sh", "cmux-terminfo.src", @@ -371,8 +372,8 @@ describe("devbox image template", () => { expect(wait).toContain("exit 1"); for (const name of ["build-devbox-freestyle.ts", "verify-devbox-image.ts", "derive-devbox-sizes.ts", "check-devbox-image-reachable.ts"]) { const script = readScript(name); - expect({ name, sleeps: /sleep 30\b|setTimeout\(resolve, 30_000\)|sleep\(30_000\)/.test(script) }) - .toEqual({ name, sleeps: false }); + const sleeps = /sleep 30\b|setTimeout\(resolve, 30_000\)|sleep\(30_000\)/.test(script); + expect({ name, sleeps }).toEqual({ name, sleeps: name === "build-devbox-freestyle.ts" }); expect({ name, waits: script.includes("devboxWaitForDaemonCommand") }).toEqual({ name, waits: true }); } // The ladder rows are independent, so they run concurrently, and the full @@ -484,13 +485,14 @@ describe("devbox image template", () => { expect(devboxBoot).toContain('if [ -x "$BIN" ]'); expect(dockerfile).toContain("COPY cmux-devbox-boot /usr/local/bin/cmux-devbox-boot"); // A Freestyle snapshot is a memory image: the supervisor keys the daemon - // identity on the platform instance id, wiping cmux-remote's default root - // state dir on a clone, and holds the daemon on the builder itself. + // identity on the platform instance id, rotating auth/connection state on + // a clone while preserving the warm journal, and holds the daemon on the builder. expect(devboxBoot).toContain('REMOTE_STATE_DIR="$CMUX_TUI_HOME/.local/state/cmux/remote"'); expect(devboxBoot).toContain("/latest/meta-data/instance-id"); expect(devboxBoot).toContain("BOUND_INSTANCE_FILE=/etc/cmux/daemon-instance-id"); expect(devboxBoot).toContain("BAKE_INSTANCE_FILE=/etc/cmux/bake-instance-id"); - expect(devboxBoot).toContain('rm -rf "$REMOTE_STATE_DIR"'); + expect(devboxBoot).toContain('find "$REMOTE_STATE_DIR/sessions"'); + expect(devboxBoot).toContain('rm -rf "$REMOTE_STATE_DIR/connections"'); // The supervisor owns the daemon as a background child so it can stop a // daemon that belongs to another machine (a clone of a live machine). expect(devboxBoot).toContain("daemon_pid=$!"); diff --git a/web/tests/vm-freestyle-provider.test.ts b/web/tests/vm-freestyle-provider.test.ts index fe60b45e9ea8..0a041d011e3b 100644 --- a/web/tests/vm-freestyle-provider.test.ts +++ b/web/tests/vm-freestyle-provider.test.ts @@ -1,5 +1,4 @@ import { readFileSync } from "node:fs"; -import { GUEST_CMUX_SHIM, GUEST_CMUX_SHIM_PATH } from "../services/vms/guestCli"; import path from "node:path"; import { describe, expect, setSystemTime, test } from "bun:test"; import { FreestyleApiError, type Freestyle } from "freestyle"; @@ -11,21 +10,15 @@ import { freestyleCmuxRemoteRoute, freestyleNetworkAddressMetadata, freestyleRouteAddressesFromMetadata, - freestyleDaemonHealthyCommand, freestyleDesktopHealCommand, freestyleEdgeRules, freestyleFirewallRules, freestylePortAddress, freestylePortUrls, - freestyleResizeRequest, - freestyleStartDaemonCommand, - freestyleTargetResources, mapFreestyleState, normalizeFreestyleExecTimeout, - freestylePinCheckCommand, } from "../services/vms/drivers/freestyle"; import type { VMProvider } from "../services/vms/drivers/types"; -import { cmuxTuiPinCheckCommand } from "../services/vms/drivers/cmuxTuiDaemon"; import { ProviderError, type VmEdgeRule } from "../services/vms/drivers/types"; import { DEVBOX_DESKTOP_NOVNC_PORT } from "../services/vms/images/desktop"; @@ -85,12 +78,6 @@ function fakeFreestyle(input: { readonly probeExit: number; readonly guestCliExi function providerWith(fake: { readonly client: Freestyle }): FreestyleProvider { return new FreestyleProvider({ client: () => fake.client, - resolveDaemonSource: async () => ({ - url: "https://files.cmux.com/cmux-tui/abc/cmux-tui-linux-x64", - sha256: "0".repeat(64), - commit: "abc", - builtAt: null, hookUrl: "https://files.cmux.com/cmux-tui/test/cmux-tui-hook-x86_64-unknown-linux-musl", hookSha256: "1".repeat(64), - }), }); } @@ -143,11 +130,7 @@ describe("Freestyle platform contract", () => { ]); }); - test("create sizes from the create response and never re-reads the machine", async () => { - // vms.create already returns the machine's resources; a status read after - // it cost ~100 ms on every prod create for nothing. A size-less image is - // the one path that still grows the machine, and it grows from the create - // response; a sized image boots at its shape and is neither read nor grown. + test("create never re-reads or resizes a snapshot-backed machine", async () => { const createResponse = (fake: ReturnType, gets: string[], resizes: unknown[]) => { const vm = fake.client.vms.ref(VM_ID); vm.resize = (async (request: unknown) => { @@ -180,7 +163,7 @@ describe("Freestyle platform contract", () => { memoryMb: 20480, } as never); expect(sizelessGets).toEqual([]); - expect(sizelessResizes).toHaveLength(1); + expect(sizelessResizes).toEqual([]); const sized = fakeFreestyle({ probeExit: 0 }); const sizedGets: string[] = []; @@ -267,27 +250,6 @@ describe("Freestyle platform contract", () => { expect(() => freestyleCmuxRemoteRoute({ publicIpv6: " " }, VM_ID)).toThrow("public IPv6"); }); - test("daemon health requires a v6-table listener; start installs the dual-stack override", () => { - // 0x0539 = 1337; a 0.0.0.0-bound daemon appears only in /proc/net/tcp and - // is unreachable at the public IPv6, so it must be restarted. - expect(freestyleDaemonHealthyCommand()).toContain("/proc/net/tcp6"); - expect(freestyleDaemonHealthyCommand()).toContain(":0539 "); - const start = freestyleStartDaemonCommand(); - expect(start).toContain("Environment=CMUX_TUI_REMOTE_WS_BIND=[::]:1337"); - expect(start).toContain("Environment=CMUX_TUI_REMOTE_WS_TRUSTED_CARRIER=1"); - expect(start).toContain("systemctl restart cmux-tui-daemon"); - expect(start).toContain("--remote-ws [::]:1337"); // non-systemd fallback - expect(start).toContain("--remote-ws-trusted-carrier"); - }); - - test("pin check trusts the pin recorded at bake time, falling back to the live pin on older images", () => { - const source = { url: "https://files.cmux.com/x", sha256: "f".repeat(64), commit: "abc", builtAt: null, hookUrl: "https://files.cmux.com/cmux-tui/test/cmux-tui-hook-x86_64-unknown-linux-musl", hookSha256: "1".repeat(64) }; - const check = freestylePinCheckCommand(source); - expect(check).toContain("if [ -s /etc/cmux/cmux-tui-pin ]; then"); - expect(check).toContain("cut -d' ' -f1 /etc/cmux/cmux-tui-pin"); - expect(check).toContain(`else ${cmuxTuiPinCheckCommand(source)}; fi`); - }); - test("edge rules map to inline egress tls rules with header transforms", () => { expect(freestyleEdgeRules([EDGE_RULE])).toEqual([ { @@ -311,26 +273,20 @@ describe("Freestyle platform contract", () => { }); - test("exec preserves an up-to-date full guest CLI without uploading it", async () => { + test("exec dispatches directly to the immutable guest CLI", async () => { const fake = fakeFreestyle({ probeExit: 0 }); const result = await providerWith(fake).exec(VM_ID, "echo hi", { timeoutMs: 5_000 }); expect(result.exitCode).toBe(0); - expect(fake.execs).toHaveLength(3); - const command = fake.execs[0] ?? ""; - expect(command).toContain(`sha256sum '${GUEST_CMUX_SHIM_PATH}'`); - expect(fake.execs[2]).toBe("echo hi"); + expect(fake.execs).toEqual(["echo hi"]); expect(fake.writes).toHaveLength(0); - expect(command).not.toContain("crt_"); }); - test("exec upgrades an absent or outdated guest CLI before running the command", async () => { + test("exec does not repair an absent guest CLI during a command", async () => { const fake = fakeFreestyle({ probeExit: 0, guestCliExit: 1 }); const result = await providerWith(fake).exec(VM_ID, "cmux self --json"); expect(result.exitCode).toBe(0); - expect(fake.writes).toHaveLength(1); - expect(fake.writes[0]?.content).toBe(GUEST_CMUX_SHIM); - expect(fake.execs.some(command => command.includes("mv -f"))).toBe(true); - expect(fake.execs.at(-1)).toBe("cmux self --json"); + expect(fake.writes).toHaveLength(0); + expect(fake.execs).toEqual(["cmux self --json"]); }); test("exec timeouts clamp to the per-exec cap; killed execs read as 124", () => { @@ -396,12 +352,6 @@ describe("Freestyle platform contract", () => { } as unknown as Freestyle; const provider = new FreestyleProvider({ client: () => client, - resolveDaemonSource: async () => ({ - url: "https://files.cmux.com/cmux-tui/abc/cmux-tui-linux-x64", - sha256: "0".repeat(64), - commit: "abc", - builtAt: null, hookUrl: "https://files.cmux.com/cmux-tui/test/cmux-tui-hook-x86_64-unknown-linux-musl", hookSha256: "1".repeat(64), - }), }); const recovered = await provider.privateNetworking!.createTunnel({ @@ -423,8 +373,106 @@ describe("Freestyle platform contract", () => { }); expect(calls).toEqual({ create: 1, list: 1, attach: 0 }); }); + + // Measured: tunnels.create answered 503 after ~25 s, yet the tunnel existed; + // only the app's later 409 retry recovered it (30.9 s to a first failure). + // A create with no answer or a 5xx must look for its own tunnel at once. + const createFailures: [string, () => Error][] = [ + ["a provider 5xx", () => new FreestyleApiError(503, { code: "INTERNAL_ERROR", message: "upstream" })], + ["no response (client timeout)", () => new DOMException("The operation timed out.", "TimeoutError")], + ]; + test.each(createFailures)("recovers the same-key tunnel after %s", async (_label, failure) => { + const calls = { create: 0, list: 0 }; + const timeouts: (number | undefined)[] = []; + const tunnel = recoverableTunnel(TUNNEL_CLIENT_KEY); + const client = { + tunnels: { + create: async () => { calls.create += 1; throw failure(); }, + list: async () => { calls.list += 1; return { tunnels: [tunnel], totalCount: 1 }; }, + attachVpc: async () => tunnel, + }, + } as unknown as Freestyle; + const provider = new FreestyleProvider({ client: (timeoutMs) => { timeouts.push(timeoutMs); return client; } }); + + const result = await provider.privateNetworking!.createTunnel({ + slug: "cmux-wg-recover", + displayName: "cmux computer", + clientPublicKey: TUNNEL_CLIENT_KEY, + networkId: "vpc-1", + }); + + expect(result).toMatchObject({ tunnel: { id: "tun-existing" }, created: false, rotated: false }); + expect(calls).toEqual({ create: 1, list: 1 }); + // The create itself runs under a bounded timeout, well inside the route's 30 s. + expect(timeouts[0]).toBeLessThanOrEqual(10_000); + }); + + test("a 5xx never adopts a tunnel that holds another client's key", async () => { + const client = { + tunnels: { + create: async () => { throw new FreestyleApiError(503, { code: "INTERNAL_ERROR", message: "upstream" }); }, + list: async () => ({ tunnels: [recoverableTunnel("other-client-key")], totalCount: 1 }), + attachVpc: async () => { throw new Error("must not attach"); }, + }, + } as unknown as Freestyle; + const provider = new FreestyleProvider({ client: () => client }); + + await expect(provider.privateNetworking!.createTunnel({ + slug: "cmux-wg-recover", + displayName: "cmux computer", + clientPublicKey: TUNNEL_CLIENT_KEY, + networkId: "vpc-1", + })).rejects.toThrow("createTunnel(cmux-wg-recover)"); + }); + + test("a definite 4xx refusal does not spend a recovery read", async () => { + let lists = 0; + const client = { + tunnels: { + create: async () => { throw new FreestyleApiError(400, { code: "BAD_REQUEST", message: "bad key" }); }, + list: async () => { lists += 1; return { tunnels: [], totalCount: 0 }; }, + }, + } as unknown as Freestyle; + const provider = new FreestyleProvider({ client: () => client }); + + await expect(provider.privateNetworking!.createTunnel({ + slug: "cmux-wg-recover", + displayName: "cmux computer", + clientPublicKey: TUNNEL_CLIENT_KEY, + networkId: "vpc-1", + })).rejects.toThrow("createTunnel(cmux-wg-recover)"); + expect(lists).toBe(0); + }); }); +function recoverableTunnel(clientPublicKey: string) { + return { + id: "tun-existing", + tunnelId: "tun-existing", + slug: "cmux-wg-recover", + displayName: "cmux computer", + clientConfig: "[Interface]\nPrivateKey =\n[Peer]\n", + endpointHost: "tun-existing.beta-vpn.freestyle.sh", + endpointPort: 51820, + clientPublicKey, + serverPublicKey: "server-key", + clientAddressV4: "100.64.0.2", + clientAddressV6: "fd00::2", + routes: ["10.0.0.0/8", "fd00::/8"], + attachments: [{ + vpcId: "vpc-1", + ipv4: "10.40.0.2", + ipv6: "fd00:40::2", + address: "10.40.0.2", + vpcCidr: "10.40.0.0/24", + allowedIps: ["10.40.0.0/24", "fd00:40::/64"], + createdAt: "2026-01-01T00:00:00.000Z", + }], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }; +} + describe("FreestyleProvider create with edge rules", () => { test("creates persistent machines with idle pausing disabled", async () => { const fake = fakeFreestyle({ probeExit: 0 }); @@ -438,7 +486,7 @@ describe("FreestyleProvider create with edge rules", () => { }); }); - test("passes the rule inline, installs only the guest adapter, and returns the machine", async () => { + test("passes the rule inline and returns a snapshot-v2 machine without guest setup", async () => { const fake = fakeFreestyle({ probeExit: 0 }); const handle = await providerWith(fake).create({ image: "sh-devbox", @@ -453,14 +501,9 @@ describe("FreestyleProvider create with edge rules", () => { tls: { rules: freestyleEdgeRules([EDGE_RULE]) }, }); expect(fake.creates[0]).not.toHaveProperty("vpcs"); - // The token reaches the platform create call and nothing else. The guest - // adapter itself is safe to write because it contains no issued token. + // The token reaches the platform create call and nothing else. expect(JSON.stringify(fake.execs)).not.toContain("crt_"); - expect(fake.writes).toHaveLength(1); - expect(fake.writes[0]?.path).toMatch(/^\/usr\/local\/libexec\/cmux-cloud-adapter\.tmp-[0-9a-f]{24}$/); - expect(fake.writes[0]?.content).toContain("cmux auth status"); - expect(fake.writes[0]?.content).not.toContain("crt_secret-token"); - expect(fake.execs.some((command) => command.includes("mv -f") && command.includes("/usr/local/libexec/cmux-cloud-adapter'"))).toBe(true); + expect(fake.writes).toHaveLength(0); expect(fake.execs.some((command) => command.includes("/api/coderouter/vm-usage/self"))).toBe(false); expect(fake.deletes).toEqual([]); }); @@ -478,10 +521,7 @@ describe("FreestyleProvider create with edge rules", () => { tls: { rules: freestyleEdgeRules([EDGE_RULE]) }, }); expect(handle.providerMetadata).toMatchObject({ networkId: "vpc_1" }); - expect(fake.writes).toHaveLength(1); - expect(fake.writes[0]?.path).toMatch(/^\/usr\/local\/libexec\/cmux-cloud-adapter\.tmp-[0-9a-f]{24}$/); - expect(fake.execs.some((command) => command.includes("mv -f") && command.includes("/usr/local/libexec/cmux-cloud-adapter'"))).toBe(true); - expect(fake.writes[0]?.content).not.toContain("crt_secret-token"); + expect(fake.writes).toHaveLength(0); expect(handle.providerMetadata).toMatchObject({ networkId: "vpc_1", networkIpv4: "10.4.0.7", @@ -492,16 +532,15 @@ describe("FreestyleProvider create with edge rules", () => { expect(JSON.stringify(fake.writes)).not.toContain("crt_secret-token"); }); - test("omits the tls block and the probe when no rules are given", async () => { + test("omits the tls block and all guest work when no rules are given", async () => { const fake = fakeFreestyle({ probeExit: 1 }); await providerWith(fake).create({ image: "sh-devbox" }); expect(fake.creates[0]).not.toHaveProperty("tls"); expect(fake.execs.some((command) => command.includes("/api/coderouter/vm-usage/self"))).toBe(false); - expect(fake.writes).toHaveLength(1); - expect(fake.writes[0]?.path).toMatch(/^\/usr\/local\/libexec\/cmux-cloud-adapter\.tmp-[0-9a-f]{24}$/); + expect(fake.writes).toHaveLength(0); }); - test("restore passes the rule inline and installs the guest adapter", async () => { + test("restore passes the rule inline without guest setup", async () => { const ok = fakeFreestyle({ probeExit: 0 }); const restored = await providerWith(ok).restore("snap-1", { edgeRules: [EDGE_RULE] }); expect(restored.image).toBe("snap-1"); @@ -510,10 +549,7 @@ describe("FreestyleProvider create with edge rules", () => { idleTimeoutSeconds: FREESTYLE_PERSISTENT_IDLE_TIMEOUT_SECONDS, tls: { rules: freestyleEdgeRules([EDGE_RULE]) }, }); - expect(ok.writes).toHaveLength(1); - expect(ok.writes[0]?.path).toMatch(/^\/usr\/local\/libexec\/cmux-cloud-adapter\.tmp-[0-9a-f]{24}$/); - expect(ok.writes[0]?.content).not.toContain("crt_secret-token"); - expect(ok.execs.some((command) => command.includes("mv -f") && command.includes("/usr/local/libexec/cmux-cloud-adapter'"))).toBe(true); + expect(ok.writes).toHaveLength(0); expect(ok.deletes).toEqual([]); }); }); @@ -609,12 +645,6 @@ describe("FreestyleProvider resume policy", () => { const client = { vms: { ref: () => vm } } as unknown as Freestyle; const provider = new FreestyleProvider({ client: () => client, - resolveDaemonSource: async () => ({ - url: "https://files.cmux.com/cmux-tui/abc/cmux-tui-linux-x64", - sha256: "0".repeat(64), - commit: "abc", - builtAt: null, hookUrl: "https://files.cmux.com/cmux-tui/test/cmux-tui-hook-x86_64-unknown-linux-musl", hookSha256: "1".repeat(64), - }), }); const handle = await provider.resume(VM_ID); @@ -675,223 +705,59 @@ describe("Freestyle client configuration", () => { }); }); -describe("Freestyle machine sizing", () => { - test("the plan machine is 5 vCPU / 20 GB / 32 GB, vCPUs following memory", () => { - expect(freestyleTargetResources(20480, {})).toEqual({ cpu: 5, memory: 20480, storage: 32768 }); - expect(freestyleTargetResources(8192, {})).toEqual({ cpu: 2, memory: 8192, storage: 32768 }); - expect(freestyleTargetResources(4096, { CMUX_VM_DISK_MB: "65536" })).toEqual({ - cpu: 1, - memory: 4096, - storage: 65536, - }); - }); - - test("resize grows the devbox snapshot size to the plan machine", () => { - // Every VM boots at its snapshot's resources; the devbox snapshot is - // 2 vCPU / 4 GB / 16 GB, so a fresh create must grow all three. - expect(freestyleResizeRequest( - { cpu: 2, memory: 4096, storage: 16384 }, - { cpu: 5, memory: 20480, storage: 32768 }, - )).toEqual({ cpu: 5, memory: 20480, storage: 32768 }); - }); - - test("resize is grow-only and sends only the dimensions that grow", () => { - // A snapshot taken from an already-sized machine restores at that size: - // nothing to do. A snapshot larger than the request is never shrunk. - expect(freestyleResizeRequest( - { cpu: 5, memory: 20480, storage: 204800 }, - { cpu: 5, memory: 20480, storage: 204800 }, - )).toBeNull(); - expect(freestyleResizeRequest( - { cpu: 8, memory: 32768, storage: 262144 }, - { cpu: 5, memory: 20480, storage: 204800 }, - )).toBeNull(); - expect(freestyleResizeRequest( - { cpu: 5, memory: 20480, storage: 16384 }, - { cpu: 5, memory: 20480, storage: 204800 }, - )).toEqual({ storage: 204800 }); - }); -}); - // The desktop and forwarded ports travel the daemon's private path: the URL // is the machine's VPC address over the owner's tunnel, nothing is minted at // the platform and nothing public is opened. noVNC on 6901 has no auth of // its own, so a machine outside a private network gets no URL at all. -describe("Freestyle openCmuxRemote: the trusted-listener heal", () => { - const PRIVATE = { publicIpv6: "2602:f75c:0:1::2a", vpcs: [{ ipv4: "10.4.0.7", ipv6: "fd00:4::7" }] }; - const SOURCE_OK = { url: "https://files.cmux.com/cmux-tui/abc/cmux-tui-linux-x64", sha256: "0".repeat(64), commit: "abc", builtAt: null, hookUrl: "https://files.cmux.com/cmux-tui/test/cmux-tui-hook-x86_64-unknown-linux-musl", hookSha256: "1".repeat(64) }; - - /** The attach bundle's fenced stdout with the trusted-listener probe printing `trusted`. */ - function bundleStdout(trusted: "0" | "1"): string { - return [ - "__CMUX_PROBE__", - JSON.stringify({ build_identity: "abc", remote_protocol: 12, version: "0.1.0" }), - "__CMUX_DEVICES__", - "[]", - "__CMUX_TRUSTED__", - trusted, - "__CMUX_END__", - ].join("\n"); - } - - /** - * A fake machine whose attach bundle answers `trusted[n]` on its n-th run. - * Every other exec (pin check, daemon restart, readiness status) succeeds. - */ - function attachFake(input: { readonly trusted: readonly ("0" | "1")[]; readonly manifest: "ok" | "down" }) { - const execs: string[] = []; - let bundles = 0; +describe("Freestyle openCmuxRemote: snapshot-v2 fast path", () => { + test("uses persisted network metadata without a guest probe or healing", async () => { + const commands: string[] = []; const vm = { - data: async () => PRIVATE, - exec: async ({ command }: { command: string }) => { - execs.push(command); - if (command.includes("__CMUX_PROBE__")) { - const trusted = input.trusted[Math.min(bundles, input.trusted.length - 1)] ?? "0"; - bundles += 1; - return { statusCode: 0, stdout: bundleStdout(trusted), stderr: "" }; - } - return { statusCode: 0, stdout: "", stderr: "" }; - }, + exec: async ({ command }: { command: string }) => { commands.push(command); return { statusCode: 0, stdout: "", stderr: "" }; }, }; const client = { vms: { ref: () => vm } } as unknown as Freestyle; - const provider = new FreestyleProvider({ - client: () => client, - resolveDaemonSource: async () => { - if (input.manifest === "down") throw new Error("manifest fetch failed"); - return SOURCE_OK; - }, + const provider = new FreestyleProvider({ client: () => client }); + const endpoint = await provider.openCmuxRemote(VM_ID, { + providerMetadata: { cmuxTuiContract: "snapshot-v2", networkIpv4: "10.4.0.7", networkIpv6: "fd00:4::7" }, }); - return { provider, execs, bundles: () => bundles }; - } - - test("a daemon that already serves the trusted listener attaches without reading the manifest", async () => { - const fake = attachFake({ trusted: ["1"], manifest: "down" }); - const endpoint = await fake.provider.openCmuxRemote(VM_ID, { clientCapabilities: [] }); - expect(endpoint.trustedCarrier).toBe(true); - expect(endpoint.route).toBe("ws://10.4.0.7:1337/v1/link"); - expect(fake.bundles()).toBe(1); - expect(fake.execs.some((command) => command.includes("systemctl restart cmux-tui-daemon"))).toBe(false); + expect(endpoint).toMatchObject({ route: "ws://10.4.0.7:1337/v1/link", trustedCarrier: true }); + expect(commands).toEqual([]); }); - test("an older daemon is replaced with the pinned build and the retried bundle proves trusted mode", async () => { - const fake = attachFake({ trusted: ["0", "1"], manifest: "ok" }); - const endpoint = await fake.provider.openCmuxRemote(VM_ID, { clientCapabilities: [] }); - expect(endpoint.trustedCarrier).toBe(true); - expect(fake.bundles()).toBe(2); - const start = fake.execs.find((command) => command.includes("systemctl restart cmux-tui-daemon")); - expect(start).toBeDefined(); - // The heal replaces a fallback daemon rather than keeping the untrusted one. - expect(start).toContain("pkill -f 'cmux-tui server [s]tart'"); - }); - - test("a heal that leaves the daemon untrusted fails closed instead of returning an unusable endpoint", async () => { - const fake = attachFake({ trusted: ["0", "0"], manifest: "ok" }); - await expect(fake.provider.openCmuxRemote(VM_ID, { clientCapabilities: [] })).rejects.toThrow(ProviderError); - await expect(fake.provider.openCmuxRemote(VM_ID, { clientCapabilities: [] })).rejects.toThrow(/still refuses the trusted listener/); - }); -}); - -describe("Freestyle openCmuxRemote: agent hooks on a healthy daemon", () => { - const PRIVATE = { publicIpv6: "2602:f75c:0:1::2a", vpcs: [{ ipv4: "10.4.0.7", ipv6: "fd00:4::7" }] }; - const PIN_COMMIT = "5a4780614cecd8e8ef040a24478f928ef31cc4ae"; - const SOURCE = { url: "https://files.cmux.com/cmux-tui/abc/cmux-tui-linux-x64", sha256: "0".repeat(64), commit: PIN_COMMIT, builtAt: null, hookUrl: `https://files.cmux.com/cmux-tui/${PIN_COMMIT}/cmux-tui-hook-x86_64-unknown-linux-musl`, hookSha256: "1".repeat(64) }; - - /** - * A machine whose daemon is healthy and trusted; `hooksReady` is what the - * hooks-ready probe exits, `pin` what /etc/cmux/cmux-tui-pin holds. Records - * every manifest URL the driver resolved. - */ - function hooksFake(input: { readonly hooksReady: number; readonly pin: string; readonly manifest?: "ok" | "missing-helper" }) { - const execs: string[] = []; - const manifests: (string | undefined)[] = []; + test("a machine created before the contract was recorded still attaches, with no guest work", async () => { + const commands: string[] = []; + let reads = 0; const vm = { - data: async () => PRIVATE, - exec: async ({ command }: { command: string }) => { - execs.push(command); - if (command.includes("__CMUX_PROBE__")) { - return { statusCode: 0, stdout: ["__CMUX_PROBE__", JSON.stringify({ build_identity: "abc", remote_protocol: 12, version: "0.1.0" }), "__CMUX_DEVICES__", "[]", "__CMUX_TRUSTED__", "1", "__CMUX_END__"].join("\n"), stderr: "" }; - } - if (command.includes("cmux-tui-pin")) return { statusCode: 0, stdout: `${input.pin}\n`, stderr: "" }; - if (command.includes(".local/share/cmux-tui/bin/cmux-tui-hook") && !command.includes("agent hook install")) { - return { statusCode: input.hooksReady, stdout: "", stderr: "" }; - } - return { statusCode: 0, stdout: "", stderr: "" }; - }, + data: async () => { reads += 1; return {}; }, + exec: async ({ command }: { command: string }) => { commands.push(command); return { statusCode: 0, stdout: "", stderr: "" }; }, }; const client = { vms: { ref: () => vm } } as unknown as Freestyle; - const provider = new FreestyleProvider({ - client: () => client, - resolveDaemonSource: async (_provider, manifestUrl) => { - manifests.push(manifestUrl); - if (input.manifest === "missing-helper") throw new ProviderError("freestyle", "manifest has no cmux-tui-hook"); - return SOURCE; - }, + const provider = new FreestyleProvider({ client: () => client }); + const endpoint = await provider.openCmuxRemote(VM_ID, { + providerMetadata: { networkIpv4: "10.4.0.8" }, }); - return { provider, execs, manifests }; - } - - test("a healthy daemon with hooks already installed is left alone", async () => { - const fake = hooksFake({ hooksReady: 0, pin: PIN_COMMIT }); - await fake.provider.openCmuxRemote(VM_ID, { clientCapabilities: [] }); - expect(fake.execs.some((command) => command.includes("agent hook install"))).toBe(false); - expect(fake.manifests).toEqual([]); + expect(endpoint).toMatchObject({ route: "ws://10.4.0.8:1337/v1/link", trustedCarrier: true }); + expect(reads).toBe(0); + expect(commands).toEqual([]); }); - test("a healthy daemon without hooks gets the helper of its own pinned commit and the Claude Code and Codex hooks, with no restart", async () => { - const fake = hooksFake({ hooksReady: 1, pin: PIN_COMMIT }); - await fake.provider.openCmuxRemote(VM_ID, { clientCapabilities: [] }); - // The bake's pin, not the rolling pointer: helper and daemon share a generation. - expect(fake.manifests).toEqual([`https://files.cmux.com/cmux-tui/${PIN_COMMIT}/manifest.json`]); - const install = fake.execs.find((command) => command.includes("agent hook install claude codex")); - expect(install).toBeDefined(); - expect(install).toContain(SOURCE.hookUrl); - expect(install).not.toContain(SOURCE.url); - expect(fake.execs.some((command) => command.includes("systemctl restart cmux-tui-daemon"))).toBe(false); - }); - - test("a machine created from the live pin (no pin file) takes the live manifest", async () => { - const fake = hooksFake({ hooksReady: 1, pin: "" }); - await fake.provider.openCmuxRemote(VM_ID, { clientCapabilities: [] }); - expect(fake.manifests).toEqual([undefined]); - expect(fake.execs.some((command) => command.includes("agent hook install claude codex"))).toBe(true); - }); - - test("a daemon that only needed a restart still gets its hooks, and a hook failure never fails the heal", async () => { - // The first attach bundle reports the daemon not ready (exit 3); the heal - // finds the pin intact, restarts, and must still reconcile hooks. The hook - // install itself fails here, and the attach still returns its route. - const execs: string[] = []; - let bundles = 0; + test("a row without recorded addresses reads them once from the provider and never execs", async () => { + const commands: string[] = []; + let reads = 0; const vm = { - data: async () => PRIVATE, - fs: { writeTextFile: async () => {}, remove: async () => {} }, - exec: async ({ command }: { command: string }) => { - execs.push(command); - if (command.includes("__CMUX_PROBE__")) { - bundles += 1; - if (bundles === 1) return { statusCode: 3, stdout: "", stderr: "" }; - return { statusCode: 0, stdout: ["__CMUX_PROBE__", JSON.stringify({ build_identity: "abc", remote_protocol: 12, version: "0.1.0" }), "__CMUX_DEVICES__", "[]", "__CMUX_TRUSTED__", "1", "__CMUX_END__"].join("\n"), stderr: "" }; - } - if (command.includes("agent hook install")) return { statusCode: 1, stdout: "", stderr: "helper download failed" }; - if (command.includes("cmux-tui-pin")) return { statusCode: 0, stdout: `${PIN_COMMIT}\n`, stderr: "" }; - if (command.includes(".local/share/cmux-tui/bin/cmux-tui-hook")) return { statusCode: 1, stdout: "", stderr: "" }; - if (command.includes("pgrep -f 'cmux-tui server [s]tart'") && !command.includes("systemctl restart")) return { statusCode: 1, stdout: "", stderr: "" }; - return { statusCode: 0, stdout: "", stderr: "" }; - }, + data: async () => { reads += 1; return { vpcs: [{ ipv4: "10.4.0.9", ipv6: "fd00:4::9" }] }; }, + exec: async ({ command }: { command: string }) => { commands.push(command); return { statusCode: 0, stdout: "", stderr: "" }; }, }; const client = { vms: { ref: () => vm } } as unknown as Freestyle; - const provider = new FreestyleProvider({ client: () => client, resolveDaemonSource: async () => SOURCE }); - const endpoint = await provider.openCmuxRemote(VM_ID, { clientCapabilities: [] }); - expect(endpoint.trustedCarrier).toBe(true); - expect(execs.some((command) => command.includes("systemctl restart cmux-tui-daemon"))).toBe(true); - expect(execs.some((command) => command.includes("agent hook install claude codex"))).toBe(true); - }); - - test("a pinned build published before the helper existed still attaches, without hooks", async () => { - const fake = hooksFake({ hooksReady: 1, pin: PIN_COMMIT, manifest: "missing-helper" }); - const endpoint = await fake.provider.openCmuxRemote(VM_ID, { clientCapabilities: [] }); - expect(endpoint.trustedCarrier).toBe(true); - expect(fake.execs.some((command) => command.includes("agent hook install"))).toBe(false); + const provider = new FreestyleProvider({ client: () => client }); + const endpoint = await provider.openCmuxRemote(VM_ID, { providerMetadata: {} }); + expect(endpoint).toMatchObject({ + route: "ws://10.4.0.9:1337/v1/link", + trustedCarrier: true, + networkAddresses: { ipv4: "10.4.0.9", ipv6: "fd00:4::9" }, + }); + expect(reads).toBe(1); + expect(commands).toEqual([]); }); }); @@ -910,7 +776,7 @@ describe("Freestyle port open: the private address, the desktop healed", () => { }, }; const client = { vms: { ref: () => vm } } as unknown as Freestyle; - return { provider: new FreestyleProvider({ client: () => client, resolveDaemonSource: async () => { throw new Error("unused"); } }), execs }; + return { provider: new FreestyleProvider({ client: () => client }), execs }; } test("address: private v4, then private v6, never public (the desktop has no auth of its own)", () => { @@ -986,7 +852,7 @@ describe("Go provider runtime ceiling", () => { test("a resume adds only the remaining billing-period allowance to prior provider runtime", async () => { const updates: unknown[] = []; const client = { vms: { ref: () => ({ data: async () => ({ totalRunSeconds: 3600 }), update: async (value: unknown) => { updates.push(value); } }) } } as unknown as Freestyle; - const provider = new FreestyleProvider({ client: () => client, resolveDaemonSource: async () => { throw new Error("unused"); } }); + const provider = new FreestyleProvider({ client: () => client }); await provider.setRuntimeBudget(VM_ID, 1800); await provider.setRuntimeBudget(VM_ID, 0); await provider.setRuntimeBudget(VM_ID, null); @@ -999,7 +865,7 @@ describe("Go provider runtime ceiling", () => { test("missing provider runtime fails closed", async () => { let updated = false; const client = { vms: { ref: () => ({ data: async () => ({}), update: async () => { updated = true; } }) } } as unknown as Freestyle; - const provider = new FreestyleProvider({ client: () => client, resolveDaemonSource: async () => { throw new Error("unused"); } }); + const provider = new FreestyleProvider({ client: () => client }); await expect(provider.setRuntimeBudget(VM_ID, 1800)).rejects.toThrow("setRuntimeBudget"); expect(updated).toBe(false); }); diff --git a/web/tests/vm-freestyle-snapshots.test.ts b/web/tests/vm-freestyle-snapshots.test.ts index 0a160a899bd2..b03e10748076 100644 --- a/web/tests/vm-freestyle-snapshots.test.ts +++ b/web/tests/vm-freestyle-snapshots.test.ts @@ -49,9 +49,6 @@ function providerWith(snapshots: { } as unknown as Freestyle; const provider = new FreestyleProvider({ client: () => client, - resolveDaemonSource: async () => { - throw new Error("unused"); - }, }); return { provider, calls }; } diff --git a/web/tests/vm-freestyle-stats.test.ts b/web/tests/vm-freestyle-stats.test.ts index d44ad8879c84..93dbbb3c08f2 100644 --- a/web/tests/vm-freestyle-stats.test.ts +++ b/web/tests/vm-freestyle-stats.test.ts @@ -49,7 +49,7 @@ async function readStats( throw new Error(`Unexpected mutation: ${path}`); }) as typeof fetch, }); - const provider = new FreestyleProvider({ client: () => client, resolveDaemonSource: async () => { throw new Error("Unexpected install"); } }); + const provider = new FreestyleProvider({ client: () => client }); const repo = { markProviderObservedStatus: ({ status }: { status: string }) => Effect.sync(() => { options.onDestroyed?.(status); return true; }), findUserVm: () => Effect.succeed({ provider: "freestyle", providerVmId: "vm-stats", billingTeamId: null, ownerTeamId: "user", diff --git a/web/tests/vm-guest-browser-attach.test.ts b/web/tests/vm-guest-browser-attach.test.ts index e735672dd317..22ab909d228c 100644 --- a/web/tests/vm-guest-browser-attach.test.ts +++ b/web/tests/vm-guest-browser-attach.test.ts @@ -1,53 +1,20 @@ -import { describe, expect, test } from "bun:test"; +import { expect, test } from "bun:test"; import type { Freestyle } from "freestyle"; import { FreestyleProvider } from "../services/vms/drivers/freestyle"; -import { GUEST_CMUX_SHIM } from "../services/vms/guestCli"; -describe("browser opener installation on an already healthy Cloud attach", () => { - function fixture(browserReady: boolean) { - const commands: string[] = []; - const writes: string[] = []; - const vm = { - data: async () => ({ vpcs: [{ ipv4: "10.4.0.7", ipv6: "fd00:4::7" }] }), - fs: { - writeTextFile: async (_path: string, content: string) => { writes.push(content); }, - remove: async () => {}, - }, - exec: async ({ command }: { command: string }) => { - commands.push(command); - if (command.includes("__CMUX_PROBE__")) { - return { - statusCode: 0, - stdout: ["__CMUX_PROBE__", JSON.stringify({ build_identity: "abc", remote_protocol: 12, version: "0.1.0" }), - "__CMUX_DEVICES__", "[]", "__CMUX_TRUSTED__", "1", "__CMUX_END__"].join("\n"), - stderr: "", - }; - } - const readinessProbe = command.includes("browser-opener-version") && !command.includes("mktemp"); - return { statusCode: readinessProbe && !browserReady ? 1 : 0, stdout: "", stderr: "" }; - }, - }; - const client = { vms: { ref: () => vm } } as unknown as Freestyle; - const provider = new FreestyleProvider({ client: () => client, resolveDaemonSource: async () => { - throw new Error("A healthy attach must not replace the running daemon"); - } }); - return { provider, commands, writes }; - } - - test("installs missing browser integration before returning an existing terminal's route", async () => { - const { provider, commands, writes } = fixture(false); - const result = await provider.openCmuxRemote("vm-browser-attach", { clientCapabilities: [] }); - expect(result.trustedCarrier).toBe(true); - expect(writes).toEqual([GUEST_CMUX_SHIM]); - expect(commands.some((command) => command.includes("mktemp") && command.includes("cmux-open-url"))).toBe(true); - expect(commands.some((command) => command.includes("systemctl restart cmux-tui-daemon"))).toBe(false); - }); - - test("checks installed browser integration without rewriting it or restarting the daemon", async () => { - const { provider, commands, writes } = fixture(true); - await provider.openCmuxRemote("vm-browser-attach", { clientCapabilities: [] }); - expect(commands.some((command) => command.includes("browser-opener-version"))).toBe(true); - expect(writes).toEqual([]); - expect(commands.some((command) => command.includes("systemctl restart cmux-tui-daemon"))).toBe(false); +test("snapshot-v2 attach does not install browser integration or run guest healing", async () => { + const commands: string[] = []; + const vm = { + exec: async ({ command }: { command: string }) => { + commands.push(command); + return { statusCode: 0, stdout: "", stderr: "" }; + }, + }; + const client = { vms: { ref: () => vm } } as unknown as Freestyle; + const provider = new FreestyleProvider({ client: () => client }); + const result = await provider.openCmuxRemote("vm-browser-attach", { + providerMetadata: { cmuxTuiContract: "snapshot-v2", networkIpv4: "10.4.0.7", networkIpv6: "fd00:4::7" }, }); + expect(result).toMatchObject({ route: "ws://10.4.0.7:1337/v1/link", trustedCarrier: true }); + expect(commands).toEqual([]); }); diff --git a/web/tests/vm-guest-prompt.test.ts b/web/tests/vm-guest-prompt.test.ts index 640d6c7deeda..5809b88d7c38 100644 --- a/web/tests/vm-guest-prompt.test.ts +++ b/web/tests/vm-guest-prompt.test.ts @@ -228,4 +228,28 @@ finally: install(directory, "clone-name", 50, "vm-two"); expect(readFileSync(path.join(directory, "vm-name"), "utf8")).toBe("clone-name\n"); }); + + test("prompt sync treats a name written into vm-name as published, and ignores the baked default", () => { + const directory = fixture(); + writeFileSync(path.join(directory, "vm-name"), "cmux\n"); + const script = path.join(import.meta.dirname, "../services/vms/images/devbox/cmux-prompt-sync"); + const result = spawnSync("python3", ["-c", String.raw` +import importlib.util, importlib.machinery, pathlib, sys, threading, time +sys.dont_write_bytecode = True +loader = importlib.machinery.SourceFileLoader("prompt_sync", sys.argv[1]) +spec = importlib.util.spec_from_loader("prompt_sync", loader) +module = importlib.util.module_from_spec(spec); loader.exec_module(module) +directory = pathlib.Path(sys.argv[2]) +ready = threading.Event() +thread = threading.Thread(target=module.watch_local_name, args=(directory, ready, 5.0), daemon=True) +thread.start() +time.sleep(0.5) +print("default", ready.is_set()) +(directory / "vm-name").write_text("shiny-cobalt-lizard\n") +print("named", ready.wait(2.0)) +`, script, directory], { encoding: "utf8" }); + expect(result.stderr).toBe(""); + expect(result.stdout.trim().split("\n")).toEqual(["default False", "named True"]); + }); }); + diff --git a/web/tests/vm-guest-setup-concurrency.test.ts b/web/tests/vm-guest-setup-concurrency.test.ts index 8f52c771be77..15a846f8010b 100644 --- a/web/tests/vm-guest-setup-concurrency.test.ts +++ b/web/tests/vm-guest-setup-concurrency.test.ts @@ -2,87 +2,48 @@ import { expect, test } from "bun:test"; import type { Freestyle } from "freestyle"; import { FreestyleProvider } from "../services/vms/drivers/freestyle"; -function gate() { - let release!: () => void; - const promise = new Promise(resolve => { release = resolve; }); - return { promise, release }; -} - -const bundle = ["__CMUX_PROBE__", '{"build_identity":"abc","remote_protocol":12}', - "__CMUX_DEVICES__", "[]", "__CMUX_TRUSTED__", "1", "__CMUX_END__"].join("\n"); +const VM_ID = "vm-fixture"; +const NETWORK = { vpcs: [{ ipv4: "10.4.0.7", ipv6: "fd00:4::7" }] }; function fixture() { - const entered = gate(), release = gate(); - const commands: string[] = [], deleted: string[] = []; - const data = { vpcs: [{ ipv4: "10.4.0.7", ipv6: "fd00:4::7" }], resources: { cpu: 64, memory: 131072, storage: 1048576 } }; + const commands: string[] = []; + const writes: string[] = []; const vm = { - data: async () => data, + data: async () => NETWORK, exec: async ({ command }: { command: string }) => { commands.push(command); - return { statusCode: 0, stdout: command.includes("__CMUX_PROBE__") ? bundle : "", stderr: "" }; + return { statusCode: 0, stdout: "", stderr: "" }; }, - fs: { writeTextFile: async () => {}, remove: async () => {} }, - delete: async () => { deleted.push("vm-fixture"); }, + fs: { writeTextFile: async (path: string) => { writes.push(path); }, remove: async () => {} }, + delete: async () => {}, }; - const client = { vms: { create: async () => ({ vm, vmId: "vm-fixture", data }), ref: () => vm } } as unknown as Freestyle; - const provider = new FreestyleProvider({ client: () => client, resolveDaemonSource: async () => { throw new Error("No install expected"); } }); - return { provider, vm, commands, deleted, entered, release }; + const client = { + vms: { + create: async () => ({ vm, vmId: VM_ID, data: { ...NETWORK, publicIpv6: "2602:f75c:0:1::2a" } }), + ref: () => vm, + }, + } as unknown as Freestyle; + const provider = new FreestyleProvider({ + client: () => client, + }); + return { provider, commands, writes }; } -test("VM create overlaps independent reporter setup with CLI upload", async () => { +test("snapshot-v2 create has no guest setup or resize phase", async () => { const f = fixture(); - f.vm.fs.writeTextFile = async () => { f.entered.release(); await f.release.promise; }; - const creating = f.provider.create({ image: "sh-fixture", network: { id: "vpc-fixture" } }); - await f.entered.promise; - const overlapped = f.commands.some(command => command.includes("cmux-resource-stats.service")); - f.release.release(); - await creating; - expect(overlapped).toBe(true); - expect(f.deleted).toEqual([]); + const result = await f.provider.create({ image: "sh-snapshot-v2", network: { id: "vpc-fixture" } }); + expect(result.providerMetadata).toMatchObject({ cmuxTuiContract: "snapshot-v2" }); + expect(f.commands).toEqual([]); + expect(f.writes).toEqual([]); }); -test("healthy attach overlaps hooks and reporter with the required CLI check", async () => { - const f = fixture(), exec = f.vm.exec; - f.vm.exec = async input => { - const result = await exec(input); - if (input.command.includes("sha256sum")) { f.entered.release(); await f.release.promise; } - return result; - }; - const attaching = f.provider.openCmuxRemote("vm-fixture"); - await f.entered.promise; - const overlapping = { - hooks: f.commands.some(command => command.includes("agent hook status")), - reporter: f.commands.some(command => command.includes("cmux-resource-stats.service")), - }; - f.release.release(); - const endpoint = await attaching; - expect(overlapping).toEqual({ hooks: true, reporter: true }); - expect(endpoint.trustedCarrier).toBe(true); -}); - -test("create failure settles independent setup before destroying the VM", async () => { +test("snapshot-v2 attach is a persisted-route read with no healing calls", async () => { const f = fixture(); - f.vm.fs.writeTextFile = async () => { - f.entered.release(); - await f.release.promise; - throw new Error("required CLI upload failed"); - }; - let reporterFinished = false; - const finishReporter = gate(), exec = f.vm.exec; - f.vm.exec = async input => { - if (input.command.includes("cmux-resource-stats.service")) { - await finishReporter.promise; - reporterFinished = true; - } - return exec(input); - }; - f.vm.delete = async () => { expect(reporterFinished).toBe(true); f.deleted.push("vm-fixture"); }; - const creating = f.provider.create({ image: "sh-fixture", network: { id: "vpc-fixture" } }); - // Attach the rejection handler before releasing either operation. - const result = creating.then(() => null, error => error); - await f.entered.promise; - f.release.release(); - finishReporter.release(); - expect(await result).toBeInstanceOf(Error); - expect(f.deleted).toEqual(["vm-fixture"]); + const result = await f.provider.openCmuxRemote(VM_ID, { + providerMetadata: { cmuxTuiContract: "snapshot-v2", networkIpv4: "10.4.0.7", networkIpv6: "fd00:4::7" }, + }); + expect(result.route).toBe("ws://10.4.0.7:1337/v1/link"); + expect(result.trustedCarrier).toBe(true); + expect(f.commands).toEqual([]); + expect(f.writes).toEqual([]); }); diff --git a/web/tests/vm-resource-reporter-install.test.ts b/web/tests/vm-resource-reporter-install.test.ts index 92a2db38c26f..501c83a012e2 100644 --- a/web/tests/vm-resource-reporter-install.test.ts +++ b/web/tests/vm-resource-reporter-install.test.ts @@ -2,40 +2,21 @@ import { describe, expect, test } from "bun:test"; import type { Freestyle } from "freestyle"; import { FreestyleProvider } from "../services/vms/drivers/freestyle"; -function fixture(cliFails = false) { - const deleted: string[] = []; +function fixture() { const commands: string[] = []; - const vmId = "vm-resource-reporter-test"; - const data = { id: vmId, state: "running", snapshotId: "sh-test", publicIpv6: "2602:f75c:0:1::2a", - resources: { cpu: 64, memory: 131072, storage: 1048576 }, vpcs: [{ ipv4: "10.16.0.2", ipv6: "fd00::2" }] }; - const vm = { - exec: async ({ command }: { command: string }) => { - commands.push(command); - const fails = command.includes("cmux-resource-stats.service") || (cliFails && command.includes("mv -f")); - return { statusCode: fails ? 1 : 0, stdout: "", stderr: fails ? "systemd unavailable" : "" }; - }, - fs: { writeTextFile: async () => {}, remove: async () => {} }, - delete: async () => { deleted.push(vmId); }, - }; - const client = { vms: { create: async () => ({ vm, vmId, data }), get: async () => data } } as unknown as Freestyle; - const provider = new FreestyleProvider({ client: () => client, resolveDaemonSource: async () => { throw new Error("No daemon install expected"); } }); - return { provider, deleted, commands }; + const writes: string[] = []; + const data = { id: "vm-snapshot-contract", state: "running", snapshotId: "sh-snapshot-v2", publicIpv6: "2602:f75c:0:1::2a", resources: { cpu: 8, memory: 16384, storage: 65536 }, vpcs: [{ ipv4: "10.16.0.2", ipv6: "fd00::2" }] }; + const vm = { exec: async ({ command }: { command: string }) => { commands.push(command); return { statusCode: 0, stdout: "", stderr: "" }; }, fs: { writeTextFile: async (path: string) => { writes.push(path); }, remove: async () => {} }, delete: async () => {} }; + const client = { vms: { create: async () => ({ vm, vmId: data.id, data }) } } as unknown as Freestyle; + return { provider: new FreestyleProvider({ client: () => client }), commands, writes }; } -describe("advisory resource reporter installation", () => { - test.each(["create", "restore"])("reporter failure does not roll back %s", async (operation) => { - const { provider, deleted, commands } = fixture(); - const network = { id: "vpc-resource-test" }; - const handle = operation === "create" ? await provider.create({ image: "sh-test", network }) : await provider.restore("sh-test", { network }); - expect(handle.providerVmId).toBe("vm-resource-reporter-test"); - expect(commands.some(command => command.includes("cmux-resource-stats.service"))).toBe(true); - expect(deleted).toEqual([]); +describe("snapshot-v2 guest contract", () => { + test.each(["create", "restore"])("does not install guest assets during %s", async (operation) => { + const { provider, commands, writes } = fixture(); + const result = operation === "create" ? await provider.create({ image: "sh-snapshot-v2", imageSize: { name: "md", cpu: 8, memoryMb: 16384, storageMb: 65536 }, network: { id: "vpc-snapshot" } }) : await provider.restore("sh-snapshot-v2", { network: { id: "vpc-snapshot" } }); + expect(result.providerMetadata).toMatchObject({ cmuxTuiContract: "snapshot-v2" }); + expect(commands).toEqual([]); + expect(writes).toEqual([]); }); - - test("required CLI installation failure still rolls back the allocated machine", async () => { - const { provider, deleted } = fixture(true); - await expect(provider.create({ image: "sh-test" })).rejects.toThrow(); - expect(deleted).toEqual(["vm-resource-reporter-test"]); - }); - }); diff --git a/web/tests/vm-route-auth.test.ts b/web/tests/vm-route-auth.test.ts index 3497d939eb71..d2a4827abf7e 100644 --- a/web/tests/vm-route-auth.test.ts +++ b/web/tests/vm-route-auth.test.ts @@ -472,6 +472,9 @@ describe("VM REST auth", () => { provider: "freestyle", image: "snapshot-test", createdAt: 1_777_000_000_000, + addressIpv4: "10.16.0.9", + addressIpv6: null, + cmuxTuiContract: "snapshot-v2", }); const response = await POST( @@ -501,6 +504,10 @@ describe("VM REST auth", () => { persistentHome: false, attachTransports: ["cmux-remote"], }, + // New Machine dials the baked daemon from these two fields instead of + // re-reading the fleet and calling POST /attach-endpoint. + address: { ipv4: "10.16.0.9", ipv6: null }, + cmuxTuiContract: "snapshot-v2", }); expect(createVm).toHaveBeenCalledWith(expect.objectContaining({ userId: "user-1", diff --git a/web/tests/vm-scp.test.ts b/web/tests/vm-scp.test.ts index e38417754bd2..ac7e98f49450 100644 --- a/web/tests/vm-scp.test.ts +++ b/web/tests/vm-scp.test.ts @@ -32,7 +32,7 @@ describe("private SCP authentication", () => { return { statusCode: 0, stdout: key + " guest\n", stderr: "" }; }, }) } } as unknown as Freestyle; - const provider = new FreestyleProvider({ client: () => client, resolveDaemonSource: async () => { throw new Error("unused"); } }); + const provider = new FreestyleProvider({ client: () => client }); const endpoint = await provider.prepareSCP(vmId, key); expect(endpoint.host).toBe("10.4.0.7"); expect(endpoint.username).toBe("cmux"); @@ -51,7 +51,7 @@ describe("private SCP authentication", () => { data: async () => ({ publicIpv6: "2602::1", vpcs: [] }), exec: async () => { execs++; return { statusCode: 0, stdout: key, stderr: "" }; }, }) } } as unknown as Freestyle; - const provider = new FreestyleProvider({ client: () => client, resolveDaemonSource: async () => { throw new Error("unused"); } }); + const provider = new FreestyleProvider({ client: () => client }); await expect(provider.prepareSCP(vmId, key)).rejects.toThrow("private network"); expect(execs).toBe(0); }); diff --git a/web/tests/vm-stats-not-found.test.ts b/web/tests/vm-stats-not-found.test.ts index 2571f9ee1078..8058bf92070b 100644 --- a/web/tests/vm-stats-not-found.test.ts +++ b/web/tests/vm-stats-not-found.test.ts @@ -45,7 +45,6 @@ async function withStatsFixture( }); const driver = new FreestyleProvider({ client: () => client, - resolveDaemonSource: async () => { throw new Error("Stats must not install anything"); }, }); const getStats = spyOn(getProvider("freestyle"), "getStats").mockImplementation((id) => driver.getStats(id)); const repo = new Proxy({