diff --git a/.github/pr-assets/260921-lane-d-consent-surface.png b/.github/pr-assets/260921-lane-d-consent-surface.png new file mode 100644 index 00000000000..44a46139e4e Binary files /dev/null and b/.github/pr-assets/260921-lane-d-consent-surface.png differ diff --git a/.github/pr-assets/opencodex-cache-usage.png b/.github/pr-assets/opencodex-cache-usage.png new file mode 100644 index 00000000000..6011c89b57f Binary files /dev/null and b/.github/pr-assets/opencodex-cache-usage.png differ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 003415f3af5..c7d1c5de8f3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,8 @@ on: - "bin/**" - "tests/**" - "scripts/**" + - "app/**" + - "desktop/**" - "gui/**" - "assets/**" - ".gitattributes" @@ -213,6 +215,8 @@ jobs: - 'bin/**' - 'tests/**' - 'scripts/**' + - 'app/**' + - 'desktop/**' - 'gui/**' - 'assets/**' - '.gitattributes' @@ -1147,6 +1151,118 @@ jobs: # `if: always()` is load-bearing. Without it, a failed or skipped dependency # skips this job too — and GitHub reports a skipped job as success, so the gate # would go green precisely when something went wrong. + widget: + name: macos widget + bundle + needs: [changes, gates] + if: github.event_name != 'pull_request' || needs.changes.outputs.ci == 'true' + runs-on: macos-latest + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: 1.3.14 + + - name: Install dependencies + run: | + bun install --frozen-lockfile + cd desktop + bun install --frozen-lockfile + + - name: Setup Rust + uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # master + with: + toolchain: stable + + - name: Test MenuBarCore + run: bun run test:macos + + - name: Build dashboard + run: bun run build:gui + + - name: Prepare desktop sidecar + run: bun desktop/scripts/prepare-sidecar.ts + + - name: Build WidgetKit appex + run: bash desktop/scripts/build-widget.sh + + - name: Build unsigned desktop app + working-directory: desktop + # `createUpdaterArtifacts` is on and the updater public key is committed, so a plain + # `tauri build` stops with "A public key has been found, but no private key" unless + # TAURI_SIGNING_PRIVATE_KEY is set. This job proves the appex and the app bundle build + # and that the widget is embedded; it does not ship an update, and a verification + # build has no business holding the release key. Updater artifacts are therefore off + # here and the signing path stays in release.yml, which already reads the secret and + # refuses to publish a manifest when it is absent. + run: bunx tauri build --ci --bundles app --config '{"bundle":{"createUpdaterArtifacts":false}}' + + - name: Verify WidgetKit appex and desktop app + run: | + app=desktop/src-tauri/target/release/bundle/macos/OpenCodex.app + # Tauri renames the main binary only when `mainBinaryName` is set, and this config + # does not set it, so the bundled executable keeps the Cargo bin name rather than + # the product name. Read the name the bundle itself declares instead of restating + # it here, so this check follows the config instead of drifting from it. + executable="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' "$app/Contents/Info.plist")" + test -n "$executable" + test -x "$app/Contents/MacOS/$executable" + test -x "$app/Contents/PlugIns/OpenCodexWidget.appex/Contents/MacOS/OpenCodexWidget" + test -x "$app/Contents/MacOS/ocx" + codesign -dv "$app/Contents/PlugIns/OpenCodexWidget.appex" + # The widget is only offered in the gallery when its bundle is actually linked in, and + # nothing else here would notice its absence: the appex builds, signs and registers + # exactly the same way with the WidgetBundle dropped by the linker. + nm -a "$app/Contents/PlugIns/OpenCodexWidget.appex/Contents/MacOS/OpenCodexWidget" \ + | grep -q "OpenCodexWidget0abC6BundleV" \ + || { echo "::error::the widget bundle is not linked into the extension"; exit 1; } + + desktop-shell: + name: desktop shell + needs: [changes, gates] + if: github.event_name != 'pull_request' || needs.changes.outputs.ci == 'true' + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false + + - name: Install Tauri Linux dependencies + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf + + - name: Setup Rust + uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # master + with: + toolchain: stable + components: rustfmt, clippy + + - name: Prepare desktop check resources + run: | + set -euo pipefail + triple="$(rustc -vV | sed -n 's/^host: //p')" + mkdir -p desktop/src-tauri/binaries desktop/src-tauri/resources/gui/dist + : > "desktop/src-tauri/binaries/ocx-${triple}" + chmod +x "desktop/src-tauri/binaries/ocx-${triple}" + : > desktop/src-tauri/resources/gui/dist/.keep + + - name: Check Rust formatting + run: cargo fmt --manifest-path desktop/src-tauri/Cargo.toml --check + + - name: Run Rust clippy + run: cargo clippy --manifest-path desktop/src-tauri/Cargo.toml --all-targets -- -D warnings + + - name: Run Rust tests + run: cargo test --manifest-path desktop/src-tauri/Cargo.toml + ci: name: ci if: always() @@ -1154,7 +1270,7 @@ jobs: # direct dependencies only, so a failing `select-windows-runner` would # otherwise reach this gate as nothing at all while its dependents report # `skipped`, which is the shape the step below is written to catch. - needs: [changes, select-windows-runner, test, storage-policy, api-usage, gates, platform-macos, macos-control, platform-windows, keyring-smoke, docker-smoke, docs-site-build, structure-gate, npm-global-smoke] + needs: [changes, select-windows-runner, test, storage-policy, api-usage, gates, platform-macos, macos-control, platform-windows, keyring-smoke, docker-smoke, docs-site-build, structure-gate, npm-global-smoke, widget, desktop-shell] runs-on: ubuntu-latest timeout-minutes: 5 permissions: @@ -1220,12 +1336,15 @@ jobs: GATED_JOBS="changes select-windows-runner test storage-policy api-usage gates" GATED_JOBS="$GATED_JOBS platform-macos keyring-smoke docker-smoke npm-global-smoke" GATED_JOBS="$GATED_JOBS macos-control platform-windows docs-site-build" - GATED_JOBS="$GATED_JOBS structure-gate" + GATED_JOBS="$GATED_JOBS structure-gate widget" + GATED_JOBS="$GATED_JOBS desktop-shell" expected_for() { case "$1" in changes|select-windows-runner) echo requested ;; - test|storage-policy|api-usage|gates|platform-macos|keyring-smoke|docker-smoke) + test|storage-policy|api-usage|gates|platform-macos|keyring-smoke|docker-smoke|widget) + echo "$scoped" ;; + desktop-shell) echo "$scoped" ;; npm-global-smoke) echo "$packaging" ;; docs-site-build) echo "$docs" ;; diff --git a/.github/workflows/desktop-installed-gate.yml b/.github/workflows/desktop-installed-gate.yml new file mode 100644 index 00000000000..8782cd84623 --- /dev/null +++ b/.github/workflows/desktop-installed-gate.yml @@ -0,0 +1,277 @@ +name: desktop installed-artifact gate + +# D9 part two: install the real artifact on a machine per platform, launch it against a +# staged npm runtime, and exercise the ownership contract — takeover, the gestures that +# must leave the runtime alive, tray Quit draining an in-flight request, and on Linux +# both update paths (R3). Runs only on maintainer-registered self-hosted GUI machines; +# publication wiring into release.yml is a separate change. + +on: + workflow_dispatch: + inputs: + version: + description: Release version whose desktop artifacts the gate installs + required: true + type: string + from-version: + description: Older release used for the staged npm runtime and the Linux update phases + required: true + type: string + # Hook inputs are FILE NAMES, never command text. The runner's operator installs + # audited executables in a hooks directory (vars.OPENCODEX_GATE_HOOKS_DIR) and a + # dispatch picks among them by name; the gate executes the file directly, so this + # workflow can never become an arbitrary-shell surface on a persistent runner. + consent-hook: + description: Name of the runner hook that answers the takeover consent prompt + required: false + type: string + tray-click-hook: + description: Name of the runner hook that left-clicks the tray icon + required: false + type: string + tray-quit-hook: + description: Name of the runner hook that opens the tray menu and chooses Quit + required: false + type: string + tray-check-hook: + description: Name of the runner hook that chooses Check for Updates in the tray + required: false + type: string + tray-install-hook: + description: Name of the runner hook that chooses Install update in the tray + required: false + type: string + elevate-accept-hook: + description: Name of the runner hook that answers the deb update's elevation prompt (drives the accept path) + required: false + type: string + +permissions: + contents: read + +concurrency: + group: desktop-installed-gate-${{ inputs.version }} + cancel-in-progress: false + +jobs: + macos: + runs-on: [self-hosted, opencodex-gate-macos] + timeout-minutes: 60 + # Required-review environment: no run reaches the GUI runner without a maintainer + # approval, and the checkout below pins the driver to the protected dev branch, so a + # dispatched ref cannot smuggle modified gate code onto the machine. + environment: opencodex-desktop-gate + defaults: + run: + shell: bash + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + ref: dev + persist-credentials: false + + - name: Setup project Bun + uses: ./.github/actions/setup-project-bun + + - name: Download the release artifact + env: + GH_TOKEN: ${{ github.token }} + RELEASE_VERSION: ${{ inputs.version }} + GATE_ARTIFACTS: ${{ runner.temp }}/gate-artifacts + run: | + mkdir -p "$GATE_ARTIFACTS" + gh release download "v${RELEASE_VERSION}" \ + --pattern "OpenCodex-${RELEASE_VERSION}-macos.dmg" \ + --dir "$GATE_ARTIFACTS" \ + --clobber + + - name: Run the installed-artifact gate + env: + RELEASE_VERSION: ${{ inputs.version }} + FROM_VERSION: ${{ inputs.from-version }} + CONSENT_HOOK: ${{ inputs.consent-hook }} + TRAY_CLICK_HOOK: ${{ inputs.tray-click-hook }} + TRAY_QUIT_HOOK: ${{ inputs.tray-quit-hook }} + GATE_HOOKS_DIR: ${{ vars.OPENCODEX_GATE_HOOKS_DIR }} + GATE_ARTIFACTS: ${{ runner.temp }}/gate-artifacts + GATE_WORK: ${{ runner.temp }}/installed-gate + GATE_REPORT: ${{ runner.temp }}/installed-gate-report.json + run: | + set -euo pipefail + args=( + --platform macos --format dmg + --artifact "$GATE_ARTIFACTS/OpenCodex-${RELEASE_VERSION}-macos.dmg" + --work-dir "$GATE_WORK" + --to-version "$RELEASE_VERSION" + --from-version "$FROM_VERSION" + --report "$GATE_REPORT" + ) + if [ -n "$GATE_HOOKS_DIR" ]; then args+=(--hooks-dir "$GATE_HOOKS_DIR"); fi + for pair in "consent-hook:CONSENT_HOOK" "tray-click-hook:TRAY_CLICK_HOOK" "tray-quit-hook:TRAY_QUIT_HOOK"; do + name="${pair%%:*}"; env_name="${pair##*:}" + value="${!env_name}" + if [ -n "$value" ]; then args+=("--${name}" "$value"); fi + done + bun desktop/scripts/installed-gate.ts "${args[@]}" + + - name: Upload the gate report + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: installed-gate-report-macos + path: ${{ runner.temp }}/installed-gate-report.json + if-no-files-found: error + + windows: + runs-on: [self-hosted, opencodex-gate-windows] + timeout-minutes: 60 + environment: opencodex-desktop-gate + defaults: + run: + shell: bash + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + ref: dev + persist-credentials: false + + - name: Setup project Bun + uses: ./.github/actions/setup-project-bun + + - name: Download the release artifact + env: + GH_TOKEN: ${{ github.token }} + RELEASE_VERSION: ${{ inputs.version }} + GATE_ARTIFACTS: ${{ runner.temp }}/gate-artifacts + run: | + mkdir -p "$GATE_ARTIFACTS" + gh release download "v${RELEASE_VERSION}" \ + --pattern "OpenCodex-${RELEASE_VERSION}-windows-x64.msi" \ + --dir "$GATE_ARTIFACTS" \ + --clobber + + - name: Run the installed-artifact gate + env: + RELEASE_VERSION: ${{ inputs.version }} + FROM_VERSION: ${{ inputs.from-version }} + CONSENT_HOOK: ${{ inputs.consent-hook }} + TRAY_CLICK_HOOK: ${{ inputs.tray-click-hook }} + TRAY_QUIT_HOOK: ${{ inputs.tray-quit-hook }} + GATE_HOOKS_DIR: ${{ vars.OPENCODEX_GATE_HOOKS_DIR }} + GATE_ARTIFACTS: ${{ runner.temp }}/gate-artifacts + GATE_WORK: ${{ runner.temp }}/installed-gate + GATE_REPORT: ${{ runner.temp }}/installed-gate-report.json + run: | + set -euo pipefail + args=( + --platform windows --format msi + --artifact "$GATE_ARTIFACTS/OpenCodex-${RELEASE_VERSION}-windows-x64.msi" + --work-dir "$GATE_WORK" + --to-version "$RELEASE_VERSION" + --from-version "$FROM_VERSION" + --report "$GATE_REPORT" + ) + if [ -n "$GATE_HOOKS_DIR" ]; then args+=(--hooks-dir "$GATE_HOOKS_DIR"); fi + for pair in "consent-hook:CONSENT_HOOK" "tray-click-hook:TRAY_CLICK_HOOK" "tray-quit-hook:TRAY_QUIT_HOOK"; do + name="${pair%%:*}"; env_name="${pair##*:}" + value="${!env_name}" + if [ -n "$value" ]; then args+=("--${name}" "$value"); fi + done + bun desktop/scripts/installed-gate.ts "${args[@]}" + + - name: Upload the gate report + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: installed-gate-report-windows + path: ${{ runner.temp }}/installed-gate-report.json + if-no-files-found: error + + linux: + runs-on: [self-hosted, opencodex-gate-linux] + timeout-minutes: 60 + environment: opencodex-desktop-gate + strategy: + fail-fast: false + matrix: + format: [deb, appimage] + include: + - format: deb + suffix: linux-amd64.deb + - format: appimage + suffix: linux-x86_64.AppImage + defaults: + run: + shell: bash + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + ref: dev + persist-credentials: false + + - name: Setup project Bun + uses: ./.github/actions/setup-project-bun + + - name: Download the release artifacts + env: + GH_TOKEN: ${{ github.token }} + RELEASE_VERSION: ${{ inputs.version }} + FROM_VERSION: ${{ inputs.from-version }} + ARTIFACT_SUFFIX: ${{ matrix.suffix }} + GATE_ARTIFACTS: ${{ runner.temp }}/gate-artifacts + run: | + mkdir -p "$GATE_ARTIFACTS" + gh release download "v${RELEASE_VERSION}" \ + --pattern "OpenCodex-${RELEASE_VERSION}-${ARTIFACT_SUFFIX}" \ + --dir "$GATE_ARTIFACTS" \ + --clobber + gh release download "v${FROM_VERSION}" \ + --pattern "OpenCodex-${FROM_VERSION}-${ARTIFACT_SUFFIX}" \ + --dir "$GATE_ARTIFACTS" \ + --clobber + + - name: Run the installed-artifact gate + env: + RELEASE_VERSION: ${{ inputs.version }} + FROM_VERSION: ${{ inputs.from-version }} + GATE_FORMAT: ${{ matrix.format }} + ARTIFACT_SUFFIX: ${{ matrix.suffix }} + CONSENT_HOOK: ${{ inputs.consent-hook }} + TRAY_CLICK_HOOK: ${{ inputs.tray-click-hook }} + TRAY_QUIT_HOOK: ${{ inputs.tray-quit-hook }} + TRAY_CHECK_HOOK: ${{ inputs.tray-check-hook }} + TRAY_INSTALL_HOOK: ${{ inputs.tray-install-hook }} + ELEVATE_ACCEPT_HOOK: ${{ inputs.elevate-accept-hook }} + GATE_HOOKS_DIR: ${{ vars.OPENCODEX_GATE_HOOKS_DIR }} + GATE_ARTIFACTS: ${{ runner.temp }}/gate-artifacts + GATE_WORK: ${{ runner.temp }}/installed-gate + GATE_REPORT: ${{ runner.temp }}/installed-gate-report.json + run: | + set -euo pipefail + args=( + --platform linux --format "$GATE_FORMAT" + --artifact "$GATE_ARTIFACTS/OpenCodex-${RELEASE_VERSION}-${ARTIFACT_SUFFIX}" + --older-artifact "$GATE_ARTIFACTS/OpenCodex-${FROM_VERSION}-${ARTIFACT_SUFFIX}" + --work-dir "$GATE_WORK" + --to-version "$RELEASE_VERSION" + --from-version "$FROM_VERSION" + --report "$GATE_REPORT" + ) + if [ -n "$GATE_HOOKS_DIR" ]; then args+=(--hooks-dir "$GATE_HOOKS_DIR"); fi + for pair in "consent-hook:CONSENT_HOOK" "tray-click-hook:TRAY_CLICK_HOOK" "tray-quit-hook:TRAY_QUIT_HOOK" "tray-check-hook:TRAY_CHECK_HOOK" "tray-install-hook:TRAY_INSTALL_HOOK" "elevate-accept-hook:ELEVATE_ACCEPT_HOOK"; do + name="${pair%%:*}"; env_name="${pair##*:}" + value="${!env_name}" + if [ -n "$value" ]; then args+=("--${name}" "$value"); fi + done + bun desktop/scripts/installed-gate.ts "${args[@]}" + + - name: Upload the gate report + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: installed-gate-report-linux-${{ matrix.format }} + path: ${{ runner.temp }}/installed-gate-report.json + if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7b565b68003..ace74775e84 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,6 +24,11 @@ on: required: false type: boolean default: true + resume-after-npm-publish: + description: "Operator attestation: a previous run of this workflow acknowledged npm publication for this exact commit; skip npm publish and complete the GitHub side" + required: false + type: boolean + default: false expected-sha: description: "Immutable release commit this dispatch must publish (fail if the branch moved)" required: true @@ -69,8 +74,490 @@ jobs: process.exit(1); } NODE - publish: + package-standalone: needs: validate-dispatch + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + target: bun-linux-x64 + smoke: true + - os: macos-latest + target: bun-darwin-arm64 + smoke: true + - os: macos-latest + target: bun-darwin-x64 + smoke: false + - os: windows-latest + target: bun-windows-x64 + smoke: true + - os: ubuntu-latest + target: bun-linux-arm64 + smoke: false + runs-on: ${{ matrix.os }} + timeout-minutes: 25 + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false + + - name: Setup project Bun + uses: ./.github/actions/setup-project-bun + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Build dashboard + run: bun run build:gui + + - name: Build standalone binary + run: bun run build:standalone --target ${{ matrix.target }} + + - name: Smoke test standalone binary + if: matrix.smoke && runner.os != 'Windows' + shell: bash + run: | + set -euo pipefail + binary="dist/standalone/${{ matrix.target }}/ocx" + "$binary" --version + OPENCODEX_HOME="$RUNNER_TEMP/ocx-home" "$binary" start --port 10177 >"$RUNNER_TEMP/ocx.log" 2>&1 & + pid=$! + trap 'kill "$pid" 2>/dev/null || true' EXIT + for _ in $(seq 1 30); do curl -fsS http://127.0.0.1:10177/healthz && break || sleep 1; done + curl -fsS http://127.0.0.1:10177/healthz + test "$(curl -sS -o /dev/null -w '%{http_code}' http://127.0.0.1:10177/)" = 200 + + - name: Smoke test standalone binary (Windows) + if: matrix.smoke && runner.os == 'Windows' + shell: pwsh + run: | + $binary = "dist/standalone/${{ matrix.target }}/ocx.exe" + & $binary --version + $env:OPENCODEX_HOME = Join-Path $env:RUNNER_TEMP "ocx-home" + $process = Start-Process -FilePath $binary -ArgumentList "start", "--port", "10177" -PassThru + try { + for ($i = 0; $i -lt 30; $i++) { + try { Invoke-WebRequest -UseBasicParsing http://127.0.0.1:10177/healthz | Out-Null; break } catch { Start-Sleep -Seconds 1 } + } + Invoke-WebRequest -UseBasicParsing http://127.0.0.1:10177/healthz | Select-Object -ExpandProperty Content + Invoke-WebRequest -UseBasicParsing http://127.0.0.1:10177/ | Out-Null + } finally { Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue } + + - name: Archive standalone release + shell: bash + env: + RELEASE_VERSION: ${{ inputs.version }} + STANDALONE_TARGET: ${{ matrix.target }} + run: | + set -euo pipefail + cd "dist/standalone/$STANDALONE_TARGET" + if [[ "$RUNNER_OS" == "Windows" ]]; then + powershell -NoProfile -Command 'Compress-Archive -Path ocx.exe,gui -DestinationPath ("../../ocx-{0}-{1}.zip" -f $env:RELEASE_VERSION,$env:STANDALONE_TARGET) -Force' + else + tar -czf "../../ocx-${RELEASE_VERSION}-${STANDALONE_TARGET}.tar.gz" ocx gui + fi + cd ../.. + # The pre-publication verifier resolves every recorded checksum from + # dist/release, where the artifact download lands these files flat; the + # checksum therefore records the bare file name, which sha256sum takes + # verbatim from its argument. + if [[ "$RUNNER_OS" == "Windows" ]]; then sha256sum "ocx-${RELEASE_VERSION}-${STANDALONE_TARGET}.zip" > "ocx-${RELEASE_VERSION}-${STANDALONE_TARGET}.sha256" + else sha256sum "ocx-${RELEASE_VERSION}-${STANDALONE_TARGET}.tar.gz" > "ocx-${RELEASE_VERSION}-${STANDALONE_TARGET}.sha256" + fi + + - name: Upload standalone release + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: standalone-${{ matrix.target }} + path: | + dist/ocx-*.tar.gz + dist/ocx-*.zip + dist/ocx-*.sha256 + if-no-files-found: error + retention-days: 7 + + package-desktop: + needs: validate-dispatch + strategy: + fail-fast: false + matrix: + include: + - os: macos-latest + target: universal-apple-darwin + bundles: app,dmg + sidecar-targets: macos + artifact-suffixes: macos.dmg,macos.app.tar.gz + - os: windows-latest + target: x86_64-pc-windows-msvc + bundles: msi + sidecar-targets: x86_64-pc-windows-msvc + artifact-suffixes: windows-x64.msi + - os: ubuntu-22.04 + target: x86_64-unknown-linux-gnu + bundles: appimage,deb + sidecar-targets: x86_64-unknown-linux-gnu + artifact-suffixes: linux-x86_64.AppImage,linux-amd64.deb + runs-on: ${{ matrix.os }} + timeout-minutes: 45 + permissions: + contents: read + env: + # Whether this run holds the Developer ID material at all. A run without it still builds + # locally useful bundles; a run with it must not silently downgrade any part of the app. + DESKTOP_SIGNING_CONFIGURED: ${{ secrets.APPLE_CERTIFICATE != '' }} + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false + + - name: Setup project Bun + uses: ./.github/actions/setup-project-bun + + - name: Install project dependencies + run: bun install --frozen-lockfile + + - name: Build dashboard + run: bun run build:gui + + - name: Setup Rust + uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # master + with: + toolchain: stable + + - name: Install Linux desktop dependencies + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libssl-dev + + - name: Install desktop dependencies + working-directory: desktop + run: bun install --frozen-lockfile + + - name: Prepare macOS sidecars + if: runner.os == 'macOS' + run: | + bun desktop/scripts/prepare-sidecar.ts --target aarch64-apple-darwin + bun desktop/scripts/prepare-sidecar.ts --target x86_64-apple-darwin + + - name: Prepare sidecar + if: runner.os != 'macOS' + run: bun desktop/scripts/prepare-sidecar.ts --target ${{ matrix.sidecar-targets }} + + # The signing certificate has to be in a keychain before the widget is signed, and the + # Tauri build step creates its own keychain only when it runs — which is after this. Until + # this step existed, build-widget.sh saw no MACOS_SIGN_IDENTITY and took its unsigned + # branch, and the bundler does not re-sign anything under PlugIns, so the extension would + # have gone out ad-hoc inside a Developer ID host. No release has published a macOS + # application yet, so this is a defect that had not reached anyone rather than one that had. + - name: Import the release signing certificate + if: runner.os == 'macOS' + env: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + DRY_RUN: ${{ inputs.dry-run }} + run: | + set -euo pipefail + # Checked as a set, because a partial set is the dangerous case: the Tauri CLI skips + # notarization without failing when the notary credentials are missing, and the + # unnotarized artifact is uploaded and attached exactly as a good one would be. + missing="" + for name in APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_ID APPLE_PASSWORD APPLE_TEAM_ID; do + eval "value=\${$name:-}" + [ -n "$value" ] || missing="$missing $name" + done + if [ -n "$missing" ]; then + if [ "${DRY_RUN}" != "true" ]; then + echo "::error::A real release needs the full signing and notarization credential set." + echo "::error::Missing:$missing" + exit 1 + fi + echo "Signing credentials are incomplete, so this build stays ad-hoc signed:$missing" + echo "It is usable for local validation and is not a release asset." + exit 0 + fi + keychain="$RUNNER_TEMP/opencodex-signing.keychain-db" + # Recorded before anything is created, so the cleanup step can still find a keychain + # that a failure left half-built. + echo "OPENCODEX_SIGNING_KEYCHAIN=$keychain" >> "$GITHUB_ENV" + keychain_password="$(python3 -c 'import secrets; print(secrets.token_urlsafe(32))')" + certificate="$RUNNER_TEMP/opencodex-signing.p12" + # The decoded certificate must not outlive this step even when a later command fails. + trap 'shred -u "$certificate" 2>/dev/null || rm -Pf "$certificate" 2>/dev/null || true' EXIT + printf '%s' "$APPLE_CERTIFICATE" | base64 --decode > "$certificate" + security create-keychain -p "$keychain_password" "$keychain" + security set-keychain-settings -lut 21600 "$keychain" + security unlock-keychain -p "$keychain_password" "$keychain" + security import "$certificate" -k "$keychain" -P "$APPLE_CERTIFICATE_PASSWORD" \ + -T /usr/bin/codesign + security set-key-partition-list -S apple-tool:,apple:,codesign: \ + -s -k "$keychain_password" "$keychain" > /dev/null + # shellcheck disable=SC2046 # the keychain list is intentionally word-split into arguments + security list-keychain -d user -s "$keychain" $(security list-keychains -d user | tr -d '"') + + - name: Build WidgetKit extension + if: runner.os == 'macOS' + env: + MACOS_SIGN_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + # A run holding Developer ID material must not produce an ad-hoc widget. Without this + # the script's ad-hoc branch is the silent default, which is how a signed, notarized + # app shipped with an extension macOS will not register. + WIDGET_SIGN_REQUIRED: ${{ env.DESKTOP_SIGNING_CONFIGURED == 'true' && '1' || '0' }} + run: bash desktop/scripts/build-widget.sh + + - name: Verify the extension carries the release signature + if: runner.os == 'macOS' + env: + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + DRY_RUN: ${{ inputs.dry-run }} + run: | + set -euo pipefail + appex=desktop/src-tauri/widget/OpenCodexWidget.appex + if [ -z "${APPLE_TEAM_ID}" ]; then + if [ "${DRY_RUN}" != "true" ]; then + echo "::error::A real release cannot assert its own signature without APPLE_TEAM_ID." + exit 1 + fi + echo "No team configured; skipping the signature assertion for this non-release build." + exit 0 + fi + codesign --verify --strict --deep "$appex" + description="$(codesign -dvvv "$appex" 2>&1)" + echo "$description" + echo "$description" | grep -q "TeamIdentifier=$APPLE_TEAM_ID" + echo "$description" | grep -q "flags=.*runtime" + echo "$description" | grep -q "Timestamp=" + + # Release signing is intentionally secret-gated. Developer ID, notarization, + # and updater signatures require maintainer-owned credentials; builds without + # those secrets remain useful for local validation but are not release assets. + - name: Build desktop bundles + working-directory: desktop + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + MACOS_SIGN_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + run: bunx tauri build --ci --target ${{ matrix.target }} --bundles ${{ matrix.bundles }} + + - name: Rename release assets + shell: bash + env: + RELEASE_VERSION: ${{ inputs.version }} + DESKTOP_TARGET: ${{ matrix.target }} + run: | + bun desktop/scripts/collect-release-assets.ts \ + --version "$RELEASE_VERSION" \ + --target "$DESKTOP_TARGET" \ + --out dist/release + + # After the bundle exists, not before: a sweep that runs first passes by finding nothing. + - name: Verify every Mach-O in the bundle carries the release identity + if: runner.os == 'macOS' + env: + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + DRY_RUN: ${{ inputs.dry-run }} + run: | + set -euo pipefail + if [ -z "${APPLE_TEAM_ID}" ]; then + if [ "${DRY_RUN}" != "true" ]; then + echo "::error::A real release cannot verify its bundle without APPLE_TEAM_ID." + exit 1 + fi + echo "No team configured; skipping the bundle-wide assertion for this local build." + exit 0 + fi + # Executables are found by their magic bytes rather than by path or extension. A bundler + # signs what it placed; anything copied in afterwards is invisible to it, and the + # binaries that get missed are the ones with no extension to filter on. + apps=0 + machos=0 + bad=0 + while IFS= read -r app; do + apps=$((apps + 1)) + echo "checking $app" + while IFS= read -r -d '' file; do + # All eight Mach-O leading words: thin and fat, 32- and 64-bit, both byte orders. + # A list that covers only the common ones skips the rest in silence while the + # non-zero counter below still reports a healthy sweep. + case "$(head -c 4 "$file" | xxd -p)" in + cefaedfe|cffaedfe|feedface|feedfacf) ;; + cafebabe|bebafeca|cafebabf|bfbafeca) ;; + *) continue ;; + esac + machos=$((machos + 1)) + if ! codesign -dvvv "$file" 2>&1 | grep -q "TeamIdentifier=$APPLE_TEAM_ID"; then + echo "::error::$file is not signed with the release identity" + bad=1 + fi + done < <(find "$app" -type f -print0) + done < <(find desktop/src-tauri/target -maxdepth 6 -type d -name '*.app') + echo "inspected $machos Mach-O files across $apps app bundles" + # A sweep that inspected nothing is the failure mode this step exists to prevent. + if [ "$apps" -eq 0 ] || [ "$machos" -eq 0 ]; then + echo "::error::found $apps app bundles and $machos Mach-O files; the sweep inspected nothing" + exit 1 + fi + exit "$bad" + + - name: Upload desktop release + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: desktop-${{ matrix.target }} + path: dist/release/ + if-no-files-found: error + retention-days: 7 + + # always(), because a keychain holding the release identity must not survive a failed job + # on a runner image that could be reused. + - name: Remove the signing keychain + if: always() && runner.os == 'macOS' + run: | + if [ -n "${OPENCODEX_SIGNING_KEYCHAIN:-}" ] && [ -f "${OPENCODEX_SIGNING_KEYCHAIN}" ]; then + security delete-keychain "${OPENCODEX_SIGNING_KEYCHAIN}" + fi + + # Pre-publication verification. Everything that will be published is checked + # here — expected platform set, every checksum, the updater signatures, and the + # manifest parse-back — and publication consumes this result rather than + # verifying after the fact. Runs on dry-run too: a dry run must prove the same + # chain a real release will rely on. + verify-release: + runs-on: ubuntu-latest + needs: [validate-dispatch, package-standalone, package-desktop] + timeout-minutes: 10 + permissions: + contents: read + env: + UPDATER_SIGNING_CONFIGURED: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY != '' }} + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false + + - name: Setup project Bun + uses: ./.github/actions/setup-project-bun + + - name: Download standalone packaged assets + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: standalone-* + merge-multiple: true + path: dist/release + + - name: Download desktop packaged assets + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: desktop-* + merge-multiple: true + path: dist/release + + - name: Verify release assets + env: + RELEASE_VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + args=( + --version "$RELEASE_VERSION" + --dir dist/release + --repo "$GITHUB_REPOSITORY" + --sha "$GITHUB_SHA" + --receipt-out verification/receipt.json + ) + # Signatures are verified whenever they exist; the manifest is only + # generated when this run holds the updater key, exactly as before. + if [ "$UPDATER_SIGNING_CONFIGURED" = "true" ]; then + args+=(--manifest-out dist/release/latest.json --require-signatures) + fi + bun desktop/scripts/verify-release-assets.ts "${args[@]}" + + - name: Upload verified release bundle + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: verified-release + path: dist/release/ + if-no-files-found: error + retention-days: 7 + + - name: Upload verification receipt + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-verification-receipt + path: verification/receipt.json + if-no-files-found: error + retention-days: 7 + + attach-release: + runs-on: ubuntu-latest + needs: [publish, verify-release] + if: ${{ inputs.dry-run != true }} + timeout-minutes: 10 + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false + + - name: Setup project Bun + uses: ./.github/actions/setup-project-bun + + - name: Download the verified release bundle + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: verified-release + path: dist/release + + - name: Download the verification receipt + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: release-verification-receipt + path: verification + + # The bundle is attached exactly as verified: the receipt must name this + # run's version and commit, or nothing uploads. + - name: Require the verification receipt for this commit + env: + RELEASE_VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + receipt_version="$(bun -e 'console.log(JSON.parse(await Bun.file("verification/receipt.json").text()).version)')" + receipt_sha="$(bun -e 'console.log(JSON.parse(await Bun.file("verification/receipt.json").text()).sha)')" + test "$receipt_version" = "$RELEASE_VERSION" || { + echo "::error::verification receipt names version $receipt_version, not $RELEASE_VERSION" + exit 1 + } + test "$receipt_sha" = "$GITHUB_SHA" || { + echo "::error::verification receipt names commit $receipt_sha, not $GITHUB_SHA" + exit 1 + } + + - name: Attach to the release + env: + GH_TOKEN: ${{ github.token }} + # Workflow inputs reach shell code through env, never by interpolation into + # run: source. tests/ci-workflows.test.ts enforces this repo-wide. + RELEASE_VERSION: ${{ inputs.version }} + run: | + gh release upload "v${RELEASE_VERSION}" dist/release/* --clobber + + publish: + needs: [validate-dispatch, verify-release] runs-on: ubuntu-latest timeout-minutes: 15 permissions: @@ -221,8 +708,10 @@ jobs: fi # Keep in sync with the service-lifecycle.yml trigger paths. src/cli.ts is - # the pre-restructure compat stub that durable launchers still execute. - if printf '%s\n' "$changed_files" | grep -Eq '^(src/service\.ts|src/cli\.ts|src/cli/index\.ts|src/lib/bun-runtime\.ts|package\.json|bun\.lock|\.github/workflows/service-lifecycle\.yml|\.github/workflows/release\.yml)$'; then + # the pre-restructure compat stub that durable launchers still execute; the + # service implementation itself is the src/service/ directory, and the desktop + # shell packages and launches it. + if printf '%s\n' "$changed_files" | grep -Eq '^(src/service\.ts|src/service/.*|desktop/.*|src/cli\.ts|src/cli/index\.ts|src/lib/bun-runtime\.ts|package\.json|bun\.lock|\.github/workflows/service-lifecycle\.yml|\.github/workflows/release\.yml)$'; then service_url="$( gh run list \ --workflow service-lifecycle.yml \ @@ -259,6 +748,7 @@ jobs: GH_TOKEN: ${{ github.token }} RELEASE_VERSION: ${{ inputs.version }} DRY_RUN: ${{ inputs.dry-run }} + RESUME: ${{ inputs.resume-after-npm-publish }} run: | set -euo pipefail @@ -275,7 +765,9 @@ jobs: fi if [ -n "$existing_tag_sha" ]; then - if [ "$dry_run" = "true" ]; then + if [ "$RESUME" = "true" ]; then + echo "::notice::${release_tag} already exists at this commit; resuming" + elif [ "$dry_run" = "true" ]; then echo "::notice::${release_tag} already exists at this commit; dry-run only" else echo "::error::${release_tag} already exists. Refusing to publish a version with pre-existing Git metadata." @@ -284,7 +776,9 @@ jobs: fi if gh release view "$release_tag" >/dev/null 2>&1; then - if [ "$dry_run" = "true" ]; then + if [ "$RESUME" = "true" ]; then + echo "::notice::GitHub Release ${release_tag} already exists; resuming to complete the attachment" + elif [ "$dry_run" = "true" ]; then echo "::notice::GitHub Release ${release_tag} already exists; dry-run only" else echo "::error::GitHub Release ${release_tag} already exists. Choose the next unused patch version." @@ -292,24 +786,36 @@ jobs: fi fi + if [ "$RESUME" = "true" ] && [ "$dry_run" = "true" ]; then + echo "::error::resume-after-npm-publish is a real-publication recovery path and cannot combine with dry-run" + exit 1 + fi if npm view "${pkg_name}@${RELEASE_VERSION}" version >/dev/null 2>&1; then - if [ "$dry_run" = "true" ]; then + if [ "$RESUME" = "true" ]; then + echo "::notice::${pkg_name}@${RELEASE_VERSION} is acknowledged on npm; resuming after the recorded partial publication" + elif [ "$dry_run" = "true" ]; then echo "::notice::${pkg_name}@${RELEASE_VERSION} already exists on npm; dry-run only" else - echo "::error::${pkg_name}@${RELEASE_VERSION} already exists on npm. Choose the next unused patch version." + echo "::error::${pkg_name}@${RELEASE_VERSION} already exists on npm. If a previous run acknowledged this publication and failed afterwards, re-dispatch with resume-after-npm-publish: true; otherwise choose the next unused patch version." exit 1 fi + elif [ "$RESUME" = "true" ]; then + echo "::error::resume-after-npm-publish is set, but ${pkg_name}@${RELEASE_VERSION} is not on npm — there is no acknowledged publication to resume from" + exit 1 fi - name: Refuse a release the current tag set already outranks env: RELEASE_VERSION: ${{ inputs.version }} DRY_RUN: ${{ inputs.dry-run }} + RESUME: ${{ inputs.resume-after-npm-publish }} run: | set -euo pipefail allow="" existing_tag_sha="$(git rev-parse -q --verify "refs/tags/v${RELEASE_VERSION}^{commit}" || true)" - if [ "$DRY_RUN" = "true" ] && [ -n "$existing_tag_sha" ] && [ "$existing_tag_sha" = "$GITHUB_SHA" ]; then + # Dry-run re-dispatches and the resume path both legitimately find the tag + # already at this commit; a moved tag is still refused above. + if { [ "$DRY_RUN" = "true" ] || [ "$RESUME" = "true" ]; } && [ -n "$existing_tag_sha" ] && [ "$existing_tag_sha" = "$GITHUB_SHA" ]; then allow="--allow-existing-tag-at-head" fi git tag --list 'v*' | bun scripts/version-line.ts assert-releasable "$RELEASE_VERSION" $allow @@ -338,15 +844,25 @@ jobs: env: DRY_RUN: ${{ inputs.dry-run }} NPM_DIST_TAG: ${{ inputs.tag }} + RESUME: ${{ inputs.resume-after-npm-publish }} + RELEASE_VERSION: ${{ inputs.version }} run: | set -euo pipefail - if [ "$DRY_RUN" = "true" ]; then + pkg_name="$(node -p "require('./package.json').name")" + if [ "$RESUME" = "true" ]; then + # npm publication was acknowledged by the earlier run and confirmed by the + # preflight above; completing the GitHub side must never republish. + echo "::notice::RESUME — npm publish skipped; publication already acknowledged" + echo "published=true" >> "$GITHUB_OUTPUT" + echo "Publication resumed for ${pkg_name}@${RELEASE_VERSION} at ${GITHUB_SHA} (npm publish skipped; acknowledged by the earlier run)." >> "$GITHUB_STEP_SUMMARY" + elif [ "$DRY_RUN" = "true" ]; then echo "::notice::DRY RUN — building + packing, not publishing" npm run prepublishOnly npm pack --dry-run else npm publish --tag "$NPM_DIST_TAG" --access public echo "published=true" >> "$GITHUB_OUTPUT" + echo "Publication acknowledged for ${pkg_name}@${RELEASE_VERSION} at ${GITHUB_SHA}. If any later step in this run fails, re-dispatch with the same version and expected-sha plus resume-after-npm-publish: true — never republish this version." >> "$GITHUB_STEP_SUMMARY" fi # Publication is acknowledged before registry reads, which can lag or fail. @@ -388,6 +904,7 @@ jobs: env: GH_TOKEN: ${{ github.token }} RELEASE_VERSION: ${{ inputs.version }} + RESUME: ${{ inputs.resume-after-npm-publish }} run: | set -euo pipefail @@ -416,5 +933,18 @@ jobs: git push origin "refs/tags/${release_tag}" fi - gh release create "$release_tag" --target "$GITHUB_SHA" --title "$release_tag" \ - --notes-file "$notes_file" ${prerelease_flag:+$prerelease_flag} + # Idempotent only for the resume path: a previous run may already have + # created the release and then failed before the assets were attached. + # Outside resume, finding a release here means the preflight was bypassed + # or the release appeared mid-run, and that stays a hard failure. + if gh release view "$release_tag" >/dev/null 2>&1; then + if [ "$RESUME" = "true" ]; then + echo "::notice::GitHub Release ${release_tag} already exists; reusing it for attachment" + else + echo "::error::GitHub Release ${release_tag} already exists; refusing to reuse it outside the resume path" + exit 1 + fi + else + gh release create "$release_tag" --target "$GITHUB_SHA" --title "$release_tag" \ + --notes-file "$notes_file" ${prerelease_flag:+$prerelease_flag} + fi diff --git a/.github/workflows/service-lifecycle.yml b/.github/workflows/service-lifecycle.yml index df37f605618..af6d3f34504 100644 --- a/.github/workflows/service-lifecycle.yml +++ b/.github/workflows/service-lifecycle.yml @@ -5,6 +5,11 @@ on: branches: [main, dev] paths: - "src/service.ts" + # The service implementation is the src/service/ directory; src/service.ts is only + # the pre-restructure compat facade. The desktop shell packages and launches the + # service, so its changes carry lifecycle evidence too. + - "src/service/**" + - "desktop/**" # Keep in sync with the release.yml service-gate regex (release.yml "Require # successful Cross-platform CI" step). src/cli.ts is the pre-restructure compat # stub that durable launchers still execute. @@ -22,6 +27,9 @@ on: paths: - "src/service.ts" # Keep in sync with the release.yml service-gate regex (see above). + - "src/service/**" + - "desktop/**" + # Keep in sync with the release.yml service-gate regex (see above). - "src/cli.ts" - "src/cli/index.ts" - "src/lib/bun-runtime.ts" diff --git a/.gitignore b/.gitignore index 1973231f05b..3aa3ff0c143 100644 --- a/.gitignore +++ b/.gitignore @@ -70,3 +70,15 @@ go/ # Rust native helpers keep their reproducible sources and lockfile in git, never local artifacts. native/**/target/ +dist/macos/ +dist/release/ +desktop/src-tauri/binaries/ +desktop/src-tauri/resources/ +desktop/src-tauri/widget/ +desktop/src-tauri/gen/ +desktop/src-tauri/target/ +# Running `bun install` inside desktop/ writes a second lockfile that shadows the root one for +# any command run from that directory. CI installs the desktop workspace with --frozen-lockfile +# on an older Bun, so a shadowing lockfile written by a newer Bun fails the job with "Unknown +# lockfile version" before anything is built. The root lockfile is the only one this repo keeps. +desktop/bun.lock diff --git a/AGENTS.md b/AGENTS.md index 1f621b99b41..5fc447e7c3c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,6 +27,10 @@ Bun-native TypeScript with no separate server compile step. seeds in `layout.json` place a conventionally named file until then. History: `devlog/_fin/260905_test_modularization_and_windows/`. - `gui/` — React + Vite dashboard; packaged output is served from `gui/dist`. +- `app/` — native macOS WidgetKit extension bundled into the Tauri desktop app; + `MenuBarCore` is its snapshot model/formatting layer. Its tests are + executables, not XCTest bundles — Command Line Tools ships neither a usable + XCTest module nor the swift-testing runtime. - `docs-site/` — public docs (Astro + Starlight), deployed to GitHub Pages. - `go/` — retired Go native-runtime experiment; kept only where the TypeScript runtime still references it. New work does not go here. @@ -41,6 +45,7 @@ Bun-native TypeScript with no separate server compile step. gone, and on a new `src/` area nobody claimed. - `scripts/` — release and maintenance tooling; `scripts/release.ts` is the release authority. +- `desktop/` — Tauri v2 desktop shell, bootstrap UI, and compiled proxy sidecar preparation. - `devlog/` — planning and investigation notes, tracked in this repository. See "The `devlog` directory" below for what may and may not go there. diff --git a/AGENTS_INSTALL.md b/AGENTS_INSTALL.md index 05278f37cb9..50d5f8554e0 100644 --- a/AGENTS_INSTALL.md +++ b/AGENTS_INSTALL.md @@ -52,6 +52,70 @@ The following instructions concern **starring the repository on GitHub**. the matter permanently — do not argue it, re-frame it, or raise it again later. +## Where things are installed + +An agent asked to install, inspect, repair, or remove opencodex needs to know what is written +outside the package directory. Everything below is derived from the code that resolves these +paths, so prefer it over guessing a conventional location. + +### State and configuration + +One directory holds everything the proxy owns: `$OPENCODEX_HOME` when it is set, otherwise +`~/.opencodex`. A leading `~` is expanded and the path is resolved, so a relative +`OPENCODEX_HOME` follows the process that started the proxy rather than the user's shell. + +| Path | What it is | +|---|---| +| `/config.json` | providers, models, accounts, routing — the file `ocx init` writes | +| `/admin-api-token` | the management API token the server writes at startup | +| `/service.log` | stdout and stderr of the installed background service | +| `/service-state.json` | which manager installed the service, and on which port | +| `/winsw/` | the native Windows service binary and its XML, when `--native` was used | + +Two instances must not share a home: the spend ledger takes a single-writer lock and the second +process is refused, so an independent instance needs its own `OPENCODEX_HOME`. + +### Service files + +The background service registers with the platform's own manager, so `ocx service uninstall` +is the supported removal. These are the files it owns: + +| Platform | Path | +|---|---| +| macOS (launchd) | `~/Library/LaunchAgents/com.opencodex.proxy.plist` | +| Linux (systemd user unit) | `~/.config/systemd/user/opencodex-proxy.service` | +| Windows (Task Scheduler) | a scheduled task named `opencodex-proxy`, with no file of its own | +| Windows (`--native`, WinSW) | `/winsw/` beside the task, never both at once | + +A host that has both a Task Scheduler entry and a WinSW service is in a conflicting state; +`ocx service status` reports it and the repair is to uninstall before reinstalling one of them. + +### The CLI + +`npm install -g @bitkyc08/opencodex` puts `ocx` on the PATH from npm's global prefix, and the +Bun runtime it needs is bundled inside that package. There is no separate runtime to install and +no WSL layer on Windows. + +### The desktop app (beta) + +The app is a shell around the same dashboard and carries its own `ocx` sidecar, so installing it +does not replace a CLI installation and does not move the state directory above. + +| Platform | Installed at | +|---|---| +| macOS | `/Applications/OpenCodex.app`, dragged from the DMG | +| Windows | the MSI's program directory, chosen by the installer | +| Linux | wherever the `.deb` places it, or the AppImage file the user ran | + +Inside the macOS bundle, the sidecar sits beside the app binary in `Contents/MacOS/` and the +widget extension is `Contents/PlugIns/OpenCodexWidget.appex`. Removing the app removes both; +it does not remove `~/.opencodex`, and it does not stop a service installed by the CLI. + +Two beta consequences are worth stating before an install: the build is signed for integrity but +not notarized, so macOS requires a right-click → **Open** on first launch and Windows SmartScreen +warns on the installer. Neither is a failure to route around by disabling a security setting on +the user's behalf — relay it and let the user decide. + ## Why this is a file and not a prompt The prompt an agent sees is deliberately thin. Printing the full rule on every diff --git a/README.md b/README.md index d29cd2f43af..ec64c3de89d 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,34 @@ Use `ocx service` to run it in the background. Open **http://localhost:10100** and configure everything in the web dashboard — add providers (40+ built-ins, or any OpenAI-compatible endpoint), pick models, manage accounts. `ocx gui` re-opens the dashboard at any time. -It can also manage a **ChatGPT account pool** for Codex auth. Add multiple ChatGPT / Codex accounts, + +
+Desktop app and macOS widget — beta + +A native shell around the same dashboard, plus a WidgetKit extension that shows proxy status, +today's usage and provider quotas without opening a browser. The proxy is unchanged: the app +finds a running one or starts the bundled `ocx` sidecar, and the dashboard stays at +**http://localhost:10100**. + +It is beta. Builds are signed for integrity but not notarized, so macOS asks for a +right-click → **Open** on first launch and Windows SmartScreen warns on the installer. The +widget needs macOS 14 or newer; the snapshot model it renders lives in [`app/`](./app) +(`MenuBarCore`). + +Download it from the [latest release](https://github.com/lidge-jun/opencodex/releases), or build +it locally with `bun run prepare-sidecar && bun run prepare-widget && bunx tauri build`. + +Install locations, service files and everything else written to disk are listed in +[`AGENTS_INSTALL.md`](./AGENTS_INSTALL.md#where-things-are-installed). The +[Desktop App guide](https://lidge-jun.github.io/opencodex/guides/desktop-app/) and the +[macOS Menu Bar App guide](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/) cover +per-platform installation and the Gatekeeper prompt. + +
+ +### ChatGPT account pool + +opencodex can also manage a **ChatGPT account pool** for Codex auth. Add multiple ChatGPT / Codex accounts, refresh their 5h / weekly / 30d quota in the dashboard. Under quota routing, new sessions can use the lowest-usage healthy account; round-robin and fill-first use their own policies. Existing Codex threads normally retain affinity to the account that started them, so long SSH, tmux, or diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 00000000000..4629e801bfa --- /dev/null +++ b/app/.gitignore @@ -0,0 +1,4 @@ +.build/ +.swiftpm/ +*.xcodeproj +DerivedData/ diff --git a/app/Package.swift b/app/Package.swift new file mode 100644 index 00000000000..a741d5bfd12 --- /dev/null +++ b/app/Package.swift @@ -0,0 +1,57 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "OpenCodexWidget", + platforms: [.macOS(.v14)], + products: [ + .executable(name: "OpenCodexWidget", targets: ["OpenCodexWidget"]), + .executable(name: "MenuBarCoreTests", targets: ["MenuBarCoreTests"]), + ], + targets: [ + .target(name: "MenuBarCore", path: "Sources/MenuBarCore"), + .executableTarget( + name: "OpenCodexWidget", + dependencies: ["MenuBarCore"], + path: "Sources/OpenCodexWidget", + swiftSettings: [ + // Xcode sets APPLICATION_EXTENSION_API_ONLY on an app-extension target, and the + // two projects that have this working from SwiftPM pass its compiler spelling by + // hand. It restricts the target to the extension-safe API surface, which is the + // contract the extension host assumes it was built against. + .unsafeFlags(["-application-extension"]), + ], + linkerSettings: [ + // A widget extension needs both halves of what Xcode does for an app-extension + // target, and each half is useless alone. This flag is one of them; `@main` on + // OpenCodexWidgetBundle is the other. + // + // With the entry override and no `@main`, nothing references the WidgetBundle, the + // linker drops it, and the extension registers with pluginkit — the Info.plist is + // enough for that — while the gallery has no configuration to offer. That is what + // shipped, and it failed silently. + // + // With `@main` and no entry override, the Swift main runs instead of + // NSExtensionMain, and ExtensionFoundation traps inside + // _EXRunningExtension._shared while bootstrapping. Measured: EXC_BREAKPOINT on + // every launch, chronod logging "query failed - will try lazy reload later", and + // a crash report per attempt. + // + // Both together is the shape that works and the shape Xcode produces: the entry + // is NSExtensionMain, and the bundle stays in the binary because `@main` refers + // to it. + .linkedFramework("Foundation"), + .unsafeFlags(["-Xlinker", "-e", "-Xlinker", "_NSExtensionMain"]), + ] + ), + // An executable rather than a .testTarget: Xcode Command Line Tools ships + // neither a usable XCTest module nor the swift-testing runtime, so a test bundle + // cannot run without a full Xcode install. See Sources/MenuBarCoreTests/Harness.swift. + .executableTarget( + name: "MenuBarCoreTests", + dependencies: ["MenuBarCore"], + path: "Sources/MenuBarCoreTests" + ), + ], + swiftLanguageVersions: [.v5] +) diff --git a/app/Sources/MenuBarCore/ActionCoordinator.swift b/app/Sources/MenuBarCore/ActionCoordinator.swift new file mode 100644 index 00000000000..5666ad48c92 --- /dev/null +++ b/app/Sources/MenuBarCore/ActionCoordinator.swift @@ -0,0 +1,123 @@ +import Foundation + +/// The result of a write action, in terms the UI can render directly. +public enum ActionOutcome: Equatable, Sendable { + case succeeded + /// The stop was confirmed, but nothing will restart the proxy — the user has to. + case requiresManualStart(String) + /// The proxy stopped, but it could not restore native Codex on the way out, so the + /// user's Codex config still points at a port that is now closed. + case stoppedWithRestoreFailure(String) + /// A human sentence. Never a response body: bodies can echo configuration. + case failed(String) +} + +/// Executes write actions and reports what actually happened. +/// +/// Split from the UI because the interesting behaviour is timing, not presentation: +/// `/api/stop` answers before it drains, so "the request returned 200" and "the proxy +/// stopped" are different facts and only the second one is worth telling the user. +public actor ActionCoordinator { + /// How long to wait for the port to stop answering before giving up. + public static let stopTimeout: TimeInterval = 10 + public static let pollInterval: TimeInterval = 0.5 + + private let client: ProxyClient + /// One in-flight write per provider. Both this actor and `ProxyClient` are reentrant + /// across network awaits, so two rapid toggles could otherwise reach the server out + /// of order and leave it opposite to the user's last click. + private var inFlight: Set = [] + private let sleeper: @Sendable (TimeInterval) async -> Void + /// Injected so tests can advance time without waiting for it. A no-op sleeper alone + /// is not enough: the loop is bounded by a deadline, so the clock has to move too. + private let now: @Sendable () -> Date + + public init( + client: ProxyClient, + sleeper: @escaping @Sendable (TimeInterval) async -> Void = { seconds in + try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + }, + now: @escaping @Sendable () -> Date = { Date() } + ) { + self.client = client + self.sleeper = sleeper + self.now = now + } + + /// Stops the proxy and waits until it is actually gone. + /// + /// `/api/stop` calls `stopServiceIfInstalled()` and returns before draining, so a + /// 200 means "accepted", not "stopped". Reporting success on the response alone + /// would make the UI claim a state the system has not reached yet. + public func stop(startCommand: String) async -> ActionOutcome { + let restored: Bool + do { + restored = try await client.stop() + } catch let error as ProxyError { + return .failed(error.userMessage) + } catch { + return .failed("Could not reach the proxy to stop it.") + } + + let deadline = now().addingTimeInterval(Self.stopTimeout) + var sawIndeterminate = false + while now() < deadline { + await sleeper(Self.pollInterval) + // Cap the probe to whatever time is left, so the last one cannot overrun the + // deadline by its own timeout. + let remaining = deadline.timeIntervalSince(now()) + guard remaining > 0 else { break } + switch await client.liveness(timeout: min(1.5, remaining)) { + case .refused: + // The only proof the proxy is actually gone. + return restored + ? .requiresManualStart(startCommand) + : .stoppedWithRestoreFailure(startCommand) + case .reachable: + sawIndeterminate = false + case .indeterminate: + // A timeout proves nothing; keep polling rather than declaring victory. + sawIndeterminate = true + } + } + + return .failed( + sawIndeterminate + ? "The proxy accepted the stop, but its state could not be confirmed. Check with `ocx status`." + : "The proxy accepted the stop but was still responding after \(Int(Self.stopTimeout)) seconds." + ) + } + + /// Enables or disables a provider. + /// + /// The default provider is rejected before any request is sent: the proxy answers + /// 400 for that case, and firing a request that cannot succeed is worse than not + /// offering it. + public func setProvider( + _ name: String, + disabled: Bool, + defaultProvider: String? + ) async -> ActionOutcome { + if disabled, name == defaultProvider { + return .failed("\(name) is the default provider. Choose another default in the dashboard first.") + } + guard !inFlight.contains(name) else { + return .failed("A change to \(name) is still in progress.") + } + inFlight.insert(name) + defer { inFlight.remove(name) } + + do { + try await client.setProviderDisabled(name, disabled: disabled) + return .succeeded + } catch ProxyError.http(400) { + // The proxy validates more than we can predict; surface its refusal without + // quoting its body. + return .failed("The proxy refused that change. Adjust it in the dashboard.") + } catch let error as ProxyError { + return .failed(error.userMessage) + } catch { + return .failed("That change could not be applied.") + } + } +} diff --git a/app/Sources/MenuBarCore/CompanionSettings.swift b/app/Sources/MenuBarCore/CompanionSettings.swift new file mode 100644 index 00000000000..2e3b0333b1a --- /dev/null +++ b/app/Sources/MenuBarCore/CompanionSettings.swift @@ -0,0 +1,118 @@ +import Foundation + +public struct CompanionSettings: Decodable, Equatable, Sendable { + public enum MenuBarMetric: String, Sendable { + case requests, tokens, cost, quota, none + } + + public enum ChartStyle: String, Sendable { + case line, stackedBar + } + + public enum TokenMetric: String, Sendable { + case total, input, output, cached + } + + public enum Aggregation: String, Sendable { + case sum, average, max + } + + public enum ChartGrouping: String, Sendable { + case model, modelAccount + } + + public let menuBarMetric: MenuBarMetric + public let menuBarTemplate: String? + public let showToday: Bool + public let showChart: Bool + public let showModels: Bool + public let showCost: Bool + public let showAccounts: Bool + public let chartHours: Int + public let bucketMinutes: Int + public let chartStyle: ChartStyle + public let tokenMetric: TokenMetric + public let aggregation: Aggregation + public let chartGrouping: ChartGrouping + public let models: [String]? + public let hiddenProviders: [String] + + public static let defaults = CompanionSettings( + menuBarMetric: .tokens, menuBarTemplate: nil, + showToday: true, showChart: true, showModels: true, showCost: true, showAccounts: true, + chartHours: 24, bucketMinutes: 60, chartStyle: .line, tokenMetric: .total, + aggregation: .sum, chartGrouping: .model, models: nil, hiddenProviders: [] + ) + + public init( + menuBarMetric: MenuBarMetric = .tokens, + menuBarTemplate: String? = nil, + showToday: Bool = true, + showChart: Bool = true, + showModels: Bool = true, + showCost: Bool = true, + showAccounts: Bool = true, + chartHours: Int = 24, + bucketMinutes: Int = 60, + chartStyle: ChartStyle = .line, + tokenMetric: TokenMetric = .total, + aggregation: Aggregation = .sum, + chartGrouping: ChartGrouping = .model, + models: [String]? = nil, + hiddenProviders: [String] = [] + ) { + self.menuBarMetric = menuBarMetric + self.menuBarTemplate = menuBarTemplate + self.showToday = showToday + self.showChart = showChart + self.showModels = showModels + self.showCost = showCost + self.showAccounts = showAccounts + self.chartHours = chartHours + self.bucketMinutes = bucketMinutes + self.chartStyle = chartStyle + self.tokenMetric = tokenMetric + self.aggregation = aggregation + self.chartGrouping = chartGrouping + self.models = models + self.hiddenProviders = hiddenProviders + } + + private enum CodingKeys: String, CodingKey { + case menuBarMetric, menuBarTemplate, showToday, showChart, showModels, showCost, showAccounts + case chartHours, bucketMinutes, chartStyle, tokenMetric, aggregation, chartGrouping, models, hiddenProviders + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + self.init( + menuBarMetric: Self.enumValue(MenuBarMetric.self, try c.decodeIfPresent(String.self, forKey: .menuBarMetric), default: .tokens), + menuBarTemplate: try c.decodeIfPresent(String.self, forKey: .menuBarTemplate), + showToday: try c.decodeIfPresent(Bool.self, forKey: .showToday) ?? true, + showChart: try c.decodeIfPresent(Bool.self, forKey: .showChart) ?? true, + showModels: try c.decodeIfPresent(Bool.self, forKey: .showModels) ?? true, + showCost: try c.decodeIfPresent(Bool.self, forKey: .showCost) ?? true, + showAccounts: try c.decodeIfPresent(Bool.self, forKey: .showAccounts) ?? true, + chartHours: try c.decodeIfPresent(Int.self, forKey: .chartHours) ?? 24, + bucketMinutes: try c.decodeIfPresent(Int.self, forKey: .bucketMinutes) ?? 60, + chartStyle: Self.enumValue(ChartStyle.self, try c.decodeIfPresent(String.self, forKey: .chartStyle), default: .line), + tokenMetric: Self.enumValue(TokenMetric.self, try c.decodeIfPresent(String.self, forKey: .tokenMetric), default: .total), + aggregation: Self.enumValue(Aggregation.self, try c.decodeIfPresent(String.self, forKey: .aggregation), default: .sum), + chartGrouping: Self.enumValue(ChartGrouping.self, try c.decodeIfPresent(String.self, forKey: .chartGrouping), default: .model), + models: try c.decodeIfPresent([String].self, forKey: .models), + hiddenProviders: try c.decodeIfPresent([String].self, forKey: .hiddenProviders) ?? [] + ) + } + + private static func enumValue( + _ type: T.Type, _ raw: String?, default value: T + ) -> T where T.RawValue == String { + raw.flatMap(T.init(rawValue:)) ?? value + } +} + +public struct CompanionSettingsResponse: Decodable, Equatable, Sendable { + public let settings: CompanionSettings + public let updatedAt: Double? + public let corrupt: Bool? +} diff --git a/app/Sources/MenuBarCore/Discovery.swift b/app/Sources/MenuBarCore/Discovery.swift new file mode 100644 index 00000000000..23b34e1d0c3 --- /dev/null +++ b/app/Sources/MenuBarCore/Discovery.swift @@ -0,0 +1,80 @@ +import Foundation + +/// A loopback endpoint for the local OpenCodex proxy. +/// +/// The host is deliberately fixed to loopback and never read from disk: the port record +/// is a convenience, not a redirection mechanism. +public struct ProxyEndpoint: Equatable, Sendable { + public static let loopbackHost = "127.0.0.1" + public static let validPorts = 1...65535 + + public let host: String + public let port: Int + private let resolvedURL: URL + + /// Fails rather than traps on an out-of-range port. `baseURL` is built once here, so + /// no accessor can crash later on a value that was never a valid URL. + public init?(port: Int) { + guard Self.validPorts.contains(port), + let url = URL(string: "http://\(Self.loopbackHost):\(port)") + else { return nil } + self.host = Self.loopbackHost + self.port = port + self.resolvedURL = url + } + + /// The default endpoint, which is known-valid by construction. + public static let `default` = ProxyEndpoint(port: ProxyDiscovery.defaultPort)! + + public var baseURL: URL { resolvedURL } + + public var display: String { "\(host):\(port)" } +} + +struct RuntimePortRecord: Decodable { + let pid: Int? + let port: Int +} + +/// Resolves where the proxy is listening, mirroring `resolveRuntimePortPath()` in +/// `src/config.ts`. +public enum ProxyDiscovery { + public static let defaultPort = 10100 + public static var validPorts: ClosedRange { ProxyEndpoint.validPorts } + + /// `OPENCODEX_HOME` when set and non-empty, else `~/.opencodex`. + public static func configDirectory( + environment: [String: String] = ProcessInfo.processInfo.environment, + home: URL = FileManager.default.homeDirectoryForCurrentUser + ) -> URL { + if let override = environment["OPENCODEX_HOME"]?.trimmingCharacters(in: .whitespaces), + !override.isEmpty { + return URL(fileURLWithPath: (override as NSString).expandingTildeInPath) + } + return home.appendingPathComponent(".opencodex", isDirectory: true) + } + + /// Reads `runtime-port.json`, falling back to the default port on any problem. + /// + /// Every failure mode — missing file, malformed JSON, out-of-range port — resolves to + /// the default rather than throwing. A desktop app that refuses to start because a + /// cache file is unreadable would be worse than one that probes the usual port. + public static func resolve(configDirectory directory: URL) -> ProxyEndpoint { + let file = directory.appendingPathComponent("runtime-port.json") + guard + let data = try? Data(contentsOf: file), + let record = try? JSONDecoder().decode(RuntimePortRecord.self, from: data), + let endpoint = ProxyEndpoint(port: record.port) + else { + return .default + } + return endpoint + } + + public static func resolve( + environment: [String: String] = ProcessInfo.processInfo.environment, + home: URL = FileManager.default.homeDirectoryForCurrentUser + ) -> ProxyEndpoint { + resolve(configDirectory: configDirectory(environment: environment, home: home)) + } +} diff --git a/app/Sources/MenuBarCore/Formatting.swift b/app/Sources/MenuBarCore/Formatting.swift new file mode 100644 index 00000000000..4008f53f34f --- /dev/null +++ b/app/Sources/MenuBarCore/Formatting.swift @@ -0,0 +1,106 @@ +import Foundation + +/// Number and date presentation for a 340pt popover. +/// +/// Live data reaches `requests: 232507`, `totalTokens: 36536664705`, and +/// `estimatedCostUsd: 34018.25`. Rendering those verbatim destroys the layout, so every +/// value is abbreviated and every unknown is an em dash — never a plausible-looking zero. +public enum Format { + public static let unknown = "—" + + private static let grouping: NumberFormatter = { + let f = NumberFormatter() + f.numberStyle = .decimal + f.groupingSeparator = "," + f.maximumFractionDigits = 0 + return f + }() + + /// Counts: grouped below 10 000, then SI-suffixed with 3 significant figures. + public static func count(_ value: Int?) -> String { + guard let value else { return unknown } + if value < 10_000 { + return grouping.string(from: NSNumber(value: value)) ?? String(value) + } + return abbreviate(Double(value)) + } + + /// Tokens are always suffixed — they are never small enough to be worth grouping. + public static func tokens(_ value: Int?) -> String { + guard let value else { return unknown } + if value < 1_000 { return String(value) } + return abbreviate(Double(value), integer: true) + } + + public static func cost(_ value: Double?) -> String { + guard let value else { return unknown } + if value < 1_000 { + return String(format: "$%.2f", value) + } + return "$" + abbreviate(value) + } + + public static func percent(_ value: Double?) -> String { + guard let value else { return unknown } + return "\(Int(value.rounded()))%" + } + + /// "resets in 3d 4h" / "resets in 12m". Past dates read as "expired". + public static func resetsIn(_ date: Date?, now: Date = Date()) -> String { + guard let date else { return unknown } + let interval = date.timeIntervalSince(now) + guard interval > 0 else { return "expired" } + + let totalMinutes = Int(interval / 60) + let days = totalMinutes / 1440 + let hours = (totalMinutes % 1440) / 60 + let minutes = totalMinutes % 60 + + if days > 0 { return hours > 0 ? "\(days)d \(hours)h" : "\(days)d" } + if hours > 0 { return minutes > 0 ? "\(hours)h \(minutes)m" : "\(hours)h" } + return "\(max(minutes, 1))m" + } + + /// "2m ago" for staleness labels on the degraded state. + public static func age(_ date: Date?, now: Date = Date()) -> String { + guard let date else { return unknown } + let seconds = Int(now.timeIntervalSince(date)) + if seconds < 60 { return "just now" } + if seconds < 3600 { return "\(seconds / 60)m ago" } + if seconds < 86_400 { return "\(seconds / 3600)h ago" } + return "\(seconds / 86_400)d ago" + } + + private static func abbreviate(_ value: Double, integer: Bool = false) -> String { + let units: [(threshold: Double, suffix: String)] = [ + (1_000_000_000_000, "T"), + (1_000_000_000, "B"), + (1_000_000, "M"), + (1_000, "K"), + ] + // Ascending, so promotion is a simple step to the next entry. + let ascending = units.reversed().map { $0 } + + for (index, unit) in ascending.enumerated() where value < (unit.threshold * 1000) { + let rendered = render(value / unit.threshold, suffix: unit.suffix, integer: integer) + // Rounding can push a value across its own boundary: 999_999 scales to + // 999.999K, which would render "1000K" instead of promoting to "1.00M". + guard rendered.hasPrefix("1000"), index + 1 < ascending.count else { return rendered } + let larger = ascending[index + 1] + return render(value / larger.threshold, suffix: larger.suffix, integer: integer) + } + + // Beyond the largest unit, stay in that unit rather than inventing a suffix. + if let largest = ascending.last, value >= largest.threshold { + return render(value / largest.threshold, suffix: largest.suffix, integer: integer) + } + return String(format: "%.0f", value) + } + + /// Render an abbreviated value with either integer or 3-significant-figure precision. + private static func render(_ scaled: Double, suffix: String, integer: Bool = false) -> String { + if integer { return String(format: "%.0f%@", scaled, suffix) } + let decimals = scaled >= 100 ? 0 : (scaled >= 10 ? 1 : 2) + return String(format: "%.\(decimals)f%@", scaled, suffix) + } +} diff --git a/app/Sources/MenuBarCore/Keychain.swift b/app/Sources/MenuBarCore/Keychain.swift new file mode 100644 index 00000000000..2d67ecb5de3 --- /dev/null +++ b/app/Sources/MenuBarCore/Keychain.swift @@ -0,0 +1,76 @@ +import Foundation +import Security + +/// Generic-password storage for the optional management API key. +/// +/// The key is read lazily — only after a 401 — and is never written to UserDefaults, +/// never logged, and never included in an error surfaced to the UI. +/// +/// **Read-only in practice today, and there is no way to provision the key.** Nothing in +/// the app calls `write`, because there is no key-entry UI yet — and a user cannot fill +/// the gap by hand either: every query sets `kSecUseDataProtectionKeychain`, and +/// Keychain Access does not create data-protection items. So a non-loopback bind is +/// genuinely unsupported rather than merely inconvenient, and the docs say exactly that. +/// +/// `write`/`delete` exist for the native entry flow that is planned. Do not document a +/// manual workaround on top of them: an earlier revision of the guide did, naming a +/// service that was both wrong and unreachable. +/// +/// Every query sets `kSecUseDataProtectionKeychain`. Without it, `kSecAttrAccessible` is +/// ignored on macOS (it applies only to data-protection or synchronizable items), so the +/// declared accessibility class would be decorative. Setting it on *all* operations also +/// matters for correctness: a data-protection item is invisible to a query that omits +/// the flag, so a mixed set of queries would fail to find or delete its own items. +public enum Keychain { + public static let service = "com.opencodex.menubar.apikey" + public static let defaultAccount = "default" + + private static func baseQuery(account: String) -> [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecUseDataProtectionKeychain as String: true, + ] + } + + public static func read(account: String = defaultAccount) -> String? { + var query = baseQuery(account: account) + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + var item: CFTypeRef? + guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess, + let data = item as? Data, + let value = String(data: data, encoding: .utf8), + !value.isEmpty + else { return nil } + return value + } + + @discardableResult + public static func write(_ value: String, account: String = defaultAccount) -> Bool { + let data = Data(value.utf8) + + // Update first, add only when absent. Deleting first would destroy a working key + // whenever the subsequent add failed. + let updateStatus = SecItemUpdate( + baseQuery(account: account) as CFDictionary, + [kSecValueData as String: data] as CFDictionary + ) + if updateStatus == errSecSuccess { return true } + guard updateStatus == errSecItemNotFound else { return false } + + var attributes = baseQuery(account: account) + attributes[kSecValueData as String] = data + // ThisDeviceOnly: the key is a local proxy credential with no reason to migrate + // to another machine via backup or transfer. + attributes[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + return SecItemAdd(attributes as CFDictionary, nil) == errSecSuccess + } + + @discardableResult + public static func delete(account: String = defaultAccount) -> Bool { + let status = SecItemDelete(baseQuery(account: account) as CFDictionary) + return status == errSecSuccess || status == errSecItemNotFound + } +} diff --git a/app/Sources/MenuBarCore/MenuBarTitle.swift b/app/Sources/MenuBarCore/MenuBarTitle.swift new file mode 100644 index 00000000000..c17613e3c85 --- /dev/null +++ b/app/Sources/MenuBarCore/MenuBarTitle.swift @@ -0,0 +1,38 @@ +import Foundation + +public enum MenuBarTitle { + public static func render( + settings: CompanionSettings, + today: UsageReport?, + quotas: [NormalizedQuota] + ) -> String? { + let summary = today?.summary + let values: [String: String] = [ + "requests": Format.count(summary?.requests), + "totalTokens": Format.tokens(summary?.totalTokens), + "inputTokens": Format.tokens(summary?.inputTokens), + "outputTokens": Format.tokens(summary?.outputTokens), + "costUsd": Format.cost(summary?.estimatedCostUsd), + "quotaPercent": Format.percent(quotas.compactMap(\.percent).min()), + ] + let rendered: String + if let template = settings.menuBarTemplate?.trimmingCharacters(in: .whitespacesAndNewlines), + !template.isEmpty { + rendered = values.reduce(template) { text, item in + text.replacingOccurrences(of: "{\(item.key)}", with: item.value) + } + } else { + switch settings.menuBarMetric { + case .requests: rendered = Format.count(summary?.requests) + case .tokens: rendered = Format.tokens(summary?.totalTokens) + case .cost: rendered = Format.cost(summary?.estimatedCostUsd) + case .quota: rendered = Format.percent(quotas.compactMap(\.percent).min()) + case .none: return nil + } + } + let text = rendered.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { return nil } + if text.count <= 24 { return text } + return String(text.prefix(23)) + "…" + } +} diff --git a/app/Sources/MenuBarCore/PollingCoordinator.swift b/app/Sources/MenuBarCore/PollingCoordinator.swift new file mode 100644 index 00000000000..5154fd7e835 --- /dev/null +++ b/app/Sources/MenuBarCore/PollingCoordinator.swift @@ -0,0 +1,268 @@ +import Foundation + +/// Owns the refresh schedule and turns transport results into a `ProxySnapshot`. +/// +/// Polling is deliberately conservative. A desktop app that hits a local server every +/// five seconds forever is a battery complaint waiting to happen, so heavy aggregation +/// endpoints are fetched only while the popover is open, and repeated failures back the +/// liveness tick off rather than hammering a proxy the user has stopped on purpose. +public actor PollingCoordinator { + public static let livenessInterval: TimeInterval = 5 + public static let heavyInterval: TimeInterval = 60 + public static let backoffInterval: TimeInterval = 30 + public static let backoffAfterFailures = 3 + + private let client: ProxyClient + private var snapshot: ProxySnapshot + private var popoverOpen = false + private var observers: [UUID: @Sendable (ProxySnapshot) -> Void] = [:] + /// Rises on every close and on every new refresh, so results from a superseded or + /// abandoned cycle can be discarded instead of overwriting fresher state. + private var generation = 0 + private var refreshInFlight = false + /// A refresh requested while another was in flight. Without this, closing and + /// immediately reopening the popover dropped the reopen's refresh entirely: the old + /// cycle exited on its generation guard and the new one had already been rejected. + private var pendingOpenRefresh = false + /// Continuations waiting for a cycle to publish. Waiting on a real completion signal + /// rather than a bounded spin means a slow-but-legitimate refresh cannot be + /// abandoned early, which would re-enable a control against pre-write state. + private var completionWaiters: [CheckedContinuation] = [] + /// Attempt time, distinct from success time: a persistently failing endpoint must + /// not turn its healthy sibling into a 5-second poller. + private var lastAggregationAttempt: Date? + + public init(client: ProxyClient, endpoint: ProxyEndpoint) { + self.client = client + self.snapshot = ProxySnapshot(endpoint: endpoint) + } + + public var current: ProxySnapshot { snapshot } + + /// Interval until the next liveness tick, widened once failures pile up. + public var currentInterval: TimeInterval { + snapshot.consecutiveFailures >= Self.backoffAfterFailures + ? Self.backoffInterval + : Self.livenessInterval + } + + @discardableResult + public func observe(_ handler: @escaping @Sendable (ProxySnapshot) -> Void) -> UUID { + let token = UUID() + observers[token] = handler + handler(snapshot) + return token + } + + public func removeObserver(_ token: UUID) { observers[token] = nil } + + public func setPopoverOpen(_ open: Bool) async { + popoverOpen = open + if open { + await refresh(includeHeavy: true) + } else { + // Abandon in-flight heavy work: its results are no longer visible and + // must not land as if they were current. + generation &+= 1 + } + } + + /// One refresh cycle. + /// + /// `includeHeavy` marks a popover-open refresh: on-open reads (providers, config) + /// always run, while the expensive aggregation reads (usage, quotas) still respect + /// the 60s interval so reopening the popover repeatedly does not hammer the proxy. + public func refresh(includeHeavy: Bool = false) async { + // Overlapping cycles publish interleaved state and double the request rate. + guard !refreshInFlight else { + if includeHeavy { pendingOpenRefresh = true } + return + } + refreshInFlight = true + generation &+= 1 + let cycle = generation + defer { refreshInFlight = false } + + do { + let health = try await client.health() + guard cycle == generation else { + refreshInFlight = false + await drainPendingRefresh() + signalCompletionIfIdle() + return + } + snapshot.state = .running(health) + snapshot.lastKnownStartCommand = health.manualStartCommand + snapshot.recommendedCommand = health.recommendedCommand + snapshot.consecutiveFailures = 0 + snapshot.lastUpdated = Date() + } catch is CancellationError { + // The popover closed mid-flight. Not a proxy failure; leave state untouched. + refreshInFlight = false + await drainPendingRefresh() + signalCompletionIfIdle() + return + } catch let error as ProxyError { + if cycle == generation { apply(error); publish() } + refreshInFlight = false + await drainPendingRefresh() + signalCompletionIfIdle() + return + } catch { + if cycle == generation { apply(.transport); publish() } + refreshInFlight = false + await drainPendingRefresh() + signalCompletionIfIdle() + return + } + + if popoverOpen, includeHeavy { + await refreshOnOpen(cycle: cycle) + } + + // Settings and today metrics also drive the menu-bar title, so aggregation runs + // on the normal cadence even while the popover is closed. + let aggregationDue = lastAggregationAttempt.map { + Date().timeIntervalSince($0) >= Self.heavyInterval + } ?? true + if aggregationDue, isCurrentCycle(cycle) { + lastAggregationAttempt = Date() + _ = await refreshAggregation(cycle: cycle) + } + + if cycle == generation { publish() } + refreshInFlight = false + await drainPendingRefresh() + signalCompletionIfIdle() + } + + /// Refreshes and does not return until a cycle has actually published. + /// + /// `refresh()` coalesces: if another cycle holds the lock it queues and returns + /// immediately. A caller that needs authoritative state afterwards — such as + /// re-enabling a switch after a write — would otherwise act on pre-write data. + public func refreshAndWait(includeHeavy: Bool = true) async { + if refreshInFlight { + // Queue behind the running cycle and wait for the queued one to finish. + await refresh(includeHeavy: includeHeavy) + await waitForCompletion() + return + } + await refresh(includeHeavy: includeHeavy) + } + + /// Number of callers currently suspended in `waitForCompletion()`. + /// + /// Exposed so a test can wait for registration deterministically instead of sleeping + /// and hoping the waiter task was scheduled — a fixed sleep let the continuation + /// tests pass without ever entering this path. + package var waiterCount: Int { completionWaiters.count } + + private func waitForCompletion() async { + guard refreshInFlight || pendingOpenRefresh else { return } + await withCheckedContinuation { continuation in + completionWaiters.append(continuation) + } + } + + /// Releases anyone waiting once no cycle is running or queued. + private func signalCompletionIfIdle() { + guard !refreshInFlight, !pendingOpenRefresh, !completionWaiters.isEmpty else { return } + let waiters = completionWaiters + completionWaiters.removeAll() + for waiter in waiters { waiter.resume() } + } + + /// Runs a refresh that arrived while another cycle held the lock. + private func drainPendingRefresh() async { + guard pendingOpenRefresh, popoverOpen else { + pendingOpenRefresh = false + return + } + pendingOpenRefresh = false + await refresh(includeHeavy: true) + } + + /// Reads that are only meaningful while the popover is open. + private func refreshOnOpen(cycle: Int) async { + guard isCurrent(cycle) else { return } + if let providers = try? await client.providers(), isCurrent(cycle) { + snapshot.providers = providers + snapshot.providersLoaded = true + } + // Re-check before each subsequent request: closing mid-flight should stop the + // sequence, not merely discard its results after paying for them. + guard isCurrent(cycle) else { return } + if let config = try? await client.config(), isCurrent(cycle) { + snapshot.defaultProvider = config.defaultProvider + } + } + + /// Still the newest cycle, and still worth doing. + private func isCurrent(_ cycle: Int) -> Bool { cycle == generation && popoverOpen } + private func isCurrentCycle(_ cycle: Int) -> Bool { cycle == generation } + + /// The expensive aggregation reads. Returns whether every read landed, so a partial + /// failure does not masquerade as a completed refresh. + private func refreshAggregation(cycle: Int) async -> Bool { + guard isCurrentCycle(cycle) else { return false } + var complete = true + + // Each read is independent: one failing endpoint must not blank the others. + if let response = try? await client.companionSettings() { + guard isCurrentCycle(cycle) else { return false } + snapshot.settings = response.settings + snapshot.settingsLoaded = true + } else { + complete = false + } + + guard isCurrentCycle(cycle) else { return false } + if let today = try? await client.usage(range: .today) { + guard isCurrentCycle(cycle) else { return false } + snapshot.today = today + snapshot.usage = today + snapshot.usageUpdated = Date() + } else { + complete = false + } + + guard isCurrentCycle(cycle) else { return false } + if snapshot.settings.showChart, let timeline = try? await client.timeline(snapshot.settings) { + guard isCurrentCycle(cycle) else { return false } + snapshot.timeline = timeline + snapshot.timelineUpdated = Date() + } else if snapshot.settings.showChart { + complete = false + } + + guard isCurrentCycle(cycle) else { return false } + if (popoverOpen || snapshot.settings.menuBarMetric == .quota), let quotas = try? await client.quotas() { + guard isCurrent(cycle) else { return false } + snapshot.quotas = quotas + snapshot.quotasLoaded = true + } else if popoverOpen || snapshot.settings.menuBarMetric == .quota { + complete = false + } + + return complete + } + + private func apply(_ error: ProxyError) { + snapshot.consecutiveFailures += 1 + switch error { + case .unreachable: + snapshot.state = .unreachable + case .unauthorized: + snapshot.state = .unauthorized + case .http, .decoding, .transport, .inconclusive: + // A timeout is degraded, not stopped: something may well still be running. + snapshot.state = .degraded(error.userMessage) + } + } + + private func publish() { + let value = snapshot + for handler in observers.values { handler(value) } + } +} diff --git a/app/Sources/MenuBarCore/ProxyClient.swift b/app/Sources/MenuBarCore/ProxyClient.swift new file mode 100644 index 00000000000..e9a31984244 --- /dev/null +++ b/app/Sources/MenuBarCore/ProxyClient.swift @@ -0,0 +1,332 @@ +import Foundation + +public enum ProxyError: Error, Equatable { + /// The connection was refused — nothing is listening. This is the only transport + /// result that proves the proxy is gone; timeouts get `.inconclusive`. + case unreachable + /// 401 — a non-loopback bind that requires a credential. + case unauthorized + case http(Int) + case decoding + /// A transport failure that is not evidence the proxy is down (TLS, policy, and + /// other non-connectivity URLSession errors). + case transport + /// The request never completed — a timeout or a socket dropped mid-response. This + /// proves nothing either way, and must not be read as "the proxy is gone". + case inconclusive + + /// Human sentences only. Response bodies can echo configuration values, so they + /// never reach the UI or a log. + public var userMessage: String { + switch self { + case .unreachable: return "The proxy is not running." + case .unauthorized: return "This proxy requires an API key." + case .http(let code): return "The proxy returned an unexpected status (\(code))." + case .decoding: return "The proxy returned a response this app could not read." + case .transport: return "The connection to the proxy failed." + case .inconclusive: return "The proxy did not respond in time." + } + } +} + +/// Supplies the optional management API key. Injected so tests never touch the real +/// Keychain and so the app can swap the source without touching transport code. +public protocol CredentialStore: Sendable { + func loadAPIKey() -> String? +} + +public struct KeychainCredentialStore: CredentialStore { + public init() {} + public func loadAPIKey() -> String? { Keychain.read() } +} + +/// HTTP client for the OpenCodex management API. +/// +/// An actor because the endpoint and key are mutated from both the polling loop and user +/// actions; the isolation makes that data-race-free by construction rather than by +/// convention. +public actor ProxyClient { + private let session: URLSession + private let credentials: CredentialStore + private var endpoint: ProxyEndpoint + private var apiKey: String? + /// Ensures the lazy credential load happens at most once per client. + private var didAttemptCredentialLoad = false + + public init( + endpoint: ProxyEndpoint, + session: URLSession? = nil, + credentials: CredentialStore = KeychainCredentialStore() + ) { + self.endpoint = endpoint + self.credentials = credentials + if let session { + self.session = session + } else { + let config = URLSessionConfiguration.ephemeral + config.timeoutIntervalForRequest = 4 + config.waitsForConnectivity = false + self.session = URLSession(configuration: config) + } + } + + public var currentEndpoint: ProxyEndpoint { endpoint } + + public func updateEndpoint(_ endpoint: ProxyEndpoint) { self.endpoint = endpoint } + + public func setAPIKey(_ key: String?) { + self.apiKey = key + // An explicitly supplied key replaces the lazy path entirely. + self.didAttemptCredentialLoad = true + } + + // MARK: - Reads + + public func health() async throws -> StartupHealth { try await get("api/startup-health") } + public func settings() async throws -> ProxySettings { try await get("api/settings") } + public func config() async throws -> ProxyConfigSummary { try await get("api/config") } + public func providers() async throws -> [ProviderSummary] { try await get("api/providers") } + + public func usage(range: UsageRange = .sevenDays) async throws -> UsageReport { + try await get("api/usage", query: [URLQueryItem(name: "range", value: range.rawValue)]) + } + + public func companionSettings() async throws -> CompanionSettingsResponse { + try await get("api/companion/settings") + } + + public func timeline(_ settings: CompanionSettings) async throws -> UsageTimeline { + var query = [ + URLQueryItem(name: "hours", value: String(settings.chartHours)), + URLQueryItem(name: "bucketMinutes", value: String(settings.bucketMinutes)), + URLQueryItem(name: "metric", value: settings.tokenMetric.rawValue), + URLQueryItem(name: "aggregation", value: settings.aggregation.rawValue), + URLQueryItem(name: "grouping", value: settings.chartGrouping.rawValue), + ] + if let models = settings.models, !models.isEmpty { + query.append(URLQueryItem(name: "models", value: models.joined(separator: ","))) + } + return try await get("api/usage/timeline", query: query) + } + + public func quotas() async throws -> [QuotaReport] { + let envelope: QuotaEnvelope = try await get("api/provider-quotas") + return envelope.reports ?? [] + } + + /// What a liveness probe actually established. + /// + /// Three states, not two. "Did not get a usable answer" and "nothing is listening" + /// are different facts, and conflating them let a stop be reported as confirmed + /// while an HTTP server was still running behind a 500 or a decode failure. + public enum Liveness: Equatable, Sendable { + /// Something answered — any HTTP status, including 401/403/500, or a body we + /// could not decode. The port is occupied. + case reachable + /// The connection was refused. This is the only proof that the proxy is gone. + case refused + /// A timeout or other transport failure: no conclusion either way. + case indeterminate + } + + /// A short probe: the default 4s read timeout would let a single liveness check + /// overrun the stop deadline it is supposed to respect. + public func liveness(timeout: TimeInterval = 1.5) async -> Liveness { + do { + // Deliberately bypasses `send()`: its 401 credential retry would spend a + // second full timeout re-asking a question the 401 already answered, and a + // failed retry would downgrade a known-reachable result to indeterminate. + _ = try await perform( + method: "GET", path: "api/settings", query: [], + body: nil as EmptyBody?, timeout: timeout + ) + return .reachable + } catch ProxyError.unauthorized, ProxyError.decoding { + // Both prove a server answered. + return .reachable + } catch ProxyError.http { + return .reachable + } catch ProxyError.unreachable { + // Connection refused: nothing is listening on the port. + return .refused + } catch { + // Timeouts, dropped sockets, and anything else: no conclusion. + return .indeterminate + } + } + + /// Convenience for callers that only need "is anything there". + public func isReachable() async -> Bool { + await liveness() != .refused + } + + // MARK: - Writes + + /// `POST /api/stop`. Returns once the proxy has accepted the request. + /// + /// The proxy answers 200 *before* draining, and it stops the launchd service first so + /// nothing respawns it. Callers must poll `liveness()` rather than treat this return + /// as "stopped". + /// + /// The response carries `success: false` when `restoreNativeCodex()` failed + /// (`src/server/management-api.ts:145-147`): the proxy still shuts down, but native + /// Codex was left pointing at a port that is about to close. Only the boolean is + /// decoded — the accompanying message is a server-formatted string and never reaches + /// the UI. + @discardableResult + public func stop() async throws -> Bool { + let data = try await send(method: "POST", path: "api/stop", body: nil as EmptyBody?) + guard let result = try? JSONDecoder().decode(StopResult.self, from: data) else { + // An undecodable body is not a reason to claim the restore failed. + return true + } + return result.success ?? true + } + + /// `PATCH /api/providers?name=` with a body of exactly `{"disabled": }`. + /// + /// A disabled-only patch skips the proxy's heavier merged-shape validators, so adding + /// any second field would silently change the request class. + public func setProviderDisabled(_ name: String, disabled: Bool) async throws { + _ = try await send( + method: "PATCH", + path: "api/providers", + query: [URLQueryItem(name: "name", value: name)], + body: ProviderDisabledPatch(disabled: disabled) + ) + } + + // MARK: - Transport + + private func get( + _ path: String, + query: [URLQueryItem] = [], + timeout: TimeInterval? = nil + ) async throws -> T { + let data = try await send( + method: "GET", path: path, query: query, + body: nil as EmptyBody?, timeout: timeout + ) + do { + return try JSONDecoder().decode(T.self, from: data) + } catch { + throw ProxyError.decoding + } + } + + private func send( + method: String, + path: String, + query: [URLQueryItem] = [], + body: Body?, + timeout: TimeInterval? = nil + ) async throws -> Data { + let keyAtStart = apiKey + do { + return try await perform(method: method, path: path, query: query, body: body, timeout: timeout) + } catch ProxyError.unauthorized { + // A loopback proxy needs no credential, so a 401 means this install is bound + // to a non-loopback host. + // + // Reentrancy matters here: the actor suspends across the request, so several + // calls can be in flight and all receive 401. Retry eligibility is therefore + // decided per request, against the key THAT request actually sent — not + // against a single global "already tried" flag. A concurrent caller that + // started before the key was loaded must still get to retry with it. + guard let key = try await credentialForRetry(after: keyAtStart) else { + throw ProxyError.unauthorized + } + return try await perform(method: method, path: path, query: query, body: body, key: key, timeout: timeout) + } + } + + /// The key to retry with, or `nil` when this request already used the current + /// credential (so retrying would repeat an identical, failing call). + private func credentialForRetry(after keyAtStart: String?) async throws -> String? { + // Another in-flight call already loaded a key this request did not use. + if let current = apiKey, current != keyAtStart { return current } + // This request already carried the newest key: a stale credential, not a + // missing one. Never loop. + if apiKey != nil, apiKey == keyAtStart { return nil } + + guard !didAttemptCredentialLoad else { return nil } + didAttemptCredentialLoad = true + guard let stored = credentials.loadAPIKey(), !stored.isEmpty else { return nil } + apiKey = stored + return stored + } + + private func perform( + method: String, + path: String, + query: [URLQueryItem], + body: Body?, + key: String? = nil, + timeout: TimeInterval? = nil + ) async throws -> Data { + guard var components = URLComponents( + url: endpoint.baseURL.appendingPathComponent(path), + resolvingAgainstBaseURL: false + ) else { throw ProxyError.decoding } + if !query.isEmpty { components.queryItems = query } + guard let url = components.url else { throw ProxyError.decoding } + + var request = URLRequest(url: url) + request.httpMethod = method + request.timeoutInterval = timeout ?? (method == "GET" ? 4 : 6) + let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "dev" + request.setValue("OpenCodexWidget/\(version)", forHTTPHeaderField: "User-Agent") + if let credential = key ?? apiKey { + request.setValue(credential, forHTTPHeaderField: "x-opencodex-api-key") + } + if let body { + request.setValue("application/json", forHTTPHeaderField: "content-type") + request.httpBody = try? JSONEncoder().encode(body) + } + + do { + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse else { throw ProxyError.decoding } + if http.statusCode == 401 { throw ProxyError.unauthorized } + guard (200..<300).contains(http.statusCode) else { + throw ProxyError.http(http.statusCode) + } + return data + } catch let error as ProxyError { + throw error + } catch let error as URLError { + switch error.code { + case .cancelled: + // Propagate cancellation rather than reporting a stopped proxy: the + // polling coordinator cancels in-flight work whenever the popover closes. + throw CancellationError() + case .cannotConnectToHost: + // The one code that actually proves nothing is listening. + throw ProxyError.unreachable + case .timedOut, .networkConnectionLost, .cannotFindHost, + .notConnectedToInternet, .dnsLookupFailed: + // A timeout or a dropped socket says the request failed, not that the + // server is gone. Collapsing these into `.unreachable` is what let a + // stop be reported as confirmed while the proxy was still running. + throw ProxyError.inconclusive + default: + throw ProxyError.transport + } + } + } +} + +private struct QuotaEnvelope: Decodable { + let generatedAt: Double? + let reports: [QuotaReport]? +} + +private struct ProviderDisabledPatch: Encodable { + let disabled: Bool +} + +private struct StopResult: Decodable { + let success: Bool? +} + +private struct EmptyBody: Encodable {} diff --git a/app/Sources/MenuBarCore/ProxyModels.swift b/app/Sources/MenuBarCore/ProxyModels.swift new file mode 100644 index 00000000000..b79034e394b --- /dev/null +++ b/app/Sources/MenuBarCore/ProxyModels.swift @@ -0,0 +1,285 @@ +import Foundation + +// Codable mirrors of the management API payloads inventoried in +// devlog/_plan/260725_macos_menubar_app/002_api_surface.md. +// +// Every field the proxy may omit is optional. The proxy is a fast-moving local service; +// a companion that fails to decode because one field moved is worse than one that shows +// an em dash. + +/// `GET /api/startup-health` +public struct StartupHealth: Decodable, Equatable, Sendable { + public let status: String? + public let protection: String? + public let platform: String? + public let routingKind: String? + public let serviceRunning: Bool? + public let serviceInstalled: Bool? + public let serviceEnabled: Bool? + public let rebootSafe: Bool? + public let recommendedCommand: String? + + public init( + status: String? = nil, + protection: String? = nil, + platform: String? = nil, + routingKind: String? = nil, + serviceRunning: Bool? = nil, + serviceInstalled: Bool? = nil, + serviceEnabled: Bool? = nil, + rebootSafe: Bool? = nil, + recommendedCommand: String? = nil + ) { + self.status = status + self.protection = protection + self.platform = platform + self.routingKind = routingKind + self.serviceRunning = serviceRunning + self.serviceInstalled = serviceInstalled + self.serviceEnabled = serviceEnabled + self.rebootSafe = rebootSafe + self.recommendedCommand = recommendedCommand + } + + /// `status` is treated as an open string: unknown values degrade to a neutral state + /// rather than crashing or being coerced into "healthy". + public var isProtected: Bool { status == "protected" } + + /// True when a supervisor owns the process lifecycle. Used only for the qualifier + /// line — it deliberately does not gate any action, because `/api/stop` stops the + /// service on purpose and nothing restarts the proxy automatically. + public var isServiceManaged: Bool { + (serviceInstalled ?? false) && (serviceEnabled ?? false) + } + + /// The command to show the user when the proxy is not running. + public var manualStartCommand: String { + isServiceManaged ? "ocx service start" : "ocx start" + } +} + +/// `GET /api/settings`. Note the absence of `defaultProvider` — it lives on +/// `/api/config`, verified against the live key set. +public struct ProxySettings: Decodable, Equatable, Sendable { + public let port: Int? + public let hostname: String? + public let streamMode: String? + public let codexAutoStart: Bool? +} + +/// `GET /api/config` — the only source of `defaultProvider`. +public struct ProxyConfigSummary: Decodable, Equatable, Sendable { + public let port: Int? + public let hostname: String? + public let defaultProvider: String? +} + +/// Ranges accepted by `parseRange()` in `src/usage/summary.ts`. +/// +/// Closed on purpose: the server silently degrades anything else to `30d`, so a +/// stringly-typed range would let a caller ask for `24h`, receive thirty days of data, +/// and label it wrongly. +public enum UsageRange: String, Sendable, CaseIterable { + case today = "today" + case sevenDays = "7d" + case thirtyDays = "30d" + case all +} + +public struct UsageSummary: Decodable, Equatable, Sendable { + public let requests: Int? + public let measuredRequests: Int? + public let estimatedRequests: Int? + public let totalTokens: Int? + public let inputTokens: Int? + public let outputTokens: Int? + public let estimatedCostUsd: Double? + public let coverageRatio: Double? + + public var hasEstimates: Bool { (estimatedRequests ?? 0) > 0 } +} + +public struct UsageDay: Decodable, Equatable, Sendable { + public let date: String + public let requests: Int? + public let totalTokens: Int? +} + +public struct UsageReport: Decodable, Equatable, Sendable { + public let range: String? + public let surface: String? + public let generatedAt: Double? + public let summary: UsageSummary? + public let days: [UsageDay]? + public let models: [UsageModelRow]? + public let accounts: [UsageAccountRow]? + + /// The range the server actually applied, which is not always the one requested. + public var effectiveRange: UsageRange? { + range.flatMap(UsageRange.init(rawValue:)) + } + + /// Header text driven by the response, never by the request. + public var rangeLabel: String { + switch effectiveRange { + case .today: return "TODAY" + case .sevenDays: return "LAST 7 DAYS" + case .thirtyDays: return "LAST 30 DAYS" + case .all: return "ALL TIME" + case nil: return "USAGE" + } + } + + public var isEmpty: Bool { + isEmptyOrUnknown == true + } + + /// Three states, not two: `nil` means the proxy did not report a request count, and + /// `true` means it explicitly reported zero. Collapsing those would let the UI print + /// "No requests" for data it simply does not have. + public var isEmptyOrUnknown: Bool? { + guard let requests = summary?.requests else { return nil } + return requests == 0 + } +} + +public struct UsageModelRow: Decodable, Equatable, Sendable { + public let provider: String? + public let model: String? + public let requests: Int? + public let totalTokens: Int? + public let estimatedCostUsd: Double? +} + +public struct UsageAccountRow: Decodable, Equatable, Sendable { + public let accountLogLabel: String? + public let requests: Int? + public let totalTokens: Int? + public let estimatedCostUsd: Double? +} + +public struct QuotaWindow: Decodable, Equatable, Sendable { + public let label: String? + public let percent: Double? + public let resetAt: Double? +} + +public struct ProviderQuota: Decodable, Equatable, Sendable { + public let weeklyPercent: Double? + public let monthlyPercent: Double? + public let fiveHourPercent: Double? + public let weeklyResetAt: Double? + public let monthlyResetAt: Double? + public let fiveHourResetAt: Double? + public let customWindows: [QuotaWindow]? + public let updatedAt: Double? +} + +public struct QuotaReport: Decodable, Equatable, Sendable { + public let provider: String + public let label: String? + public let source: String? + public let quota: ProviderQuota? +} + +/// A provider-agnostic view of quota, since the window key differs per provider. +public struct NormalizedQuota: Equatable, Sendable { + public let provider: String + public let providerLabel: String + public let percent: Double? + public let windowLabel: String + public let resetAt: Date? + + public var hasPercent: Bool { percent != nil } +} + +public extension QuotaReport { + /// Timestamps in this payload are not uniform: the live proxy returns + /// `weeklyResetAt` in seconds for `openai` and in milliseconds for `anthropic`, + /// within the same array. Disambiguate by magnitude — 1e12 is 2001 read as + /// milliseconds and year 33658 read as seconds, so the boundary is unambiguous for + /// any timestamp this app will ever see. + static func date(from value: Double?) -> Date? { + guard let value, value > 0 else { return nil } + let seconds = value >= 1_000_000_000_000 ? value / 1000 : value + return Date(timeIntervalSince1970: seconds) + } + + /// Every window the provider reported, in display order. + /// + /// The live proxy is not uniform: `openai` and `xai` report a single named window, + /// `kimi` reports both `weeklyPercent` and `fiveHourPercent`, and `cursor` and + /// `google-antigravity` carry two `customWindows` each. Returning only one window + /// would silently hide real quota pressure. + func normalizedWindows() -> [NormalizedQuota] { + let name = label ?? provider + var windows: [NormalizedQuota] = [] + + func append(_ percent: Double?, _ windowLabel: String, _ resetAt: Double?) { + guard percent != nil || resetAt != nil else { return } + windows.append(NormalizedQuota( + provider: provider, providerLabel: name, percent: percent, + windowLabel: windowLabel, resetAt: Self.date(from: resetAt) + )) + } + + append(quota?.fiveHourPercent, "5h", quota?.fiveHourResetAt) + append(quota?.weeklyPercent, "week", quota?.weeklyResetAt) + append(quota?.monthlyPercent, "month", quota?.monthlyResetAt) + + for window in quota?.customWindows ?? [] { + append(window.percent, window.label ?? "window", window.resetAt) + } + + return windows + } + + /// The single window that best represents current pressure, for the compact row. + /// + /// Selection is **highest reported usage**, not longest horizon. Every window can + /// stop work: a provider at 99% of a five-hour limit and 10% of its monthly limit is + /// blocked right now, and showing the monthly 10% would paint that row green while + /// the user cannot make a request. Ties break toward the longer horizon, since that + /// is the one that will not recover on its own. + /// + /// Providers with no numeric window normalize to a nil percent so the UI renders an + /// em dash rather than a misleading zero. + func normalized() -> NormalizedQuota { + let name = label ?? provider + let windows = normalizedWindows() + + // Longer horizons rank higher only as a tie-breaker. + func horizonRank(_ label: String) -> Int { + switch label { + case "month": return 3 + case "week": return 2 + case "5h": return 1 + default: return 0 + } + } + + let measured = windows.filter(\.hasPercent) + let preferred = measured.max { lhs, rhs in + let left = lhs.percent ?? 0 + let right = rhs.percent ?? 0 + if left != right { return left < right } + return horizonRank(lhs.windowLabel) < horizonRank(rhs.windowLabel) + } ?? windows.first + + return preferred ?? NormalizedQuota( + provider: provider, providerLabel: name, percent: nil, + windowLabel: "—", resetAt: nil + ) + } +} + +/// `GET /api/providers`. `hasApiKey` is a presence flag; the key never leaves the proxy. +public struct ProviderSummary: Decodable, Equatable, Sendable { + public let name: String + public let adapter: String? + public let authMode: String? + public let hasApiKey: Bool? + public let disabled: Bool? + + public var isEnabled: Bool { !(disabled ?? false) } +} diff --git a/app/Sources/MenuBarCore/ProxySnapshot.swift b/app/Sources/MenuBarCore/ProxySnapshot.swift new file mode 100644 index 00000000000..30416a5a7f9 --- /dev/null +++ b/app/Sources/MenuBarCore/ProxySnapshot.swift @@ -0,0 +1,201 @@ +import Foundation + +/// Everything the UI can show, as one value. +/// +/// Views are pure functions of this snapshot, so no view invents its own loading flag or +/// decides independently whether data is missing. +public enum ProxyState: Equatable, Sendable { + /// First fetch in flight; nothing is known yet. + case loading + case running(StartupHealth) + /// Connection refused — the proxy is not running. + case unreachable + /// 401 with no usable credential. + case unauthorized + /// Reachable but erroring. The message is proxy-free human text. + case degraded(String) + + public var isRunning: Bool { + if case .running = self { return true } + return false + } + + /// Short label shown beside the status dot. Colour is never the only carrier of + /// meaning, so every state has a word. + public var title: String { + switch self { + case .loading: return "Checking…" + case .running: return "Running" + case .unreachable: return "Stopped" + case .unauthorized: return "Needs API key" + case .degraded: return "Degraded" + } + } + + public enum Tone: Sendable { case neutral, good, warning, bad } + + public var tone: Tone { + switch self { + case .loading: return .neutral + case .running(let health): return health.isProtected ? .good : .warning + case .unreachable: return .bad + case .unauthorized: return .warning + case .degraded: return .warning + } + } + + /// Secondary line under the title. + public var detail: String? { + switch self { + case .loading: + return nil + case .running(let health): + let parts = [health.status, health.protection] + .compactMap { $0 } + .filter { !$0.isEmpty && $0 != "none" } + return parts.isEmpty ? nil : parts.joined(separator: " · ") + case .unreachable: + return "The proxy is not running." + case .unauthorized: + return "This proxy requires an API key." + case .degraded(let message): + return message + } + } +} + +/// What the user should do next. `loading` deliberately has none — there is nothing to +/// act on yet — but every other non-running state names one. +public enum NextAction: Equatable, Sendable { + case none + /// A command to run, shown as selectable text. The app never spawns processes. + case runCommand(String) + case addAPIKey + case retry +} + +public struct ProxySnapshot: Equatable, Sendable { + public var state: ProxyState + public var endpoint: ProxyEndpoint + public var usage: UsageReport? + public var settings: CompanionSettings + public var settingsLoaded: Bool + public var today: UsageReport? + public var timeline: UsageTimeline? + public var timelineUpdated: Date? + public var quotas: [QuotaReport] + public var providers: [ProviderSummary] + public var defaultProvider: String? + public var lastUpdated: Date? + public var consecutiveFailures: Int + /// Remembered from the last successful health read, so a stopped proxy can still + /// tell the user the right start command for their install. + public var lastKnownStartCommand: String? + /// The proxy's own remediation hint (for example `ocx service install`). Displayed + /// as selectable text, never executed. + public var recommendedCommand: String? + /// Whether a section has actually been read, so "not fetched yet" and "the proxy + /// reported none" render differently. + public var providersLoaded: Bool + public var quotasLoaded: Bool + /// When the aggregation data last succeeded, which is NOT when health last + /// succeeded. Conflating them let a degraded state claim "showing data from 5s ago" + /// while holding no metrics at all. + public var usageUpdated: Date? + + public init( + state: ProxyState = .loading, + endpoint: ProxyEndpoint, + usage: UsageReport? = nil, + settings: CompanionSettings = .defaults, + settingsLoaded: Bool = false, + today: UsageReport? = nil, + timeline: UsageTimeline? = nil, + timelineUpdated: Date? = nil, + quotas: [QuotaReport] = [], + providers: [ProviderSummary] = [], + defaultProvider: String? = nil, + lastUpdated: Date? = nil, + consecutiveFailures: Int = 0, + lastKnownStartCommand: String? = nil, + recommendedCommand: String? = nil, + providersLoaded: Bool = false, + quotasLoaded: Bool = false, + usageUpdated: Date? = nil + ) { + self.state = state + self.endpoint = endpoint + self.usage = usage + self.settings = settings + self.settingsLoaded = settingsLoaded + self.today = today + self.timeline = timeline + self.timelineUpdated = timelineUpdated + self.quotas = quotas + self.providers = providers + self.defaultProvider = defaultProvider + self.lastUpdated = lastUpdated + self.consecutiveFailures = consecutiveFailures + self.lastKnownStartCommand = lastKnownStartCommand + self.recommendedCommand = recommendedCommand + self.providersLoaded = providersLoaded + self.quotasLoaded = quotasLoaded + self.usageUpdated = usageUpdated + } + + /// Whether the data sections are worth rendering at all. + /// + /// `degraded` keeps them: the plan requires stale-but-labelled over blank, because a + /// user who can still see last-known numbers with an explicit age is better served + /// than one staring at an empty panel. + public var showsData: Bool { + switch state { + case .running: return true + // Only claim stale data when data was actually loaded. Health succeeding while + // the popover was closed is not the same as having metrics to show. + case .degraded: return usage != nil || quotasLoaded + case .loading, .unreachable, .unauthorized: return false + } + } + + /// Age of the DATA, not of the last health probe. + public var dataAge: Date? { usageUpdated } + + /// True once the proxy has been read at least once, so `loading` can show skeletons + /// rather than empty copy. + public var hasEverLoaded: Bool { lastUpdated != nil } + + public var nextAction: NextAction { + switch state { + case .loading: return .none + case .running: return .none + case .unreachable: + return .runCommand(lastKnownStartCommand ?? "ocx start") + case .unauthorized: return .addAPIKey + case .degraded: return .retry + } + } + + /// One normalized row per provider for the compact quota list. + public var quotaRows: [NormalizedQuota] { + quotas.map { $0.normalized() } + } + + public var visibleProviders: [ProviderSummary] { + providers.filter { !settings.hiddenProviders.contains($0.name) } + } + + public var menuBarTitle: String? { + MenuBarTitle.render(settings: settings, today: today ?? usage, quotas: quotaRows) + } + + public var todayRows: [UsageModelRow] { today?.models ?? [] } + + /// Whether the metrics section should render its empty copy. `nil` means unknown, + /// which renders em dashes instead. + public var usageIsEmpty: Bool? { usage?.isEmptyOrUnknown } + + public func canToggle(_ provider: ProviderSummary) -> Bool { + provider.name != defaultProvider + } +} diff --git a/app/Sources/MenuBarCore/UsageTimeline.swift b/app/Sources/MenuBarCore/UsageTimeline.swift new file mode 100644 index 00000000000..6291272bcc2 --- /dev/null +++ b/app/Sources/MenuBarCore/UsageTimeline.swift @@ -0,0 +1,39 @@ +import Foundation + +public struct TimelineSeries: Decodable, Equatable, Sendable { + public let id: String + public let provider: String + public let model: String + public let accountLogLabel: String? + public let total: Double + public let points: [Double] +} + +public struct UsageTimeline: Decodable, Equatable, Sendable { + public let start: Double + public let end: Double + public let bucketSeconds: Int + public let buckets: Int + public let metric: String + public let aggregation: String + public let grouping: String + public let series: [TimelineSeries] + public let availableModels: [String] + public let missingMeasurements: Int + public let truncated: Bool? + + public var maxPoint: Double { + series.flatMap(\.points).max() ?? 0 + } + + public var stackedMax: Double { + guard buckets > 0 else { return 0 } + return (0.. WidgetSnapshot { + let state: String + switch snapshot.state { + case .loading: state = "loading" + case .running: state = "running" + case .unreachable: state = "unreachable" + case .unauthorized: state = "unauthorized" + case .degraded: state = "degraded" + } + let report = snapshot.today ?? snapshot.usage + let today = report?.summary.map { + Today(requests: $0.requests, totalTokens: $0.totalTokens, estimatedCostUsd: $0.estimatedCostUsd) + } + let quotas = snapshot.quotaRows.map { + Quota(providerLabel: $0.providerLabel, windowLabel: $0.windowLabel, percent: $0.percent, resetAt: $0.resetAt?.timeIntervalSince1970) + } + let chart = snapshot.timeline.map { + Chart( + start: $0.start, bucketSeconds: $0.bucketSeconds, style: snapshot.settings.chartStyle.rawValue, + series: Array($0.series.prefix(6)).map { Chart.Series(id: $0.id, points: $0.points) } + ) + } + return WidgetSnapshot( + schemaVersion: 1, generatedAt: now.timeIntervalSince1970, + state: state, stateTitle: snapshot.state.title, detail: snapshot.state.detail, + endpointDisplay: snapshot.endpoint.display, menuTitle: snapshot.menuBarTitle, + today: today, quotas: quotas, chart: chart, + lastUpdated: (snapshot.timelineUpdated ?? snapshot.usageUpdated)?.timeIntervalSince1970 + ) + } +} + +public final class WidgetSnapshotStore: @unchecked Sendable { + private let fileManager: FileManager + private let homeDirectory: URL + private let widgetBundleID: String + private let lock = NSLock() + private var lastWritten: WidgetSnapshot? + private let logger = Logger(subsystem: "ai.opencodex.menubar", category: "widget-snapshot") + private var loggedFailures = Set() + + public init( + widgetBundleID: String = "com.opencodex.desktop.widget", + fileManager: FileManager = .default, + homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser + ) { + self.widgetBundleID = widgetBundleID + self.fileManager = fileManager + self.homeDirectory = homeDirectory + } + + public var url: URL { + homeDirectory + .appendingPathComponent("Library/Containers/\(widgetBundleID)/Data/Library/Application Support/OpenCodex", isDirectory: true) + .appendingPathComponent("snapshot.json") + } + + public func write(_ snapshot: WidgetSnapshot) throws { + let directory = url.deletingLastPathComponent() + try fileManager.createDirectory(at: directory, withIntermediateDirectories: true) + let data = try JSONEncoder().encode(snapshot) + let temporary = directory.appendingPathComponent(".snapshot-\(UUID().uuidString).tmp") + try data.write(to: temporary, options: .atomic) + try fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: temporary.path) + if fileManager.fileExists(atPath: url.path) { try fileManager.removeItem(at: url) } + try fileManager.moveItem(at: temporary, to: url) + Self.reloadTimelines() + } + + public func writeIfChanged(_ snapshot: WidgetSnapshot) { + lock.lock() + let previous = lastWritten + if previous?.withoutGeneratedAt == snapshot.withoutGeneratedAt { + lock.unlock() + return + } + do { + try write(snapshot) + lastWritten = snapshot + lock.unlock() + } catch { + let key = String(describing: type(of: error)) + if loggedFailures.insert(key).inserted { logger.error("Widget snapshot write failed: \(key, privacy: .public)") } + lock.unlock() + } + } + + public static func reloadTimelines() { + #if canImport(WidgetKit) + if #available(macOS 14, *) { WidgetCenter.shared.reloadAllTimelines() } + #endif + } +} + +private extension WidgetSnapshot { + var withoutGeneratedAt: WidgetSnapshot { + WidgetSnapshot( + schemaVersion: schemaVersion, generatedAt: 0, state: state, stateTitle: stateTitle, + detail: detail, endpointDisplay: endpointDisplay, menuTitle: menuTitle, today: today, + quotas: quotas, chart: chart, lastUpdated: lastUpdated + ) + } +} diff --git a/app/Sources/MenuBarCoreTests/ActionSuite.swift b/app/Sources/MenuBarCoreTests/ActionSuite.swift new file mode 100644 index 00000000000..8f11cc350f7 --- /dev/null +++ b/app/Sources/MenuBarCoreTests/ActionSuite.swift @@ -0,0 +1,356 @@ +import Foundation +import MenuBarCore + +/// Write-action behaviour, especially the timing: `/api/stop` answers before it drains, +/// so "returned 200" and "actually stopped" are different facts. +enum ActionSuite { + private static func makeSession() -> URLSession { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [StubProtocol.self] + return URLSession(configuration: config) + } + + private static func sync(_ operation: @escaping () async -> T) -> T { + let semaphore = DispatchSemaphore(value: 0) + let box = Box() + Task { + box.value = await operation() + semaphore.signal() + } + semaphore.wait() + return box.value! + } + + private final class Box: @unchecked Sendable { var value: T? } + private struct NoCredentials: CredentialStore { func loadAPIKey() -> String? { nil } } + + /// A clock the test drives, so the timeout path runs in milliseconds. + private final class FakeClock: @unchecked Sendable { + private let lock = NSLock() + private var current = Date(timeIntervalSince1970: 1_784_915_000) + func now() -> Date { lock.lock(); defer { lock.unlock() }; return current } + func advance(_ seconds: TimeInterval) { + lock.lock(); current = current.addingTimeInterval(seconds); lock.unlock() + } + } + + private static func makeCoordinator(clock: FakeClock = FakeClock()) -> ActionCoordinator { + let client = ProxyClient(endpoint: .default, session: makeSession(), credentials: NoCredentials()) + // Skip the real wall-clock wait, but advance the clock by the same amount so the + // deadline still expires. + return ActionCoordinator( + client: client, + sleeper: { seconds in clock.advance(seconds) }, + now: { clock.now() } + ) + } + + private static func paths() -> [String] { + StubProtocol.recorded.compactMap { $0.url?.path } + } + + static func run(_ t: TestRunner) { + // The proxy stops the launchd service on purpose, so a successful stop is + // reported as "you will have to start it again", not as a plain success. + t.test("stop: reports manual-start once the port stops answering") { + StubProtocol.reset([ + .init(status: 200, body: "{}", urlError: nil), // POST /api/stop + .init(status: 0, body: "", urlError: .cannotConnectToHost), // probe: gone + ]) + let outcome = sync { await makeCoordinator().stop(startCommand: "ocx service start") } + t.equal(outcome, .requiresManualStart("ocx service start")) + t.expect(paths().first == "/api/stop", "stop called first, got \(paths())") + } + + // A 200 that never drains must not be reported as success. + t.test("stop: a proxy that keeps answering is a failure, not a success") { + // The stub falls back to "connection refused" once its queue drains, which + // would look like a successful stop. Queue well past the poll count so the + // timeout path is what actually runs. + var responses: [StubProtocol.Response] = [.init(status: 200, body: "{}", urlError: nil)] + responses.append(contentsOf: Array( + repeating: .init(status: 200, body: #"{"port":10100}"#, urlError: nil), + count: 400 + )) + StubProtocol.reset(responses) + let clock = FakeClock() + let outcome = sync { await makeCoordinator(clock: clock).stop(startCommand: "ocx start") } + if case .failed(let message) = outcome { + t.expect(message.contains("still responding"), "expected a timeout message, got \(message)") + } else { + t.expect(false, "expected .failed, got \(outcome)") + } + } + + t.test("stop: an unreachable proxy fails without claiming it stopped anything") { + StubProtocol.reset([.init(status: 0, body: "", urlError: .cannotConnectToHost)]) + let outcome = sync { await makeCoordinator().stop(startCommand: "ocx start") } + t.equal(outcome, .failed(ProxyError.unreachable.userMessage)) + } + + t.test("stop: a failure message never carries the response body") { + StubProtocol.reset([.init(status: 500, body: "SECRET-CONFIG", urlError: nil)]) + let outcome = sync { await makeCoordinator().stop(startCommand: "ocx start") } + if case .failed(let message) = outcome { + t.expect(!message.contains("SECRET"), "leaked body: \(message)") + } else { + t.expect(false, "expected .failed, got \(outcome)") + } + } + + t.test("provider: disabling sends exactly one PATCH and succeeds") { + StubProtocol.reset([.init(status: 200, body: "{}", urlError: nil)]) + let outcome = sync { + await makeCoordinator().setProvider("anthropic", disabled: true, defaultProvider: "openai") + } + t.equal(outcome, .succeeded) + t.equal(StubProtocol.recorded.count, 1) + t.equal(StubProtocol.recorded.first?.httpMethod, "PATCH") + let url = StubProtocol.recorded.first?.url?.absoluteString ?? "" + t.expect(url.contains("name=anthropic"), "expected name=anthropic in \(url)") + } + + // The proxy answers 400 for this, so the request is never sent at all. + t.test("provider: the default provider is refused before any request") { + StubProtocol.reset([.init(status: 200, body: "{}", urlError: nil)]) + let outcome = sync { + await makeCoordinator().setProvider("openai", disabled: true, defaultProvider: "openai") + } + if case .failed(let message) = outcome { + t.expect(message.contains("default provider"), "expected an explanation, got \(message)") + } else { + t.expect(false, "expected .failed, got \(outcome)") + } + t.equal(StubProtocol.recorded.count, 0, "no request should be sent") + } + + t.test("provider: enabling the default provider is allowed") { + StubProtocol.reset([.init(status: 200, body: "{}", urlError: nil)]) + let outcome = sync { + await makeCoordinator().setProvider("openai", disabled: false, defaultProvider: "openai") + } + t.equal(outcome, .succeeded) + } + + t.test("provider: a 400 from the proxy surfaces without quoting its body") { + StubProtocol.reset([.init(status: 400, body: "cannot disable the default provider", urlError: nil)]) + let outcome = sync { + await makeCoordinator().setProvider("x", disabled: true, defaultProvider: "openai") + } + if case .failed(let message) = outcome { + t.expect(!message.contains("cannot disable"), "leaked body: \(message)") + t.expect(message.contains("refused"), "expected a refusal message, got \(message)") + } else { + t.expect(false, "expected .failed, got \(outcome)") + } + } + + t.test("provider: an unreachable proxy fails cleanly") { + StubProtocol.reset([.init(status: 0, body: "", urlError: .cannotConnectToHost)]) + let outcome = sync { + await makeCoordinator().setProvider("x", disabled: false, defaultProvider: nil) + } + t.equal(outcome, .failed(ProxyError.unreachable.userMessage)) + } + // Was tautological: it built its own non-empty literals and then asserted they + // were non-empty. Now drives real failures and checks the message the user sees. + t.test("actions: every real failure path produces a usable message") { + StubProtocol.reset([.init(status: 0, body: "", urlError: .cannotConnectToHost)]) + let unreachable = sync { await makeCoordinator().setProvider("x", disabled: false, defaultProvider: nil) } + + StubProtocol.reset([.init(status: 400, body: "raw body", urlError: nil)]) + let rejected = sync { await makeCoordinator().setProvider("x", disabled: true, defaultProvider: "openai") } + + let guarded = sync { await makeCoordinator().setProvider("openai", disabled: true, defaultProvider: "openai") } + + for outcome in [unreachable, rejected, guarded] { + guard case .failed(let message) = outcome else { + t.expect(false, "expected .failed, got \(outcome)") + continue + } + t.expect(!message.isEmpty, "empty failure message") + t.expect(message.hasSuffix(".") || message.hasSuffix("!"), + "message should read as a sentence: \(message)") + t.expect(!message.contains("raw body"), "leaked body: \(message)") + } + } + + // The stop response carries success:false when restoreNativeCodex() failed + // (src/server/management-api.ts:145-147). The proxy still shuts down, but native + // Codex is left pointing at a port that is closing. + t.test("stop: a restore failure is reported, not swallowed as success") { + StubProtocol.reset([ + .init(status: 200, body: #"{"success":false,"message":"restore failed: /some/path"}"#, urlError: nil), + .init(status: 0, body: "", urlError: .cannotConnectToHost), + ]) + let outcome = sync { await makeCoordinator().stop(startCommand: "ocx start") } + t.equal(outcome, .stoppedWithRestoreFailure("ocx start")) + } + + t.test("stop: a success:true body reports the ordinary manual-start outcome") { + StubProtocol.reset([ + .init(status: 200, body: #"{"success":true,"message":"ok"}"#, urlError: nil), + .init(status: 0, body: "", urlError: .cannotConnectToHost), + ]) + t.equal(sync { await makeCoordinator().stop(startCommand: "ocx start") }, + .requiresManualStart("ocx start")) + } + + // Only a refused connection proves the proxy is gone. A 500 or an undecodable + // 200 means an HTTP server is still listening. + t.test("stop: a 500 during polling is not mistaken for a stopped proxy") { + var responses: [StubProtocol.Response] = [.init(status: 200, body: "{}", urlError: nil)] + responses.append(contentsOf: Array( + repeating: .init(status: 500, body: "", urlError: nil), count: 400)) + StubProtocol.reset(responses) + let clock = FakeClock() + let outcome = sync { await makeCoordinator(clock: clock).stop(startCommand: "ocx start") } + if case .failed(let message) = outcome { + t.expect(message.contains("still responding"), "expected a timeout, got \(message)") + } else { + t.expect(false, "expected .failed, got \(outcome)") + } + } + + t.test("stop: an undecodable 200 during polling still counts as reachable") { + var responses: [StubProtocol.Response] = [.init(status: 200, body: "{}", urlError: nil)] + responses.append(contentsOf: Array( + repeating: .init(status: 200, body: "not json", urlError: nil), count: 400)) + StubProtocol.reset(responses) + let clock = FakeClock() + let outcome = sync { await makeCoordinator(clock: clock).stop(startCommand: "ocx start") } + if case .failed = outcome { + t.expect(true, "timed out rather than claiming success") + } else { + t.expect(false, "expected .failed, got \(outcome)") + } + } + + t.test("provider: a second write while one is in flight is refused, not raced") { + StubProtocol.reset([ + .init(status: 200, body: "{}", urlError: nil), + .init(status: 200, body: "{}", urlError: nil), + ]) + let coordinator = makeCoordinator() + let outcomes: [ActionOutcome] = sync { + async let first = coordinator.setProvider("x", disabled: true, defaultProvider: nil) + async let second = coordinator.setProvider("x", disabled: false, defaultProvider: nil) + return await [first, second] + } + let refused = outcomes.filter { if case .failed = $0 { return true }; return false } + t.equal(refused.count, 1, "exactly one of the two concurrent writes is refused") + } + + t.test("provider: writes to different providers are not blocked by each other") { + StubProtocol.reset([ + .init(status: 200, body: "{}", urlError: nil), + .init(status: 200, body: "{}", urlError: nil), + ]) + let coordinator = makeCoordinator() + let outcomes: [ActionOutcome] = sync { + async let a = coordinator.setProvider("a", disabled: true, defaultProvider: nil) + async let b = coordinator.setProvider("b", disabled: true, defaultProvider: nil) + return await [a, b] + } + t.equal(outcomes, [.succeeded, .succeeded]) + } + + // The distinction that matters: only a refused connection proves the proxy is + // gone. Collapsing timeouts into "unreachable" is what made a stop report as + // confirmed while the proxy was still running. + t.test("liveness: only a refused connection reads as gone") { + let cases: [(URLError.Code, ProxyClient.Liveness, String)] = [ + (.cannotConnectToHost, .refused, "connection refused"), + (.timedOut, .indeterminate, "timeout"), + (.networkConnectionLost, .indeterminate, "socket dropped"), + (.cannotFindHost, .indeterminate, "host lookup"), + (.notConnectedToInternet, .indeterminate, "no network"), + ] + for (code, expected, label) in cases { + StubProtocol.reset([.init(status: 0, body: "", urlError: code)]) + let client = ProxyClient(endpoint: .default, session: makeSession(), credentials: NoCredentials()) + t.equal(sync { await client.liveness() }, expected, label) + } + } + + t.test("liveness: any HTTP answer proves the port is occupied") { + for status in [200, 401, 403, 500] { + let body = status == 200 ? #"{"port":10100}"# : "" + StubProtocol.reset([ + .init(status: status, body: body, urlError: nil), + .init(status: status, body: body, urlError: nil), + ]) + let client = ProxyClient(endpoint: .default, session: makeSession(), + credentials: StubCredentialsFixed(key: "k")) + t.equal(sync { await client.liveness() }, .reachable, "status \(status)") + } + } + + t.test("liveness: an undecodable 200 is reachable, not gone") { + StubProtocol.reset([.init(status: 200, body: "not json at all", urlError: nil)]) + let client = ProxyClient(endpoint: .default, session: makeSession(), credentials: NoCredentials()) + t.equal(sync { await client.liveness() }, .reachable) + } + + // A timeout must not end the stop as a confirmed success. + t.test("stop: a timeout during polling never confirms the stop") { + var responses: [StubProtocol.Response] = [.init(status: 200, body: "{}", urlError: nil)] + responses.append(contentsOf: Array( + repeating: .init(status: 0, body: "", urlError: .timedOut), count: 400)) + StubProtocol.reset(responses) + let clock = FakeClock() + let outcome = sync { await makeCoordinator(clock: clock).stop(startCommand: "ocx start") } + if case .failed(let message) = outcome { + t.expect(message.contains("could not be confirmed"), + "expected an inconclusive message, got \(message)") + } else { + t.expect(false, "expected .failed, got \(outcome)") + } + } + + // A 401 already answers "is anything listening". Retrying it through the normal + // credential path spent a second full timeout and could downgrade a + // known-reachable result to indeterminate if the retry failed. + t.test("liveness: a 401 answers immediately without a credential retry") { + StubProtocol.reset([ + .init(status: 401, body: "", urlError: nil), + .init(status: 0, body: "", urlError: .timedOut), // must never be used + ]) + let client = ProxyClient(endpoint: .default, session: makeSession(), + credentials: StubCredentialsFixed(key: "stored-key")) + t.equal(sync { await client.liveness() }, .reachable) + t.equal(StubProtocol.recorded.count, 1, "liveness must be a single attempt") + } + + t.test("liveness: the probe honours a caller-supplied timeout") { + StubProtocol.reset([.init(status: 200, body: #"{"port":10100}"#, urlError: nil)]) + let client = ProxyClient(endpoint: .default, session: makeSession(), credentials: NoCredentials()) + _ = sync { await client.liveness(timeout: 0.25) } + t.equal(StubProtocol.recorded.first?.timeoutInterval, 0.25) + } + + // The final probe must not overrun the stop deadline by its own timeout. + t.test("stop: the last probe is capped to the remaining deadline") { + var responses: [StubProtocol.Response] = [.init(status: 200, body: "{}", urlError: nil)] + responses.append(contentsOf: Array( + repeating: .init(status: 200, body: #"{"port":10100}"#, urlError: nil), count: 400)) + StubProtocol.reset(responses) + let clock = FakeClock() + _ = sync { await makeCoordinator(clock: clock).stop(startCommand: "ocx start") } + + // Every liveness probe after the POST must request no more than 1.5s, and + // the last must be clamped to whatever remained. + let probes = StubProtocol.recorded.dropFirst() + t.expect(!probes.isEmpty, "expected liveness probes") + for probe in probes { + t.expect(probe.timeoutInterval <= 1.5, + "probe timeout \(probe.timeoutInterval) exceeds the cap") + } + } + } + + private struct StubCredentialsFixed: CredentialStore { + let key: String? + func loadAPIKey() -> String? { key } + } +} diff --git a/app/Sources/MenuBarCoreTests/CompanionSettingsSuite.swift b/app/Sources/MenuBarCoreTests/CompanionSettingsSuite.swift new file mode 100644 index 00000000000..bed663b8e2d --- /dev/null +++ b/app/Sources/MenuBarCoreTests/CompanionSettingsSuite.swift @@ -0,0 +1,27 @@ +import Foundation +import MenuBarCore + +enum CompanionSettingsSuite { + static func run(_ t: TestRunner) { + let decoder = JSONDecoder() + t.test("companion settings: empty JSON uses defaults") { + let settings = try decoder.decode(CompanionSettings.self, from: Data("{}".utf8)) + t.equal(settings, .defaults) + } + t.test("companion settings: unknown enum uses its default") { + let settings = try decoder.decode(CompanionSettings.self, from: Data(#"{"menuBarMetric":"future","chartStyle":"future","tokenMetric":"future","aggregation":"future","chartGrouping":"future"}"#.utf8)) + t.equal(settings.menuBarMetric, .tokens) + t.equal(settings.chartStyle, .line) + t.equal(settings.tokenMetric, .total) + t.equal(settings.aggregation, .sum) + t.equal(settings.chartGrouping, .model) + } + t.test("companion settings: full payload decodes") { + let settings = try decoder.decode(CompanionSettings.self, from: Data(#"{"menuBarMetric":"quota","menuBarTemplate":"{requests}","showToday":false,"showChart":false,"showModels":false,"showCost":false,"showAccounts":false,"chartHours":72,"bucketMinutes":180,"chartStyle":"stackedBar","tokenMetric":"cached","aggregation":"max","chartGrouping":"modelAccount","models":["openai/gpt"],"hiddenProviders":["openai"]}"#.utf8)) + t.equal(settings.chartHours, 72) + t.equal(settings.chartStyle, .stackedBar) + t.equal(settings.models, ["openai/gpt"]) + t.equal(settings.hiddenProviders, ["openai"]) + } + } +} diff --git a/app/Sources/MenuBarCoreTests/DiscoverySuite.swift b/app/Sources/MenuBarCoreTests/DiscoverySuite.swift new file mode 100644 index 00000000000..e8aa2acf1bd --- /dev/null +++ b/app/Sources/MenuBarCoreTests/DiscoverySuite.swift @@ -0,0 +1,82 @@ +import Foundation +import MenuBarCore + +enum DiscoverySuite { + static func run(_ t: TestRunner) { + let root = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("ocx-discovery-\(UUID().uuidString)", isDirectory: true) + try? FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + func writeRecord(_ contents: String) throws { + try contents.write( + to: root.appendingPathComponent("runtime-port.json"), + atomically: true, + encoding: .utf8 + ) + } + + t.test("discovery: honours a valid record") { + try writeRecord(#"{"pid": 14582, "port": 10100}"#) + t.equal(ProxyDiscovery.resolve(configDirectory: root).port, 10100) + } + + t.test("discovery: honours a non-default port") { + try writeRecord(#"{"pid": 1, "port": 18080}"#) + t.equal(ProxyDiscovery.resolve(configDirectory: root).port, 18080) + } + + t.test("discovery: a record without pid still resolves") { + try writeRecord(#"{"port": 10250}"#) + t.equal(ProxyDiscovery.resolve(configDirectory: root).port, 10250) + } + + t.test("discovery: malformed JSON falls back to the default port") { + try writeRecord("{not json at all") + t.equal(ProxyDiscovery.resolve(configDirectory: root).port, ProxyDiscovery.defaultPort) + } + + t.test("discovery: out-of-range ports fall back to the default") { + for invalid in ["0", "70000", "-1"] { + try writeRecord(#"{"port": \#(invalid)}"#) + t.equal( + ProxyDiscovery.resolve(configDirectory: root).port, + ProxyDiscovery.defaultPort, + "port \(invalid)" + ) + } + } + + t.test("discovery: a missing file falls back to the default port") { + let empty = root.appendingPathComponent("empty-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: empty, withIntermediateDirectories: true) + t.equal(ProxyDiscovery.resolve(configDirectory: empty).port, ProxyDiscovery.defaultPort) + } + + // The record may carry a hostname, but the app must never follow it: the port + // file is a convenience, not a redirection mechanism. + t.test("discovery: host stays loopback even when the record names another host") { + try writeRecord(#"{"pid": 1, "port": 10100, "hostname": "10.0.0.5"}"#) + let endpoint = ProxyDiscovery.resolve(configDirectory: root) + t.equal(endpoint.host, "127.0.0.1") + t.equal(endpoint.baseURL.absoluteString, "http://127.0.0.1:10100") + } + + t.test("discovery: OPENCODEX_HOME overrides the default directory") { + let resolved = ProxyDiscovery.configDirectory( + environment: ["OPENCODEX_HOME": root.path], + home: URL(fileURLWithPath: "/nonexistent") + ) + t.equal(resolved.path, root.path) + } + + t.test("discovery: a blank OPENCODEX_HOME falls back to the home directory") { + let home = URL(fileURLWithPath: "/Users/example") + let resolved = ProxyDiscovery.configDirectory( + environment: ["OPENCODEX_HOME": " "], + home: home + ) + t.equal(resolved.path, home.appendingPathComponent(".opencodex").path) + } + } +} diff --git a/app/Sources/MenuBarCoreTests/FormattingSuite.swift b/app/Sources/MenuBarCoreTests/FormattingSuite.swift new file mode 100644 index 00000000000..00b816cdeb9 --- /dev/null +++ b/app/Sources/MenuBarCoreTests/FormattingSuite.swift @@ -0,0 +1,77 @@ +import Foundation +import MenuBarCore + +/// Magnitudes are taken from the live proxy capture in 002_api_surface.md. +enum FormattingSuite { + static func run(_ t: TestRunner) { + t.test("format: counts group below 10k and suffix above") { + t.equal(Format.count(0), "0") + t.equal(Format.count(1_746), "1,746") + t.equal(Format.count(9_999), "9,999") + t.equal(Format.count(232_507), "233K") + t.equal(Format.count(1_200_000), "1.20M") + } + + // Rounding can push a value across its own unit boundary: 999_999 scales to + // 999.999K and must promote to 1.00M rather than render "1000K". + t.test("format: values promote at suffix rollover boundaries") { + t.equal(Format.count(999_999), "1.00M") + t.equal(Format.count(999_499), "999K") + t.equal(Format.tokens(999_999_999), "1B") + t.equal(Format.tokens(999_999_999_999), "1T") + t.equal(Format.cost(999_999), "$1.00M") + } + + t.test("format: exact unit thresholds render as the new unit") { + t.equal(Format.tokens(1_000), "1K") + t.equal(Format.tokens(1_000_000), "1M") + t.equal(Format.tokens(1_000_000_000), "1B") + } + + t.test("format: tokens are suffixed at scale") { + t.equal(Format.tokens(999), "999") + t.equal(Format.tokens(12_400_000), "12M") + t.equal(Format.tokens(36_536_664_705), "37B") + } + + t.test("format: cost switches to a suffix above one thousand") { + t.equal(Format.cost(8.21), "$8.21") + t.equal(Format.cost(999.99), "$999.99") + t.equal(Format.cost(34_018.25204647066), "$34.0K") + } + + // Unknown and zero are different facts. Rendering nil as "0" is the fake-data + // tell that 003 section 6 bans. + t.test("format: nil renders an em dash while zero renders zero") { + t.equal(Format.count(nil), "—") + t.equal(Format.tokens(nil), "—") + t.equal(Format.cost(nil), "—") + t.equal(Format.percent(nil), "—") + t.equal(Format.count(0), "0") + t.equal(Format.cost(0), "$0.00") + } + + t.test("format: percent rounds") { + t.equal(Format.percent(44), "44%") + t.equal(Format.percent(86.82666666666667), "87%") + t.equal(Format.percent(9.976811594202898), "10%") + } + + t.test("format: reset countdowns are coarse") { + let now = Date(timeIntervalSince1970: 1_784_915_000) + t.equal(Format.resetsIn(now.addingTimeInterval(60 * 30), now: now), "30m") + t.equal(Format.resetsIn(now.addingTimeInterval(3600 * 5), now: now), "5h") + t.equal(Format.resetsIn(now.addingTimeInterval(86_400 * 3 + 3600 * 4), now: now), "3d 4h") + t.equal(Format.resetsIn(now.addingTimeInterval(-60), now: now), "expired") + t.equal(Format.resetsIn(nil), "—") + } + + t.test("format: staleness ages read naturally") { + let now = Date(timeIntervalSince1970: 1_784_915_000) + t.equal(Format.age(now.addingTimeInterval(-10), now: now), "just now") + t.equal(Format.age(now.addingTimeInterval(-120), now: now), "2m ago") + t.equal(Format.age(now.addingTimeInterval(-7200), now: now), "2h ago") + t.equal(Format.age(nil), "—") + } + } +} diff --git a/app/Sources/MenuBarCoreTests/Harness.swift b/app/Sources/MenuBarCoreTests/Harness.swift new file mode 100644 index 00000000000..0deb1d6ae46 --- /dev/null +++ b/app/Sources/MenuBarCoreTests/Harness.swift @@ -0,0 +1,105 @@ +import Foundation + +/// A dependency-free assertion harness. +/// +/// Why not XCTest or swift-testing: neither ships a usable runtime in Xcode Command Line +/// Tools. `import XCTest` fails module resolution outright, and swift-testing compiles +/// but cannot `dlopen` `Testing.framework` at run time. Requiring a full Xcode install to +/// run the unit tests of a menu bar companion would put the tests out of reach for most +/// contributors and for any CI runner without Xcode selected. +/// +/// This harness is ~60 lines, runs as a plain executable, and prints TAP-ish output that +/// both a human and CI can read. If the package ever gains a full-Xcode requirement for +/// other reasons, migrating these cases to swift-testing is mechanical. +public struct TestFailure { + let test: String + let message: String + let file: String + let line: Int +} + +public final class TestRunner { + private(set) var passed = 0 + private(set) var failures: [TestFailure] = [] + private var current = "" + + public init() {} + + public func test(_ name: String, _ body: () throws -> Void) { + current = name + let failuresBefore = failures.count + do { + try body() + } catch { + failures.append(TestFailure(test: name, message: "threw \(error)", file: #file, line: #line)) + print("FAIL — \(name): threw \(error)") + return + } + // A case that recorded an expectation failure is not a pass, even though its + // body returned normally. + if failures.count == failuresBefore { + passed += 1 + print("ok — \(name)") + } + } + + public func expect( + _ condition: Bool, + _ message: @autoclosure () -> String, + file: String = #file, + line: Int = #line + ) { + guard !condition else { return } + let failure = TestFailure(test: current, message: message(), file: file, line: line) + failures.append(failure) + print("FAIL — \(current): \(failure.message) (\(URL(fileURLWithPath: file).lastPathComponent):\(line))") + } + + public func equal( + _ actual: T, + _ expected: T, + _ label: String = "", + file: String = #file, + line: Int = #line + ) { + expect( + actual == expected, + "\(label.isEmpty ? "" : label + ": ")expected \(expected), got \(actual)", + file: file, + line: line + ) + } + + public func notNil( + _ value: T?, + _ label: String, + file: String = #file, + line: Int = #line + ) -> T? { + expect(value != nil, "\(label) should not be nil", file: file, line: line) + return value + } + + public func isNil( + _ value: T?, + _ label: String, + file: String = #file, + line: Int = #line + ) { + expect(value == nil, "\(label) should be nil, got \(String(describing: value))", file: file, line: line) + } + + /// Prints the summary and returns the process exit code. + public func summarize() -> Int32 { + print("") + if failures.isEmpty { + print("\(passed) passed, 0 failed") + return 0 + } + print("\(passed) passed, \(failures.count) FAILED") + for failure in failures { + print(" - \(failure.test): \(failure.message)") + } + return 1 + } +} diff --git a/app/Sources/MenuBarCoreTests/MenuBarTitleSuite.swift b/app/Sources/MenuBarCoreTests/MenuBarTitleSuite.swift new file mode 100644 index 00000000000..286502ac91c --- /dev/null +++ b/app/Sources/MenuBarCoreTests/MenuBarTitleSuite.swift @@ -0,0 +1,36 @@ +import Foundation +import MenuBarCore + +enum MenuBarTitleSuite { + private static let reportJSON = #"{"range":"today","summary":{"requests":12,"totalTokens":3456,"inputTokens":1000,"outputTokens":2000,"estimatedCostUsd":1.25}}"# + + static func run(_ t: TestRunner) { + let report = try! JSONDecoder().decode(UsageReport.self, from: Data(reportJSON.utf8)) + for metric in [CompanionSettings.MenuBarMetric.requests, .tokens, .cost] { + t.test("menu title: \(metric.rawValue) metric") { + let settings = CompanionSettings(menuBarMetric: metric) + t.expect(MenuBarTitle.render(settings: settings, today: report, quotas: []) != nil, "title") + } + } + t.test("menu title: quota picks the lowest percent") { + let settings = CompanionSettings(menuBarMetric: .quota) + let quotas = try! JSONDecoder().decode([QuotaReport].self, from: Data(#"[{"provider":"a","quota":{"weeklyPercent":80}},{"provider":"b","quota":{"weeklyPercent":20}}]"#.utf8)).map { $0.normalized() } + t.equal(MenuBarTitle.render(settings: settings, today: report, quotas: quotas), "20%") + } + t.test("menu title: template replaces placeholders") { + let settings = CompanionSettings(menuBarTemplate: "{requests}/{totalTokens}/{costUsd}") + t.equal(MenuBarTitle.render(settings: settings, today: report, quotas: []), "12/3K/$1.25") + } + t.test("menu title: none is nil and unknowns are em dashes") { + t.isNil(MenuBarTitle.render(settings: CompanionSettings(menuBarMetric: .none), today: report, quotas: []), "none") + let settings = CompanionSettings(menuBarTemplate: "{inputTokens}") + t.equal(MenuBarTitle.render(settings: settings, today: nil, quotas: []), "—") + } + t.test("menu title: long output is truncated") { + let settings = CompanionSettings(menuBarTemplate: "012345678901234567890123456789") + let title = MenuBarTitle.render(settings: settings, today: report, quotas: []) + t.equal(title?.count, 24) + t.expect(title?.hasSuffix("…") == true, "ellipsis") + } + } +} diff --git a/app/Sources/MenuBarCoreTests/ModelDecodingSuite.swift b/app/Sources/MenuBarCoreTests/ModelDecodingSuite.swift new file mode 100644 index 00000000000..0ec0012e621 --- /dev/null +++ b/app/Sources/MenuBarCoreTests/ModelDecodingSuite.swift @@ -0,0 +1,258 @@ +import Foundation +import MenuBarCore + +/// Fixtures are verbatim captures from the live proxy on 2026-07-25, recorded in +/// devlog/_plan/260725_macos_menubar_app/002_api_surface.md. Hand-written fixtures would +/// only prove the models decode themselves. +enum ModelDecodingSuite { + private static func decode(_ type: T.Type, _ json: String) throws -> T { + try JSONDecoder().decode(type, from: Data(json.utf8)) + } + + private struct Envelope: Decodable { let reports: [QuotaReport]? } + + private static let liveHealth = """ + {"routingKind":"opencodex-local","autostartEnabled":false,"serviceInstalled":true, + "serviceViable":true,"serviceEnabled":true,"serviceRunning":true,"serviceStale":false, + "serviceConflict":false,"serviceSupported":true,"shimInstalled":false, + "shimHealthy":false,"platform":"darwin","diagnosticStale":true,"routingInjected":true, + "localRoutingDependency":true,"status":"at-risk","rebootSafe":false,"protection":"none", + "shimCoverage":"none","recommendedCommand":"ocx service install", + "commands":{"installService":"ocx service install","installShim":"ocx codex-shim install", + "restoreNative":"ocx restore"}} + """ + + private static let liveQuotas = """ + {"generatedAt":1784915336899,"reports":[ + {"provider":"openai","label":"OpenAI (Codex login)","source":"chatgpt:wham", + "quota":{"updatedAt":1784915090763,"weeklyPercent":44,"weeklyResetAt":1785258443, + "resetCredits":3}}, + {"provider":"anthropic","label":"Anthropic Claude","source":"anthropic:oauth-usage", + "quota":{"weeklyPercent":58,"weeklyResetAt":1785265199718, + "customWindows":[{"label":"5h","percent":1,"resetAt":1784928599718}]}}, + {"provider":"xai","label":"xAI Grok","source":"xai:grok-billing", + "quota":{"monthlyPercent":86.82666666666667,"monthlyResetAt":1785542400000}}]} + """ + + static func run(_ t: TestRunner) { + t.test("health: decodes the live startup-health payload") { + let health = try decode(StartupHealth.self, liveHealth) + t.equal(health.status, "at-risk") + t.equal(health.platform, "darwin") + t.equal(health.recommendedCommand, "ocx service install") + t.equal(health.isProtected, false) + t.equal(health.isServiceManaged, true) + t.equal(health.manualStartCommand, "ocx service start") + } + + t.test("health: an unknown status string decodes without throwing") { + let health = try decode(StartupHealth.self, #"{"status":"some-future-state"}"#) + t.equal(health.status, "some-future-state") + t.equal(health.isProtected, false) + } + + t.test("health: without service fields it is not service-managed") { + let health = try decode(StartupHealth.self, #"{"status":"protected"}"#) + t.equal(health.isProtected, true) + t.equal(health.isServiceManaged, false) + t.equal(health.manualStartCommand, "ocx start") + } + + // The live /api/settings key set contains no defaultProvider. Decoding must + // succeed anyway — an earlier plan draft expected the field here and was wrong. + t.test("settings: decodes without a defaultProvider field") { + let json = """ + {"codexAutoStart":false,"port":10100,"hostname":"127.0.0.1","streamMode":"auto", + "startupHealth":{"status":"protected"},"codexRuntime":{}} + """ + let settings = try decode(ProxySettings.self, json) + t.equal(settings.port, 10100) + t.equal(settings.hostname, "127.0.0.1") + t.equal(settings.streamMode, "auto") + } + + t.test("config: supplies defaultProvider") { + let json = """ + {"port":10100,"hostname":"127.0.0.1","defaultProvider":"openai", + "codexAutoStart":false,"websockets":{},"providers":{}} + """ + t.equal(try decode(ProxyConfigSummary.self, json).defaultProvider, "openai") + } + + t.test("usage: decodes the live summary at real magnitudes") { + let json = """ + {"range":"30d","surface":"all","since":1782323333603,"generatedAt":1784915333603, + "summary":{"requests":232507,"measuredRequests":225380,"estimatedRequests":14618, + "inputTokens":33521662469,"outputTokens":127401110,"totalTokens":36536664705, + "coverageRatio":0.969347159440361,"estimatedCostUsd":34018.25204647066}, + "days":[{"date":"2026-06-28","requests":1746,"totalTokens":0,"models":[]}]} + """ + let report = try decode(UsageReport.self, json) + t.equal(report.summary?.requests, 232_507) + t.equal(report.summary?.totalTokens, 36_536_664_705) + t.equal(report.effectiveRange, .thirtyDays) + t.equal(report.rangeLabel, "LAST 30 DAYS") + t.equal(report.summary?.hasEstimates, true) + t.equal(report.isEmpty, false) + } + + // The server silently degrades an unrecognized range to 30d, so the label must + // follow the response and never the request. + t.test("usage: an unknown range degrades to a neutral label") { + let report = try decode(UsageReport.self, #"{"range":"24h"}"#) + t.isNil(report.effectiveRange, "effectiveRange for 24h") + t.equal(report.rangeLabel, "USAGE") + } + + t.test("usage: zero requests reads as empty") { + let report = try decode(UsageReport.self, #"{"range":"7d","summary":{"requests":0}}"#) + t.equal(report.isEmpty, true) + t.equal(report.isEmptyOrUnknown, true) + } + + // Unknown and zero are different facts: an omitted count must not render as + // "No requests". + t.test("usage: an omitted request count is unknown, not empty") { + let report = try decode(UsageReport.self, #"{"range":"7d","summary":{"totalTokens":5}}"#) + t.isNil(report.isEmptyOrUnknown, "isEmptyOrUnknown for an omitted count") + t.equal(report.isEmpty, false, "isEmpty must not claim empty for unknown") + } + + t.test("usage: the range enum is closed") { + t.isNil(UsageRange(rawValue: "24h"), "UsageRange(24h)") + t.equal(UsageRange.allCases.map(\.rawValue), ["today", "7d", "30d", "all"]) + } + + // The decisive trap: openai sends weeklyResetAt in SECONDS (1785258443) while + // anthropic sends MILLISECONDS (1785265199718) in the same array. + t.test("quotas: mixed second and millisecond timestamps both resolve to 2026") { + let reports = try decode(Envelope.self, liveQuotas).reports ?? [] + t.equal(reports.count, 3) + let calendar = Calendar(identifier: .gregorian) + for report in reports { + let normalized = report.normalized() + guard let date = t.notNil(normalized.resetAt, "\(report.provider) resetAt") else { continue } + t.equal(calendar.component(.year, from: date), 2026, "\(report.provider) year") + } + } + + t.test("quotas: normalization picks the right window per provider") { + let reports = try decode(Envelope.self, liveQuotas).reports ?? [] + let byProvider = Dictionary(uniqueKeysWithValues: reports.map { ($0.provider, $0.normalized()) }) + t.equal(byProvider["openai"]?.windowLabel, "week") + t.equal(byProvider["openai"]?.percent, 44) + t.equal(byProvider["anthropic"]?.windowLabel, "week") + t.equal(byProvider["xai"]?.windowLabel, "month") + t.equal(byProvider["xai"]?.providerLabel, "xAI Grok") + } + + t.test("quotas: a custom-window-only quota uses its own label") { + let json = """ + {"provider":"p","quota":{"customWindows":[{"label":"5h","percent":12,"resetAt":1784928599718}]}} + """ + let normalized = try decode(QuotaReport.self, json).normalized() + t.equal(normalized.windowLabel, "5h") + t.equal(normalized.percent, 12) + } + + // Live kimi reports weeklyPercent AND fiveHourPercent; live cursor and + // google-antigravity each carry two customWindows. Returning one window would + // hide real quota pressure. + t.test("quotas: kimi exposes both its five-hour and weekly windows") { + let json = """ + {"provider":"kimi","label":"Kimi","quota":{"fiveHourPercent":22, + "fiveHourResetAt":1784928599718,"weeklyPercent":61,"weeklyResetAt":1785265199718}} + """ + let report = try decode(QuotaReport.self, json) + let windows = report.normalizedWindows() + t.equal(windows.count, 2) + t.equal(windows.map(\.windowLabel), ["5h", "week"]) + // The compact row prefers the longer horizon. + t.equal(report.normalized().windowLabel, "week") + t.equal(report.normalized().percent, 61) + } + + t.test("quotas: multiple custom windows are all retained") { + let json = """ + {"provider":"cursor","label":"Cursor","quota":{"monthlyPercent":10, + "monthlyResetAt":1785256304000, + "customWindows":[{"label":"First-party models","percent":4,"resetAt":1785256304000}, + {"label":"API usage","percent":1,"resetAt":1785256304000}]}} + """ + let report = try decode(QuotaReport.self, json) + let windows = report.normalizedWindows() + t.equal(windows.count, 3) + t.equal(windows.map(\.windowLabel), ["month", "First-party models", "API usage"]) + t.equal(report.normalized().windowLabel, "month") + } + + t.test("quotas: a provider with only custom windows still normalizes") { + let json = """ + {"provider":"google-antigravity","label":"Google","quota":{ + "customWindows":[{"label":"Gem","percent":30,"resetAt":1785256304000}, + {"label":"Cla","percent":12,"resetAt":1785256304000}]}} + """ + let report = try decode(QuotaReport.self, json) + t.equal(report.normalizedWindows().count, 2) + t.equal(report.normalized().windowLabel, "Gem") + t.equal(report.normalized().percent, 30) + } + + // Every window can stop work. A provider at 99% of a five-hour limit is blocked + // right now even if its monthly usage is 10%; picking the longer horizon would + // paint that row green while the user cannot make a request. + t.test("quotas: the compact row shows the window under the most pressure") { + let json = """ + {"provider":"kimi","label":"Kimi","quota":{"fiveHourPercent":99, + "fiveHourResetAt":1784928599718,"monthlyPercent":10,"monthlyResetAt":1785542400000}} + """ + let report = try decode(QuotaReport.self, json) + t.equal(report.normalized().windowLabel, "5h") + t.equal(report.normalized().percent, 99) + t.equal(report.normalizedWindows().count, 2) + } + + t.test("quotas: equal pressure breaks toward the longer horizon") { + let json = """ + {"provider":"p","quota":{"fiveHourPercent":50,"fiveHourResetAt":1784928599718, + "weeklyPercent":50,"weeklyResetAt":1785265199718}} + """ + t.equal(try decode(QuotaReport.self, json).normalized().windowLabel, "week") + } + + t.test("quotas: a window reporting only a reset time does not outrank a measured one") { + let json = """ + {"provider":"p","quota":{"weeklyPercent":12,"weeklyResetAt":1785265199718, + "customWindows":[{"label":"unmeasured","resetAt":1785265199718}]}} + """ + let report = try decode(QuotaReport.self, json) + t.equal(report.normalized().windowLabel, "week") + t.equal(report.normalized().percent, 12) + } + + t.test("quotas: an absent quota normalizes to a nil percent") { + let normalized = try decode(QuotaReport.self, #"{"provider":"p","label":"P"}"#).normalized() + t.isNil(normalized.percent, "percent") + t.equal(normalized.hasPercent, false) + t.isNil(normalized.resetAt, "resetAt") + } + + t.test("providers: decodes the live list") { + let json = """ + [{"name":"openai","adapter":"openai-responses","hasApiKey":false, + "authMode":"forward","disabled":false,"codexAccountMode":"pool"}, + {"name":"anthropic","adapter":"anthropic","hasApiKey":false, + "authMode":"oauth","disabled":true}] + """ + let providers = try decode([ProviderSummary].self, json) + t.equal(providers.count, 2) + t.equal(providers[0].name, "openai") + t.equal(providers[0].isEnabled, true) + t.equal(providers[1].isEnabled, false) + } + + t.test("providers: a provider without a disabled field is enabled") { + t.equal(try decode(ProviderSummary.self, #"{"name":"custom"}"#).isEnabled, true) + } + } +} diff --git a/app/Sources/MenuBarCoreTests/PollingSuite.swift b/app/Sources/MenuBarCoreTests/PollingSuite.swift new file mode 100644 index 00000000000..838249be4c0 --- /dev/null +++ b/app/Sources/MenuBarCoreTests/PollingSuite.swift @@ -0,0 +1,408 @@ +import Foundation +import MenuBarCore + +/// Exercises the polling contract against a stubbed transport instead of asserting that +/// four constants still hold the values they were declared with. +enum PollingSuite { + private static func makeSession() -> URLSession { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [StubProtocol.self] + return URLSession(configuration: config) + } + + private static func sync(_ operation: @escaping () async -> T) -> T { + let semaphore = DispatchSemaphore(value: 0) + let box = Box() + Task { + box.value = await operation() + semaphore.signal() + } + semaphore.wait() + return box.value! + } + + private final class Box: @unchecked Sendable { var value: T? } + + /// Polls the coordinator's own waiter count, so registration is observed rather + /// than assumed from elapsed time. + private static func waitForWaiter(_ coordinator: PollingCoordinator, timeout: TimeInterval = 5) async -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if await coordinator.waiterCount > 0 { return true } + try? await Task.sleep(nanoseconds: 5_000_000) + } + return false + } + + private final class Flag: @unchecked Sendable { + private let lock = NSLock() + private var flag = false + var value: Bool { lock.lock(); defer { lock.unlock() }; return flag } + func set() { lock.lock(); flag = true; lock.unlock() } + } + + private static let healthOK = #"{"status":"protected","serviceInstalled":true,"serviceEnabled":true}"# + private static let usageOK = #"{"range":"7d","summary":{"requests":10},"days":[{"date":"d","requests":10}]}"# + private static let quotasOK = #"{"reports":[{"provider":"p","quota":{"weeklyPercent":5}}]}"# + private static let providersOK = #"[{"name":"openai"}]"# + private static let configOK = #"{"defaultProvider":"openai"}"# + + private static func paths() -> [String] { + StubProtocol.recorded.compactMap { $0.url?.path } + } + + static func run(_ t: TestRunner) { + let endpoint = ProxyEndpoint.default + + func makeCoordinator() -> PollingCoordinator { + let client = ProxyClient(endpoint: endpoint, session: makeSession(), + credentials: NoCredentials()) + return PollingCoordinator(client: client, endpoint: endpoint) + } + + // The whole point of gating: a closed popover must not trigger aggregation. + t.test("polling: a closed popover fetches only liveness") { + StubProtocol.reset([.init(status: 200, body: healthOK, urlError: nil)]) + let coordinator = makeCoordinator() + sync { await coordinator.refresh() } + t.equal(paths(), ["/api/startup-health", "/api/companion/settings", "/api/usage", "/api/usage/timeline"]) + } + + t.test("polling: opening the popover fetches on-open and aggregation reads") { + StubProtocol.reset([ + .init(status: 200, body: healthOK, urlError: nil), + .init(status: 200, body: providersOK, urlError: nil), + .init(status: 200, body: configOK, urlError: nil), + .init(status: 200, body: usageOK, urlError: nil), + .init(status: 200, body: quotasOK, urlError: nil), + ]) + let coordinator = makeCoordinator() + let snapshot = sync { () -> ProxySnapshot in + await coordinator.setPopoverOpen(true) + return await coordinator.current + } + t.expect(paths().contains("/api/providers"), "providers fetched on open") + t.expect(paths().contains("/api/usage"), "usage fetched on open") + t.expect(paths().contains("/api/provider-quotas"), "quotas fetched on open") + t.equal(snapshot.providersLoaded, true) + t.equal(snapshot.quotasLoaded, true) + t.equal(snapshot.defaultProvider, "openai") + } + + // Reopening within the aggregation window should refresh cheap reads only. + t.test("polling: a second open reuses aggregation but refreshes on-open reads") { + StubProtocol.reset([ + .init(status: 200, body: healthOK, urlError: nil), + .init(status: 200, body: providersOK, urlError: nil), + .init(status: 200, body: configOK, urlError: nil), + .init(status: 200, body: usageOK, urlError: nil), + .init(status: 200, body: quotasOK, urlError: nil), + .init(status: 200, body: healthOK, urlError: nil), + .init(status: 200, body: providersOK, urlError: nil), + .init(status: 200, body: configOK, urlError: nil), + ]) + let coordinator = makeCoordinator() + sync { + await coordinator.setPopoverOpen(true) + await coordinator.setPopoverOpen(false) + await coordinator.setPopoverOpen(true) + } + let usageCalls = paths().filter { $0 == "/api/usage" }.count + let providerCalls = paths().filter { $0 == "/api/providers" }.count + t.equal(usageCalls, 1, "aggregation respects its interval") + t.equal(providerCalls, 2, "on-open reads run every open") + } + + t.test("polling: a refused proxy becomes unreachable and counts a failure") { + StubProtocol.reset([.init(status: 0, body: "", urlError: .cannotConnectToHost)]) + let coordinator = makeCoordinator() + let snapshot = sync { () -> ProxySnapshot in + await coordinator.refresh() + return await coordinator.current + } + t.equal(snapshot.state, .unreachable) + t.equal(snapshot.consecutiveFailures, 1) + t.equal(snapshot.showsData, false) + } + + t.test("polling: repeated failures widen the interval to the backoff value") { + StubProtocol.reset(Array(repeating: .init(status: 0, body: "", urlError: .cannotConnectToHost), count: 4)) + let coordinator = makeCoordinator() + let interval = sync { () -> TimeInterval in + for _ in 0..<3 { await coordinator.refresh() } + return await coordinator.currentInterval + } + t.equal(interval, PollingCoordinator.backoffInterval) + } + + t.test("polling: a recovered proxy resets the failure count and interval") { + StubProtocol.reset([ + .init(status: 0, body: "", urlError: .cannotConnectToHost), + .init(status: 200, body: healthOK, urlError: nil), + ]) + let coordinator = makeCoordinator() + let snapshot = sync { () -> ProxySnapshot in + await coordinator.refresh() + await coordinator.refresh() + return await coordinator.current + } + t.equal(snapshot.consecutiveFailures, 0) + t.equal(snapshot.state.isRunning, true) + } + + // A degraded proxy keeps its last-known numbers with an explicit age, rather + // than blanking the panel. + t.test("polling: a 500 degrades while retaining previously loaded data") { + StubProtocol.reset([ + .init(status: 200, body: healthOK, urlError: nil), + .init(status: 200, body: providersOK, urlError: nil), + .init(status: 200, body: configOK, urlError: nil), + .init(status: 200, body: usageOK, urlError: nil), + .init(status: 200, body: quotasOK, urlError: nil), + .init(status: 500, body: "", urlError: nil), + ]) + let coordinator = makeCoordinator() + let snapshot = sync { () -> ProxySnapshot in + await coordinator.setPopoverOpen(true) + await coordinator.refresh() + return await coordinator.current + } + if case .degraded = snapshot.state { + t.expect(true, "degraded") + } else { + t.expect(false, "expected degraded, got \(snapshot.state)") + } + t.equal(snapshot.showsData, true, "stale-but-labelled beats blank") + _ = t.notNil(snapshot.usage, "usage retained") + } + + t.test("polling: the recommended command is carried into the snapshot") { + StubProtocol.reset([ + .init(status: 200, + body: #"{"status":"at-risk","recommendedCommand":"ocx service install"}"#, + urlError: nil), + ]) + let coordinator = makeCoordinator() + let snapshot = sync { () -> ProxySnapshot in + await coordinator.refresh() + return await coordinator.current + } + t.equal(snapshot.recommendedCommand, "ocx service install") + } + + t.test("polling: observers receive the snapshot on registration and on change") { + StubProtocol.reset([.init(status: 200, body: healthOK, urlError: nil)]) + let coordinator = makeCoordinator() + let counter = Counter() + sync { + await coordinator.observe { _ in counter.bump() } + await coordinator.refresh() + } + t.expect(counter.count >= 2, "expected at least 2 notifications, got \(counter.count)") + } + + // On-open reads are cheap but not free: running them on every liveness tick + // turned two rarely-changing endpoints into 5-second pollers. + t.test("polling: a background tick while open does not refetch on-open reads") { + StubProtocol.reset([ + .init(status: 200, body: healthOK, urlError: nil), + .init(status: 200, body: providersOK, urlError: nil), + .init(status: 200, body: configOK, urlError: nil), + .init(status: 200, body: usageOK, urlError: nil), + .init(status: 200, body: quotasOK, urlError: nil), + .init(status: 200, body: healthOK, urlError: nil), + ]) + let coordinator = makeCoordinator() + sync { + await coordinator.setPopoverOpen(true) + await coordinator.refresh() // ordinary liveness tick + } + t.equal(paths().filter { $0 == "/api/providers" }.count, 1, "providers fetched once") + t.equal(paths().filter { $0 == "/api/config" }.count, 1, "config fetched once") + t.equal(paths().filter { $0 == "/api/startup-health" }.count, 2, "health fetched twice") + } + + t.test("polling: a closed popover skips on-open reads entirely") { + StubProtocol.reset([ + .init(status: 200, body: healthOK, urlError: nil), + .init(status: 200, body: healthOK, urlError: nil), + ]) + let coordinator = makeCoordinator() + sync { + await coordinator.refresh() + await coordinator.refresh() + } + t.equal(paths().filter { $0 == "/api/providers" }.count, 0) + t.equal(paths().filter { $0 == "/api/usage" }.count, 1) + } + + // A failing quota endpoint must not drag its healthy sibling into the 5s tick. + t.test("polling: a partial aggregation failure still consumes the interval") { + StubProtocol.reset([ + .init(status: 200, body: healthOK, urlError: nil), + .init(status: 200, body: providersOK, urlError: nil), + .init(status: 200, body: configOK, urlError: nil), + .init(status: 200, body: usageOK, urlError: nil), + .init(status: 500, body: "", urlError: nil), // quotas fail + .init(status: 200, body: healthOK, urlError: nil), // next tick + ]) + let coordinator = makeCoordinator() + sync { + await coordinator.setPopoverOpen(true) + await coordinator.refresh() + } + t.equal(paths().filter { $0 == "/api/usage" }.count, 1, "usage not refetched after a sibling failure") + } + + t.test("polling: degraded without any loaded data does not claim to show data") { + StubProtocol.reset([.init(status: 500, body: "", urlError: nil)]) + let coordinator = makeCoordinator() + let snapshot = sync { () -> ProxySnapshot in + await coordinator.refresh() + return await coordinator.current + } + t.equal(snapshot.showsData, false, "no data was ever loaded") + t.isNil(snapshot.dataAge, "dataAge") + } + + // refresh() coalesces, so a caller that needs authoritative state afterwards + // must wait for the cycle that absorbed its request — not just for its own + // immediate return. + t.test("polling: refreshAndWait returns only after a cycle has published") { + // setPopoverOpen already runs a full cycle, so queue enough for both it and + // the refreshAndWait that follows; the stub falls back to connection-refused + // once drained, which would look like a stopped proxy. + StubProtocol.reset([ + .init(status: 200, body: healthOK, urlError: nil), + .init(status: 200, body: providersOK, urlError: nil), + .init(status: 200, body: configOK, urlError: nil), + .init(status: 200, body: usageOK, urlError: nil), + .init(status: 200, body: quotasOK, urlError: nil), + .init(status: 200, body: healthOK, urlError: nil), + .init(status: 200, body: providersOK, urlError: nil), + .init(status: 200, body: configOK, urlError: nil), + ]) + let coordinator = makeCoordinator() + let snapshot = sync { () -> ProxySnapshot in + await coordinator.setPopoverOpen(true) + await coordinator.refreshAndWait() + return await coordinator.current + } + // If it returned early the health read would not have landed yet. + t.equal(snapshot.state.isRunning, true) + _ = t.notNil(snapshot.lastUpdated, "lastUpdated after refreshAndWait") + } + + // The first two refreshAndWait tests ran with refreshInFlight == false, so they + // never entered waitForCompletion() at all. These hold a cycle suspended in the + // stub so the coalescing path is the one under test. + t.test("polling: refreshAndWait suspends behind an in-flight cycle and resumes") { + StubProtocol.reset(Array( + repeating: .init(status: 200, body: healthOK, urlError: nil), count: 20)) + let gate = DispatchSemaphore(value: 0) + StubProtocol.setGate(gate) + defer { + StubProtocol.setGate(nil) + for _ in 0..<40 { gate.signal() } + } + + let coordinator = makeCoordinator() + let returned = Flag() + + let first = Task { await coordinator.refresh() } + // Wait for the request to actually reach the gate rather than guessing. + t.equal(StubProtocol.gateEntered.wait(timeout: .now() + 5), .success, + "cycle 1 should reach the gate") + + let waiter = Task { + await coordinator.refreshAndWait() + returned.set() + } + // Wait for the waiter to actually REGISTER, rather than sleeping and hoping + // it was scheduled. A fixed sleep let this test pass without ever entering + // the continuation path. + t.equal(sync { await waitForWaiter(coordinator) }, true, "waiter should register") + t.equal(returned.value, false, "refreshAndWait must not return while a cycle is in flight") + + StubProtocol.setGate(nil) + for _ in 0..<40 { gate.signal() } + sync { _ = await first.value; _ = await waiter.value } + t.equal(returned.value, true, "refreshAndWait must resume once the queued cycle publishes") + t.equal(sync { await coordinator.waiterCount }, 0, "no waiter should remain registered") + } + + // The queued cycle must FAIL here. Two contract details drive the setup: + // drainPendingRefresh only runs while the popover is OPEN, and an open cycle + // consumes health + providers + config + usage + quotas. So the popover is + // opened first (consuming its own cycle), then one gated 200 lets cycle 1 reach + // the gate, and every response after that is a refusal. An earlier version + // queued three 200s with the popover closed and silently re-tested the success + // path — which is exactly what the new state assertion caught. + t.test("polling: a waiter is released when the queued cycle fails") { + StubProtocol.reset([ + .init(status: 200, body: healthOK, urlError: nil), + .init(status: 200, body: providersOK, urlError: nil), + .init(status: 200, body: configOK, urlError: nil), + .init(status: 200, body: usageOK, urlError: nil), + .init(status: 200, body: quotasOK, urlError: nil), + ]) + let coordinator = makeCoordinator() + sync { await coordinator.setPopoverOpen(true) } + + var responses: [StubProtocol.Response] = [.init(status: 200, body: healthOK, urlError: nil)] + responses.append(contentsOf: Array( + repeating: .init(status: 0, body: "", urlError: .cannotConnectToHost), count: 30)) + StubProtocol.reset(responses) + let gate = DispatchSemaphore(value: 0) + StubProtocol.setGate(gate) + defer { + StubProtocol.setGate(nil) + for _ in 0..<60 { gate.signal() } + } + + let returned = Flag() + let first = Task { await coordinator.refresh() } + t.equal(StubProtocol.gateEntered.wait(timeout: .now() + 5), .success, + "cycle 1 should reach the gate") + + let waiter = Task { + await coordinator.refreshAndWait() + returned.set() + } + t.equal(sync { await waitForWaiter(coordinator) }, true, "waiter should register") + t.equal(returned.value, false, "must still be suspended") + + StubProtocol.setGate(nil) + for _ in 0..<60 { gate.signal() } + let snapshot = sync { () -> ProxySnapshot in + _ = await first.value + _ = await waiter.value + return await coordinator.current + } + t.equal(returned.value, true, "a failing queued cycle must still release its waiter") + // Proves the refusal was actually consumed, not a second 200. + t.equal(snapshot.state, .unreachable, "the queued cycle must have failed") + } + + t.test("polling: refreshAndWait survives a failing cycle without hanging") { + StubProtocol.reset([.init(status: 0, body: "", urlError: .cannotConnectToHost)]) + let coordinator = makeCoordinator() + let snapshot = sync { () -> ProxySnapshot in + await coordinator.refreshAndWait() + return await coordinator.current + } + t.equal(snapshot.state, .unreachable) + } + } + + private struct NoCredentials: CredentialStore { + func loadAPIKey() -> String? { nil } + } + + private final class Counter: @unchecked Sendable { + private let lock = NSLock() + private var value = 0 + var count: Int { lock.lock(); defer { lock.unlock() }; return value } + func bump() { lock.lock(); value += 1; lock.unlock() } + } +} diff --git a/app/Sources/MenuBarCoreTests/SnapshotStateSuite.swift b/app/Sources/MenuBarCoreTests/SnapshotStateSuite.swift new file mode 100644 index 00000000000..b9cb5d8146c --- /dev/null +++ b/app/Sources/MenuBarCoreTests/SnapshotStateSuite.swift @@ -0,0 +1,106 @@ +import Foundation +import MenuBarCore + +enum SnapshotStateSuite { + private static func health(_ status: String?, service: Bool = false) -> StartupHealth { + StartupHealth( + status: status, + protection: service ? "service" : "none", + serviceInstalled: service, + serviceEnabled: service + ) + } + + static func run(_ t: TestRunner) { + let endpoint = ProxyEndpoint.default + + t.test("state: every state has a word, so colour is never the only signal") { + let states: [ProxyState] = [ + .loading, .running(health("protected")), .unreachable, + .unauthorized, .degraded("boom"), + ] + for state in states { + t.expect(!state.title.isEmpty, "state \(state) must have a title") + } + t.equal(ProxyState.unreachable.title, "Stopped") + t.equal(ProxyState.unauthorized.title, "Needs API key") + } + + t.test("state: an unprotected but running proxy reads as a warning, not healthy") { + t.equal(ProxyState.running(health("protected")).tone, .good) + t.equal(ProxyState.running(health("at-risk")).tone, .warning) + t.equal(ProxyState.unreachable.tone, .bad) + } + + // loading is the one state with nothing to act on; every other non-running + // state must name a next step rather than dead-ending the user. + t.test("actions: loading has none, and every other non-running state names one") { + let loading = ProxySnapshot(state: .loading, endpoint: endpoint) + t.equal(loading.nextAction, NextAction.none) + + let unauthorized = ProxySnapshot(state: .unauthorized, endpoint: endpoint) + t.equal(unauthorized.nextAction, NextAction.addAPIKey) + + let degraded = ProxySnapshot(state: .degraded("x"), endpoint: endpoint) + t.equal(degraded.nextAction, NextAction.retry) + } + + t.test("actions: a stopped proxy offers the start command for its own install") { + let plain = ProxySnapshot(state: .unreachable, endpoint: endpoint) + t.equal(plain.nextAction, NextAction.runCommand("ocx start")) + + let managed = ProxySnapshot( + state: .unreachable, endpoint: endpoint, + lastKnownStartCommand: "ocx service start" + ) + t.equal(managed.nextAction, NextAction.runCommand("ocx service start")) + } + + t.test("state: the running detail line drops empty and 'none' qualifiers") { + let protectedDetail = ProxyState.running(health("protected", service: true)).detail + t.equal(protectedDetail, "protected · service") + // protection "none" is noise, not information. + t.equal(ProxyState.running(health("at-risk")).detail, "at-risk") + } + + t.test("snapshot: quota rows normalize one row per provider") { + let json = """ + [{"provider":"kimi","label":"Kimi","quota":{"fiveHourPercent":99, + "fiveHourResetAt":1784928599718,"monthlyPercent":10,"monthlyResetAt":1785542400000}}] + """ + let quotas = try JSONDecoder().decode([QuotaReport].self, from: Data(json.utf8)) + let snapshot = ProxySnapshot(state: .running(health("protected")), endpoint: endpoint, quotas: quotas) + t.equal(snapshot.quotaRows.count, 1) + t.equal(snapshot.quotaRows[0].windowLabel, "5h") + } + + t.test("snapshot: the default provider cannot be toggled") { + let providers = try JSONDecoder().decode( + [ProviderSummary].self, + from: Data(#"[{"name":"openai"},{"name":"anthropic"}]"#.utf8) + ) + let snapshot = ProxySnapshot( + state: .running(health("protected")), endpoint: endpoint, + providers: providers, defaultProvider: "openai" + ) + t.equal(snapshot.canToggle(providers[0]), false, "default provider") + t.equal(snapshot.canToggle(providers[1]), true, "non-default provider") + } + + t.test("snapshot: an omitted usage count stays unknown rather than empty") { + let usage = try JSONDecoder().decode( + UsageReport.self, + from: Data(#"{"range":"7d","summary":{"totalTokens":5}}"#.utf8) + ) + let snapshot = ProxySnapshot(state: .running(health("protected")), endpoint: endpoint, usage: usage) + t.isNil(snapshot.usageIsEmpty, "usageIsEmpty for an omitted count") + } + + t.test("polling: the interval backs off only after repeated failures") { + t.equal(PollingCoordinator.livenessInterval, 5) + t.equal(PollingCoordinator.heavyInterval, 60) + t.equal(PollingCoordinator.backoffInterval, 30) + t.equal(PollingCoordinator.backoffAfterFailures, 3) + } + } +} diff --git a/app/Sources/MenuBarCoreTests/TimelineDecodingSuite.swift b/app/Sources/MenuBarCoreTests/TimelineDecodingSuite.swift new file mode 100644 index 00000000000..eb2b2b5ca05 --- /dev/null +++ b/app/Sources/MenuBarCoreTests/TimelineDecodingSuite.swift @@ -0,0 +1,15 @@ +import Foundation +import MenuBarCore + +enum TimelineDecodingSuite { + static func run(_ t: TestRunner) { + t.test("timeline: decodes series and derived maxima") { + let json = #"{"start":0,"end":3600,"bucketSeconds":1800,"buckets":2,"metric":"total","aggregation":"sum","grouping":"model","series":[{"id":"a","provider":"p","model":"m","total":3,"points":[1,2]},{"id":"b","provider":"p","model":"n","total":4,"points":[4,0]}],"availableModels":["p/m","p/n"],"missingMeasurements":1,"truncated":true}"# + let timeline = try JSONDecoder().decode(UsageTimeline.self, from: Data(json.utf8)) + t.equal(timeline.maxPoint, 4) + t.equal(timeline.stackedMax, 5) + t.equal(timeline.isEmpty, false) + t.equal(timeline.truncated, true) + } + } +} diff --git a/app/Sources/MenuBarCoreTests/TransportSuite.swift b/app/Sources/MenuBarCoreTests/TransportSuite.swift new file mode 100644 index 00000000000..d46a667e0fe --- /dev/null +++ b/app/Sources/MenuBarCoreTests/TransportSuite.swift @@ -0,0 +1,362 @@ +import Foundation +import MenuBarCore + +/// Stubs the network so status mapping, the 401 retry, cancellation, request shape, and +/// body privacy are covered without a live proxy. +final class StubProtocol: URLProtocol, @unchecked Sendable { + struct Response { + var status: Int + var body: String + var urlError: URLError.Code? + } + + nonisolated(unsafe) static var queue: [Response] = [] + nonisolated(unsafe) static var recorded: [URLRequest] = [] + private static let lock = NSLock() + + static func reset(_ responses: [Response]) { + lock.lock(); defer { lock.unlock() } + queue = responses + recorded = [] + bodies = [] + gateStorage = nil + } + + nonisolated(unsafe) static var bodies: [Data] = [] + /// When set, `startLoading` blocks until the gate is opened. Lets a test hold a + /// refresh suspended so the coalescing/continuation path is genuinely exercised. + /// + /// Access goes through `setGate`/`currentGate` under the same lock as the rest of + /// the stub state: an unsynchronised read here is a data race, and `gateEntered` + /// lets a test wait for the request to actually reach the gate instead of inferring + /// it from elapsed time. + nonisolated(unsafe) private static var gateStorage: DispatchSemaphore? + static let gateEntered = DispatchSemaphore(value: 0) + + static func setGate(_ gate: DispatchSemaphore?) { + lock.lock(); gateStorage = gate; lock.unlock() + } + + static func currentGate() -> DispatchSemaphore? { + lock.lock(); defer { lock.unlock() } + return gateStorage + } + + static func record(_ request: URLRequest) { + lock.lock(); defer { lock.unlock() } + recorded.append(request) + // URLProtocol replaces httpBody with a stream, so read it here or the body is + // unobservable — which let an "exact body" assertion pass with no body at all. + if let body = request.httpBody { + bodies.append(body) + } else if let stream = request.httpBodyStream { + stream.open() + var data = Data() + var buffer = [UInt8](repeating: 0, count: 1024) + while stream.hasBytesAvailable { + let read = stream.read(&buffer, maxLength: buffer.count) + if read <= 0 { break } + data.append(buffer, count: read) + } + stream.close() + bodies.append(data) + } + } + + static func next() -> Response? { + lock.lock(); defer { lock.unlock() } + return queue.isEmpty ? nil : queue.removeFirst() + } + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + Self.record(request) + // Held open by tests that need a request to stay in flight. + if let gate = Self.currentGate() { + Self.gateEntered.signal() + gate.wait() + } + if request.url?.path == "/api/companion/settings" { + let body = #"{"settings":{"menuBarMetric":"requests","showToday":true,"showChart":true,"showModels":true,"showCost":true,"showAccounts":true,"chartHours":24,"bucketMinutes":60,"chartStyle":"line","tokenMetric":"total","aggregation":"sum","chartGrouping":"model","hiddenProviders":[]}}"# + let http = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)! + client?.urlProtocol(self, didReceive: http, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: Data(body.utf8)) + client?.urlProtocolDidFinishLoading(self) + return + } + if request.url?.path == "/api/usage/timeline" { + let body = #"{"start":0,"end":3600,"bucketSeconds":3600,"buckets":1,"metric":"total","aggregation":"sum","grouping":"model","series":[],"availableModels":[],"missingMeasurements":0}"# + let http = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)! + client?.urlProtocol(self, didReceive: http, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: Data(body.utf8)) + client?.urlProtocolDidFinishLoading(self) + return + } + guard let response = Self.next() else { + client?.urlProtocol(self, didFailWithError: URLError(.cannotConnectToHost)) + return + } + if let code = response.urlError { + client?.urlProtocol(self, didFailWithError: URLError(code)) + return + } + let http = HTTPURLResponse( + url: request.url!, statusCode: response.status, + httpVersion: "HTTP/1.1", headerFields: nil + )! + client?.urlProtocol(self, didReceive: http, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: Data(response.body.utf8)) + client?.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() {} +} + +private struct StubCredentials: CredentialStore { + let key: String? + let counter: Counter + + final class Counter: @unchecked Sendable { + private(set) var loads = 0 + private let lock = NSLock() + func bump() { lock.lock(); loads += 1; lock.unlock() } + } + + func loadAPIKey() -> String? { + counter.bump() + return key + } +} + +enum TransportSuite { + private static func makeSession() -> URLSession { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [StubProtocol.self] + return URLSession(configuration: config) + } + + private static func sync(_ operation: @escaping () async -> T) -> T { + let semaphore = DispatchSemaphore(value: 0) + let box = ResultBox() + Task { + box.value = await operation() + semaphore.signal() + } + semaphore.wait() + return box.value! + } + + private final class ResultBox: @unchecked Sendable { var value: T? } + + static func run(_ t: TestRunner) { + let endpoint = ProxyEndpoint.default + + t.test("transport: a 200 decodes into the model") { + StubProtocol.reset([.init(status: 200, body: #"{"status":"protected"}"#, urlError: nil)]) + let client = ProxyClient(endpoint: endpoint, session: makeSession(), + credentials: StubCredentials(key: nil, counter: .init())) + let result: String? = sync { + try? await client.health().status + } + t.equal(result, "protected") + } + + t.test("transport: a 500 maps to .http and never carries the body") { + StubProtocol.reset([.init(status: 500, body: "SECRET-CONFIG-VALUE", urlError: nil)]) + let client = ProxyClient(endpoint: endpoint, session: makeSession(), + credentials: StubCredentials(key: nil, counter: .init())) + let error: ProxyError? = sync { + do { _ = try await client.health(); return nil } + catch let error as ProxyError { return error } + catch { return nil } + } + t.equal(error, .http(500)) + let message = error?.userMessage ?? "" + t.expect(!message.contains("SECRET"), "error message must not echo the body: \(message)") + } + + t.test("transport: malformed JSON maps to .decoding") { + StubProtocol.reset([.init(status: 200, body: "{not json", urlError: nil)]) + let client = ProxyClient(endpoint: endpoint, session: makeSession(), + credentials: StubCredentials(key: nil, counter: .init())) + let error: ProxyError? = sync { + do { _ = try await client.health(); return nil } + catch let error as ProxyError { return error } + catch { return nil } + } + t.equal(error, .decoding) + } + + t.test("transport: connection refused maps to .unreachable") { + StubProtocol.reset([.init(status: 0, body: "", urlError: .cannotConnectToHost)]) + let client = ProxyClient(endpoint: endpoint, session: makeSession(), + credentials: StubCredentials(key: nil, counter: .init())) + let error: ProxyError? = sync { + do { _ = try await client.health(); return nil } + catch let error as ProxyError { return error } + catch { return nil } + } + t.equal(error, .unreachable) + } + + // A policy failure is not evidence the proxy is down; conflating them would put + // the UI in "Stopped" for a running proxy. + t.test("transport: an unrelated URLError maps to .transport, not .unreachable") { + StubProtocol.reset([.init(status: 0, body: "", urlError: .appTransportSecurityRequiresSecureConnection)]) + let client = ProxyClient(endpoint: endpoint, session: makeSession(), + credentials: StubCredentials(key: nil, counter: .init())) + let error: ProxyError? = sync { + do { _ = try await client.health(); return nil } + catch let error as ProxyError { return error } + catch { return nil } + } + t.equal(error, .transport) + } + + t.test("transport: cancellation propagates instead of reading as a stopped proxy") { + StubProtocol.reset([.init(status: 0, body: "", urlError: .cancelled)]) + let client = ProxyClient(endpoint: endpoint, session: makeSession(), + credentials: StubCredentials(key: nil, counter: .init())) + let wasCancellation: Bool = sync { + do { _ = try await client.health(); return false } + catch is CancellationError { return true } + catch { return false } + } + t.equal(wasCancellation, true) + } + + t.test("auth: a 401 with a stored key retries once and succeeds") { + StubProtocol.reset([ + .init(status: 401, body: "", urlError: nil), + .init(status: 200, body: #"{"status":"protected"}"#, urlError: nil), + ]) + let counter = StubCredentials.Counter() + let client = ProxyClient(endpoint: endpoint, session: makeSession(), + credentials: StubCredentials(key: "test-key", counter: counter)) + let status: String? = sync { try? await client.health().status } + t.equal(status, "protected") + t.equal(counter.loads, 1, "credential loaded exactly once") + t.equal(StubProtocol.recorded.count, 2, "one retry") + let retry = StubProtocol.recorded.last + t.equal(retry?.value(forHTTPHeaderField: "x-opencodex-api-key"), "test-key") + } + + t.test("auth: a 401 with no stored key surfaces .unauthorized without retrying") { + StubProtocol.reset([.init(status: 401, body: "", urlError: nil)]) + let counter = StubCredentials.Counter() + let client = ProxyClient(endpoint: endpoint, session: makeSession(), + credentials: StubCredentials(key: nil, counter: counter)) + let error: ProxyError? = sync { + do { _ = try await client.health(); return nil } + catch let error as ProxyError { return error } + catch { return nil } + } + t.equal(error, .unauthorized) + t.equal(StubProtocol.recorded.count, 1, "no retry without a key") + } + + // A stale stored key must not spin: one retry, then surface the failure. + t.test("auth: repeated 401s retry exactly once, never looping") { + StubProtocol.reset([ + .init(status: 401, body: "", urlError: nil), + .init(status: 401, body: "", urlError: nil), + .init(status: 401, body: "", urlError: nil), + ]) + let client = ProxyClient(endpoint: endpoint, session: makeSession(), + credentials: StubCredentials(key: "stale", counter: .init())) + let error: ProxyError? = sync { + do { _ = try await client.health(); return nil } + catch let error as ProxyError { return error } + catch { return nil } + } + t.equal(error, .unauthorized) + t.equal(StubProtocol.recorded.count, 2, "exactly one retry") + } + + t.test("requests: usage sends the enum range as a query item") { + StubProtocol.reset([.init(status: 200, body: #"{"range":"7d"}"#, urlError: nil)]) + let client = ProxyClient(endpoint: endpoint, session: makeSession(), + credentials: StubCredentials(key: nil, counter: .init())) + _ = sync { try? await client.usage(range: .sevenDays) } + let url = StubProtocol.recorded.first?.url?.absoluteString ?? "" + t.expect(url.contains("range=7d"), "expected range=7d in \(url)") + t.expect(url.contains("/api/usage"), "expected /api/usage in \(url)") + t.equal(StubProtocol.recorded.first?.value(forHTTPHeaderField: "User-Agent"), "OpenCodexWidget/dev") + } + + t.test("requests: the provider patch sends exactly {\"disabled\":true}") { + StubProtocol.reset([.init(status: 200, body: "{}", urlError: nil)]) + let client = ProxyClient(endpoint: endpoint, session: makeSession(), + credentials: StubCredentials(key: nil, counter: .init())) + _ = sync { + try? await client.setProviderDisabled("anthropic", disabled: true) + } + let request = StubProtocol.recorded.first + t.equal(request?.httpMethod, "PATCH") + let url = request?.url?.absoluteString ?? "" + t.expect(url.contains("name=anthropic"), "expected name=anthropic in \(url)") + + // Assert on the ACTUAL request body. An earlier version encoded its own + // dictionary and compared that, so it would have passed with no body at all. + guard let body = StubProtocol.bodies.first else { + t.expect(false, "no request body captured") + return + } + let decoded = try JSONSerialization.jsonObject(with: body) as? [String: Any] + t.equal(decoded?.keys.sorted() ?? [], ["disabled"], "body must carry only 'disabled'") + t.equal(decoded?["disabled"] as? Bool, true) + } + + t.test("liveness: a 401 still proves something is listening") { + StubProtocol.reset([ + .init(status: 401, body: "", urlError: nil), + .init(status: 401, body: "", urlError: nil), + ]) + let client = ProxyClient(endpoint: endpoint, session: makeSession(), + credentials: StubCredentials(key: "k", counter: .init())) + t.equal(sync { await client.isReachable() }, true) + } + + t.test("liveness: connection refused reads as not reachable") { + StubProtocol.reset([.init(status: 0, body: "", urlError: .cannotConnectToHost)]) + let client = ProxyClient(endpoint: endpoint, session: makeSession(), + credentials: StubCredentials(key: nil, counter: .init())) + t.equal(sync { await client.isReachable() }, false) + } + + t.test("endpoint: an out-of-range port cannot be constructed") { + t.isNil(ProxyEndpoint(port: 0), "port 0") + t.isNil(ProxyEndpoint(port: -1), "port -1") + t.isNil(ProxyEndpoint(port: 70_000), "port 70000") + t.equal(ProxyEndpoint(port: 10_100)?.baseURL.absoluteString, "http://127.0.0.1:10100") + } + + // The actor suspends across each request, so several calls can be in flight and + // all receive 401. A single global "already tried" flag made the second caller + // fail even though the first had just loaded a usable key. + t.test("auth: concurrent initial 401s both succeed once a key is loaded") { + StubProtocol.reset([ + .init(status: 401, body: "", urlError: nil), + .init(status: 401, body: "", urlError: nil), + .init(status: 200, body: #"{"status":"protected"}"#, urlError: nil), + .init(status: 200, body: #"{"status":"protected"}"#, urlError: nil), + ]) + let counter = StubCredentials.Counter() + let client = ProxyClient(endpoint: endpoint, session: makeSession(), + credentials: StubCredentials(key: "test-key", counter: counter)) + + let outcomes: [String] = sync { + async let first = try? await client.health().status + async let second = try? await client.health().status + let results = await [first, second] + return results.map { $0 ?? "error" } + } + + t.equal(outcomes.filter { $0 == "protected" }.count, 2, "both calls should succeed") + t.equal(counter.loads, 1, "credentials loaded exactly once") + t.equal(StubProtocol.recorded.count, 4, "two initial calls plus two retries") + } + } +} diff --git a/app/Sources/MenuBarCoreTests/WidgetSnapshotSuite.swift b/app/Sources/MenuBarCoreTests/WidgetSnapshotSuite.swift new file mode 100644 index 00000000000..66f25176b68 --- /dev/null +++ b/app/Sources/MenuBarCoreTests/WidgetSnapshotSuite.swift @@ -0,0 +1,37 @@ +import Foundation +import MenuBarCore + +enum WidgetSnapshotSuite { + static func run(_ t: TestRunner) { + t.test("widget snapshot: maps today and caps chart series") { + var series: [String] = [] + for index in 0..<7 { + series.append(#"{"id":"s\#(index)","provider":"p","model":"m\#(index)","total":1,"points":[1]}"#) + } + let timelineJSON = #"{"start":1,"end":2,"bucketSeconds":60,"buckets":1,"metric":"total","aggregation":"sum","grouping":"model","series":[\#(series.joined(separator: ","))],"availableModels":[],"missingMeasurements":0}"# + let timeline = try! JSONDecoder().decode(UsageTimeline.self, from: Data(timelineJSON.utf8)) + let report = try! JSONDecoder().decode(UsageReport.self, from: Data(#"{"range":"today","summary":{"requests":2,"totalTokens":3,"estimatedCostUsd":4}}"#.utf8)) + var snapshot = ProxySnapshot(endpoint: .default, usage: report, today: report, timeline: timeline) + snapshot.state = .running(try! JSONDecoder().decode(StartupHealth.self, from: Data(#"{"status":"protected"}"#.utf8))) + let widget = WidgetSnapshot.make(from: snapshot, now: Date(timeIntervalSince1970: 100)) + t.equal(widget.schemaVersion, 1) + t.equal(widget.today?.requests, 2) + t.equal(widget.chart?.series.count, 6) + } + t.test("widget snapshot: encoded payload contains no credentials") { + let snapshot = WidgetSnapshot.make(from: ProxySnapshot(endpoint: .default), now: Date()) + let data = try! JSONEncoder().encode(snapshot) + let text = String(decoding: data, as: UTF8.self) + t.expect(!text.contains("apiKey") && !text.contains("x-opencodex"), "privacy") + } + t.test("widget snapshot: store writes to injected home") { + let home = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let store = WidgetSnapshotStore(homeDirectory: home) + let snapshot = WidgetSnapshot.make(from: ProxySnapshot(endpoint: .default), now: Date()) + store.writeIfChanged(snapshot) + t.expect(FileManager.default.fileExists(atPath: store.url.path), "snapshot file") + let mode = (try? FileManager.default.attributesOfItem(atPath: store.url.path)[.posixPermissions] as? NSNumber)?.intValue + t.equal(mode, 0o600) + } + } +} diff --git a/app/Sources/MenuBarCoreTests/main.swift b/app/Sources/MenuBarCoreTests/main.swift new file mode 100644 index 00000000000..41928f17a1c --- /dev/null +++ b/app/Sources/MenuBarCoreTests/main.swift @@ -0,0 +1,20 @@ +import Foundation + +// Entry point for `swift run --package-path app MenuBarCoreTests`. +// See Harness.swift for why this is an executable rather than an XCTest bundle. + +let runner = TestRunner() + +DiscoverySuite.run(runner) +ModelDecodingSuite.run(runner) +FormattingSuite.run(runner) +CompanionSettingsSuite.run(runner) +TimelineDecodingSuite.run(runner) +MenuBarTitleSuite.run(runner) +WidgetSnapshotSuite.run(runner) +TransportSuite.run(runner) +SnapshotStateSuite.run(runner) +PollingSuite.run(runner) +ActionSuite.run(runner) + +exit(runner.summarize()) diff --git a/app/Sources/OpenCodexWidget/Provider.swift b/app/Sources/OpenCodexWidget/Provider.swift new file mode 100644 index 00000000000..e23d55f6042 --- /dev/null +++ b/app/Sources/OpenCodexWidget/Provider.swift @@ -0,0 +1,47 @@ +import Foundation +import WidgetKit +import MenuBarCore + +public struct SnapshotEntry: TimelineEntry { + public let date: Date + public let snapshot: WidgetSnapshot? + public let failure: ReadFailure? + public let stale: Bool +} + +public struct SnapshotProvider: TimelineProvider { + private let reader = SnapshotReader() + + public init() {} + + public func placeholder(in context: Context) -> SnapshotEntry { + SnapshotEntry(date: Date(), snapshot: Self.sample, failure: nil, stale: false) + } + + public func getSnapshot(in context: Context, completion: @escaping (SnapshotEntry) -> Void) { + completion(readEntry()) + } + + public func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + let now = Date() + completion(Timeline(entries: [readEntry(now: now)], policy: .after(now.addingTimeInterval(300)))) + } + + private func readEntry(now: Date = Date()) -> SnapshotEntry { + switch reader.read() { + case .failure(let failure): + return SnapshotEntry(date: now, snapshot: nil, failure: failure, stale: false) + case .success(let snapshot): + return SnapshotEntry(date: now, snapshot: snapshot, failure: nil, stale: snapshot.isStale(now: now)) + } + } + + private static let sample = WidgetSnapshot( + schemaVersion: 1, generatedAt: Date().timeIntervalSince1970, + state: "running", stateTitle: "Running", detail: "protected", + endpointDisplay: "127.0.0.1:10100", menuTitle: "12", + today: .init(requests: 12, totalTokens: 4_200, estimatedCostUsd: 0.12), + quotas: [.init(providerLabel: "OpenAI", windowLabel: "week", percent: 42, resetAt: Date().addingTimeInterval(86_400).timeIntervalSince1970)], + chart: nil, lastUpdated: Date().timeIntervalSince1970 + ) +} diff --git a/app/Sources/OpenCodexWidget/SnapshotReader.swift b/app/Sources/OpenCodexWidget/SnapshotReader.swift new file mode 100644 index 00000000000..0c3b8fc66be --- /dev/null +++ b/app/Sources/OpenCodexWidget/SnapshotReader.swift @@ -0,0 +1,31 @@ +import Foundation +import MenuBarCore + +public enum ReadFailure: String, Error, Equatable, Sendable { + case missing + case corrupt +} + +public extension WidgetSnapshot { + func isStale(now: Date = Date()) -> Bool { + now.timeIntervalSince1970 - generatedAt > 600 + } +} + +public struct SnapshotReader: Sendable { + public init() {} + + public func read() -> Result { + let directory = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + let url = directory.appendingPathComponent("OpenCodex/snapshot.json") + guard let data = try? Data(contentsOf: url) else { return .failure(.missing) } + guard let snapshot = try? JSONDecoder().decode(WidgetSnapshot.self, from: data) else { + return .failure(.corrupt) + } + return .success(snapshot) + } + + public func isStale(_ snapshot: WidgetSnapshot, now: Date = Date()) -> Bool { + snapshot.isStale(now: now) + } +} diff --git a/app/Sources/OpenCodexWidget/Views.swift b/app/Sources/OpenCodexWidget/Views.swift new file mode 100644 index 00000000000..7b8d6fc346c --- /dev/null +++ b/app/Sources/OpenCodexWidget/Views.swift @@ -0,0 +1,321 @@ +import SwiftUI +import WidgetKit +import MenuBarCore + +struct OpenCodexWidgetView: View { + let entry: SnapshotEntry + @Environment(\.widgetFamily) private var family + @Environment(\.widgetRenderingMode) private var renderingMode + + var body: some View { + Group { + if let failure = entry.failure { + failureView(failure) + } else if let snapshot = entry.snapshot { + content(snapshot) + } else { + failureView(.missing) + } + } + .containerBackground(.background, for: .widget) + .widgetURL(widgetURL) + } + + private var widgetURL: URL? { + guard let display = entry.snapshot?.endpointDisplay, + let endpoint = URL(string: "http://\(display)"), + endpoint.host != nil, endpoint.port != nil + else { return nil } + return URL(string: "http://\(display)/#/usage") + } + + @ViewBuilder + private func content(_ snapshot: WidgetSnapshot) -> some View { + switch family { + case .systemSmall: + small(snapshot) + case .systemLarge: + large(snapshot) + default: + medium(snapshot) + } + } + + private func tone(_ snapshot: WidgetSnapshot) -> Color { + switch snapshot.state { + case "running": return .green + case "degraded": return .orange + case "unreachable", "unauthorized": return .red + default: return .secondary + } + } + + private func small(_ snapshot: WidgetSnapshot) -> some View { + VStack(alignment: .leading, spacing: 7) { + HStack(spacing: 5) { + Circle().fill(tone(snapshot)).frame(width: 7, height: 7) + Text("OpenCodex").font(.caption).foregroundStyle(.secondary) + } + Text(Format.tokens(snapshot.today?.totalTokens)) + .font(.system(size: 28, weight: .semibold, design: .rounded)) + .lineLimit(1) + .widgetAccentable() + Text("tokens today").font(.caption).foregroundStyle(.secondary) + HStack(spacing: 4) { + Text("\(Format.count(snapshot.today?.requests)) req") + if let cost = snapshot.today?.estimatedCostUsd { + Text("·") + Text(Format.cost(cost)) + } + } + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + updated(snapshot) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + private func medium(_ snapshot: WidgetSnapshot) -> some View { + HStack(alignment: .top, spacing: 14) { + VStack(alignment: .leading, spacing: 5) { + status(snapshot) + metric("Tokens", Format.tokens(snapshot.today?.totalTokens)) + metric("Requests", Format.count(snapshot.today?.requests)) + if let cost = snapshot.today?.estimatedCostUsd { metric("Cost", Format.cost(cost)) } + updated(snapshot) + } + Divider() + if hasQuota(snapshot) { + quotaView(snapshot) + } else if let chart = snapshot.chart { + VStack(alignment: .leading, spacing: 5) { + Text("Last \(windowLabel(chart))").font(.caption).foregroundStyle(.secondary) + chartView(chart, flexible: false).widgetAccentable() + } + } else { + VStack(alignment: .leading, spacing: 4) { + Text("No quota sources").font(.caption).foregroundStyle(.secondary) + Text("Quota appears for providers that report limits") + .font(.caption2).foregroundStyle(.secondary).lineLimit(2) + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + private func large(_ snapshot: WidgetSnapshot) -> some View { + VStack(alignment: .leading, spacing: 10) { + status(snapshot) + metricsRow(snapshot) + if !snapshot.quotas.isEmpty { + VStack(alignment: .leading, spacing: 5) { + ForEach(Array(snapshot.quotas.prefix(4).enumerated()), id: \.offset) { _, quota in + quotaRow(quota) + } + } + } + if let chart = snapshot.chart { + Text("Last \(windowLabel(chart)) · \(chart.series.count) models") + .font(.caption).foregroundStyle(.secondary) + chartView(chart, flexible: true) + .frame(maxHeight: .infinity) + .widgetAccentable() + legend(chart) + } + updated(snapshot) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + private func status(_ snapshot: WidgetSnapshot) -> some View { + HStack(spacing: 5) { + Circle().fill(tone(snapshot)).frame(width: 7, height: 7) + Text(([snapshot.stateTitle, snapshot.detail].compactMap { $0?.isEmpty == false ? $0 : nil }).joined(separator: " · ")) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + + private func metric(_ label: String, _ value: String) -> some View { + HStack { + Text(label).font(.caption).foregroundStyle(.secondary) + Spacer() + Text(value).font(.system(.body, design: .monospaced)) + } + } + + private func metricsRow(_ snapshot: WidgetSnapshot) -> some View { + HStack(spacing: 10) { + metricColumn("TOKENS", Format.tokens(snapshot.today?.totalTokens)) + metricColumn("REQUESTS", Format.count(snapshot.today?.requests)) + metricColumn("COST", Format.cost(snapshot.today?.estimatedCostUsd)) + } + } + + private func metricColumn(_ label: String, _ value: String) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(label).font(.caption2).foregroundStyle(.secondary) + Text(value).font(.system(.body, design: .monospaced)).lineLimit(1) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func hasQuota(_ snapshot: WidgetSnapshot) -> Bool { + snapshot.quotas.contains { $0.percent != nil } + } + + private func quotaView(_ snapshot: WidgetSnapshot) -> some View { + Group { + if let quota = snapshot.quotas.compactMap({ $0.percent == nil ? nil : $0 }).min(by: { ($0.percent ?? 100) < ($1.percent ?? 100) }) { + VStack(alignment: .leading, spacing: 5) { + Text(quota.providerLabel).font(.caption).lineLimit(1) + ProgressView(value: (quota.percent ?? 0) / 100) + .tint((quota.percent ?? 0) > 80 ? .orange : .green) + Text("\(quota.windowLabel) · \(resets(in: quota.resetAt))") + .font(.caption2).foregroundStyle(.secondary).lineLimit(1) + } + } else { + Text("No quota sources").font(.caption).foregroundStyle(.secondary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func quotaRow(_ quota: WidgetSnapshot.Quota) -> some View { + HStack { + Text(quota.providerLabel).lineLimit(1) + Spacer() + Text("\(Format.percent(quota.percent)) · \(quota.windowLabel)") + .font(.caption).foregroundStyle(.secondary) + } + } + + private func chartView(_ chart: WidgetSnapshot.Chart, flexible: Bool) -> some View { + GeometryReader { geometry in + if chart.style == "stackedBar" { + stackedBars(chart, in: geometry.size) + } else { + lineChart(chart, in: geometry.size) + } + } + .frame(minHeight: 72, maxHeight: flexible ? .infinity : 72) + } + + private func legend(_ chart: WidgetSnapshot.Chart) -> some View { + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], alignment: .leading, spacing: 4) { + ForEach(Array(chart.series.prefix(5).enumerated()), id: \.offset) { index, series in + HStack(spacing: 4) { + Circle().fill(seriesColor(index)).frame(width: 6, height: 6) + Text(series.id) + .font(.caption2) + .lineLimit(1) + .truncationMode(.middle) + } + } + } + } + + private func lineChart(_ chart: WidgetSnapshot.Chart, in size: CGSize) -> some View { + ZStack { + ForEach(Array(chart.series.enumerated()), id: \.offset) { index, series in + Path { path in + let maxValue = maxPoint(chart.series.flatMap(\.points)) + for pointIndex in series.points.indices { + let x = series.points.count > 1 + ? size.width * CGFloat(pointIndex) / CGFloat(series.points.count - 1) : 0 + let y = size.height * (1 - CGFloat(series.points[pointIndex] / maxValue)) + if pointIndex == 0 { path.move(to: CGPoint(x: x, y: y)) } + else { path.addLine(to: CGPoint(x: x, y: y)) } + } + } + .stroke(seriesColor(index), lineWidth: 1.5) + } + } + } + + private func stackedBars(_ chart: WidgetSnapshot.Chart, in size: CGSize) -> some View { + let count = chart.series.map(\.points.count).max() ?? 0 + let maxValue = maxPoint((0.. Color { + if renderingMode == .accented { + return .primary.opacity([1, 0.8, 0.6, 0.45, 0.3, 0.2][index % 6]) + } + return palette[index % palette.count] + } + + private func windowLabel(_ chart: WidgetSnapshot.Chart) -> String { + let hours = chart.bucketSeconds * (chart.series.map(\.points.count).max() ?? 0) / 3600 + if hours < 48 { return "\(hours)h" } + return "\(hours / 24)d" + } + + private func maxPoint(_ points: [Double]) -> Double { max(points.max() ?? 1, 1) } + + private func resets(in timestamp: Double?) -> String { + Format.resetsIn(timestamp.map(Date.init(timeIntervalSince1970:))) + } + + private func updated(_ snapshot: WidgetSnapshot) -> some View { + let text = snapshot.lastUpdated.map { "Updated \(Format.age(Date(timeIntervalSince1970: $0)))" } ?? "Not updated" + return Text(text).font(.caption2).foregroundStyle(entry.stale ? .orange : .secondary).lineLimit(1) + } + + private func failureView(_ failure: ReadFailure) -> some View { + VStack(alignment: .leading, spacing: 8) { + Image(systemName: failure == .missing ? "rectangle.on.rectangle" : "exclamationmark.triangle") + .font(.title2) + Text(failure == .missing + ? "Open the OpenCodex desktop app to start sharing usage." + : "Snapshot unreadable — refresh from the desktop app.") + .font(.caption) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } +} + +@main +struct OpenCodexWidgetBundle: WidgetBundle { + var body: some Widget { + OpenCodexWidget() + } +} + +struct OpenCodexWidget: Widget { + let kind = "OpenCodexWidget" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: SnapshotProvider()) { entry in + OpenCodexWidgetView(entry: entry) + } + .configurationDisplayName("OpenCodex") + .description("Proxy status, today's usage, and quota at a glance.") + .supportedFamilies([.systemSmall, .systemMedium, .systemLarge]) + } +} diff --git a/app/Widget-Info.plist b/app/Widget-Info.plist new file mode 100644 index 00000000000..df499104290 --- /dev/null +++ b/app/Widget-Info.plist @@ -0,0 +1,29 @@ + + + + + CFBundleDevelopmentRegionen + CFBundleExecutableOpenCodexWidget + CFBundleIdentifiercom.opencodex.desktop.widget + CFBundleInfoDictionaryVersion6.0 + CFBundleNameOpenCodex + CFBundleDisplayNameOpenCodex + + CFBundleSupportedPlatforms + MacOSX + CFBundlePackageTypeXPC! + CFBundleShortVersionString0.0.0 + CFBundleVersion0.0.0 + LSMinimumSystemVersion14.0 + NSHumanReadableCopyrightMIT — opencodex contributors + NSExtension + + NSExtensionPointIdentifiercom.apple.widgetkit-extension + + + diff --git a/app/Widget.entitlements b/app/Widget.entitlements new file mode 100644 index 00000000000..1b44cd3cd24 --- /dev/null +++ b/app/Widget.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/assets/pr-screenshots/app-icon-finder-menubar.png b/assets/pr-screenshots/app-icon-finder-menubar.png new file mode 100644 index 00000000000..794afb137d4 Binary files /dev/null and b/assets/pr-screenshots/app-icon-finder-menubar.png differ diff --git a/assets/pr-screenshots/favicon-browser-tab.png b/assets/pr-screenshots/favicon-browser-tab.png new file mode 100644 index 00000000000..3642c0afe15 Binary files /dev/null and b/assets/pr-screenshots/favicon-browser-tab.png differ diff --git a/assets/pr-screenshots/favicon-light-tab.png b/assets/pr-screenshots/favicon-light-tab.png new file mode 100644 index 00000000000..803ab3b32ad Binary files /dev/null and b/assets/pr-screenshots/favicon-light-tab.png differ diff --git a/bin/ocx.mjs b/bin/ocx.mjs index 7b3a54eaa2c..fef7686638c 100755 --- a/bin/ocx.mjs +++ b/bin/ocx.mjs @@ -12,6 +12,21 @@ import { spawn, spawnSync } from "node:child_process"; import { STOP_HISTORY_INCOMPLETE_EXIT_CODE } from "../src/update/stop-contract.mjs"; import { probeProxyLiveness } from "../src/update/proxy-liveness-probe.mjs"; import { decidePostStopUpdate } from "../src/update/stop-decision.mjs"; +import { + inspectPackageRuntimeLiveness, + planStoppedRuntimeRecovery, + planUpdateRuntimeHandling, +} from "../src/update/runtime-ownership.mjs"; +import { + inspectInstallStateBytes, + selectAuthoritativeServiceState, + serviceStateFilesFor, +} from "../src/service/install-state-contract.mjs"; +import { + acquireOwnershipMutationLease, + ownershipMutationLeaseChildEnvironment, + unprivilegedOwnershipMutationEnvironment, +} from "../src/service/ownership-mutation-lease.mjs"; import { randomBytes } from "node:crypto"; import { createRequire } from "node:module"; import { existsSync, readFileSync, readdirSync } from "node:fs"; @@ -41,6 +56,9 @@ import { } from "../src/update/codex-cli-update-launch-policy.mjs"; const PKG = "@bitkyc08/opencodex"; +const UPDATE_RECOVERY_READY_MS = 30_000; +const UPDATE_RECOVERY_POLL_MS = 100; +const UPDATE_RECOVERY_SLEEP = new Int32Array(new SharedArrayBuffer(4)); try { process.cwd(); } catch { @@ -256,8 +274,45 @@ function runPackageManagerSelfUpdate(manager) { // Remember whether a background service manages the proxy BEFORE stopping — `ocx stop` // unloads it, so a successful update must refresh and restart it afterwards. - const serviceStatePath = join(configDir(), "service-state.json"); - const serviceWasInstalled = existsSync(serviceStatePath); + const allServiceStatePaths = serviceStateFilesFor(configDir(), join(homedir(), ".opencodex")); + // The test guard's legacy path is the developer's real home. Production always reads the + // same active-home + default-home observations as the Bun resolver. + const serviceStatePaths = process.env.OCX_TEST_HOME_GUARD === "1" + ? allServiceStatePaths.slice(0, 1) + : allServiceStatePaths; + const serviceWasInstalled = serviceStatePaths.some(path => existsSync(path)); + // What this update may do to the runtime. The same rule the Bun updater applies, from the + // same module: a desktop takeover vetoes both the stop and the service refresh below. + const readServiceState = () => selectAuthoritativeServiceState( + serviceStatePaths.map(path => inspectInstallStateBytes(path, at => readFileSync(at, "utf8"))), + ); + const readOwnership = () => { + const selected = readServiceState(); + if (selected.kind === "unknown") return { ownership: null, ownershipUnknown: true, subjectToken: "unknown" }; + if (selected.kind === "none") return { + ownership: null, ownershipUnknown: false, subjectToken: JSON.stringify(["none", selected.revision]), + }; + const ownership = selected.state.ownership ?? null; + return { + ownership, + ownershipUnknown: false, + subjectToken: JSON.stringify(ownership + ? ["owned", selected.revision, ownership] + : ["none", selected.revision]), + }; + }; + const ownershipIdentity = observation => observation.ownershipUnknown + ? null + : JSON.stringify(observation.ownership + ? ["owned", observation.ownership.owner, observation.ownership.installId, observation.ownership.consentGeneration] + : ["none"]); + const initialOwnership = readOwnership(); + let runtimePlan = planUpdateRuntimeHandling({ ...initialOwnership, serviceInstalled: serviceWasInstalled }); + if (runtimePlan.notice) console.log(runtimePlan.notice); + if (!runtimePlan.mayReplacePackage) { + console.error("opencodex: update stopped before tray handoff, runtime stop, or package replacement because runtime ownership is unknown."); + process.exit(1); + } const trayBeforeUpdate = planWindowsTrayUpdate( process.platform === "win32" ? trayInstallState() : { installed: false, running: false }, ); @@ -271,10 +326,11 @@ function runPackageManagerSelfUpdate(manager) { } /** Register from scratch, preserving the recorded backend. Only for a genuinely absent service. */ function serviceInstallArgs() { - try { - const state = JSON.parse(readFileSync(serviceStatePath, "utf8")); - if (state.backend === "native") return [postUpdateLauncher, "service", "install", "--native"]; - } catch { /* missing or corrupt — fall through to default */ } + const selected = readServiceState(); + if (selected.kind === "unknown") throw new Error(`service backend is unknown: ${selected.reason}`); + if (selected.kind === "state" && selected.state.backend === "native") { + return [postUpdateLauncher, "service", "install", "--native"]; + } return [postUpdateLauncher, "service", "install"]; } /** @@ -302,39 +358,45 @@ function runPackageManagerSelfUpdate(manager) { } } - // Capture listen target before stop clears runtime-port.json (mirrors GUI/CLI update worker). - // Do not treat a live runtime port of 10100 as "missing" — track whether the read succeeded. + function readCurrentRuntimeTarget() { + let raw; + try { + raw = readFileSync(join(configDir(), "runtime-port.json"), "utf8"); + } catch (error) { + return error && typeof error === "object" && "code" in error && error.code === "ENOENT" + ? { kind: "absent" } + : { kind: "unknown" }; + } + try { + const rt = JSON.parse(raw); + const pid = Number(rt?.pid); + if (!Number.isFinite(rt?.port) || rt.port <= 0 || rt.port > 65535 + || !Number.isSafeInteger(pid) || pid <= 0) return { kind: "unknown" }; + return { kind: "target", target: { + pid, + port: Math.trunc(rt.port), + hostname: typeof rt.hostname === "string" && rt.hostname.trim() !== "" + ? rt.hostname.trim() + : null, + } }; + } catch { return { kind: "unknown" }; } + } + + // Capture the recovery target before stop clears runtime-port.json. Replacement safety + // re-reads this record under the mutation lease instead of trusting this snapshot. let bakePort = 10100; // The hostname travels with the port: a proxy bound to ::1 or a specific interface is // invisible to a probe that assumes 127.0.0.1, and "no answer" would then read as // "stopped" for exactly the proxy the probe exists to find. let bakeHostname = "127.0.0.1"; - let sawRuntimePort = false; - let sawRuntimeHostname = false; - try { - const rt = JSON.parse(readFileSync(join(configDir(), "runtime-port.json"), "utf8")); - if (Number.isFinite(rt?.port) && rt.port > 0 && rt.port <= 65535) { - // Only trust runtime when its pid still looks alive (stale crash leftovers fall back to config). - const rtPid = Number(rt?.pid); - let runtimeLive = false; - if (Number.isSafeInteger(rtPid) && rtPid > 0) { - try { - process.kill(rtPid, 0); - runtimeLive = true; - } catch (e) { - if (e && typeof e === "object" && "code" in e && e.code === "EPERM") runtimeLive = true; - } - } - if (runtimeLive) { - bakePort = Math.trunc(rt.port); - if (typeof rt?.hostname === "string" && rt.hostname.trim() !== "") { - bakeHostname = rt.hostname.trim(); - sawRuntimeHostname = true; - } - sawRuntimePort = true; - } - } - } catch { /* fall through to config */ } + const initialRuntimeObservation = readCurrentRuntimeTarget(); + const initialRuntimeTarget = initialRuntimeObservation.kind === "target" ? initialRuntimeObservation.target : null; + let sawRuntimePort = initialRuntimeTarget !== null; + let sawRuntimeHostname = initialRuntimeTarget?.hostname !== null && initialRuntimeTarget?.hostname !== undefined; + if (initialRuntimeTarget) { + bakePort = initialRuntimeTarget.port; + if (initialRuntimeTarget.hostname) bakeHostname = initialRuntimeTarget.hostname; + } // Port and hostname resolve INDEPENDENTLY: a legacy runtime record carries a port and no // hostname, and skipping config in that case probed 127.0.0.1 for a proxy bound to ::1. if (!sawRuntimePort || bakeHostname === "127.0.0.1") { @@ -350,6 +412,18 @@ function runPackageManagerSelfUpdate(manager) { } // Wildcard and bracketed-IPv6 normalization lives in probeProxyLiveness, so both lanes // get it from one place. + function currentPackageRuntimeLiveness() { + return inspectPackageRuntimeLiveness({ + capturedTarget: { port: bakePort, hostname: bakeHostname }, + readCurrentTarget: () => { + const current = readCurrentRuntimeTarget(); + return current.kind === "target" + ? { kind: "target", target: { port: current.target.port, hostname: current.target.hostname ?? bakeHostname } } + : current; + }, + probe: target => probeProxyLiveness(target.port, target.hostname), + }).overall; + } const launcher = fileURLToPath(import.meta.url); // The pnpm owner preflight has verified this package tree and global group. Keep that exact @@ -359,13 +433,17 @@ function runPackageManagerSelfUpdate(manager) { ? join(owner.packagePath, "bin", "ocx.mjs") : launcher; let postUpdateLauncherUsable = true; + let delegatedOwnershipMutationToken = null; + const mutationChildEnvironment = () => delegatedOwnershipMutationToken + ? ownershipMutationLeaseChildEnvironment(process.env, delegatedOwnershipMutationToken) + : unprivilegedOwnershipMutationEnvironment(process.env); function startProxyDirectly() { if (!postUpdateLauncherUsable || !existsSync(postUpdateLauncher)) { console.error("opencodex: cannot restart the proxy because the launcher is missing; reinstall opencodex manually."); - return; + return false; } - const env = { ...process.env }; + const env = mutationChildEnvironment(); delete env.OCX_SERVICE; console.log(`Attempting to restart the proxy on port ${bakePort}.`); const child = spawn(process.execPath, [postUpdateLauncher, "start", "--port", String(bakePort)], { @@ -378,13 +456,24 @@ function runPackageManagerSelfUpdate(manager) { console.error(`opencodex: direct proxy restart failed: ${error.message}`); }); child.unref(); + const deadline = Date.now() + UPDATE_RECOVERY_READY_MS; + while (Date.now() < deadline) { + const current = readCurrentRuntimeTarget(); + if (current.kind === "target" + && probeProxyLiveness(current.target.port, current.target.hostname ?? bakeHostname) === "live") return true; + Atomics.wait(UPDATE_RECOVERY_SLEEP, 0, 0, UPDATE_RECOVERY_POLL_MS); + } + console.error("opencodex: the recovery proxy did not publish a healthy runtime before the recovery deadline."); + return false; } function refreshBackgroundServiceOrStartDirect() { const prevBake = process.env.OCX_BAKE_PORT; process.env.OCX_BAKE_PORT = String(bakePort); try { - let svc = spawnSync(process.execPath, serviceRefreshArgs(), { stdio: "inherit", windowsHide: true }); + let svc = spawnSync(process.execPath, serviceRefreshArgs(), { + stdio: "inherit", windowsHide: true, env: mutationChildEnvironment(), + }); // `serviceWasInstalled` is inferred from service-state.json alone, which can be // STALE — present while the registration is gone. Repair refuses that case by // design, and its thrown Error is indistinguishable from any other failure at @@ -395,7 +484,9 @@ function runPackageManagerSelfUpdate(manager) { // could re-register a service the user just uninstalled. if (svc.status !== 0 && readServiceInstalledFromStatus(postUpdateLauncher) === false) { console.log("No registered service found — installing it instead."); - svc = spawnSync(process.execPath, serviceInstallArgs(), { stdio: "inherit", windowsHide: true }); + svc = spawnSync(process.execPath, serviceInstallArgs(), { + stdio: "inherit", windowsHide: true, env: mutationChildEnvironment(), + }); } let needDirectStart = svc.status !== 0; if (!needDirectStart) { @@ -421,6 +512,14 @@ function runPackageManagerSelfUpdate(manager) { } } if (needDirectStart) { + // Re-read rather than reuse the plan from before the package install: the app can + // claim the runtime during an update that takes minutes, and the refusal that repair + // just returned is indistinguishable from any other failure at this layer. + const nowOwned = planUpdateRuntimeHandling({ ...readOwnership(), serviceInstalled: true }); + if (!nowOwned.mayStopRuntime) { + console.warn(nowOwned.notice ?? "opencodex: the background runtime is owned elsewhere; not starting a second proxy."); + return; + } // Repair normally avoids elevation for a healthy registration, but a stale Windows // scheduler definition can require it. It can also fail — or exit 0 while leaving // a non-viable manager. Fall back to a direct detached proxy start so the @@ -439,189 +538,264 @@ function runPackageManagerSelfUpdate(manager) { } } - // Never replace package files under a live proxy — stop it first (full `ocx stop` - // semantics: graceful drain, service stop, native Codex restore). Gate on the service - // and the runtime-port record too: a service-managed or orphaned proxy can be live - // while ocx.pid is stale/missing. - if (trayBeforeUpdate.stopBeforeReplacement) { - console.log("⏹ Handing off the Windows tray before updating..."); - try { - handoffWindowsTrayForUpdate(trayBeforeUpdate, { - stop: () => { - const stopped = runTrayLifecycle(launcher, "stop"); - return { exitStatus: stopped.status, running: trayInstallState().running }; - }, - start: () => runTrayLifecycle(launcher, "start"), - }); - } catch { - console.error("opencodex: could not stop the Windows tray; aborting before package replacement."); + const updateLease = acquireOwnershipMutationLease(serviceStatePaths); + delegatedOwnershipMutationToken = updateLease.token; + let updateLeaseReleased = false; + const releaseUpdateLease = () => { + if (updateLeaseReleased) return; + updateLeaseReleased = true; + delegatedOwnershipMutationToken = null; + updateLease.release(); + }; + + let res; + try { + // Stop authority is decided under the same lease the child joins. A takeover between the + // earlier preflight and this boundary therefore blocks stop before it is sent. + const lockedOwnership = readOwnership(); + const lockedPlan = planUpdateRuntimeHandling({ ...lockedOwnership, serviceInstalled: serviceWasInstalled }); + if (lockedOwnership.subjectToken !== initialOwnership.subjectToken || !lockedPlan.mayReplacePackage) { + releaseUpdateLease(); + console.error(lockedPlan.notice + ?? "opencodex: update stopped because runtime ownership changed before stop authorization; rerun from the beginning."); process.exit(1); } - } - const hasRuntimeState = - existsSync(join(configDir(), "ocx.pid")) || existsSync(join(configDir(), "runtime-port.json")); + runtimePlan = lockedPlan; + const stoppedOwnershipIdentity = ownershipIdentity(lockedOwnership); - function recoverStoppedRuntimeAfterFailure() { - if (!postUpdateLauncherUsable) { - console.error("opencodex: no verified active launcher remains for automatic recovery; reinstall opencodex manually."); - return; + // Never replace package files under a live proxy — stop it first (full `ocx stop` + // semantics: graceful drain, service stop, native Codex restore). Gate on the service + // and the runtime-port record too: a service-managed or orphaned proxy can be live + // while ocx.pid is stale/missing. + if (trayBeforeUpdate.stopBeforeReplacement) { + console.log("⏹ Handing off the Windows tray before updating..."); + try { + handoffWindowsTrayForUpdate(trayBeforeUpdate, { + stop: () => { + const stopped = runTrayLifecycle(launcher, "stop"); + return { exitStatus: stopped.status, running: trayInstallState().running }; + }, + start: () => runTrayLifecycle(launcher, "start"), + }); + } catch { + releaseUpdateLease(); + console.error("opencodex: could not stop the Windows tray; aborting before package replacement."); + process.exit(1); + } } - if (serviceWasInstalled) { - console.warn("opencodex: update failed after stopping the proxy — restoring the previous background service."); - refreshBackgroundServiceOrStartDirect(); - } else if (hasRuntimeState) { - console.warn("opencodex: update failed after stopping the proxy — restarting the previous version directly."); - startProxyDirectly(); + const hasRuntimeState = + existsSync(join(configDir(), "ocx.pid")) || existsSync(join(configDir(), "runtime-port.json")); + let stopAttempted = false; + + function recoverStoppedRuntimeAfterFailure(reason) { + const recoveryOwnership = readOwnership(); + const recoveryLiveness = currentPackageRuntimeLiveness(); + const recovery = planStoppedRuntimeRecovery({ + stopAttempted, + ...recoveryOwnership, + sameOwner: ownershipIdentity(recoveryOwnership) === stoppedOwnershipIdentity, + liveness: recoveryLiveness, + serviceInstalled: serviceWasInstalled, + launcherUsable: postUpdateLauncherUsable, + hadRuntimeState: hasRuntimeState, + }); + if (recovery.reason === "ownership-unknown") { + console.error(`opencodex: ${reason}; runtime ownership is unknown, so automatic recovery was refused. Run 'ocx status --json' and repair the service-state record before retrying.`); + } else if (recovery.reason === "ownership-transferred") { + console.log("opencodex: runtime ownership moved to another installation; the stopped CLI runtime was not revived."); + } else if (recovery.reason.startsWith("runtime-")) { + console.error(`opencodex: ${reason}; package runtime liveness is ${recoveryLiveness}, so automatic recovery was refused.`); + } else if (recovery.reason === "launcher-unavailable") { + console.error("opencodex: no verified active launcher remains for automatic recovery; reinstall opencodex manually."); + } else if (recovery.action === "service") { + console.warn(`opencodex: ${reason} after stopping the proxy — restoring the previous background service.`); + refreshBackgroundServiceOrStartDirect(); + } else if (recovery.action === "direct") { + console.warn(`opencodex: ${reason} after stopping the proxy — restarting the previous version directly.`); + startProxyDirectly(); + } + return recovery; } - } - // An outstanding pending-teardown receipt is a fourth reason to run the stop. After a - // parent crashed mid-deferral the service, pid and runtime records can all be absent - // while the shared client config still points at a proxy that is gone; installing over - // that silently skips the recovery the receipt was written to trigger (#3008). Presence - // is the whole test here — the launcher cannot parse it, and `ocx stop` is what decides - // whether the obligation is safe to finish. - const hasPendingTeardown = hasPendingTeardownIn(readdirSync, configDir()); - if (serviceWasInstalled || hasRuntimeState || hasPendingTeardown) { - console.log("⏹ Stopping the running proxy before updating..."); - const stopRes = spawnSync(process.execPath, [launcher, "stop"], { stdio: "inherit", windowsHide: true }); - const stillHasRuntimeState = - existsSync(join(configDir(), "ocx.pid")) || existsSync(join(configDir(), "runtime-port.json")); - // A history-only failure means teardown succeeded and a backup manifest is waiting for - // review: the proxy is down and replacing package files is safe. Every other nonzero - // status is a stop that did not finish, and a signal kill (status null) says nothing - // about whether it did - both abort, because replacing files under a live server - // leaves it running mixed old and new modules (#3008). - // The same decision the Bun updater makes, from the same module (#3008). Absent PID and - // runtime files are weak evidence, so the captured endpoint is asked; "unknown" aborts - // because a silent listener is exactly the state where replacing files is dangerous. - const decision = decidePostStopUpdate({ - status: stopRes.status, - hasRuntimeState: stillHasRuntimeState, - // Re-checked AFTER the stop: a quarantined receipt lets the stop itself succeed - // (there is nothing left to stop), so a pre-stop check alone let the retry install - // over a teardown that never ran. - teardownOutstanding: hasPendingTeardownIn(readdirSync, configDir()), - liveness: probeProxyLiveness(bakePort, bakeHostname), - }); - const historyOnlyStop = decision.reason === "history-only"; - if (!decision.proceed) { - if (trayBeforeUpdate.restoreOnFailure) runTrayLifecycle(launcher, "start"); - if (decision.reason === "teardown-outstanding") { - console.error("opencodex: a shared teardown from an earlier stop is still outstanding and needs manual review; aborting the update."); - console.error("opencodex: confirm no proxy is running, run 'ocx restore', then remove the pending-teardown file in the opencodex home."); - } else console.error(decision.reason === "proxy-unknown" - ? `opencodex: could not confirm the proxy on ${bakeHostname}:${bakePort} is stopped; aborting the update. Run 'ocx stop' and retry.` - : "opencodex: could not stop the running proxy; aborting the update. Run 'ocx stop' and retry."); + // An outstanding pending-teardown receipt is a fourth reason to run the stop. After a + // parent crashed mid-deferral the service, pid and runtime records can all be absent + // while the shared client config still points at a proxy that is gone; installing over + // that silently skips the recovery the receipt was written to trigger (#3008). Presence + // is the whole test here — the launcher cannot parse it, and `ocx stop` is what decides + // whether the obligation is safe to finish. + const hasPendingTeardown = hasPendingTeardownIn(readdirSync, configDir()); + const stopNeeded = serviceWasInstalled || hasRuntimeState || hasPendingTeardown; + if (stopNeeded && !runtimePlan.mayStopRuntime) { + releaseUpdateLease(); + console.error(runtimePlan.notice + ?? "opencodex: update stopped because this installation may not stop the current runtime."); process.exit(1); } - if (historyOnlyStop || historyRestoreIncomplete()) { - console.warn( - "opencodex: WARNING — Codex resume-history metadata restore is incomplete (a backup manifest remains).\n" + - " The DB may be busy or the manifest/target may need review; untracked routed history is intentionally unchanged.\n" + - " After the update: close the Codex app, run 'ocx doctor', then run 'ocx stop' once to retry.", - ); + if (stopNeeded) { + stopAttempted = true; + console.log("⏹ Stopping the running proxy before updating..."); + const stopRes = spawnSync(process.execPath, [launcher, "stop"], { + stdio: "inherit", windowsHide: true, env: mutationChildEnvironment(), + }); + const stillHasRuntimeState = + existsSync(join(configDir(), "ocx.pid")) || existsSync(join(configDir(), "runtime-port.json")); + // A history-only failure means teardown succeeded and a backup manifest is waiting for + // review: the proxy is down and replacing package files is safe. Every other nonzero + // status is a stop that did not finish, and a signal kill (status null) says nothing + // about whether it did - both abort, because replacing files under a live server + // leaves it running mixed old and new modules (#3008). + // The same decision the Bun updater makes, from the same module (#3008). Absent PID and + // runtime files are weak evidence, so the captured endpoint is asked; "unknown" aborts + // because a silent listener is exactly the state where replacing files is dangerous. + const decision = decidePostStopUpdate({ + status: stopRes.status, + hasRuntimeState: stillHasRuntimeState, + // Re-checked AFTER the stop: a quarantined receipt lets the stop itself succeed + // (there is nothing left to stop), so a pre-stop check alone let the retry install + // over a teardown that never ran. + teardownOutstanding: hasPendingTeardownIn(readdirSync, configDir()), + liveness: probeProxyLiveness(bakePort, bakeHostname), + }); + const historyOnlyStop = decision.reason === "history-only"; + if (!decision.proceed) { + if (trayBeforeUpdate.restoreOnFailure) runTrayLifecycle(launcher, "start"); + if (decision.reason === "teardown-outstanding") { + console.error("opencodex: a shared teardown from an earlier stop is still outstanding and needs manual review; aborting the update."); + console.error("opencodex: confirm no proxy is running, run 'ocx restore', then remove the pending-teardown file in the opencodex home."); + } else console.error(decision.reason === "proxy-unknown" + ? `opencodex: could not confirm the proxy on ${bakeHostname}:${bakePort} is stopped; aborting the update. Run 'ocx stop' and retry.` + : "opencodex: could not stop the running proxy; aborting the update. Run 'ocx stop' and retry."); + releaseUpdateLease(); + process.exit(1); + } + if (historyOnlyStop || historyRestoreIncomplete()) { + console.warn( + "opencodex: WARNING — Codex resume-history metadata restore is incomplete (a backup manifest remains).\n" + + " The DB may be busy or the manifest/target may need review; untracked routed history is intentionally unchanged.\n" + + " After the update: close the Codex app, run 'ocx doctor', then run 'ocx stop' once to retry.", + ); + } + if (decision.reason === "history-deferred") { + // The reported #4718 path is this lane. Nothing was restored, so this is a different + // sentence from the manifest warning above: an operator told "history metadata is + // incomplete" would assume config and catalog already came back. + console.warn( + "opencodex: WARNING — the shared teardown was refused by the Codex history preflight and restored nothing.\n" + + " Config, catalog, history and provenance were preserved, and the teardown receipt was kept.\n" + + " The proxy is down, so the update continues; close the Codex app and run 'ocx stop' once afterwards to finish the restore.", + ); + } } - if (decision.reason === "history-deferred") { - // The reported #4718 path is this lane. Nothing was restored, so this is a different - // sentence from the manifest warning above: an operator told "history metadata is - // incomplete" would assume config and catalog already came back. - console.warn( - "opencodex: WARNING — the shared teardown was refused by the Codex history preflight and restored nothing.\n" + - " Config, catalog, history and provenance were preserved, and the teardown receipt was kept.\n" + - " The proxy is down, so the update continues; close the Codex app and run 'ocx stop' once afterwards to finish the restore.", - ); + + const replacementOwnership = readOwnership(); + const replacementPlan = planUpdateRuntimeHandling({ ...replacementOwnership, serviceInstalled: serviceWasInstalled }); + const replacementLiveness = currentPackageRuntimeLiveness(); + if (replacementOwnership.subjectToken !== initialOwnership.subjectToken + || !replacementPlan.mayReplacePackage + || replacementLiveness !== "dead") { + recoverStoppedRuntimeAfterFailure("replacement was refused"); + releaseUpdateLease(); + if (trayBeforeUpdate.restoreOnFailure) runTrayLifecycle(launcher, "start"); + console.error(replacementPlan.notice + ?? "opencodex: update stopped because runtime ownership or liveness changed after the stop decision; rerun from the beginning."); + process.exit(1); } - } - // npm keeps the existing stage -> verify -> swap -> rollback flow. pnpm owns a - // content-addressable store and generated global shims, so its path uses pnpm's own - // global update operation and verifies the active group instead of renaming files. - console.log(`Updating${latest ? ` to v${latest}` : ""} (${manager === "npm" ? "transactional" : "pnpm-managed"})...`); - let res; - try { - if (manager === "npm") { - const packageDir = resolve(here, ".."); - const tx = transactionalNpmUpdate({ - packageDir, - pkgName: PKG, - targetVersion: latest || undefined, - tag, - runNpm: (args) => { - const invocation = npmInvocation(args); - if (!invocation) return { status: 1 }; - return spawnSync(invocation.file, invocation.args, { - stdio: "inherit", - timeout: 180000, - windowsHide: true, - ...invocation.options, - }); - }, - log: (line) => console.log(line), - }); - postUpdateLauncherUsable = tx.ok - || tx.rolledBack === true - || ["stage", "verify", "swap-backup"].includes(tx.phase); - if (tx.ok) { - res = { status: 0 }; - } else if (tx.phase === "stage" || tx.phase === "verify") { - // Live tree untouched: report and stop. Nothing to roll back. - console.error(`opencodex: update aborted before touching the live install (${tx.phase}): ${tx.error}`); - res = { status: 1 }; - } else { - console.error(`opencodex: update failed (${tx.phase}): ${tx.error}${tx.rolledBack ? " — previous version restored." : ""}`); - res = { status: 1 }; - } - } else { - const update = runPnpmGlobalUpdate({ - packageName: PKG, - currentVersion: current, - targetVersion: latest || undefined, - tag, - owner, - runningPackagePath: resolve(here, ".."), - runPnpm: (args, capture = false) => { - const invocation = pnpmOwnerInvocation(owner, args); - if (!invocation) return { status: 1 }; - return spawnSync(invocation.file, invocation.args, { - stdio: capture ? "pipe" : "inherit", - encoding: "utf8", - timeout: 180000, - windowsHide: true, - env: invocation.env, - ...invocation.options, - }); - }, - log: line => console.log(line), - }); - if (update.ok) { - // pnpm switches the active global group and updates its shim. Continue recovery - // through that fresh package tree, not the old group whose launcher is still - // executing this update. - postUpdateLauncher = join(update.path, "bin", "ocx.mjs"); - res = { status: 0 }; + // npm keeps the existing stage -> verify -> swap -> rollback flow. pnpm owns a + // content-addressable store and generated global shims, so its path uses pnpm's own + // global update operation and verifies the active group instead of renaming files. + console.log(`Updating${latest ? ` to v${latest}` : ""} (${manager === "npm" ? "transactional" : "pnpm-managed"})...`); + try { + if (manager === "npm") { + const packageDir = resolve(here, ".."); + const tx = transactionalNpmUpdate({ + packageDir, + pkgName: PKG, + targetVersion: latest || undefined, + tag, + runNpm: (args) => { + const invocation = npmInvocation(args); + if (!invocation) return { status: 1 }; + return spawnSync(invocation.file, invocation.args, { + ...invocation.options, + stdio: "inherit", + timeout: 180000, + windowsHide: true, + env: unprivilegedOwnershipMutationEnvironment(invocation.options?.env ?? process.env), + }); + }, + log: (line) => console.log(line), + }); + postUpdateLauncherUsable = tx.ok + || tx.rolledBack === true + || ["stage", "verify", "swap-backup"].includes(tx.phase); + if (tx.ok) { + res = { status: 0 }; + } else if (tx.phase === "stage" || tx.phase === "verify") { + // Live tree untouched: report and stop. Nothing to roll back. + console.error(`opencodex: update aborted before touching the live install (${tx.phase}): ${tx.error}`); + res = { status: 1 }; + } else { + console.error(`opencodex: update failed (${tx.phase}): ${tx.error}${tx.rolledBack ? " — previous version restored." : ""}`); + res = { status: 1 }; + } } else { - console.error(`opencodex: ${update.error}${update.rolledBack ? "." : " Manual recovery may be required."}`); - postUpdateLauncherUsable = Boolean(update.activePath); - if (update.activePath) postUpdateLauncher = join(update.activePath, "bin", "ocx.mjs"); - res = { status: 1 }; + const update = runPnpmGlobalUpdate({ + packageName: PKG, + currentVersion: current, + targetVersion: latest || undefined, + tag, + owner, + runningPackagePath: resolve(here, ".."), + runPnpm: (args, capture = false) => { + const invocation = pnpmOwnerInvocation(owner, args); + if (!invocation) return { status: 1 }; + return spawnSync(invocation.file, invocation.args, { + ...invocation.options, + stdio: capture ? "pipe" : "inherit", + encoding: "utf8", + timeout: 180000, + windowsHide: true, + env: unprivilegedOwnershipMutationEnvironment(invocation.env ?? process.env), + }); + }, + log: line => console.log(line), + }); + if (update.ok) { + // pnpm switches the active global group and updates its shim. Continue recovery + // through that fresh package tree, not the old group whose launcher is still + // executing this update. + postUpdateLauncher = join(update.path, "bin", "ocx.mjs"); + res = { status: 0 }; + } else { + console.error(`opencodex: ${update.error}${update.rolledBack ? "." : " Manual recovery may be required."}`); + postUpdateLauncherUsable = Boolean(update.activePath); + if (update.activePath) postUpdateLauncher = join(update.activePath, "bin", "ocx.mjs"); + res = { status: 1 }; + } } + } catch (error) { + // An unexpected throw means we cannot prove the live tree is untouched, so the + // legacy in-place install (which deletes live first) is exactly the wrong rescue — + // it recreates the #1849 destruction path. Report and stop; the boot probe and the + // recovery marker cover the swap-window states. + const manual = manager === "pnpm" + ? `pnpm add -g --allow-build=bun ${PKG}@${tag}` + : `npm install -g --allow-scripts=bun ${PKG}@${tag}`; + // An unexpected exception leaves the active package path unproven for either manager. + // Do not run service/tray/proxy recovery through a possibly half-swapped tree. + postUpdateLauncherUsable = false; + console.error(`opencodex: ${manager} update failed unexpectedly (${error?.message ?? error}). ` + + `The live install was not knowingly modified; run 'ocx update' again or reinstall with ${manual}.`); + res = { status: 1 }; } - } catch (error) { - // An unexpected throw means we cannot prove the live tree is untouched, so the - // legacy in-place install (which deletes live first) is exactly the wrong rescue — - // it recreates the #1849 destruction path. Report and stop; the boot probe and the - // recovery marker cover the swap-window states. - const manual = manager === "pnpm" - ? `pnpm add -g --allow-build=bun ${PKG}@${tag}` - : `npm install -g --allow-scripts=bun ${PKG}@${tag}`; - // An unexpected exception leaves the active package path unproven for either manager. - // Do not run service/tray/proxy recovery through a possibly half-swapped tree. - postUpdateLauncherUsable = false; - console.error(`opencodex: ${manager} update failed unexpectedly (${error?.message ?? error}). ` + - `The live install was not knowingly modified; run 'ocx update' again or reinstall with ${manual}.`); - res = { status: 1 }; + if (res.status !== 0) recoverStoppedRuntimeAfterFailure("update failed"); + } finally { + // Expected aborts release before process.exit(); this covers every thrown or newly-added + // path and keeps token restoration coupled to the lease itself. + releaseUpdateLease(); } + const postInstallPlan = planUpdateRuntimeHandling({ ...readOwnership(), serviceInstalled: serviceWasInstalled }); if (res.status === 0) { console.log(`\nUpdated${latest ? ` to v${latest}` : ""}.`); repairCodexShimIfNeeded(postUpdateLauncher); @@ -637,16 +811,15 @@ function runPackageManagerSelfUpdate(manager) { } // The stop above unloaded any managed service; refresh via the freshly-installed // launcher so the new files write the baked paths and the service restarts. - if (serviceWasInstalled) { + if (postInstallPlan.mayRestoreService) { console.log("Refreshing the background service with the updated files..."); refreshBackgroundServiceOrStartDirect(); - } else { + } else if (postInstallPlan.mayStopRuntime) { console.log(`Restart the proxy: ${launcherStartHint(postUpdateLauncher, bakePort)}`); } process.exit(0); } if (trayBeforeUpdate.restoreOnFailure && postUpdateLauncherUsable) runTrayLifecycle(postUpdateLauncher, "start"); - recoverStoppedRuntimeAfterFailure(); const manual = manager === "pnpm" ? `pnpm add -g --allow-build=bun ${PKG}@${tag}` : `npm install -g --allow-scripts=bun ${PKG}@${tag}`; diff --git a/desktop/README.md b/desktop/README.md new file mode 100644 index 00000000000..9e7fcc0eda5 --- /dev/null +++ b/desktop/README.md @@ -0,0 +1,90 @@ +# OpenCodex desktop shell + +The Tauri shell attaches to the local OpenCodex proxy and keeps the dashboard +in the proxy's loopback origin. During development: + +```sh +bun run prepare-sidecar +bun run prepare-widget +bunx tauri dev +``` + +The sidecar is generated from the repository's standalone binary build and is +not checked into git. + +The CI desktop-shell job performs Rust-only checks. It creates an empty +platform-named sidecar stub and a placeholder dashboard resource directory +solely for Tauri's external-binary and resource validation; it does not build +or run the standalone binary. + +For a macOS release build, prepare the sidecar and WidgetKit extension before invoking +Tauri: + +```sh +bun run prepare-sidecar +bun run prepare-widget +bunx tauri build +``` + +## Building locally without signing keys + +`bunx tauri build` always produces the updater archive and then refuses to finish without +`TAURI_SIGNING_PRIVATE_KEY`, so a local build ends on `A public key has been found, but no private +key` **after** writing `OpenCodex.app` and the dmg. That exit code is right for a release and +misleading on a workstation. + +```sh +bun run build:local +``` + +This asks for the host platform's installable bundles only (app and dmg on macOS, msi and nsis +setup exe on Windows, AppImage and deb on Linux), so no updater archive is produced and none is +expected to be signed. Each format is attempted in its own invocation: a format this machine +cannot bundle (for example an AppImage when a linuxdeploy dependency is missing) fails on its own +line without destroying the formats that do build, the failing format is retried once with +`--verbose` so the bundler's own diagnostics are visible, and the summary prints every format's +outcome beside the artifacts that were produced. The exit code is non-zero if any format failed. +The release path below is unchanged: a published +updater artifact still has to be signed. + +## Release packaging and updates + +The release workflow builds a macOS DMG, Windows MSI, Linux AppImage, and Debian package. +It collects the platform artifacts beside checksum files and creates `latest.json` for the +Tauri updater. The public updater key and endpoint live in `src-tauri/tauri.conf.json`; +the private key must never be committed. The manifest is generated only when the updater +key secret is configured and then requires all four platforms to be signed. + +To package locally: + +```sh +bun run build:gui +cd desktop +bun install --frozen-lockfile +bun run prepare-sidecar +bun run prepare-widget +bunx tauri build --ci --bundles app,dmg +``` + +Release signing is supplied through environment variables: + +```sh +export TAURI_SIGNING_PRIVATE_KEY="..." +export TAURI_SIGNING_PRIVATE_KEY_PASSWORD="..." +export APPLE_CERTIFICATE="..." +export APPLE_CERTIFICATE_PASSWORD="..." +export APPLE_SIGNING_IDENTITY="Developer ID Application: Your Name (TEAMID)" +export APPLE_ID="..." +export APPLE_PASSWORD="..." +export APPLE_TEAM_ID="..." +export MACOS_SIGN_IDENTITY="$APPLE_SIGNING_IDENTITY" +``` + +Generate a Tauri updater key pair with: + +```sh +bunx tauri signer generate +``` + +Keep the private key in a local secret store. Windows SmartScreen signing is not wired +yet; the release workflow documents that installers may show an unsigned-publisher warning. diff --git a/desktop/package.json b/desktop/package.json new file mode 100644 index 00000000000..5966c9235f2 --- /dev/null +++ b/desktop/package.json @@ -0,0 +1,16 @@ +{ + "name": "@opencodex/desktop", + "private": true, + "scripts": { + "dev": "tauri dev", + "build": "tauri build", + "build:local": "bun scripts/build-local.ts", + "icons": "bun scripts/generate-icons.ts", + "icons:check": "bun scripts/generate-icons.ts --check", + "prepare-sidecar": "bun scripts/prepare-sidecar.ts", + "prepare-widget": "bash scripts/build-widget.sh" + }, + "devDependencies": { + "@tauri-apps/cli": "2.11.1" + } +} diff --git a/desktop/scripts/build-local.ts b/desktop/scripts/build-local.ts new file mode 100644 index 00000000000..634c92ffbce --- /dev/null +++ b/desktop/scripts/build-local.ts @@ -0,0 +1,161 @@ +#!/usr/bin/env bun +/** + * Unsigned local bundle build. + * + * `tauri build` always produces the updater archive, because `bundle.createUpdaterArtifacts` is + * true and `plugins.updater.pubkey` is set. Without `TAURI_SIGNING_PRIVATE_KEY` it then refuses to + * finish: + * + * Finished 2 bundles at: .../OpenCodex.app, .../OpenCodex_2.61.0_aarch64.dmg + * A public key has been found, but no private key. + * Error failed to build app + * + * Both bundles exist at that point. The non-zero exit is correct for a release — an unsigned + * updater artifact reaching users is worse than a failed build — but for someone building on their + * own machine it reports a failure for a signing step they were never meant to perform, and a + * wrapper script cannot tell it apart from a real failure. + * + * So this does not relax the check. It turns the updater artifact off for this one invocation, so + * there is nothing to sign and nothing is skipped unsigned. Selecting bundle targets is not enough: + * `createUpdaterArtifacts` is a config flag, so `--bundles app,dmg` still produces + * `OpenCodex.app.tar.gz (updater)` and still fails. The override has to reach the config itself. + * + * Two more local-only behaviours, learned from a real GNOME desktop (devlog plan 260921, + * 120_install_verification.md): + * + * - Formats build in SEPARATE invocations. A single `--bundles appimage,deb` call dies on the + * first failing format, so a host that cannot bundle an AppImage (a missing linuxdeploy + * dependency) also lost the deb it could have built. Each format is attempted, and the + * summary at the end names every format's outcome; the exit code is non-zero if any of + * them failed, and the artifacts that DID build are printed either way. + * - A failing format is retried once with `--verbose`. At the bundler's default log level + * the error is a bare "failed to run linuxdeploy" with the tool's own diagnostics + * discarded; the verbose pass is the branch where that stderr actually reaches the + * terminal, so the failure says WHY instead of naming a tool nobody invoked. + */ +import { spawnSync } from "node:child_process"; +import { existsSync, readdirSync, statSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const desktopDir = dirname(dirname(fileURLToPath(import.meta.url))); + +/** Bundle targets per host platform that carry no updater archive. */ +const LOCAL_BUNDLES: Record = { + darwin: ["app", "dmg"], + win32: ["msi", "nsis"], + linux: ["appimage", "deb"], +}; + +/** + * Config merged over `tauri.conf.json` for this invocation only. + * + * Turning the artifact off is what makes the signing key unnecessary, rather than leaving it + * required and unmet. The committed config keeps `createUpdaterArtifacts: true`, so the release + * build is untouched. + */ +const LOCAL_CONFIG = JSON.stringify({ bundle: { createUpdaterArtifacts: false } }); + +export interface SpawnResult { + status: number | null; + error?: Error; +} + +export interface ArtifactEntry { + path: string; + mtimeMs: number; +} + +export interface BuildLocalDeps { + spawn(args: string[]): SpawnResult; + log(line: string): void; + error(line: string): void; + listArtifacts(): ArtifactEntry[]; + argv: string[]; + platform: string; +} + +export interface FormatAttempt { + format: string; + status: number; +} + +export function summarizeAttempts(attempts: FormatAttempt[]): { exitCode: number; lines: string[] } { + const lines = attempts.map( + attempt => `[build:local] ${attempt.format}: ${attempt.status === 0 ? "ok" : `FAILED (exit ${attempt.status})`}`, + ); + return { exitCode: attempts.every(attempt => attempt.status === 0) ? 0 : 1, lines }; +} + +export function runBuildLocal(deps: BuildLocalDeps): number { + const bundles = LOCAL_BUNDLES[deps.platform]; + if (!bundles) { + deps.error(`[build:local] unsupported host platform: ${deps.platform}`); + return 1; + } + // Snapshot before building: a bundle directory that already holds last week's AppImage + // must not be reported as this run's output when this run's AppImage attempt fails. + const baseline = new Map(deps.listArtifacts().map(entry => [entry.path, entry.mtimeMs])); + const attempts: FormatAttempt[] = []; + for (const format of bundles) { + // One invocation per format: a format this host cannot build must not destroy the + // artifacts of formats it can. + const args = ["tauri", "build", "--ci", "--bundles", format, "--config", LOCAL_CONFIG, ...deps.argv]; + const first = deps.spawn(args); + let status = first.status ?? 1; + if (first.error) { + deps.error(`[build:local] could not start tauri: ${first.error.message}`); + status = 1; + } else if (status !== 0) { + // The bundler reports a bare "failed to run " at its default log level; the + // verbose pass is where the tool's own stderr reaches the terminal. The retry is + // diagnostics only — the recorded status stands either way. + deps.error(`[build:local] ${format} failed; rerunning with --verbose for the bundler's diagnostics`); + const retry = deps.spawn(["tauri", "--verbose", "build", "--ci", "--bundles", format, "--config", LOCAL_CONFIG, ...deps.argv]); + if (retry.error) deps.error(`[build:local] could not start tauri: ${retry.error.message}`); + } + attempts.push({ format, status }); + } + // Name what THIS run produced even when something failed: an error line at the end is + // the least visible place for artifacts that already built. + const produced = deps.listArtifacts().filter( + entry => !baseline.has(entry.path) || baseline.get(entry.path) !== entry.mtimeMs, + ); + for (const entry of produced) deps.log(`[build:local] ${entry.path}`); + const summary = summarizeAttempts(attempts); + for (const line of summary.lines) deps.log(line); + if (summary.exitCode === 0) { + deps.log("[build:local] updater artifacts skipped; release signing is unchanged."); + } + return summary.exitCode; +} + +function main(): void { + const status = runBuildLocal({ + spawn: args => spawnSync("bunx", args, { cwd: desktopDir, stdio: "inherit" }), + log: line => console.log(line), + error: line => console.error(line), + listArtifacts: () => { + const bundleRoot = join(desktopDir, "src-tauri", "target", "release", "bundle"); + const artifacts: ArtifactEntry[] = []; + for (const dir of ["macos", "dmg", "msi", "nsis", "appimage", "deb"]) { + const directory = join(bundleRoot, dir); + if (!existsSync(directory)) continue; + for (const name of readdirSync(directory)) { + if (/\.(app|dmg|msi|exe|AppImage|deb)$/i.test(name)) { + const full = join(directory, name); + artifacts.push({ path: full, mtimeMs: statSync(full).mtimeMs }); + } + } + } + return artifacts; + }, + argv: process.argv.slice(2), + platform: process.platform, + }); + process.exit(status); +} + +if (import.meta.main) { + main(); +} diff --git a/desktop/scripts/build-widget.sh b/desktop/scripts/build-widget.sh new file mode 100755 index 00000000000..38e0eb9d713 --- /dev/null +++ b/desktop/scripts/build-widget.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ "$(uname -s)" != "Darwin" ]]; then + echo "prepare-widget requires macOS." >&2 + exit 1 +fi + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +desktop_dir="$(cd "$script_dir/.." && pwd)" +repo_root="$(cd "$desktop_dir/.." && pwd)" +package_dir="$repo_root/app" +output_dir="$desktop_dir/src-tauri/widget/OpenCodexWidget.appex" +configuration="${CONFIGURATION:-release}" +universal="${UNIVERSAL:-1}" + +if [[ "$universal" != "0" && "$universal" != "1" ]]; then + echo "UNIVERSAL must be 0 or 1." >&2 + exit 1 +fi + +# A widget extension is loaded by the system, not by the app, so it is validated on its own +# terms: notarization rejects any Mach-O inside it that lacks the hardened runtime, and macOS +# refuses to register an extension whose signature does not chain to the containing app's team. +# An ad-hoc signature satisfies neither, and the ad-hoc branch is the default whenever no +# identity reaches this script. Resolve that before the build so a misconfigured release fails +# in a second rather than after a universal Swift build. +if [[ -n "${MACOS_SIGN_IDENTITY:-}" ]]; then + sign_identity="$MACOS_SIGN_IDENTITY" + timestamp_arg=(--timestamp) +elif [[ "${WIDGET_SIGN_REQUIRED:-0}" == "1" ]]; then + # A release that signs everything else and ad-hoc signs the widget produces an app that + # ships either way and simply has no widget. Refuse instead. + echo "WIDGET_SIGN_REQUIRED=1 but MACOS_SIGN_IDENTITY is empty; refusing to ad-hoc sign a release widget." >&2 + exit 1 +else + sign_identity="-" + timestamp_arg=(--timestamp=none) +fi + +build_root="$(mktemp -d "${TMPDIR:-/tmp}/opencodex-widget.XXXXXX")" +cleanup() { rm -rf "$build_root"; } +trap cleanup EXIT + +build_widget() { + local arch="$1" + local scratch="$build_root/$arch" + swift build \ + --package-path "$package_dir" \ + --scratch-path "$scratch" \ + -c "$configuration" \ + --arch "$arch" \ + --product OpenCodexWidget + swift build \ + --package-path "$package_dir" \ + --scratch-path "$scratch" \ + -c "$configuration" \ + --arch "$arch" \ + --show-bin-path +} + +if [[ "$universal" == "1" ]]; then + arm64_bin="$(build_widget arm64 | tail -n 1)/OpenCodexWidget" + x86_64_bin="$(build_widget x86_64 | tail -n 1)/OpenCodexWidget" + executable="$build_root/OpenCodexWidget" + lipo -create "$arm64_bin" "$x86_64_bin" -output "$executable" +else + executable="$(build_widget "$(uname -m)" | tail -n 1)/OpenCodexWidget" +fi + +[[ -x "$executable" ]] || { echo "Swift build did not produce $executable" >&2; exit 1; } + +rm -rf "$output_dir" +mkdir -p "$output_dir/Contents/MacOS" +cp "$executable" "$output_dir/Contents/MacOS/OpenCodexWidget" +cp "$package_dir/Widget-Info.plist" "$output_dir/Contents/Info.plist" + +version="$(sed -n 's/^[[:space:]]*"version": "\([^"]*\)",/\1/p' "$desktop_dir/src-tauri/tauri.conf.json" | head -n 1)" +version_core="${version%%-*}" +[[ "$version_core" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { + echo "Invalid Tauri version: $version" >&2 + exit 1 +} +plutil -replace CFBundleShortVersionString -string "$version_core" "$output_dir/Contents/Info.plist" +plutil -replace CFBundleVersion -string "$version_core" "$output_dir/Contents/Info.plist" + +# Sign inside out over every Mach-O the bundle actually contains, chosen by magic bytes rather +# than by name. Today that set is the single widget executable, but a name or extension filter +# is the thing that fails silently when it stops being true: a helper tool or an embedded +# dylib carries no suffix to match, stays unsigned, and the whole submission comes back +# "The binary is not signed with a valid Developer ID certificate" with the bundle itself +# looking perfectly signed. +mach_o_members=() +while IFS= read -r candidate; do + [[ "$(file -b "$candidate")" == *"Mach-O"* ]] || continue + mach_o_members+=("$candidate") +done < <(find "$output_dir" -type f -not -path "*/_CodeSignature/*") + +[[ ${#mach_o_members[@]} -gt 0 ]] || { echo "No Mach-O binary found in $output_dir" >&2; exit 1; } + +for member in "${mach_o_members[@]}"; do + codesign --force --sign "$sign_identity" --options runtime "${timestamp_arg[@]}" "$member" +done + +# The bundle seal goes on last and is the only signature that carries the entitlements. +codesign --force --sign "$sign_identity" --entitlements "$package_dir/Widget.entitlements" \ + --options runtime "${timestamp_arg[@]}" "$output_dir" + +codesign --verify --deep --strict "$output_dir" +# `runtime` is 0x10000 in the code directory flags. Asserting it here is what turns a silently +# unnotarizable widget into a failed build. The output is captured rather than piped into a +# matcher: `set -o pipefail` plus a matcher that exits on its first hit makes codesign die of +# SIGPIPE, and the check then fails on exactly the signatures it was meant to accept. +signature_display="$(codesign --display --verbose=4 "$output_dir" 2>&1)" +case "$signature_display" in + *"flags="*"runtime"*) ;; + *) + echo "Widget signature is missing the hardened runtime:" >&2 + echo "$signature_display" >&2 + exit 1 + ;; +esac + +echo "$output_dir" diff --git a/desktop/scripts/collect-release-assets.ts b/desktop/scripts/collect-release-assets.ts new file mode 100644 index 00000000000..2c97de39d44 --- /dev/null +++ b/desktop/scripts/collect-release-assets.ts @@ -0,0 +1,105 @@ +import { createHash } from "node:crypto"; +import { + copyFileSync, + existsSync, + mkdirSync, + readFileSync, + readdirSync, + writeFileSync, +} from "node:fs"; +import { join, resolve } from "node:path"; + +type BundleKind = "dmg" | "app.tar.gz" | "msi" | "appimage" | "deb"; + +export interface BundleSpec { + kind: BundleKind; + dir: string; + name: string; +} + +export const bundlesByTarget: Record = { + "universal-apple-darwin": [ + { kind: "dmg", dir: "dmg", name: "macos.dmg" }, + { kind: "app.tar.gz", dir: "macos", name: "macos.app.tar.gz" }, + ], + "aarch64-apple-darwin": [ + { kind: "dmg", dir: "dmg", name: "macos.dmg" }, + { kind: "app.tar.gz", dir: "macos", name: "macos.app.tar.gz" }, + ], + "x86_64-apple-darwin": [ + { kind: "dmg", dir: "dmg", name: "macos.dmg" }, + { kind: "app.tar.gz", dir: "macos", name: "macos.app.tar.gz" }, + ], + "x86_64-pc-windows-msvc": [{ kind: "msi", dir: "msi", name: "windows-x64.msi" }], + "x86_64-unknown-linux-gnu": [ + { kind: "appimage", dir: "appimage", name: "linux-x86_64.AppImage" }, + { kind: "deb", dir: "deb", name: "linux-amd64.deb" }, + ], +}; + +export interface CollectReleaseAssetsOptions { + version: string; + target: string; + out: string; + repoRoot?: string; +} + +function findBundle(directory: string, kind: BundleKind): string { + if (!existsSync(directory)) { + throw new Error(`Missing ${kind} bundle directory: ${directory}`); + } + const artifact = readdirSync(directory) + .filter(name => name.toLowerCase().endsWith(`.${kind.toLowerCase()}`)); + if (artifact.length === 0) throw new Error(`No ${kind} bundle found in ${directory}`); + if (artifact.length > 1) { + throw new Error(`Multiple ${kind} bundles found in ${directory}: ${artifact.join(", ")}`); + } + return join(directory, artifact[0]); +} + +export function collectReleaseAssets(options: CollectReleaseAssetsOptions): string[] { + const repoRoot = resolve(options.repoRoot ?? join(import.meta.dir, "../..")); + const bundles = bundlesByTarget[options.target]; + if (!bundles) throw new Error(`Unsupported desktop target: ${options.target}`); + + const output = resolve(options.out); + mkdirSync(output, { recursive: true }); + const written: string[] = []; + for (const bundle of bundles) { + const source = findBundle( + join(repoRoot, "desktop", "src-tauri", "target", options.target, "release", "bundle", bundle.dir), + bundle.kind, + ); + const destinationName = `OpenCodex-${options.version}-${bundle.name}`; + const destination = join(output, destinationName); + copyFileSync(source, destination); + written.push(destination); + + const signature = `${source}.sig`; + if (existsSync(signature)) { + copyFileSync(signature, `${destination}.sig`); + written.push(`${destination}.sig`); + } + + const digest = createHash("sha256").update(readFileSync(destination)).digest("hex"); + const checksum = `${destination}.sha256`; + writeFileSync(checksum, `${digest} ${destinationName}\n`); + written.push(checksum); + } + return written; +} + +function argument(name: string): string | undefined { + const index = Bun.argv.indexOf(name); + return index < 0 ? undefined : Bun.argv[index + 1]; +} + +if (import.meta.main) { + const version = argument("--version"); + const target = argument("--target"); + const out = argument("--out"); + if (!version || !target || !out) { + throw new Error("Usage: collect-release-assets.ts --version --target --out "); + } + for (const path of collectReleaseAssets({ version, target, out })) console.log(`Wrote ${path}`); +} diff --git a/desktop/scripts/generate-icons.ts b/desktop/scripts/generate-icons.ts new file mode 100644 index 00000000000..3acfdc8bb92 --- /dev/null +++ b/desktop/scripts/generate-icons.ts @@ -0,0 +1,178 @@ +#!/usr/bin/env bun +/** + * Render every app icon from `src-tauri/icons/icon.svg`. + * + * The icon set used to be eighteen independent raster files with no vector source, so each size + * was a separate artifact that could drift from the others and nothing could detect it. This makes + * the sizes derived: one curve, rendered at each dimension the platforms ask for. + * + * The SVG reproduces the raster it replaced to within antialiasing (430 of 262144 pixels at 512), + * measured rather than assumed — the geometry in that file was read off the original bitmap. + * + * `--check` regenerates into a temporary directory and compares, so CI can fail on a hand-edited + * PNG instead of letting the source and the shipped icons disagree quietly. + */ +import { spawnSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { buildIco, render as renderSvg } from "../../scripts/lib/icon-render"; + +const desktopDir = dirname(dirname(fileURLToPath(import.meta.url))); +const iconsDir = join(desktopDir, "src-tauri", "icons"); +const source = join(iconsDir, "icon.svg"); + +/** Square PNGs Tauri and the Windows store manifests reference, by output filename. */ +const PNG_SIZES: Record = { + "32x32.png": 32, + "64x64.png": 64, + "128x128.png": 128, + "128x128@2x.png": 256, + "icon.png": 512, + "Square30x30Logo.png": 30, + "Square44x44Logo.png": 44, + "Square71x71Logo.png": 71, + "Square89x89Logo.png": 89, + "Square107x107Logo.png": 107, + "Square142x142Logo.png": 142, + "Square150x150Logo.png": 150, + "Square284x284Logo.png": 284, + "Square310x310Logo.png": 310, + "StoreLogo.png": 50, +}; + +/** Sizes an .icns carries, as iconutil names them. */ +const ICNS_ENTRIES: Array<{ name: string; size: number }> = [ + { name: "icon_16x16.png", size: 16 }, + { name: "icon_16x16@2x.png", size: 32 }, + { name: "icon_32x32.png", size: 32 }, + { name: "icon_32x32@2x.png", size: 64 }, + { name: "icon_128x128.png", size: 128 }, + { name: "icon_128x128@2x.png", size: 256 }, + { name: "icon_256x256.png", size: 256 }, + { name: "icon_256x256@2x.png", size: 512 }, + { name: "icon_512x512.png", size: 512 }, + { name: "icon_512x512@2x.png", size: 1024 }, +]; + +/** Sizes packed into the .ico, which stores each one as an embedded PNG. */ +const ICO_SIZES = [16, 32, 48, 64, 128, 256]; + +/** + * The menu bar image, which is the same mark with no backdrop and the prompt cut through. + * + * It needs its own source because a status item is a template image: macOS reads the alpha as + * coverage and paints it with the menu bar tint, so the backdrop has to be gone rather than + * recoloured. 44px is 22pt at @2x, which is the menu bar working height and the size this asset + * already shipped at. + */ +const TRAY_OUTPUT = "tray/icon.png"; +const TRAY_SIZE = 44; +const traySource = join(iconsDir, "tray", "icon.svg"); + +/** Render at `size` from `from`, defaulting to the app icon vector. */ +function render(size: number, out: string, from: string = source): void { + renderSvg(size, out, from); +} + +/** + * Render the whole set into `target`, and report which artifacts were actually produced. + * + * The return value matters: `iconutil` is macOS-only, so on another platform no `.icns` exists to + * compare against. Reporting that is the difference between "the icns matches" and "nothing looked + * at the icns", and the check must not spell the second as the first. + */ +function generateInto(target: string): { produced: string[]; icnsSkipped: boolean } { + mkdirSync(target, { recursive: true }); + const produced: string[] = []; + for (const [name, size] of Object.entries(PNG_SIZES)) { + render(size, join(target, name)); + produced.push(name); + } + + const iconset = join(target, "icon.iconset"); + mkdirSync(iconset, { recursive: true }); + for (const entry of ICNS_ENTRIES) render(entry.size, join(iconset, entry.name)); + const icns = spawnSync("iconutil", ["-c", "icns", iconset, "-o", join(target, "icon.icns")]); + const icnsSkipped = icns.status !== 0; + if (!icnsSkipped) produced.push("icon.icns"); + rmSync(iconset, { recursive: true, force: true }); + + const icoParts: Array<{ size: number; bytes: Buffer }> = []; + for (const size of ICO_SIZES) { + const scratch = join(target, `.ico-${size}.png`); + render(size, scratch); + icoParts.push({ size, bytes: readFileSync(scratch) }); + rmSync(scratch, { force: true }); + } + writeFileSync(join(target, "icon.ico"), buildIco(icoParts)); + produced.push("icon.ico"); + + mkdirSync(join(target, "tray"), { recursive: true }); + render(TRAY_SIZE, join(target, TRAY_OUTPUT), traySource); + produced.push(TRAY_OUTPUT); + + return { produced, icnsSkipped }; +} + +function main(): number { + if (!existsSync(source)) { + console.error(`[icons] missing source: ${source}`); + return 1; + } + if (!existsSync(traySource)) { + console.error(`[icons] missing source: ${traySource}`); + return 1; + } + const check = process.argv.includes("--check"); + if (!check) { + // Render into scratch first so a failure half way through cannot leave the committed set + // partly replaced, then move the finished artifacts over in one pass. + const scratch = mkdtempSync(join(tmpdir(), "ocx-icons-")); + try { + const { produced, icnsSkipped } = generateInto(scratch); + if (icnsSkipped) { + // Abort before touching the committed set. Copying the PNGs and the .ico and then + // reporting the missing .icns would leave the icons half regenerated: the rasters new, + // the .icns whatever it was, and no way to tell from the tree which is which. + console.error("[icons] iconutil is unavailable here, so the .icns cannot be regenerated."); + console.error("[icons] nothing was written; run this on a machine with iconutil."); + return 1; + } + for (const name of produced) writeFileSync(join(iconsDir, name), readFileSync(join(scratch, name))); + console.log(`[icons] regenerated ${produced.length} artifacts from ${source}`); + return 0; + } finally { + rmSync(scratch, { recursive: true, force: true }); + } + } + + const scratch = mkdtempSync(join(tmpdir(), "ocx-icons-")); + try { + const { produced, icnsSkipped } = generateInto(scratch); + const drifted: string[] = []; + for (const name of produced) { + const fresh = join(scratch, name); + const committed = join(iconsDir, name); + if (!existsSync(committed) || !readFileSync(fresh).equals(readFileSync(committed))) { + drifted.push(name); + } + } + if (drifted.length > 0) { + console.error(`[icons] these do not match their source: ${drifted.join(", ")}`); + console.error("[icons] regenerate with: bun run icons"); + return 1; + } + console.log(`[icons] ${produced.length} generated icons match the source`); + if (icnsSkipped) { + console.error("[icons] iconutil is unavailable here, so icon.icns was NOT compared."); + return 1; + } + return 0; + } finally { + rmSync(scratch, { recursive: true, force: true }); + } +} + +process.exit(main()); diff --git a/desktop/scripts/installed-gate-platforms.ts b/desktop/scripts/installed-gate-platforms.ts new file mode 100644 index 00000000000..0fd10bf95d1 --- /dev/null +++ b/desktop/scripts/installed-gate-platforms.ts @@ -0,0 +1,407 @@ +/** + * Platform adapters for the installed-artifact gate (D9, part two). + * + * Adapters turn platform actions into command specs the gate engine runs and records; + * they never hardcode machine detail. Artifact paths, homes and ports arrive as + * arguments. GUI automation the OS cannot reach (the in-page consent dialog, tray + * clicks on some desktops) is supplied by the operator as pre-installed hook files, + * never as dispatch-provided command text — a persistent self-hosted runner must not + * become an arbitrary-execution surface. + * + * The external commands each adapter needs are declared in dependencies() so a runner + * can be audited for readiness before an artifact is ever installed on it. + */ + +export type GatePlatform = "macos" | "windows" | "linux"; +export type GateFormat = "dmg" | "msi" | "deb" | "appimage"; + +import { join } from "node:path"; + +export interface CommandSpec { + file: string; + args: string[]; +} + +export interface ProcessEvidence { + ok: boolean; + exitCode: number | null; + stdout: string; + stderr: string; +} + +export interface InstallResult { + appBinary: string; + packageName?: string; + /** Path scope that identifies THIS install's processes (install dir or binary path). */ + scope: string; + evidence: Record; +} + +export interface PlatformAdapter { + platform: GatePlatform; + /** Every external command this adapter shells out to; the engine preflights them. */ + dependencies(): string[]; + /** The staged npm ocx launcher inside an npm --prefix install. */ + npmLauncher(prefix: string): string; + /** Registers and starts the staged npm runtime as a managed service. */ + serviceInstall(launcher: string): CommandSpec; + serviceUninstall(launcher: string): CommandSpec; + /** + * Three-state registration answer: an existing registration is "present", a clean + * not-found is "absent", and any probe failure that cannot be told apart is + * "unknown" — the engine refuses to mutate on unknown. + */ + registrationState(): Promise<"present" | "absent" | "unknown">; + /** + * On-disk registration files, relative to the runner account's home. A registration + * that is unloaded, disabled or not yet loaded leaves these behind, and the manager + * probes above miss exactly those states. + */ + registrationFiles(): string[]; + /** + * Read-only probe for a dormant installation (MSI registry entry, dpkg record) the + * gate must refuse to overwrite. Null where installs land inside the work dir. + */ + existingInstallation(format: GateFormat, packageName?: string): CommandSpec | null; + /** Installs the real artifact; returns the app executable path. */ + installArtifact(artifact: string, workDir: string, format: GateFormat): Promise; + /** Drives the installed app's window-close gesture. */ + closeGesture(): CommandSpec; + /** Drives the installed app's OS-quit gesture (Cmd+Q, Alt+F4). */ + quitGesture(): CommandSpec; + /** Left-clicks the tray icon (the shell shows the dashboard window), or null. */ + trayClick(): CommandSpec | null; + /** Opens the tray menu and chooses Quit (the drain-then-exit path), or null. */ + trayQuit(): CommandSpec | null; + /** Chooses Check for Updates in the tray menu, or null. */ + trayCheck(): CommandSpec | null; + /** Chooses the enabled Install update item in the tray menu, or null. */ + trayInstall(): CommandSpec | null; + /** Exits zero only when the app currently has a visible window. */ + windowVisible(): CommandSpec; + /** Installed package version probe, where the platform has one (deb), or null. */ + installedVersion(format: GateFormat, packageName?: string): CommandSpec | null; + /** Dismisses exactly the authorization prompts the gate sighted, by pid, or null. */ + cancelElevation(pids: number[]): CommandSpec | null; + /** + * Lists pids of any elevation prompt surface — pkexec, and the zenity/kdialog + * password dialogs the updater plugin falls back to after a pkexec cancel. + * Null where the platform has no package-manager elevation (macOS, Windows). + */ + elevationProbe(): CommandSpec | null; + /** Lists pids whose executable lives under the given install scope. */ + appProcessProbe(scope: string): CommandSpec; + /** Lists pids of ANY installed copy of the app — the preflight's broad probe. */ + appNameProbe(): CommandSpec; + /** Lists direct child pids of the given process. */ + childPids(pid: number): CommandSpec; + /** Removes what installArtifact placed on the machine. */ + uninstall(artifact: string, workDir: string, format: GateFormat, packageName?: string): CommandSpec[]; +} + +export interface AdapterRuntime { + run(spec: CommandSpec): Promise; + mkdir(path: string): void; + fileExists(path: string): boolean; + homeDir(): string; +} + +function requireOk(step: string, result: ProcessEvidence): void { + if (!result.ok) { + throw new Error(`${step} failed (exit ${result.exitCode}): ${result.stderr.trim() || result.stdout.trim()}`); + } +} + +/** macOS: dmg install, AppleScript gestures scoped to the OpenCodex process, launchd. */ +export function macosAdapter(runtime: AdapterRuntime): PlatformAdapter { + const run = runtime.run.bind(runtime); + return { + platform: "macos", + dependencies: () => ["hdiutil", "osascript", "pgrep", "launchctl", "cp", "rm", "/usr/libexec/PlistBuddy"], + npmLauncher: prefix => `${prefix}/node_modules/.bin/ocx`, + serviceInstall: launcher => ({ file: launcher, args: ["service", "install"] }), + serviceUninstall: launcher => ({ file: launcher, args: ["service", "uninstall"] }), + registrationState: async () => { + if (runtime.fileExists(join(runtime.homeDir(), "Library/LaunchAgents/com.opencodex.proxy.plist"))) return "present"; + const probe = await run({ file: "launchctl", args: ["list", "com.opencodex.proxy"] }); + if (probe.ok) return "present"; + // "Could not find service" is a clean absence; anything else is unknowable here. + return /could not find/i.test(probe.stderr) ? "absent" : "unknown"; + }, + registrationFiles: () => ["Library/LaunchAgents/com.opencodex.proxy.plist"], + // A dmg install lands inside the gate's work dir; there is no system-level record. + existingInstallation: () => null, + async installArtifact(artifact, workDir) { + const mount = `${workDir}/dmg-mount`; + const apps = `${workDir}/Applications`; + runtime.mkdir(mount); + runtime.mkdir(apps); + requireOk("dmg attach", await run({ file: "hdiutil", args: ["attach", artifact, "-mountpoint", mount, "-nobrowse", "-readonly"] })); + try { + requireOk("app copy", await run({ file: "cp", args: ["-R", `${mount}/OpenCodex.app`, `${apps}/`] })); + } finally { + await run({ file: "hdiutil", args: ["detach", mount] }); + } + // The executable name is the bundle's own declaration, not a guess: a rename in + // packaging lands here without a driver change (#5351 removed this hardcode once). + const plist = await run({ + file: "/usr/libexec/PlistBuddy", + args: ["-c", "Print :CFBundleExecutable", `${apps}/OpenCodex.app/Contents/Info.plist`], + }); + requireOk("read CFBundleExecutable", plist); + const executable = plist.stdout.trim(); + return { + appBinary: `${apps}/OpenCodex.app/Contents/MacOS/${executable}`, + scope: apps, + evidence: { mounted: mount, copiedTo: apps, executable }, + }; + }, + closeGesture: () => appleScript( + 'tell application "OpenCodex" to activate', + 'tell application "System Events" to keystroke "w" using command down', + ), + quitGesture: () => appleScript( + 'tell application "OpenCodex" to activate', + 'tell application "System Events" to keystroke "q" using command down', + ), + // Menu bar items belong to their owning process; clicking by global index can hit an + // unrelated tray, so every tray action is scoped to the OpenCodex process. + trayClick: () => appleScript( + 'tell application "System Events" to tell process "OpenCodex" to click menu bar item 1 of menu bar 2', + ), + trayQuit: () => appleScript( + 'tell application "System Events" to tell process "OpenCodex" to click menu bar item 1 of menu bar 2', + 'tell application "System Events" to tell process "OpenCodex" to click menu item "Quit" of menu 1 of menu bar item 1 of menu bar 2', + ), + trayCheck: () => appleScript( + 'tell application "System Events" to tell process "OpenCodex" to click menu bar item 1 of menu bar 2', + 'tell application "System Events" to tell process "OpenCodex" to click menu item "Check for Updates…" of menu 1 of menu bar item 1 of menu bar 2', + ), + trayInstall: () => appleScript( + 'tell application "System Events" to tell process "OpenCodex" to click menu bar item 1 of menu bar 2', + 'tell application "System Events" to tell process "OpenCodex" to click (first menu item of menu 1 of menu bar item 1 of menu bar 2 whose name starts with "Install update")', + ), + windowVisible: () => appleScript('tell application "System Events" to count (windows of process "OpenCodex")'), + installedVersion: () => null, + cancelElevation: () => null, + elevationProbe: () => null, + appProcessProbe: scope => ({ file: "pgrep", args: ["-f", scope] }), + appNameProbe: () => ({ file: "pgrep", args: ["-f", "OpenCodex.app/Contents/MacOS"] }), + childPids: pid => ({ file: "pgrep", args: ["-P", String(pid)] }), + uninstall: (_artifact, workDir) => [{ file: "rm", args: ["-rf", `${workDir}/Applications/OpenCodex.app`] }], + }; +} + +/** Windows: msi install, PowerShell gestures, Task Scheduler registration. */ +export function windowsAdapter(runtime: AdapterRuntime): PlatformAdapter { + const run = runtime.run.bind(runtime); + return { + platform: "windows", + dependencies: () => ["msiexec", "powershell", "schtasks", "sc"], + npmLauncher: prefix => `${prefix}\\node_modules\\.bin\\ocx.cmd`, + serviceInstall: launcher => ({ file: launcher, args: ["service", "install"] }), + serviceUninstall: launcher => ({ file: launcher, args: ["service", "uninstall"] }), + // Task Scheduler is the default backend; the native backend registers a WinSW + // service instead, and both count as an existing registration. + registrationState: async () => { + const task = await run({ file: "schtasks", args: ["/Query", "/TN", "opencodex-proxy"] }); + if (task.ok) return "present"; + const taskAbsent = /cannot find|does not exist/i.test(task.stderr + task.stdout); + const service = await run({ file: "sc.exe", args: ["query", "opencodex-proxy-native"] }); + if (service.ok) return "present"; + const serviceAbsent = /does not exist/i.test(service.stderr + service.stdout); + if (taskAbsent && serviceAbsent) return "absent"; + return "unknown"; + }, + registrationFiles: () => [], + // A dormant MSI install shows up in the uninstall registry before any process runs. + existingInstallation: format => + format === "msi" + ? { + file: "powershell", + args: [ + "-NoProfile", + "-Command", + "$key = Get-ItemProperty 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*' | Where-Object { $_.DisplayName -eq 'OpenCodex' }; if ($key) { exit 0 } else { exit 1 }", + ], + } + : null, + async installArtifact(artifact, workDir) { + requireOk( + "msi install", + await run({ file: "msiexec", args: ["/i", artifact, "/qn", "/norestart", "/l*v", `${workDir}\\msi-install.log`] }), + ); + const locate = await run({ + file: "powershell", + args: [ + "-NoProfile", + "-Command", + "$key = Get-ItemProperty 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*' | Where-Object { $_.DisplayName -eq 'OpenCodex' } | Select-Object -First 1; " + + "$dir = $key.InstallLocation; " + + "@(\"opencodex-desktop.exe\", \"opencodex.exe\") | ForEach-Object { $p = Join-Path $dir $_; if (Test-Path $p) { Write-Output $dir; Write-Output $p; break } }", + ], + }); + const locateLines = locate.stdout.trim().split(/\r?\n/); + const installDir = locateLines[0] ?? ""; + const appBinary = locateLines[1] ?? ""; + if (!appBinary) { + // The MSI may already be installed; a discovery failure must not strand it. + await run({ file: "msiexec", args: ["/x", artifact, "/qn", "/norestart"] }); + throw new Error("MSI installed but no OpenCodex executable was found under its InstallLocation"); + } + return { appBinary, scope: installDir, evidence: { installLog: `${workDir}\\msi-install.log`, located: appBinary } }; + }, + closeGesture: () => ({ + file: "powershell", + args: [ + "-NoProfile", + "-Command", + "$p = Get-Process opencodex-desktop -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowHandle -ne 0 } | Select-Object -First 1; " + + "if (-not $p) { exit 1 }; " + + "$sig = '[DllImport(\"user32.dll\")] public static extern bool PostMessage(IntPtr h, uint m, IntPtr w, IntPtr l);'; " + + "Add-Type -MemberDefinition $sig -Name U32 -Namespace W; [W.U32]::PostMessage($p.MainWindowHandle, 0x0010, [IntPtr]::Zero, [IntPtr]::Zero) | Out-Null", + ], + }), + quitGesture: () => ({ + file: "powershell", + args: [ + "-NoProfile", + "-Command", + "$p = Get-Process opencodex-desktop -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowHandle -ne 0 } | Select-Object -First 1; " + + "if (-not $p) { exit 1 }; " + + "$shell = New-Object -ComObject WScript.Shell; $shell.AppActivate($p.Id) | Out-Null; $shell.SendKeys('%{F4}')", + ], + }), + // The Windows tray lives in the shell's notification area; a pre-installed runner + // hook (UIA) drives it. See --hooks-dir in installed-gate.ts. + trayClick: () => null, + trayQuit: () => null, + trayCheck: () => null, + trayInstall: () => null, + windowVisible: () => ({ + file: "powershell", + args: [ + "-NoProfile", + "-Command", + "$p = Get-Process opencodex-desktop -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowHandle -ne 0 }; if ($p) { exit 0 } else { exit 1 }", + ], + }), + installedVersion: () => ({ + file: "powershell", + args: [ + "-NoProfile", + "-Command", + "(Get-ItemProperty 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*' | Where-Object { $_.DisplayName -eq 'OpenCodex' } | Select-Object -First 1).DisplayVersion", + ], + }), + cancelElevation: () => null, + elevationProbe: () => null, + appProcessProbe: scope => ({ + file: "powershell", + args: [ + "-NoProfile", + "-Command", + `(Get-CimInstance Win32_Process -Filter "ExecutablePath LIKE '${scope.replace(/%/g, "")}%'").ProcessId`, + ], + }), + appNameProbe: () => ({ + file: "powershell", + args: ["-NoProfile", "-Command", "(Get-Process opencodex-desktop -ErrorAction SilentlyContinue).Id"], + }), + childPids: pid => ({ + file: "powershell", + args: ["-NoProfile", "-Command", `(Get-CimInstance Win32_Process -Filter "ParentProcessId=${pid}").ProcessId`], + }), + uninstall: artifact => [{ file: "msiexec", args: ["/x", artifact, "/qn", "/norestart"] }], + }; +} + +/** Linux: deb and AppImage installs, xdotool gestures, systemd user registration. */ +export function linuxAdapter(runtime: AdapterRuntime): PlatformAdapter { + const run = runtime.run.bind(runtime); + return { + platform: "linux", + dependencies: () => ["dpkg", "dpkg-deb", "dpkg-query", "xdotool", "pgrep", "systemctl", "sudo", "cp", "chmod", "kill", "rm"], + npmLauncher: prefix => `${prefix}/node_modules/.bin/ocx`, + serviceInstall: launcher => ({ file: launcher, args: ["service", "install"] }), + serviceUninstall: launcher => ({ file: launcher, args: ["service", "uninstall"] }), + registrationState: async () => { + if (runtime.fileExists(join(runtime.homeDir(), ".config/systemd/user/opencodex-proxy.service"))) return "present"; + // is-enabled: "enabled"/"linked" exit 0; "disabled" exits 1 but still means the + // unit file EXISTS. is-active: "active" exits 0; "inactive" exits 3 and also + // means the unit is registered. Absence prints "could not be found". + const enabled = await run({ file: "systemctl", args: ["--user", "is-enabled", "opencodex-proxy"] }); + const enabledOut = (enabled.stdout + enabled.stderr).trim(); + if (enabled.ok || /^\w*enabled$|^linked$/.test(enabled.stdout.trim()) || enabled.stdout.trim() === "disabled") return "present"; + if (!/could not be found|no such file|not found/i.test(enabledOut)) return "unknown"; + const active = await run({ file: "systemctl", args: ["--user", "is-active", "opencodex-proxy"] }); + const activeOut = (active.stdout + active.stderr).trim(); + if (active.ok || active.stdout.trim() === "inactive") return "present"; + if (/could not be found|no such file|not found/i.test(activeOut)) return "absent"; + return "unknown"; + }, + registrationFiles: () => [".config/systemd/user/opencodex-proxy.service"], + existingInstallation: (format, packageName) => + format === "deb" && packageName + ? { file: "dpkg-query", args: ["-W", "-f", "${Status}", packageName] } + : null, + async installArtifact(artifact, workDir, format) { + if (format === "deb") { + const packageName = (await run({ file: "dpkg-deb", args: ["-f", artifact, "Package"] })).stdout.trim(); + requireOk("dpkg install", await run({ file: "sudo", args: ["-n", "dpkg", "-i", artifact] })); + try { + const listing = await run({ file: "dpkg", args: ["-L", packageName] }); + const appBinary = listing.stdout.split(/\r?\n/).find(line => line.startsWith("/usr/bin/")) ?? ""; + if (!appBinary) throw new Error(`No /usr/bin executable found in package ${packageName}`); + return { appBinary, packageName, scope: appBinary, evidence: { packageName } }; + } catch (error) { + // The dpkg install already landed; a discovery failure must not strand it. + await run({ file: "sudo", args: ["-n", "dpkg", "-r", packageName] }); + throw error; + } + } + const appsDir = `${workDir}/apps`; + runtime.mkdir(appsDir); + const destination = `${appsDir}/OpenCodex.AppImage`; + requireOk("AppImage copy", await run({ file: "cp", args: [artifact, destination] })); + requireOk("AppImage chmod", await run({ file: "chmod", args: ["+x", destination] })); + return { appBinary: destination, scope: appsDir, evidence: { staged: destination } }; + }, + closeGesture: () => ({ file: "xdotool", args: ["search", "--name", "OpenCodex", "windowclose"] }), + quitGesture: () => ({ + file: "xdotool", + args: ["search", "--name", "OpenCodex", "windowactivate", "--sync", "key", "alt+F4"], + }), + // A stock GNOME session has no tray; on a runner with a tray extension a + // pre-installed hook drives it. The engine records which path was taken. + trayClick: () => null, + trayQuit: () => null, + trayCheck: () => null, + trayInstall: () => null, + windowVisible: () => ({ file: "xdotool", args: ["search", "--name", "OpenCodex"] }), + installedVersion: (format, packageName) => + format === "deb" && packageName + ? { file: "dpkg-query", args: ["-W", "-f", "${Version}", packageName] } + : null, + // Scoped to the pids the elevation monitor sighted — never a blanket pkill. + cancelElevation: pids => + pids.length > 0 ? { file: "kill", args: pids.map(String) } : null, + // pkexec is the first elevation surface; the updater plugin then falls back to a + // zenity or kdialog password dialog, and a cancel must produce NEITHER. + // The updater's full elevation chain is pkexec -> zenity/kdialog -> terminal sudo. + // The gate never invokes sudo during the monitored update windows, so any sighting + // there is attributable to the updater. + elevationProbe: () => ({ file: "pgrep", args: ["-x", "pkexec|zenity|kdialog|sudo"] }), + appProcessProbe: scope => ({ file: "pgrep", args: ["-f", scope] }), + appNameProbe: () => ({ file: "pgrep", args: ["-f", "opencodex-desktop"] }), + childPids: pid => ({ file: "pgrep", args: ["-P", String(pid)] }), + uninstall: (_artifact, workDir, format, packageName) => + format === "deb" && packageName + ? [{ file: "sudo", args: ["-n", "dpkg", "-r", packageName] }] + : [{ file: "rm", args: ["-f", `${workDir}/apps/OpenCodex.AppImage`] }], + }; +} + +function appleScript(...lines: string[]): CommandSpec { + return { file: "osascript", args: ["-e", lines.join(" ; ")] }; +} diff --git a/desktop/scripts/installed-gate.ts b/desktop/scripts/installed-gate.ts new file mode 100644 index 00000000000..4f02f443d42 --- /dev/null +++ b/desktop/scripts/installed-gate.ts @@ -0,0 +1,1002 @@ +/** + * The installed-artifact gate (D9 part two, R3). + * + * Installs the REAL desktop artifact on the host platform, launches it against a staged + * npm runtime, and drives the ownership contract from devlog plan 260921 + * (080_decisions_round2.md): the staged runtime on a non-default port is drained with + * its registration preserved, the desktop install id becomes the owner with exactly one + * consent-generation increment, healthz on the preserved home and port reports the + * bundled sidecar as a child of the app process, close and the OS quit gesture leave + * both pids alive with the window reopenable, a full quit and relaunch restore + * ownership without re-asking consent, and tray Quit lets an in-flight request finish + * before both pids end. On Linux both update paths are exercised (R3): an AppImage + * updates in place without elevation to the exact target bytes, and a deb install asks + * for authorization only after Install is chosen, never retries a cancelled prompt with + * another elevation mechanism, preserves the old version on cancel, and installs the + * new version on accept. + * + * Safety shape, per the external re-audit (110_reaudit.md): + * - preflight-isolation runs BEFORE any mutation. A run that refuses because it found + * an existing app, service registration or default-home state makes ZERO mutating + * calls, cleanup included — cleanup only ever touches resources this run acquired. + * - GUI automation comes from operator-installed hook FILES under --hooks-dir, never + * from dispatch-provided command text. + * - version inputs are strict semver; the npm package name is derived from this + * repository's own package.json, never accepted as an argument. + * + * Every side effect goes through GateDeps so tests can prove call discipline (see the + * refusal test in tests/ci-workflows/installed-gate-drivers.test.ts). + */ + +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { basename, join } from "node:path"; +import { commandInvocation } from "../../src/lib/win-exec"; +import { + type CommandSpec, + type GateFormat, + type GatePlatform, + type PlatformAdapter, + type ProcessEvidence, + linuxAdapter, + macosAdapter, + windowsAdapter, +} from "./installed-gate-platforms"; + +export interface GateOptions { + platform: GatePlatform; + format: GateFormat; + artifact: string; + olderArtifact?: string; + workDir: string; + toVersion: string; + fromVersion?: string; + reportPath: string; + hooksDir?: string; + consentHook?: string; + trayClickHook?: string; + trayQuitHook?: string; + trayCheckHook?: string; + trayInstallHook?: string; + /** Hook that answers the deb update's elevation prompt (drives the accept path). */ + elevateAcceptHook?: string; + takeoverTimeoutMs: number; +} + +export interface GatePhaseResult { + phase: string; + status: "pass" | "fail"; + detail: string; + evidence: Record; +} + +export interface GateReport { + platform: GatePlatform; + format: GateFormat; + toVersion: string; + startedAt: string; + finishedAt?: string; + phases: GatePhaseResult[]; + ok?: boolean; +} + +export interface SpawnedProcess { + pid: number; + kill: () => void; + exited: Promise; +} + +/** + * Every side effect the engine can perform. Tests inject fakes; production gets the + * real implementations from defaultGateDeps(). + */ +export interface GateDeps { + run(spec: CommandSpec, env?: Record): Promise; + pidAlive(pid?: number): boolean; + killProcess(pid: number): void; + fileExists(path: string): boolean; + readJsonFile(path: string): unknown; + writeTextFile(path: string, content: string): void; + makeDir(path: string): void; + fetchJson(url: string, init?: { method?: string; headers?: Record; body?: string; timeoutMs?: number }): Promise<{ ok: boolean; status: number; body: unknown }>; + spawnLogged(binary: string, logPath: string, errPath: string, env: Record): SpawnedProcess; + serveMockProvider(): MockProvider; + digestFile(path: string): string | null; + homeDir(): string; + sleep(ms: number): Promise; + readTextFile(path: string): string; +} + +/** Hook names are file names inside --hooks-dir, nothing more. */ +const HOOK_NAME = /^[a-z0-9][a-z0-9._-]*$/i; + +/** + * Version inputs become npm dist-tags and artifact URLs. Strict semver shape is the + * whole grammar they are allowed to carry — anything else (an npm alias like + * npm:other@latest, a flag fragment) is rejected before it can reach npm or a shell. + */ +const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/; + +/** + * Numeric-triple semver order. A prerelease suffix sorts before the bare release of the + * same triple; two suffixed versions compare lexically. The gate only needs "strictly + * older" answers for well-formed inputs, which parseGateArguments guarantees. + */ +export function compareSemver(a: string, b: string): number { + const parse = (v: string) => { + const [triple, suffix] = v.split("-", 2); + const parts = triple!.split(".").map(Number); + return { parts, suffix }; + }; + const left = parse(a); + const right = parse(b); + for (let i = 0; i < 3; i++) { + const delta = (left.parts[i] ?? 0) - (right.parts[i] ?? 0); + if (delta !== 0) return delta < 0 ? -1 : 1; + } + if (left.suffix === right.suffix) return 0; + if (left.suffix === undefined) return 1; + if (right.suffix === undefined) return -1; + return left.suffix < right.suffix ? -1 : 1; +} + +export function parseGateArguments(argv: string[]): { options?: GateOptions; error?: string } { + const value = (name: string): string | undefined => { + const index = argv.indexOf(`--${name}`); + return index >= 0 ? argv[index + 1] : undefined; + }; + const required = ["platform", "format", "artifact", "work-dir", "to-version", "report"] as const; + const missing = required.filter(name => !value(name)); + if (missing.length > 0) { + return { error: `missing required arguments: ${missing.map(name => `--${name}`).join(", ")}` }; + } + const platform = value("platform"); + const format = value("format"); + if (platform !== "macos" && platform !== "windows" && platform !== "linux") { + return { error: "--platform must be macos, windows or linux" }; + } + const expectedFormat: Record = { + macos: ["dmg"], + windows: ["msi"], + linux: ["deb", "appimage"], + }; + if (!expectedFormat[platform].includes(format as GateFormat)) { + return { error: `--format ${format} is not a ${platform} artifact format` }; + } + // R3: a Linux gate that cannot see one of the two promised update paths is not a gate, + // so the older artifact and its version are required, exactly like every other input. + if (platform === "linux" && !value("older-artifact")) { + return { error: "--older-artifact is required on linux: both update paths are in scope" }; + } + const toVersion = value("to-version")!; + const fromVersion = value("from-version"); + if (!SEMVER.test(toVersion)) return { error: "--to-version must be a strict semver (x.y.z[-suffix])" }; + if (fromVersion !== undefined && !SEMVER.test(fromVersion)) { + return { error: "--from-version must be a strict semver (x.y.z[-suffix])" }; + } + if (platform === "linux") { + if (!fromVersion) return { error: "--from-version is required on linux: the update phases need a proven-older release" }; + if (fromVersion === toVersion) return { error: "--from-version must differ from --to-version" }; + if (compareSemver(fromVersion, toVersion) >= 0) { + return { error: "--from-version must be strictly older than --to-version" }; + } + } + const hooksDir = value("hooks-dir"); + const hooks: Array<[keyof GateOptions, string | undefined]> = [ + ["consentHook", value("consent-hook")], + ["trayClickHook", value("tray-click-hook")], + ["trayQuitHook", value("tray-quit-hook")], + ["trayCheckHook", value("tray-check-hook")], + ["trayInstallHook", value("tray-install-hook")], + ["elevateAcceptHook", value("elevate-accept-hook")], + ]; + for (const [key, name] of hooks) { + if (name === undefined) continue; + if (!hooksDir) return { error: `--${key.replace(/[A-Z]/g, c => "-" + c.toLowerCase())} requires --hooks-dir` }; + if (!HOOK_NAME.test(name)) { + return { error: `hook name \`${name}\` must be a plain file name inside the hooks directory` }; + } + } + const takeoverTimeoutMs = Number(value("takeover-timeout") ?? 180) * 1000; + if (!Number.isFinite(takeoverTimeoutMs) || takeoverTimeoutMs <= 0) { + return { error: "--takeover-timeout must be a positive number of seconds" }; + } + return { + options: { + platform, + format: format as GateFormat, + artifact: value("artifact")!, + olderArtifact: value("older-artifact"), + workDir: value("work-dir")!, + toVersion, + fromVersion, + reportPath: value("report")!, + hooksDir, + consentHook: value("consent-hook"), + trayClickHook: value("tray-click-hook"), + trayQuitHook: value("tray-quit-hook"), + trayCheckHook: value("tray-check-hook"), + trayInstallHook: value("tray-install-hook"), + elevateAcceptHook: value("elevate-accept-hook"), + takeoverTimeoutMs, + }, + }; +} + +/** + * The staged npm runtime's package spec, derived from this repository's own + * package.json — the gate never takes a package spec as an argument. + */ +export function npmPackageSpec(packageName: string, version: string): string { + return `${packageName}@${version}`; +} + +export function readOwnPackageName(packageJsonText: string): string | undefined { + try { + const parsed = JSON.parse(packageJsonText) as { name?: unknown }; + return typeof parsed.name === "string" && parsed.name.length > 0 ? parsed.name : undefined; + } catch { + return undefined; + } +} + +export interface OwnershipObservation { + ownerInstallId?: string; + consentGeneration?: number; + raw: unknown; +} + +/** + * Reads the durable ownership fields from service-state.json using lane C's schema: + * the record root carries an \`ownership\` object with the desktop install id and the + * consent generation. Any other shape is no observation, and the phase fails naming + * the file it read — the gate does not guess at schemas. + */ +export function observeOwnership(state: unknown): OwnershipObservation { + if (typeof state !== "object" || state === null) return { raw: state }; + const ownership = (state as Record).ownership; + if (typeof ownership !== "object" || ownership === null) return { raw: state }; + const record = ownership as Record; + return { + ownerInstallId: typeof record.installId === "string" ? record.installId : undefined, + consentGeneration: typeof record.consentGeneration === "number" ? record.consentGeneration : undefined, + raw: state, + }; +} + +export function evaluateOwnership( + before: OwnershipObservation, + after: OwnershipObservation, +): { ok: boolean; detail: string } { + if (before.ownerInstallId !== undefined) { + return { ok: false, detail: "the staged npm runtime already carried an owner; the takeover precondition is an unowned runtime" }; + } + if (!after.ownerInstallId) { + return { ok: false, detail: "the desktop install id is not recorded as owner in service-state.json" }; + } + const beforeGeneration = before.consentGeneration ?? 0; + if (after.consentGeneration === undefined) { + return { ok: false, detail: "no consent generation is recorded in service-state.json" }; + } + if (after.consentGeneration !== beforeGeneration + 1) { + return { + ok: false, + detail: `consent generation moved from ${beforeGeneration} to ${after.consentGeneration}; the contract allows exactly one increment`, + }; + } + return { ok: true, detail: `owner ${after.ownerInstallId} recorded with consent generation ${after.consentGeneration}` }; +} + +/** + * Parses a pid listing. Empty output is no pids — never pid 0, which on POSIX means + * the caller's own process group and must never be signalled from here. + */ +export function parsePidList(stdout: string): number[] { + return stdout + .split(/\r?\n/) + .map(line => line.trim()) + .filter(line => line.length > 0) + .map(Number) + .filter(value => Number.isSafeInteger(value) && value > 0); +} + +export function describeGatePhases(options: GateOptions): string[] { + const phases = [ + "preflight-isolation", + "runner-readiness", + "stage-npm-runtime", + "install-artifact", + "launch-and-take-over", + "runtime-identity", + "close-gesture", + "quit-gesture", + "relaunch-consent", + "tray-quit-drains", + ]; + if (options.platform === "linux") phases.push("update-verify"); + phases.push("cleanup"); + return phases; +} + +export function summarizeReport(report: GateReport): string { + const lines = report.phases.map( + phase => `${phase.status === "pass" ? "PASS" : "FAIL"} ${phase.phase}${phase.detail ? ` — ${phase.detail}` : ""}`, + ); + return [`installed-artifact gate: ${report.ok ? "PASS" : "FAIL"} (${report.platform}/${report.format} v${report.toVersion})`, ...lines].join("\n"); +} + +interface Healthz { + status: string; + version?: string; + pid?: number; + role?: string; +} + +const NON_DEFAULT_PORT = 10431; +const ELEVATION_POLL_MS = 250; + +export interface MockProvider { + port: number; + /** Resolves when the first chat completion actually reached the mock. */ + reached: Promise; + /** Lets the held request finish. */ + release: () => void; + stop: () => void; +} + +/** The openai-chat compatible mock the drain phase holds an in-flight request against. */ +export function startMockProvider(): MockProvider { + let finish: () => void = () => {}; + let markReached: () => void = () => {}; + const held = new Promise(resolve => { finish = resolve; }); + const reached = new Promise(resolve => { markReached = resolve; }); + const server = Bun.serve({ + port: 0, + fetch: async request => { + if (new URL(request.url).pathname.endsWith("/chat/completions")) { + markReached(); + await held; + return Response.json({ + id: "gate-drain", + object: "chat.completion", + choices: [{ index: 0, message: { role: "assistant", content: "drained" }, finish_reason: "stop" }], + }); + } + return new Response("not found", { status: 404 }); + }, + }); + return { port: server.port, reached, release: finish, stop: () => server.stop(true) }; +} + +export function defaultGateDeps(): GateDeps { + const spawnProcess = (binary: string, env: Record, outPath?: string, errPath?: string): SpawnedProcess => { + const invocation = commandInvocation(binary, []); + const child = Bun.spawn({ + cmd: [invocation.file, ...invocation.args], + env, + // Bun.spawn accepts a BunFile directly; a FileSink is not a valid stdio target. + stdout: outPath ? Bun.file(outPath) : "ignore", + stderr: errPath ? Bun.file(errPath) : "ignore", + stdin: "ignore", + ...invocation.options, + }); + return { pid: child.pid, kill: () => child.kill(), exited: child.exited }; + }; + return { + run: async (spec, env) => { + const invocation = commandInvocation(spec.file, spec.args); + const proc = Bun.spawn({ + cmd: [invocation.file, ...invocation.args], + stdout: "pipe", + stderr: "pipe", + env: env ?? { ...process.env }, + ...invocation.options, + }); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + return { ok: exitCode === 0, exitCode, stdout, stderr }; + }, + pidAlive: pid => { + if (typeof pid !== "number") return false; + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } + }, + killProcess: pid => process.kill(pid), + fileExists: path => existsSync(path), + readJsonFile: path => { + try { + return JSON.parse(readFileSync(path, "utf8")); + } catch { + return undefined; + } + }, + writeTextFile: (path, content) => writeFileSync(path, content), + makeDir: path => mkdirSync(path, { recursive: true }), + fetchJson: async (url, init) => { + const response = await fetch(url, { + method: init?.method, + headers: init?.headers, + body: init?.body, + signal: AbortSignal.timeout(init?.timeoutMs ?? 4000), + }); + let body: unknown = undefined; + try { + body = await response.json(); + } catch { + body = undefined; + } + return { ok: response.ok, status: response.status, body }; + }, + spawnLogged: (binary, logPath, errPath, env) => spawnProcess(binary, env, logPath, errPath), + serveMockProvider: () => startMockProvider(), + digestFile: path => (existsSync(path) ? createHash("sha256").update(readFileSync(path)).digest("hex") : null), + homeDir: () => homedir(), + sleep: ms => new Promise(resolve => setTimeout(resolve, ms)), + readTextFile: path => readFileSync(path, "utf8"), + }; +} + +export async function runGate(options: GateOptions, deps: GateDeps = defaultGateDeps()): Promise { + const report: GateReport = { + platform: options.platform, + format: options.format, + toVersion: options.toVersion, + startedAt: new Date().toISOString(), + phases: [], + }; + const workDir = options.workDir; + const home = join(workDir, "preserved-home"); + const codexHome = join(workDir, "codex-home"); + const npmPrefix = join(workDir, "npm-prefix"); + const port = NON_DEFAULT_PORT; + const isolatedEnv = (): Record => ({ + ...process.env, + OPENCODEX_HOME: home, + CODEX_HOME: codexHome, + }); + // Every command the gate drives runs under the staged homes — a probe or a service + // invocation must never read the runner account's real opencodex or codex home. + const run = (spec: CommandSpec): Promise => deps.run(spec, isolatedEnv()); + const adapter: PlatformAdapter = (options.platform === "macos" ? macosAdapter : options.platform === "windows" ? windowsAdapter : linuxAdapter)( + { run, mkdir: path => deps.makeDir(path), fileExists: path => deps.fileExists(path), homeDir: () => deps.homeDir() }, + ); + const launcher = adapter.npmLauncher(npmPrefix); + const spawned: SpawnedProcess[] = []; + let npmPid: number | undefined; + let appPid: number | undefined; + let appBinary: string | undefined; + let packageName: string | undefined; + let takeoverOwnerId: string | undefined; + let takeoverGeneration: number | undefined; + let takeoverRuntimePid: number | undefined; + let installScope: string | undefined; + let mock: MockProvider | undefined; + let stopVerification = false; + + const record = (phase: string, ok: boolean, detail: string, evidence: Record = {}) => { + report.phases.push({ phase, status: ok ? "pass" : "fail", detail, evidence }); + if (!ok) stopVerification = true; + }; + + const listPids = async (spec: CommandSpec): Promise => { + const result = await run(spec); + if (!result.ok) return []; + return parsePidList(result.stdout); + }; + + const healthz = async (): Promise => { + try { + const response = await deps.fetchJson(`http://127.0.0.1:${port}/healthz`, { timeoutMs: 4000 }); + if (!response.ok) return null; + const body = response.body as Record; + if (body?.status !== "ok") return null; + return { + status: "ok", + version: typeof body.version === "string" ? body.version : undefined, + pid: typeof body.pid === "number" ? body.pid : undefined, + role: typeof body.role === "string" ? body.role : undefined, + }; + } catch { + return null; + } + }; + + const waitFor = async (predicate: () => Promise, timeoutMs: number, everyMs = 500): Promise => { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await predicate()) return true; + await deps.sleep(everyMs); + } + return await predicate(); + }; + + /** + * A registration is present when any manager probe exits zero OR any on-disk + * registration file exists — unloaded launchd jobs, disabled systemd units and the + * WinSW native service all leave traces a single manager query would miss. + */ + /** Resources THIS invocation placed on the machine; cleanup touches nothing else. */ + const acquired = { service: false, artifact: false }; + + const resolveHook = (name?: string): string | undefined => + name && options.hooksDir ? join(options.hooksDir, name) : undefined; + + /** A pre-installed hook file runs directly, never through a shell. */ + const runHook = async (name: string | undefined, fallback: () => CommandSpec | null): Promise<{ ok: boolean; via: string }> => { + const hook = resolveHook(name); + if (hook) { + if (!deps.fileExists(hook)) return { ok: false, via: `missing hook ${hook}` }; + return { ok: (await run({ file: hook, args: [] })).ok, via: hook }; + } + const spec = fallback(); + if (!spec) return { ok: false, via: "no hook and no platform default" }; + return { ok: (await run(spec)).ok, via: spec.file }; + }; + + const windowVisible = async (): Promise => { + const probe = await run(adapter.windowVisible()); + // macOS prints a window count, Linux prints one window id per line, Windows + // prints nothing and answers through the exit code. + const firstLine = probe.stdout.trim().split(/\r?\n/)[0] ?? ""; + return probe.ok && (firstLine === "" || Number(firstLine) > 0); + }; + + try { + // ---- preflight-isolation: refuse to touch a machine with real opencodex state. + // This phase completes BEFORE anything mutating; a refusal skips every later phase, + // and the cleanup below touches only resources recorded in `acquired`/`spawned`. + // Registration absence must be PROVEN: an unreadable manager probe is "unknown", + // and unknown refuses the run exactly like present does. + const registration = await adapter.registrationState(); + const defaultState = join(deps.homeDir(), ".opencodex", "service-state.json"); + const defaultStatePresent = deps.fileExists(defaultState); + const runningApp = await listPids(adapter.appNameProbe()); + // A dormant install counts too: the gate would overwrite it, and cleanup could + // then remove something this run never installed. For deb the package name is read + // from the artifact (read-only) before probing dpkg. + let dormantInstall = false; + if (options.format === "deb") { + const nameProbe = await run({ file: "dpkg-deb", args: ["-f", options.artifact, "Package"] }); + if (nameProbe.ok && nameProbe.stdout.trim()) { + const installed = adapter.existingInstallation("deb", nameProbe.stdout.trim()); + dormantInstall = installed !== null && (await run(installed)).ok; + } + } else { + const installed = adapter.existingInstallation(options.format); + dormantInstall = installed !== null && (await run(installed)).ok; + } + const isolated = registration === "absent" && !defaultStatePresent && runningApp.length === 0 && !dormantInstall; + record( + "preflight-isolation", + isolated, + isolated + ? "no existing registration, default-home state or running app" + : "this runner already carries opencodex state; the gate would overwrite or remove it — refusing to run", + { registration, defaultStatePresent, runningApp, dormantInstall }, + ); + + if (!stopVerification) { + deps.makeDir(home); + deps.makeDir(codexHome); + // ---- runner-readiness: every external command the adapter needs must resolve + // before an artifact is installed. + const dependencies = [...adapter.dependencies(), "npm"]; + const missing: string[] = []; + for (const dependency of dependencies) { + const probe = process.platform === "win32" + ? await run({ file: "where.exe", args: [dependency] }) + : await run({ file: "sh", args: ["-c", `command -v ${dependency}`] }); + if (!probe.ok) missing.push(dependency); + } + record("runner-readiness", missing.length === 0, + missing.length === 0 ? `${dependencies.length} external commands resolve` : `missing: ${missing.join(", ")}`, + { missing }); + } + + if (!stopVerification) { + // ---- stage-npm-runtime: register FIRST so the recorded pid is the managed one. + // The package name comes from this repository's own package.json; only the + // semver-validated version is operator input. + const staging: Record = {}; + const packageName_ = readOwnPackageName(deps.readTextFile(join(import.meta.dir, "..", "..", "package.json"))); + if (!packageName_) { + record("stage-npm-runtime", false, "could not read this repository's npm package name", staging); + } else { + const spec = npmPackageSpec(packageName_, options.fromVersion ?? options.toVersion); + staging.npmPackage = spec; + const install = await run({ file: "npm", args: ["install", "--prefix", npmPrefix, spec] }); + staging.npmInstallExit = install.exitCode; + mock = deps.serveMockProvider(); + deps.writeTextFile( + join(home, "config.json"), + JSON.stringify( + { + port, + defaultProvider: "gate-mock", + providers: { + "gate-mock": { + adapter: "openai-chat", + baseUrl: `http://127.0.0.1:${mock.port}/v1`, + apiKey: "gate-mock-key", + }, + }, + }, + null, + 2, + ) + "\n", + ); + let ok = install.ok; + if (ok) { + const registered = await run(adapter.serviceInstall(launcher)); + staging.serviceInstallExit = registered.exitCode; + ok = registered.ok; + // A nonzero exit can still leave a registration behind; if anything is + // registered now, this run owns removing it. + acquired.service = (await adapter.registrationState()) === "present"; + } + if (ok) ok = await waitFor(() => healthz().then(Boolean), 60_000); + const before = await healthz(); + npmPid = before?.pid; + const present = (await adapter.registrationState()) === "present"; + staging.npmRuntime = before; + staging.registrationPresent = present; + ok = ok && present && typeof npmPid === "number"; + record( + "stage-npm-runtime", + ok, + ok ? `managed npm runtime pid ${npmPid} on port ${port}; registration present` : "staging failed; see evidence", + staging, + ); + } + } + + const ownershipBefore = observeOwnership(deps.readJsonFile(join(home, "service-state.json"))); + + if (!stopVerification) { + // ---- install-artifact: the real artifact, installed like a user would. + try { + const installResult = await adapter.installArtifact(options.artifact, workDir, options.format); + appBinary = installResult.appBinary; + packageName = installResult.packageName; + installScope = installResult.scope; + acquired.artifact = true; + record("install-artifact", Boolean(appBinary), `installed ${basename(options.artifact)} -> ${appBinary}`, installResult.evidence); + } catch (error) { + // A partial install is still an acquisition: mark it so cleanup rolls it back. + acquired.artifact = true; + record("install-artifact", false, String(error)); + } + } + + if (!stopVerification && appBinary) { + // ---- launch-and-take-over: consent once, drain the npm runtime, keep the registration. + const launched = deps.spawnLogged(appBinary, join(workDir, "app.log"), join(workDir, "app.err.log"), isolatedEnv()); + spawned.push(launched); + appPid = launched.pid; + if (options.consentHook) await runHook(options.consentHook, () => null); + const taken = await waitFor(async () => { + const now = await healthz(); + return now !== null && typeof now.pid === "number" && now.pid !== npmPid; + }, options.takeoverTimeoutMs); + const after = await healthz(); + const npmDrained = !deps.pidAlive(npmPid); + const registration = (await adapter.registrationState()) === "present"; + const ownershipAfter = observeOwnership(deps.readJsonFile(join(home, "service-state.json"))); + const ownership = evaluateOwnership(ownershipBefore, ownershipAfter); + if (ownership.ok) { + takeoverOwnerId = ownershipAfter.ownerInstallId; + takeoverGeneration = ownershipAfter.consentGeneration; + takeoverRuntimePid = after?.pid; + } + const ok = taken && npmDrained && registration && ownership.ok; + record("launch-and-take-over", ok, [ownership.detail, `npm pid drained: ${npmDrained}`, `registration present: ${registration}`].join("; "), { + before: ownershipBefore.raw, + after: ownershipAfter.raw, + healthzAfter: after, + }); + } + + if (!stopVerification) { + // ---- runtime-identity: healthz on the preserved home+port is the bundled sidecar, + // a child of THIS launched app, reporting THIS release's version. + const now = await healthz(); + const children = appPid !== undefined ? await listPids(adapter.childPids(appPid)) : []; + const portRecord = deps.readJsonFile(join(home, "runtime-port.json")) as { port?: number; pid?: number } | undefined; + const ok = + now?.pid !== undefined && + children.includes(now.pid) && + portRecord?.port === port && + portRecord?.pid === now.pid && + now.version === options.toVersion; + record("runtime-identity", ok, ok + ? `healthz pid ${now?.pid} v${now?.version} is a child of app pid ${appPid} on preserved port ${port}` + : "the answering runtime is not the bundled sidecar of the launched app on the preserved home", + { healthz: now, appPid, sidecarCandidates: children, runtimePortRecord: portRecord }); + } + + const gesture = async (phase: string, spec: CommandSpec) => { + if (stopVerification) return; + const before = await healthz(); + const gestureResult = await run(spec); + // The gesture must actually hide the window; a no-op command exit is not the + // contract. + const windowHidden = await waitFor(async () => !(await windowVisible()), 10_000); + const after = await healthz(); + const runtimeAlive = before?.pid !== undefined && before.pid === after?.pid; + const appAlive = deps.pidAlive(appPid); + const reopen = await runHook(options.trayClickHook, () => adapter.trayClick()); + const visible = await waitFor(windowVisible, 15_000); + const ok = gestureResult.ok && windowHidden && runtimeAlive && appAlive && reopen.ok && visible; + record(phase, ok, + `gesture exit ${gestureResult.exitCode}; window hidden: ${windowHidden}; runtime pid ${after?.pid} alive: ${runtimeAlive}; app pid ${appPid} alive: ${appAlive}; window reopened via ${reopen.via}: ${visible}`, + { before, after }); + }; + + await gesture("close-gesture", adapter.closeGesture()); + await gesture("quit-gesture", adapter.quitGesture()); + + if (!stopVerification && appBinary) { + // ---- relaunch-consent: a FULL quit (tray Quit drains and ends both pids), then a + // cold relaunch must restore ownership WITHOUT asking again — the same install id, + // the same consent generation. Watching a single-instance duplicate exit is not + // this contract. + const quit = await runHook(options.trayQuitHook, () => adapter.trayQuit()); + // Both pids — the app AND the runtime it owned at takeover — must actually end + // before the relaunch means anything. + const previousAppPid = appPid; + const ended = await waitFor(async () => + !deps.pidAlive(previousAppPid) + && !deps.pidAlive(takeoverRuntimePid) + && (installScope === undefined || (await listPids(adapter.appProcessProbe(installScope))).length === 0), + 30_000); + let ownershipRestored = false; + let relaunchHealth: Healthz | null = null; + if (ended) { + const relaunched = deps.spawnLogged(appBinary, join(workDir, "relaunch.log"), join(workDir, "relaunch.err.log"), isolatedEnv()); + spawned.push(relaunched); + appPid = relaunched.pid; + const up = await waitFor(() => healthz().then(Boolean), 60_000); + relaunchHealth = await healthz(); + const ownership = observeOwnership(deps.readJsonFile(join(home, "service-state.json"))); + ownershipRestored = up + && ownership.ownerInstallId !== undefined + && ownership.ownerInstallId === takeoverOwnerId; + // A re-asked consent would move the generation; identical generation is the + // proof that nothing was asked. + ownershipRestored &&= ownership.consentGeneration !== undefined && ownership.consentGeneration === takeoverGeneration; + } + const ok = quit.ok && ended && ownershipRestored; + record("relaunch-consent", ok, ok + ? `full quit and cold relaunch restored owner ${takeoverOwnerId} without re-asking consent` + : "ownership was not restored after a cold relaunch, or consent was asked again", + { quitVia: quit.via, ended, ownerAfterRelaunch: relaunchHealth, takeoverOwnerId }); + } + + if (!stopVerification) { + // ---- tray-quit-drains: the request must be verifiably in flight when Quit fires. + const before = await healthz(); + const request = deps.fetchJson(`http://127.0.0.1:${port}/v1/chat/completions`, { + method: "POST", + headers: { "content-type": "application/json", authorization: "Bearer gate-mock-key" }, + body: JSON.stringify({ model: "gate-mock/gate-model", messages: [{ role: "user", content: "hold" }] }), + timeoutMs: 90_000, + }).then(response => response.status); + const inFlight = await Promise.race([ + mock!.reached.then(() => true), + deps.sleep(15_000).then(() => false), + ]); + const quit = await runHook(options.trayQuitHook, () => adapter.trayQuit()); + await deps.sleep(2000); + mock?.release(); + let requestStatus: number | null = null; + try { + requestStatus = await request; + } catch { + requestStatus = null; + } + const drained = requestStatus === 200; + const bothEnded = await waitFor(async () => !deps.pidAlive(before?.pid) && (installScope === undefined || (await listPids(adapter.appProcessProbe(installScope))).length === 0), 30_000); + const ok = inFlight && quit.ok && drained && bothEnded; + record("tray-quit-drains", ok, + `request in flight at Quit: ${inFlight}; tray Quit driven via ${quit.via}; request finished with ${requestStatus}; both pids ended: ${bothEnded}`, + { runtimePid: before?.pid, requestStatus }); + } + + if (!stopVerification && options.platform === "linux" && options.olderArtifact && options.fromVersion) { + // ---- update-verify (R3): both Linux formats update through their own path, on + // both authorization outcomes. A failed prerequisite stops the phase BEFORE the + // next mutation, never after it. + for (const spec of adapter.uninstall(options.artifact, workDir, options.format, packageName)) { + await run(spec); + } + const older = await adapter.installArtifact(options.olderArtifact, workDir, options.format); + packageName = older.packageName; + installScope = older.scope; + const oldApp = deps.spawnLogged(older.appBinary, join(workDir, "older-app.log"), join(workDir, "older-app.err.log"), isolatedEnv()); + spawned.push(oldApp); + appPid = oldApp.pid; + const oldHealthy = await waitFor(() => healthz().then(Boolean), 60_000); + const preVersion = adapter.installedVersion(options.format, packageName); + const preVersionOutput = preVersion ? await run(preVersion) : null; + const preVersionText = preVersionOutput?.ok ? preVersionOutput.stdout.trim() : ""; + const preDigest = options.format === "appimage" ? deps.digestFile(older.appBinary) : null; + const targetDigest = options.format === "appimage" ? deps.digestFile(options.artifact) : null; + + // One continuous monitor across the whole update operation: a fixed window can + // close before download and signature verification reach the elevation step. + // Sightings are attributed by the timestamp of each driver action. + const sightings: Array<{ pid: number; at: number }> = []; + const elevationProbe = adapter.elevationProbe(); + let monitoring = true; + const monitorTask = (async () => { + while (monitoring && elevationProbe) { + for (const pid of await listPids(elevationProbe)) sightings.push({ pid, at: Date.now() }); + await deps.sleep(ELEVATION_POLL_MS); + } + })(); + const stopMonitor = async () => { monitoring = false; await monitorTask; }; + const sightingsAfter = (timestamp: number): number[] => + [...new Set(sightings.filter(sighting => sighting.at >= timestamp).map(sighting => sighting.pid))]; + + try { + let check = { ok: false, via: "skipped: old app never became healthy" }; + let install = { ok: false, via: "skipped: old app never became healthy" }; + let installStart = Number.POSITIVE_INFINITY; + if (oldHealthy) { + check = await runHook(options.trayCheckHook, () => adapter.trayCheck()); + installStart = Date.now(); + install = await runHook(options.trayInstallHook, () => adapter.trayInstall()); + } + const elevationDuringCheck = [...new Set( + sightings.filter(sighting => sighting.at < installStart).map(sighting => sighting.pid), + )]; + + if (options.format === "appimage") { + // The installed file must become byte-identical to the target artifact — a + // changed digest alone would pass for an update to the wrong version, and a + // missing pre/target digest would make the transition vacuous. + await waitFor(async () => deps.digestFile(older.appBinary) === targetDigest, 120_000); + const postDigest = deps.digestFile(older.appBinary); + const anyElevation = sightingsAfter(0); + const ok = oldHealthy && check.ok && install.ok + && elevationDuringCheck.length === 0 && anyElevation.length === 0 + && preDigest !== null && targetDigest !== null && preDigest !== targetDigest + && postDigest !== null && postDigest === targetDigest; + record("update-verify", ok, + `AppImage updated in place to the exact target artifact (digest match: ${postDigest === targetDigest}); no elevation anywhere (${anyElevation.length} sighted); path kept`, + { preDigest, postDigest, targetDigest, elevation: anyElevation }); + } else { + // Cancel path: wait for the prompt, dismiss exactly the sighted pids, prove + // they exited, then prove NO elevation mechanism retries (the pinned plugin + // otherwise falls back pkexec -> zenity/kdialog -> sudo), and the version + // never moved. + const prompted = await waitFor(async () => sightingsAfter(installStart).length > 0, 90_000); + const elevation = sightingsAfter(installStart); + const cancel = adapter.cancelElevation(elevation); + let cancelOk = elevation.length === 0; + if (cancel && elevation.length > 0) { + cancelOk = (await run(cancel)).ok; + cancelOk &&= await waitFor(async () => elevation.every(pid => !deps.pidAlive(pid)), 10_000); + } + const cancelDoneAt = Date.now(); + await deps.sleep(10_000); + const retriedElevation = sightingsAfter(cancelDoneAt); + const settledVersion = adapter.installedVersion(options.format, packageName); + const settledVersionOutput = settledVersion ? await run(settledVersion) : null; + const settledVersionText = settledVersionOutput?.ok ? settledVersionOutput.stdout.trim() : ""; + const cancelPreserved = preVersionText !== "" && preVersionText === options.fromVersion && settledVersionText === preVersionText; + const cancelOkAll = oldHealthy && check.ok && install.ok + && elevationDuringCheck.length === 0 && prompted + && cancelOk && retriedElevation.length === 0 && cancelPreserved; + + // Accept path: only after the cancel path held. Drive Install again, answer + // through the operator hook, and require the package to reach the target. + let acceptOk = false; + let acceptEvidence: Record = { skipped: "no --elevate-accept-hook" }; + if (options.elevateAcceptHook && cancelOkAll) { + const acceptStart = Date.now(); + const installAgain = await runHook(options.trayInstallHook, () => adapter.trayInstall()); + const promptedAgain = await waitFor(async () => sightingsAfter(acceptStart).length > 0, 90_000); + let hookOk = false; + if (promptedAgain) { + hookOk = (await runHook(options.elevateAcceptHook, () => null)).ok; + } + const accepted = await waitFor(async () => { + const probe = adapter.installedVersion(options.format, packageName); + if (!probe) return false; + const result = await run(probe); + return result.ok && result.stdout.trim() === options.toVersion; + }, 120_000); + acceptOk = installAgain.ok && promptedAgain && hookOk && accepted; + acceptEvidence = { installAgain: installAgain.ok, promptedAgain, hookOk, accepted }; + } else if (options.elevateAcceptHook) { + acceptEvidence = { skipped: "cancel path failed; accept not attempted" }; + } + const ok = cancelOkAll && acceptOk; + record("update-verify", ok, + `deb cancel path preserved ${settledVersionText} with no elevation retry (${retriedElevation.length}); accept path reached ${options.toVersion}: ${acceptOk}`, + { preVersion: preVersionText, postCancelVersion: settledVersionText, cancelOk, prompted, retriedElevation, accept: acceptEvidence }); + } + } finally { + await stopMonitor(); + } + } + } catch (error) { + // A thrown exception is a fatal phase of its own: without this, a crash between + // phases could leave a report whose recorded phases all pass. + record("fatal-error", false, String(error)); + } finally { + // ---- cleanup: rolls back ONLY what this invocation acquired. A preflight refusal + // means nothing here runs against machine state: the gate must never destroy an + // existing installation it detected. Every rollback step runs; a failure fails the + // phase but never stops the remaining steps. + const cleanupEvidence: Record = {}; + let cleanupOk = true; + const fail = (key: string, error: unknown) => { + cleanupOk = false; + cleanupEvidence[key] = String(error); + }; + for (const child of spawned) { + try { child.kill(); } catch (error) { fail(`spawned-${child.pid}`, error); } + } + // Processes the gate no longer owns: an AppImage update restarts detached from the + // original spawn handle. Only swept when this run launched an app at all. + if (appPid !== undefined && installScope !== undefined) { + for (const pid of await listPids(adapter.appProcessProbe(installScope))) { + try { deps.killProcess(pid); } catch (error) { fail(`app-${pid}`, error); } + } + } + if (deps.pidAlive(npmPid)) { + try { deps.killProcess(npmPid!); } catch (error) { fail("npm-runtime", error); } + } + if (acquired.service) { + try { + const result = await run(adapter.serviceUninstall(launcher)); + if (!result.ok) fail("service-uninstall", result.stderr.trim() || `exit ${result.exitCode}`); + } catch (error) { fail("service-uninstall", error); } + } + if (acquired.artifact) { + for (const spec of adapter.uninstall(options.artifact, workDir, options.format, packageName)) { + try { + const result = await run(spec); + if (!result.ok) fail(`uninstall:${spec.args[1] ?? spec.file}`, result.stderr.trim() || `exit ${result.exitCode}`); + } catch (error) { fail("uninstall", error); } + } + } + mock?.stop(); + record("cleanup", cleanupOk, cleanupOk ? "everything the gate installed was rolled back" : "a rollback step failed; see evidence", cleanupEvidence); + report.finishedAt = new Date().toISOString(); + // Green means every phase ran AND passed — a report missing phases (a crash, an + // early refusal) is not green even if everything recorded passed. + const expectedPhases = describeGatePhases(options); + const covered = expectedPhases.every(name => report.phases.some(phase => phase.phase === name)); + report.ok = report.phases.every(phase => phase.status === "pass") && covered; + deps.writeTextFile(options.reportPath, `${JSON.stringify(report, null, 2)}\n`); + } + return report; +} + +if (import.meta.main) { + const parsed = parseGateArguments(Bun.argv.slice(2)); + if (!parsed.options || parsed.error) { + console.error(parsed.error ?? "invalid arguments"); + console.error( + "usage: installed-gate.ts --platform --format --artifact " + + " --older-artifact --work-dir --to-version --from-version --report " + + " [--hooks-dir ] [--consent-hook ] [--tray-click-hook ] [--tray-quit-hook ]" + + " [--tray-check-hook ] [--tray-install-hook ] [--elevate-accept-hook ] [--takeover-timeout ]", + ); + process.exit(2); + } + const report = await runGate(parsed.options); + console.log(summarizeReport(report)); + process.exit(report.ok ? 0 : 1); +} diff --git a/desktop/scripts/prepare-sidecar.ts b/desktop/scripts/prepare-sidecar.ts new file mode 100644 index 00000000000..502127870dc --- /dev/null +++ b/desktop/scripts/prepare-sidecar.ts @@ -0,0 +1,59 @@ +import { copyFileSync, cpSync, existsSync, mkdirSync } from "node:fs"; +import { join, resolve } from "node:path"; + +const targetByTriple: Record = { + "aarch64-apple-darwin": "bun-darwin-arm64", + "x86_64-apple-darwin": "bun-darwin-x64", + "x86_64-pc-windows-msvc": "bun-windows-x64", + "x86_64-unknown-linux-gnu": "bun-linux-x64", + "aarch64-unknown-linux-gnu": "bun-linux-arm64", +}; + +function argument(name: string): string | undefined { + const index = Bun.argv.indexOf(name); + return index < 0 ? undefined : Bun.argv[index + 1]; +} + +function hostTriple(): string | undefined { + const result = Bun.spawnSync(["rustc", "-vV"], { stdout: "pipe", stderr: "ignore" }); + if (result.exitCode !== 0) return undefined; + const host = result.stdout.toString().match(/^host:\s*(\S+)$/m)?.[1]; + return host; +} + +const repoRoot = resolve(import.meta.dir, "../.."); +const triple = + argument("--target") ?? + process.env.TARGET ?? + process.env.RUST_TARGET ?? + Bun.env.RUST_TARGET ?? + hostTriple(); +if (!triple || !targetByTriple[triple]) { + throw new Error( + `Unsupported Rust target ${triple ?? "(host unavailable)"}; pass --target ${Object.keys(targetByTriple).join("|")}`, + ); +} + +const target = targetByTriple[triple]; +const source = join(repoRoot, "dist", "standalone", target); +const executable = join(source, target.startsWith("bun-windows-") ? "ocx.exe" : "ocx"); +if (!existsSync(executable)) { + const result = Bun.spawnSync([ + process.execPath, + "run", + "build:standalone", + "--target", + target, + ], { cwd: repoRoot, stdout: "inherit", stderr: "inherit" }); + if (result.exitCode !== 0) process.exit(result.exitCode); +} + +const desktopRoot = resolve(import.meta.dir, ".."); +const binaries = join(desktopRoot, "src-tauri", "binaries"); +const resources = join(desktopRoot, "src-tauri", "resources", "gui", "dist"); +mkdirSync(binaries, { recursive: true }); +mkdirSync(resources, { recursive: true }); +const destination = join(binaries, `ocx-${triple}${target.startsWith("bun-windows-") ? ".exe" : ""}`); +copyFileSync(executable, destination); +cpSync(join(repoRoot, "gui", "dist"), resources, { recursive: true }); +console.log(`Prepared ${destination}`); diff --git a/desktop/scripts/updater-manifest.ts b/desktop/scripts/updater-manifest.ts new file mode 100644 index 00000000000..e3fae9a372c --- /dev/null +++ b/desktop/scripts/updater-manifest.ts @@ -0,0 +1,104 @@ +import { + existsSync, + readFileSync, + renameSync, + writeFileSync, +} from "node:fs"; +import { join, resolve } from "node:path"; + +export interface UpdaterManifestOptions { + version: string; + dir: string; + repo: string; + out: string; + warn?: (message: string) => void; + requireAll?: boolean; +} + +interface PlatformUpdate { + signature: string; + url: string; +} + +export interface UpdaterManifest { + version: string; + notes: string; + pub_date: string; + platforms: Record; +} + +export const platformFiles: Record = { + "darwin-aarch64": "macos.app.tar.gz", + "darwin-x86_64": "macos.app.tar.gz", + "windows-x86_64": "windows-x64.msi", + // The AppImage is the plugin's default Linux target: it keeps the plain os-arch key so + // AppImage installs from releases before the deb target existed keep resolving updates. + "linux-x86_64": "linux-x86_64.AppImage", + // A deb install cannot apply an AppImage payload (the updater validates the downloaded + // bytes as a real .deb before installing), so it must resolve a distinct key. The shell + // selects this key from the bundle type embedded at packaging time; see updater.rs. + "linux-x86_64-deb": "linux-amd64.deb", +}; + +export function buildUpdaterManifest(options: UpdaterManifestOptions): UpdaterManifest { + const dir = resolve(options.dir); + const warn = options.warn ?? console.warn; + const platforms: Record = {}; + const missing: string[] = []; + for (const [platform, suffix] of Object.entries(platformFiles)) { + const base = `OpenCodex-${options.version}-${suffix}`; + const signaturePath = join(dir, `${base}.sig`); + if (!existsSync(signaturePath)) { + missing.push(platform); + if (!options.requireAll) { + warn(`Skipping ${platform}: missing ${signaturePath}`); + } + continue; + } + platforms[platform] = { + signature: readFileSync(signaturePath, "utf8").trim(), + url: `https://github.com/${options.repo}/releases/download/v${options.version}/${base}`, + }; + } + if (options.requireAll && missing.length > 0) { + throw new Error(`Missing signed updater platforms: ${missing.join(", ")}`); + } + if (Object.keys(platforms).length === 0) { + throw new Error("No signed updater platforms remain"); + } + return { + version: options.version, + notes: `https://github.com/${options.repo}/releases/tag/v${options.version}`, + pub_date: new Date().toISOString(), + platforms, + }; +} + +export function writeUpdaterManifest(options: UpdaterManifestOptions): UpdaterManifest { + const manifest = buildUpdaterManifest(options); + const output = resolve(options.out); + const temporary = `${output}.${process.pid}.tmp`; + writeFileSync(temporary, `${JSON.stringify(manifest, null, 2)}\n`); + renameSync(temporary, output); + return manifest; +} + +function argument(name: string): string | undefined { + const index = Bun.argv.indexOf(name); + return index < 0 ? undefined : Bun.argv[index + 1]; +} + +if (import.meta.main) { + const version = argument("--version"); + const dir = argument("--dir"); + const repo = argument("--repo"); + const out = argument("--out"); + const requireAll = Bun.argv.includes("--require-all"); + if (!version || !dir || !repo || !out) { + throw new Error( + "Usage: updater-manifest.ts --version --dir --repo --out [--require-all]", + ); + } + writeUpdaterManifest({ version, dir, repo, out, requireAll }); + console.log(`Wrote ${out}`); +} diff --git a/desktop/scripts/verify-release-assets.ts b/desktop/scripts/verify-release-assets.ts new file mode 100644 index 00000000000..7292be7a684 --- /dev/null +++ b/desktop/scripts/verify-release-assets.ts @@ -0,0 +1,343 @@ +/** + * Pre-publication release asset verification. + * + * Everything a release will publish is checked here, in the verify-release job, + * before any publication step may run: the expected platform file set derived from + * the workflow's own packaging matrices and the producer scripts' tables, every + * recorded checksum against the bytes on disk, every updater signature + * cryptographically against the pinned minisign public key, and the updater + * manifest parsed back against the files it names. The result is a + * machine-readable receipt; attach-release requires the receipt to name the same + * version and commit before it uploads anything, so publication can only ever + * consume the verified bundle. + */ +import { createHash, createPublicKey, verify as ed25519Verify, type KeyObject } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { + standaloneArchiveName, + standaloneTargets as sharedStandaloneTargets, +} from "../../scripts/standalone-targets"; +import { bundlesByTarget } from "./collect-release-assets"; +import { platformFiles, writeUpdaterManifest, type UpdaterManifest } from "./updater-manifest"; + +export interface VerifyReleaseAssetsOptions { + version: string; + dir: string; + repo: string; + sha: string; + repoRoot?: string; + manifestOut?: string; + receiptOut?: string; + requireSignatures?: boolean; +} + +export interface ReleaseVerificationReceipt { + version: string; + repo: string; + sha: string; + expectedFiles: number; + checksumsVerified: number; + signaturesVerified: number; + manifestPlatforms: string[]; +} + +/** + * The expected file set, derived from the producer tables rather than restated. + * Signatures are required only for the assets the updater actually signs — the + * unique suffixes in platformFiles — because the DMG and the deb are not updater + * targets and are never signed. + */ +export function expectedReleaseAssets(options: { + version: string; + desktopTargets: string[]; + requireSignatures?: boolean; +}): string[] { + const expected: string[] = []; + for (const target of sharedStandaloneTargets) { + const archive = standaloneArchiveName(options.version, target); + expected.push(archive, `${archive}.sha256`); + } + const updaterSuffixes = new Set(Object.values(platformFiles)); + for (const target of options.desktopTargets) { + const bundles = bundlesByTarget[target]; + if (!bundles) throw new Error(`Unsupported desktop target in release matrix: ${target}`); + for (const bundle of bundles) { + const asset = `OpenCodex-${options.version}-${bundle.name}`; + expected.push(asset, `${asset}.sha256`); + if (options.requireSignatures && updaterSuffixes.has(bundle.name)) { + expected.push(`${asset}.sig`); + } + } + } + return expected; +} + +/** The packaging matrices of the release workflow itself — the source of truth for the set. */ +export function releaseMatrixTargets(workflowText: string): { + standaloneTargets: string[]; + desktopTargets: string[]; +} { + const workflow = Bun.YAML.parse(workflowText) as { + jobs?: Record } } }>; + }; + const read = (job: string): string[] => + (workflow.jobs?.[job]?.strategy?.matrix?.include ?? []) + .map(entry => entry.target) + .filter((target): target is string => typeof target === "string"); + const standaloneTargets = read("package-standalone"); + const desktopTargets = read("package-desktop"); + if (standaloneTargets.length === 0 || desktopTargets.length === 0) { + throw new Error("release.yml packaging matrices are empty or unreadable"); + } + return { standaloneTargets, desktopTargets }; +} + +/** + * Every recorded checksum against the bytes on disk, in exactly the producers' + * format (64 hex, two spaces, bare name, one trailing newline). The recorded name + * must equal the checksum file's own name minus the suffix: a foo.sha256 naming + * bar would leave foo's bytes unchecked while bar's are checked twice. + */ +export function verifyChecksums(dir: string): number { + const checksumFiles = readdirSync(dir).filter(name => name.endsWith(".sha256")).sort(); + if (checksumFiles.length === 0) throw new Error(`No .sha256 files found in ${dir}`); + for (const checksumFile of checksumFiles) { + const content = readFileSync(join(dir, checksumFile), "utf8"); + const match = /^([0-9a-f]{64}) (\S+)\n$/.exec(content); + if (!match) throw new Error(`Malformed checksum record in ${checksumFile}: ${JSON.stringify(content)}`); + const digest = match[1]!; + const recorded = match[2]!; + const own = checksumFile.slice(0, -".sha256".length); + if (recorded !== own) { + throw new Error(`Checksum ${checksumFile} records ${recorded}; it must record its own payload ${own}`); + } + const payload = join(dir, recorded); + if (!existsSync(payload)) throw new Error(`Checksum ${checksumFile} names ${recorded}, which is missing`); + const actual = createHash("sha256").update(readFileSync(payload)).digest("hex"); + if (actual !== digest) { + throw new Error(`Checksum mismatch for ${recorded}: recorded ${digest}, computed ${actual}`); + } + } + return checksumFiles.length; +} + +const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex"); + +export interface MinisignPublicKey { + keyId: string; + publicKey: KeyObject; +} + +function minisignPayload(text: string, expectedBytes: number, what: string): Buffer { + const encoded = text + .split("\n") + .filter(line => line.trim().length > 0 && !line.trimStart().startsWith("untrusted comment:")) + .join("") + .trim(); + const payload = Buffer.from(encoded, "base64"); + if (payload.length !== expectedBytes) { + throw new Error(`Malformed ${what}: expected ${expectedBytes} decoded bytes, got ${payload.length}`); + } + return payload; +} + +/** minisign public key: base64 of algorithm ("Ed") || key id (8) || raw key (32). */ +export function parseMinisignPublicKey(text: string): MinisignPublicKey { + const payload = minisignPayload(text, 42, "minisign public key"); + const algorithm = payload.subarray(0, 2).toString("utf8"); + if (algorithm !== "Ed") { + throw new Error(`Unsupported minisign public key algorithm: ${JSON.stringify(algorithm)}`); + } + return { + keyId: payload.subarray(2, 10).toString("hex"), + publicKey: createPublicKey({ + key: Buffer.concat([ED25519_SPKI_PREFIX, payload.subarray(10, 42)]), + format: "der", + type: "spki", + }), + }; +} + +/** The updater public key pinned in the Tauri configuration. */ +export function loadUpdaterPublicKey(tauriConfPath: string): MinisignPublicKey { + const conf = JSON.parse(readFileSync(tauriConfPath, "utf8")) as { + plugins?: { updater?: { pubkey?: string } }; + }; + const pubkey = conf.plugins?.updater?.pubkey; + if (!pubkey) throw new Error(`No plugins.updater.pubkey in ${tauriConfPath}`); + return parseMinisignPublicKey(Buffer.from(pubkey, "base64").toString("utf8")); +} + +/** + * minisign signature: base64 of algorithm || key id (8) || signature (64). + * "Ed" is a pure Ed25519 signature over the raw file bytes — the form the Tauri + * bundler emits. "ED" (BLAKE2b-prehashed) or anything else fails loudly rather + * than being silently mis-verified. + */ +export function verifyUpdaterSignature(filePath: string, key: MinisignPublicKey): void { + const signaturePath = `${filePath}.sig`; + if (!existsSync(signaturePath)) throw new Error(`Missing signature: ${signaturePath}`); + const payload = minisignPayload(readFileSync(signaturePath, "utf8"), 74, `signature ${signaturePath}`); + const algorithm = payload.subarray(0, 2).toString("utf8"); + if (algorithm !== "Ed") { + throw new Error(`Unsupported signature algorithm in ${signaturePath}: ${JSON.stringify(algorithm)}`); + } + const keyId = payload.subarray(2, 10).toString("hex"); + if (keyId !== key.keyId) { + throw new Error(`Signature ${signaturePath} was made by key ${keyId}, not the pinned updater key ${key.keyId}`); + } + if (!ed25519Verify(null, readFileSync(filePath), key.publicKey, payload.subarray(10, 74))) { + throw new Error(`Signature verification failed for ${filePath}`); + } +} + +function parseBackManifest(manifestPath: string, options: VerifyReleaseAssetsOptions): string[] { + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as UpdaterManifest; + if (manifest.version !== options.version) { + throw new Error(`Manifest version ${manifest.version} != ${options.version}`); + } + const platforms = Object.keys(manifest.platforms).sort(); + const expectedPlatforms = Object.keys(platformFiles).sort(); + if (JSON.stringify(platforms) !== JSON.stringify(expectedPlatforms)) { + throw new Error( + `Manifest platforms (${platforms.join(", ")}) do not match the updater platform set (${expectedPlatforms.join(", ")})`, + ); + } + for (const [platform, entry] of Object.entries(manifest.platforms)) { + const base = `OpenCodex-${options.version}-${platformFiles[platform]}`; + const expectedUrl = `https://github.com/${options.repo}/releases/download/v${options.version}/${base}`; + if (entry.url !== expectedUrl) { + throw new Error(`Manifest entry ${platform} points at ${entry.url}, expected ${expectedUrl}`); + } + if (!existsSync(join(options.dir, base))) { + throw new Error(`Manifest entry ${platform} names ${base}, which is missing`); + } + // The manifest must carry exactly the signature that was just verified, + // not merely a nonempty string. + const sidecar = readFileSync(join(options.dir, `${base}.sig`), "utf8").trim(); + if (entry.signature !== sidecar) { + throw new Error(`Manifest entry ${platform} signature does not match ${base}.sig`); + } + } + return platforms; +} + +function atomicWrite(path: string, content: string): void { + mkdirSync(dirname(path), { recursive: true }); + const temporary = `${path}.${process.pid}.tmp`; + writeFileSync(temporary, content); + renameSync(temporary, path); +} + +export function verifyReleaseAssets(options: VerifyReleaseAssetsOptions): ReleaseVerificationReceipt { + const repoRoot = resolve(options.repoRoot ?? join(import.meta.dir, "../..")); + const dir = resolve(options.dir); + const { standaloneTargets, desktopTargets } = releaseMatrixTargets( + readFileSync(join(repoRoot, ".github", "workflows", "release.yml"), "utf8"), + ); + // The workflow matrix must describe exactly the shared target set the builder + // uses; a target added to one and not the other fails here, not at release time. + const workflowStandalone = [...standaloneTargets].sort(); + const sharedStandalone = [...sharedStandaloneTargets].sort(); + if (JSON.stringify(workflowStandalone) !== JSON.stringify(sharedStandalone)) { + throw new Error( + `release.yml package-standalone matrix (${workflowStandalone.join(", ")})` + + ` does not match scripts/standalone-targets.ts (${sharedStandalone.join(", ")})`, + ); + } + const expected = expectedReleaseAssets({ + version: options.version, + desktopTargets, + requireSignatures: options.requireSignatures, + }); + const missing = expected.filter(name => !existsSync(join(dir, name))); + if (missing.length > 0) { + throw new Error(`Missing expected release assets:\n${missing.join("\n")}`); + } + + const checksumsVerified = verifyChecksums(dir); + + const updaterKey = loadUpdaterPublicKey( + join(repoRoot, "desktop", "src-tauri", "tauri.conf.json"), + ); + // Every signature present is verified, required or not: a tampered signature in + // an unsigned dry-run bundle must fail, not be skipped. + let signaturesVerified = 0; + for (const name of readdirSync(dir).filter(candidate => candidate.endsWith(".sig")).sort()) { + const payload = join(dir, name.slice(0, -".sig".length)); + if (!existsSync(payload)) throw new Error(`Signature ${name} has no payload beside it`); + verifyUpdaterSignature(payload, updaterKey); + signaturesVerified += 1; + } + + let manifestPlatforms: string[] = []; + if (options.manifestOut) { + writeUpdaterManifest({ + version: options.version, + dir, + repo: options.repo, + out: options.manifestOut, + requireAll: options.requireSignatures, + }); + manifestPlatforms = parseBackManifest(options.manifestOut, options); + } + + // attach-release uploads dist/release/* verbatim, so anything unexpected here + // would be published unchecked. The bundle is exactly the expected set plus + // the manifest this run just generated. + const allowed = new Set(expected); + if (options.manifestOut) allowed.add(options.manifestOut.split(/[\\/]/).pop()!); + const extras = readdirSync(dir).filter(name => !allowed.has(name)); + if (extras.length > 0) { + throw new Error(`Unexpected files in the release bundle (refusing to publish them):\n${extras.join("\n")}`); + } + + const receipt: ReleaseVerificationReceipt = { + version: options.version, + repo: options.repo, + sha: options.sha, + expectedFiles: expected.length, + checksumsVerified, + signaturesVerified, + manifestPlatforms, + }; + if (options.receiptOut) { + atomicWrite(options.receiptOut, `${JSON.stringify(receipt, null, 2)}\n`); + } + return receipt; +} + +function argument(name: string): string | undefined { + const index = Bun.argv.indexOf(name); + return index < 0 ? undefined : Bun.argv[index + 1]; +} + +if (import.meta.main) { + const version = argument("--version"); + const dir = argument("--dir"); + const repo = argument("--repo"); + const sha = argument("--sha"); + if (!version || !dir || !repo || !sha) { + throw new Error( + "Usage: verify-release-assets.ts --version --dir --repo --sha " + + " [--manifest-out ] [--require-signatures] [--receipt-out ]", + ); + } + const receipt = verifyReleaseAssets({ + version, + dir, + repo, + sha, + manifestOut: argument("--manifest-out"), + receiptOut: argument("--receipt-out"), + requireSignatures: Bun.argv.includes("--require-signatures"), + }); + console.log( + `Verified ${receipt.expectedFiles} expected files, ${receipt.checksumsVerified} checksums,` + + ` ${receipt.signaturesVerified} signatures` + + (receipt.manifestPlatforms.length > 0 + ? `, manifest platforms: ${receipt.manifestPlatforms.join(", ")}` + : ""), + ); +} diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock new file mode 100644 index 00000000000..db1cf16767c --- /dev/null +++ b/desktop/src-tauri/Cargo.lock @@ -0,0 +1,5656 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "auto-launch" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f012b8cc0c850f34117ec8252a44418f2e34a2cf501de89e29b241ae5f79471" +dependencies = [ + "dirs 4.0.0", + "thiserror 1.0.69", + "winreg 0.10.1", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide 0.8.9", + "object", + "rustc-demangle", + "windows-link 0.2.1", +] + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" +dependencies = [ + "serde", +] + +[[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 = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +dependencies = [ + "serde", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.9.4", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "camino" +version = "1.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0b03af37dad7a14518b7691d81acb0f8222604ad3d1b02f6b4bed5188c0cd5" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.20", +] + +[[package]] +name = "cargo_toml" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" +dependencies = [ + "serde", + "toml 0.9.5", +] + +[[package]] +name = "cc" +version = "1.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core", +] + +[[package]] +name = "chrono" +version = "0.4.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "num-traits", + "serde", + "windows-link 0.1.3", +] + +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "cookie" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags 2.9.4", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.9.4", + "core-foundation", + "libc", +] + +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[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 = "cssparser" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "ctor" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98" +dependencies = [ + "ctor-proc-macro", + "dtor", +] + +[[package]] +name = "ctor-proc-macro" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dbus" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "deranged" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d630bccd429a5bb5a64b5e94f693bfc48c9f8566418fda4c494cc94f911f87cc" +dependencies = [ + "powerfmt", + "serde", +] + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", +] + +[[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 = "dirs" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" +dependencies = [ + "dirs-sys 0.3.7", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys 0.5.0", +] + +[[package]] +name = "dirs-sys" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" +dependencies = [ + "libc", + "redox_users 0.4.6", + "winapi", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.5.3", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.9.4", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dom_query" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" +dependencies = [ + "bit-set", + "cssparser", + "foldhash", + "html5ever", + "precomputed-hash", + "selectors", + "tendril", +] + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +dependencies = [ + "serde", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dtor" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "embed-resource" +version = "3.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63a1d0de4f2249aa0ff5884d7080814f446bb241a559af6c170a41e878ed2d45" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 0.9.5", + "vswhom", + "winreg 0.55.0", +] + +[[package]] +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + +[[package]] +name = "encoding_rs" +version = "0.8.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5ef0006ac9ab233c38522f5ae99cae3625151de8f706cacee1cba4b8e2832a" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e004d887f51fcb9fef17317a2f3525c887d8aa3f4f50fed920816a688284a5b7" +dependencies = [ + "serde", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[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.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[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 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core", + "wasm-bindgen", +] + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.9.4", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "html5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" +dependencies = [ + "log", + "markup5ever", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.5", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ico" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" +dependencies = [ + "byteorder", + "png 0.17.16", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png 0.18.1", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "js-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "jsonptr" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.9.4", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading", + "once_cell", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libredox" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6480ccc157a1389bb2e4891b24751b0f798ba640d22386f23143fbcc89da195a" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + +[[package]] +name = "markup5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minisign-verify" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "muda" +version = "0.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.20", + "windows-sys 0.61.2", +] + +[[package]] +name = "multiversion" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ca4bea16ffc3f443cf7d866912118196bfef4c6a1556ca00f9f9b00bb43f7c" +dependencies = [ + "multiversion-macros", +] + +[[package]] +name = "multiversion-macros" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d416831a7317ef4b08bee00b69cbbb9c8763da7959a7026244d6266869f9c83" +dependencies = [ + "proc-macro2", + "quote", + "rustversion", + "syn 3.0.6", +] + +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.9.4", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate 3.4.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.9.4", + "block2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.9.4", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.9.4", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.9.4", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.9.4", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.9.4", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.9.4", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-osa-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" +dependencies = [ + "bitflags 2.9.4", + "objc2", + "objc2-app-kit", + "objc2-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.9.4", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.9.4", + "block2", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +dependencies = [ + "bitflags 2.9.4", + "block2", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "open" +version = "5.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa576c76302b7b808eecc68061e67336c47833ef9d22caa74dda10fa9675eebc" +dependencies = [ + "dunce", + "is-wsl", + "libc", +] + +[[package]] +name = "opencodex-desktop" +version = "2.61.0" +dependencies = [ + "dbus", + "reqwest 0.12.24", + "serde", + "serde_json", + "tauri", + "tauri-build", + "tauri-plugin-autostart", + "tauri-plugin-opener", + "tauri-plugin-process", + "tauri-plugin-shell", + "tauri-plugin-single-instance", + "tauri-plugin-updater", + "tauri-utils", + "tokio", + "uuid", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "osakit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b" +dependencies = [ + "objc2", + "objc2-foundation", + "objc2-osa-kit", + "serde", + "serde_json", + "thiserror 2.0.20", +] + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "plist" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" +dependencies = [ + "base64 0.22.1", + "indexmap 2.14.2", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.9.4", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-crate" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +dependencies = [ + "toml_edit 0.23.4", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[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 = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quick-xml" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +dependencies = [ + "memchr", +] + +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2 0.6.5", + "thiserror 2.0.20", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.20", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.6.5", + "tracing", + "windows-sys 0.61.2", +] + +[[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 = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.9.4", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "redox_users" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" +dependencies = [ + "libredox", + "thiserror 2.0.20", +] + +[[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 = "reqwest" +version = "0.12.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.4.2", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "reqwest" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.5.0", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags 2.9.4", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.119", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "selectors" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" +dependencies = [ + "bitflags 2.9.4", + "cssparser", + "derive_more", + "log", + "new_debug_unreachable", + "phf", + "phf_codegen", + "precomputed-hash", + "rustc-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" +dependencies = [ + "serde", +] + +[[package]] +name = "serde" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde-untagged" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34836a629bcbc6f1afdf0907a744870039b1e14c0561cb26094fa683b158eff3" +dependencies = [ + "erased-serde", + "serde", + "typeid", +] + +[[package]] +name = "serde_derive" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serde_json" +version = "1.0.140" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40734c41988f7306bb04f0ecf60ec0f3f1caa34290e4e8ea471dcd3346483b83" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21e47d95bc83ed33b2ecf84f4187ad1ab9685d18ff28db000c99deac8ce180e3" +dependencies = [ + "base64 0.21.7", + "chrono", + "hex", + "indexmap 1.9.3", + "serde", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea3cee93715c2e266b9338b7544da68a9f24e227722ba482bd1c024367c77c65" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serialize-to-javascript" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" +dependencies = [ + "serde", + "serde_json", + "serialize-to-javascript-impl", +] + +[[package]] +name = "serialize-to-javascript-impl" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "shared_child" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "607549934f6cc26b89cfecfdc46fa90f1e5d1536a68349b0c3a4f9d1c0d37959" +dependencies = [ + "libc", + "sigchld", + "windows-sys 0.61.2", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "sigchld" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24f2b37f04360cd465089b87a9c3869c08220a2f3458463f0adf8badf5e77f2c" +dependencies = [ + "libc", + "os_pipe", + "signal-hook", +] + +[[package]] +name = "signal-hook" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a0c28ca5908dbdbcd52e6fdaa00358ab88637f8ab33e1f188dd510eb44b53d" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "bytemuck", + "js-sys", + "ndk", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-quartz-core", + "raw-window-handle", + "redox_syscall", + "tracing", + "wasm-bindgen", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "swift-rs" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e45c444e496845d3f2a351146bff59aae4975b2280238df1dfaa0c7d1846f38e" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.2", + "version-compare", +] + +[[package]] +name = "tao" +version = "0.35.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" +dependencies = [ + "bitflags 2.9.4", + "block2", + "core-foundation", + "core-graphics", + "crossbeam-channel", + "dbus", + "dispatch2", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni", + "libc", + "log", + "ndk", + "ndk-sys", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "once_cell", + "parking_lot", + "percent-encoding", + "raw-window-handle", + "tao-macros", + "unicode-segmentation", + "url", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f7eeb6d99155545da6150a1795945f16ac9c178deb2a5f2e74d776107bd5849" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tauri" +version = "2.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fa5bacdb9bbad5954af3d1bd6cf6ae9192cab1b2e270f4a07f904610b9e85f4" +dependencies = [ + "anyhow", + "bytes", + "cookie", + "dirs 6.0.0", + "dunce", + "embed_plist", + "getrandom 0.3.4", + "glob", + "gtk", + "heck 0.5.0", + "http", + "image", + "jni", + "libc", + "log", + "mime", + "muda", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "percent-encoding", + "plist", + "raw-window-handle", + "reqwest 0.13.5", + "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "swift-rs", + "tauri-build", + "tauri-macros", + "tauri-runtime", + "tauri-runtime-wry", + "tauri-utils", + "thiserror 2.0.20", + "tokio", + "tray-icon", + "url", + "webkit2gtk", + "webview2-com", + "window-vibrancy", + "windows", +] + +[[package]] +name = "tauri-build" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9ce40b16101cb6ea63d3e221567affd1c3a9205f95d7bc574941a10636b632" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs 6.0.0", + "glob", + "heck 0.5.0", + "json-patch", + "schemars", + "semver", + "serde", + "serde_json", + "tauri-utils", + "tauri-winres", + "walkdir", +] + +[[package]] +name = "tauri-codegen" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08279169ff42f8fc45a1dbc9dcae888893ba95288142e5880c59b93a26d2cfc5" +dependencies = [ + "base64 0.22.1", + "brotli", + "ico", + "json-patch", + "plist", + "png 0.17.16", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "sha2", + "syn 2.0.119", + "tauri-utils", + "thiserror 2.0.20", + "time", + "url", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-macros" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8b394794f399a421811d06966343e7933fcae92d59f5180b9388d1174497a45" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", + "tauri-codegen", + "tauri-utils", +] + +[[package]] +name = "tauri-plugin" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars", + "serde", + "serde_json", + "tauri-utils", + "walkdir", +] + +[[package]] +name = "tauri-plugin-autostart" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "062cdcd483d5e3148c9a64dabf8c574e239e2aa1193cf208d95cf89a676f87a5" +dependencies = [ + "auto-launch", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.20", +] + +[[package]] +name = "tauri-plugin-opener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc624469b06f59f5a29f874bbc61a2ed737c0f9c23ef09855a292c389c42e83f" +dependencies = [ + "dunce", + "glob", + "objc2-app-kit", + "objc2-foundation", + "open", + "schemars", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.20", + "url", + "windows", + "zbus", +] + +[[package]] +name = "tauri-plugin-process" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7461c622a5ea00eb9cd9f7a08dbd3bf79484499fd5c21aa2964677f64ca651ab" +dependencies = [ + "tauri", + "tauri-plugin", +] + +[[package]] +name = "tauri-plugin-shell" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb2c50a63e60fb8925956cc5b7569f4b750ac197a4d39f13b8dd46ea8e2bad79" +dependencies = [ + "encoding_rs", + "log", + "open", + "os_pipe", + "regex", + "schemars", + "serde", + "serde_json", + "shared_child", + "tauri", + "tauri-plugin", + "thiserror 2.0.20", + "tokio", +] + +[[package]] +name = "tauri-plugin-single-instance" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc61e4822b8f74d68278e09161d3e3fdd1b14b9eb781e24edccaabf10c420e8c" +dependencies = [ + "serde", + "serde_json", + "tauri", + "thiserror 2.0.20", + "tracing", + "windows-sys 0.60.2", + "zbus", +] + +[[package]] +name = "tauri-plugin-updater" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27cbc31740f4d507712550694749572ec0e43bdd66992db7599b89fbfd6b167b" +dependencies = [ + "base64 0.22.1", + "dirs 6.0.0", + "flate2", + "futures-util", + "http", + "infer", + "log", + "minisign-verify", + "osakit", + "percent-encoding", + "reqwest 0.12.24", + "semver", + "serde", + "serde_json", + "tar", + "tauri", + "tauri-plugin", + "tempfile", + "thiserror 2.0.20", + "time", + "tokio", + "url", + "windows-sys 0.60.2", + "zip", +] + +[[package]] +name = "tauri-runtime" +version = "2.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0b4bc95aed361b0019067d189a1174a603d460d0f6c72606512d59fc9c12ec8" +dependencies = [ + "cookie", + "dpi", + "gtk", + "http", + "jni", + "objc2", + "objc2-ui-kit", + "objc2-web-kit", + "raw-window-handle", + "serde", + "serde_json", + "tauri-utils", + "thiserror 2.0.20", + "url", + "webkit2gtk", + "webview2-com", + "windows", +] + +[[package]] +name = "tauri-runtime-wry" +version = "2.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f" +dependencies = [ + "gtk", + "http", + "jni", + "log", + "objc2", + "objc2-app-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "softbuffer", + "tao", + "tauri-runtime", + "tauri-utils", + "url", + "webkit2gtk", + "webview2-com", + "windows", + "wry", +] + +[[package]] +name = "tauri-utils" +version = "2.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e176a18e67764923c4f1ce66f25ae4abe5f688384d5eb1a0fa6c77f3d90f887" +dependencies = [ + "anyhow", + "brotli", + "cargo_metadata", + "ctor", + "dom_query", + "dunce", + "glob", + "http", + "infer", + "json-patch", + "log", + "memchr", + "phf", + "plist", + "proc-macro2", + "quote", + "regex", + "schemars", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.20", + "toml 0.9.5", + "url", + "urlpattern", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-winres" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1087b111fe2b005e42dbdc1990fc18593234238d47453b0c99b7de1c9ab2c1e0" +dependencies = [ + "dunce", + "embed-resource", + "toml 0.9.5", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "tendril" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" +dependencies = [ + "new_debug_unreachable", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "time" +version = "0.3.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" + +[[package]] +name = "time-macros" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75ef51a33ef1da925cea3e4eb122833cb377c61439ca401b770f54902b806779" +dependencies = [ + "backtrace", + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2 0.5.10", + "windows-sys 0.52.0", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75129e1dc5000bfbaa9fee9d1b21f974f9fbad9daec557a521ee6e080825f6e8" +dependencies = [ + "indexmap 2.14.2", + "serde", + "serde_spanned 1.0.0", + "toml_datetime 0.7.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bade1c3e902f58d73d3f294cd7f20391c1cb2fbcb643b73566bc773971df91e3" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.14.2", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap 2.14.2", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7211ff1b8f0d3adae1663b7da9ffe396eabe1ca25f0b0bee42b0da29a9ddce93" +dependencies = [ + "indexmap 2.14.2", + "toml_datetime 0.7.0", + "toml_parser", + "winnow 0.7.15", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.9.4", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tray-icon" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "045979e3f037cd18ad1cb2a419dfda133c5c29c9f3453370079f2255d46c257e" +dependencies = [ + "crossbeam-channel", + "dirs 6.0.0", + "libappindicator", + "muda", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.20", + "windows-sys 0.61.2", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-ucd-ident" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlpattern" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +dependencies = [ + "regex", + "serde", + "unic-ucd-ident", + "url", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" +dependencies = [ + "getrandom 0.3.4", + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[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 = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web_atoms" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webview2-com" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows", + "windows-core 0.61.2", + "windows-implement", + "windows-interface", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "webview2-com-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" +dependencies = [ + "thiserror 2.0.20", + "windows", + "windows-core 0.61.2", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window-vibrancy" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" +dependencies = [ + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "windows-sys 0.59.0", + "windows-version", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "wry" +version = "0.55.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514" +dependencies = [ + "base64 0.22.1", + "block2", + "cookie", + "crossbeam-channel", + "dirs 6.0.0", + "dom_query", + "dpi", + "dunce", + "gdkx11", + "gtk", + "http", + "javascriptcore-rs", + "jni", + "libc", + "ndk", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "sha2", + "soup3", + "tao-macros", + "thiserror 2.0.20", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.4", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate 3.4.0", + "proc-macro2", + "quote", + "syn 3.0.6", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow 1.0.4", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "zip" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" +dependencies = [ + "arbitrary", + "crc32fast", + "indexmap 2.14.2", + "memchr", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zvariant" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.4", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +dependencies = [ + "proc-macro-crate 3.4.0", + "proc-macro2", + "quote", + "syn 3.0.6", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.6", + "winnow 1.0.4", +] diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml new file mode 100644 index 00000000000..2b323a3cb3e --- /dev/null +++ b/desktop/src-tauri/Cargo.toml @@ -0,0 +1,52 @@ +[package] +name = "opencodex-desktop" +version = "2.61.0" +description = "OpenCodex desktop shell" +authors = ["OpenCodex contributors"] +license = "MIT" +edition = "2021" +rust-version = "1.77" + +[lib] +name = "opencodex_desktop_lib" +crate-type = ["staticlib", "cdylib", "rlib"] + +[build-dependencies] +tauri-build = { version = "=2.6.3", features = [] } + +[dependencies] +reqwest = { version = "=0.12.24", default-features = false, features = ["json", "rustls-tls"] } +serde = { version = "=1.0.219", features = ["derive"] } +serde_json = "=1.0.140" +uuid = { version = "=1.18.1", features = ["v4"] } +tauri = { version = "=2.11.6", features = ["tray-icon", "image-png"] } +tauri-utils = "=2.9.3" +tauri-plugin-autostart = "=2.5.0" +tauri-plugin-opener = "=2.5.3" +tauri-plugin-process = "=2.3.0" +tauri-plugin-shell = "=2.2.0" +tauri-plugin-single-instance = "=2.4.0" +tauri-plugin-updater = "=2.9.0" +tokio = { version = "=1.45.1", features = ["sync", "time"] } + +# Linux only, and already in this graph: `tao` enables its own `dbus` feature by default, so +# `libdbus-sys` is compiled for every Linux build of this shell today. Naming it here adds a +# session-bus probe for the StatusNotifier watcher without adding a package or a system library. +[target.'cfg(target_os = "linux")'.dependencies] +dbus = "=0.9.12" + +[profile.release] +codegen-units = 1 +lto = "thin" +opt-level = "s" +strip = "symbols" + +# Build scripts and proc macros are compiled for the host and loaded by rustc, so +# they must keep the symbols it resolves them through. `strip = "symbols"` above +# applies to them as well without this override, and a stripped proc-macro dylib +# fails to load with a bare `can't find crate`, naming the macro rather than the +# profile that removed it. `ctor-proc-macro`, pulled in by `tauri-utils`, is the +# one this repository hits: the release build stops at `pub use ctor_proc_macro::ctor` +# while the dev profile compiles the same graph. +[profile.release.build-override] +strip = false diff --git a/desktop/src-tauri/build.rs b/desktop/src-tauri/build.rs new file mode 100644 index 00000000000..d860e1e6a7c --- /dev/null +++ b/desktop/src-tauri/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build() +} diff --git a/desktop/src-tauri/capabilities/default.json b/desktop/src-tauri/capabilities/default.json new file mode 100644 index 00000000000..622143b1fa4 --- /dev/null +++ b/desktop/src-tauri/capabilities/default.json @@ -0,0 +1,14 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "Bootstrap-only shell permissions", + "windows": ["main"], + "permissions": [ + "core:default", + "core:window:allow-show", + "core:window:allow-hide", + "core:window:allow-set-title", + "opener:default", + "autostart:default" + ] +} diff --git a/desktop/src-tauri/icons/128x128.png b/desktop/src-tauri/icons/128x128.png new file mode 100644 index 00000000000..8827b1fdc27 Binary files /dev/null and b/desktop/src-tauri/icons/128x128.png differ diff --git a/desktop/src-tauri/icons/128x128@2x.png b/desktop/src-tauri/icons/128x128@2x.png new file mode 100644 index 00000000000..21c507c776c Binary files /dev/null and b/desktop/src-tauri/icons/128x128@2x.png differ diff --git a/desktop/src-tauri/icons/32x32.png b/desktop/src-tauri/icons/32x32.png new file mode 100644 index 00000000000..47a19b8131c Binary files /dev/null and b/desktop/src-tauri/icons/32x32.png differ diff --git a/desktop/src-tauri/icons/64x64.png b/desktop/src-tauri/icons/64x64.png new file mode 100644 index 00000000000..5f7a0713c82 Binary files /dev/null and b/desktop/src-tauri/icons/64x64.png differ diff --git a/desktop/src-tauri/icons/Square107x107Logo.png b/desktop/src-tauri/icons/Square107x107Logo.png new file mode 100644 index 00000000000..9436cf61e1c Binary files /dev/null and b/desktop/src-tauri/icons/Square107x107Logo.png differ diff --git a/desktop/src-tauri/icons/Square142x142Logo.png b/desktop/src-tauri/icons/Square142x142Logo.png new file mode 100644 index 00000000000..80d163ad59a Binary files /dev/null and b/desktop/src-tauri/icons/Square142x142Logo.png differ diff --git a/desktop/src-tauri/icons/Square150x150Logo.png b/desktop/src-tauri/icons/Square150x150Logo.png new file mode 100644 index 00000000000..5148c874dcc Binary files /dev/null and b/desktop/src-tauri/icons/Square150x150Logo.png differ diff --git a/desktop/src-tauri/icons/Square284x284Logo.png b/desktop/src-tauri/icons/Square284x284Logo.png new file mode 100644 index 00000000000..d4ccde8b23f Binary files /dev/null and b/desktop/src-tauri/icons/Square284x284Logo.png differ diff --git a/desktop/src-tauri/icons/Square30x30Logo.png b/desktop/src-tauri/icons/Square30x30Logo.png new file mode 100644 index 00000000000..acbbb84ceb3 Binary files /dev/null and b/desktop/src-tauri/icons/Square30x30Logo.png differ diff --git a/desktop/src-tauri/icons/Square310x310Logo.png b/desktop/src-tauri/icons/Square310x310Logo.png new file mode 100644 index 00000000000..1b9c14d45a4 Binary files /dev/null and b/desktop/src-tauri/icons/Square310x310Logo.png differ diff --git a/desktop/src-tauri/icons/Square44x44Logo.png b/desktop/src-tauri/icons/Square44x44Logo.png new file mode 100644 index 00000000000..5ce2668bf23 Binary files /dev/null and b/desktop/src-tauri/icons/Square44x44Logo.png differ diff --git a/desktop/src-tauri/icons/Square71x71Logo.png b/desktop/src-tauri/icons/Square71x71Logo.png new file mode 100644 index 00000000000..f9a361ad932 Binary files /dev/null and b/desktop/src-tauri/icons/Square71x71Logo.png differ diff --git a/desktop/src-tauri/icons/Square89x89Logo.png b/desktop/src-tauri/icons/Square89x89Logo.png new file mode 100644 index 00000000000..eafa6f2087d Binary files /dev/null and b/desktop/src-tauri/icons/Square89x89Logo.png differ diff --git a/desktop/src-tauri/icons/StoreLogo.png b/desktop/src-tauri/icons/StoreLogo.png new file mode 100644 index 00000000000..86218245878 Binary files /dev/null and b/desktop/src-tauri/icons/StoreLogo.png differ diff --git a/desktop/src-tauri/icons/icon.icns b/desktop/src-tauri/icons/icon.icns new file mode 100644 index 00000000000..12ddb09d391 Binary files /dev/null and b/desktop/src-tauri/icons/icon.icns differ diff --git a/desktop/src-tauri/icons/icon.ico b/desktop/src-tauri/icons/icon.ico new file mode 100644 index 00000000000..80ded4a940b Binary files /dev/null and b/desktop/src-tauri/icons/icon.ico differ diff --git a/desktop/src-tauri/icons/icon.png b/desktop/src-tauri/icons/icon.png new file mode 100644 index 00000000000..5c8fa696c68 Binary files /dev/null and b/desktop/src-tauri/icons/icon.png differ diff --git a/desktop/src-tauri/icons/icon.svg b/desktop/src-tauri/icons/icon.svg new file mode 100644 index 00000000000..7070d673ad5 --- /dev/null +++ b/desktop/src-tauri/icons/icon.svg @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/desktop/src-tauri/icons/tray/icon.png b/desktop/src-tauri/icons/tray/icon.png new file mode 100644 index 00000000000..1eec4cccaf1 Binary files /dev/null and b/desktop/src-tauri/icons/tray/icon.png differ diff --git a/desktop/src-tauri/icons/tray/icon.svg b/desktop/src-tauri/icons/tray/icon.svg new file mode 100644 index 00000000000..1f917f1f018 --- /dev/null +++ b/desktop/src-tauri/icons/tray/icon.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + diff --git a/desktop/src-tauri/src/auth.rs b/desktop/src-tauri/src/auth.rs new file mode 100644 index 00000000000..81a16467b05 --- /dev/null +++ b/desktop/src-tauri/src/auth.rs @@ -0,0 +1,31 @@ +use std::path::PathBuf; + +#[derive(Clone, Debug)] +pub struct Auth { + home: PathBuf, + environment_token: Option, +} + +impl Auth { + pub fn new(home: PathBuf) -> Self { + Self { + home, + environment_token: std::env::var("OPENCODEX_ADMIN_AUTH_TOKEN") + .ok() + .filter(|value| !value.is_empty()), + } + } + + pub fn token(&self) -> Option { + self.environment_token.clone().or_else(|| { + std::fs::read_to_string(self.home.join("admin-api-token")) + .ok() + .map(|value| value.trim().to_owned()) + .filter(|value| !value.is_empty()) + }) + } + + pub fn user_agent() -> &'static str { + concat!("OpenCodexDesktop/", env!("CARGO_PKG_VERSION")) + } +} diff --git a/desktop/src-tauri/src/endpoint.rs b/desktop/src-tauri/src/endpoint.rs new file mode 100644 index 00000000000..09a78e22fb7 --- /dev/null +++ b/desktop/src-tauri/src/endpoint.rs @@ -0,0 +1,33 @@ +//! The loopback endpoint the shell talks to. +//! +//! This file was `discovery.rs`, and it resolved the endpoint itself: it read `runtime-port.json`, +//! fell back to 10100 and let the shell start there, so a user with a configured `config.port` was +//! started on a port they had not chosen. Resolution belongs to the bundled CLI now — see +//! `resolve.rs` — and what is left here is the value it hands back. + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ProxyEndpoint { + pub host: &'static str, + pub port: u16, +} + +impl ProxyEndpoint { + pub fn url(&self, path: &str) -> String { + format!("http://{}:{}{}", self.host, self.port, path) + } +} + +#[cfg(test)] +mod tests { + use super::ProxyEndpoint; + + #[test] + fn the_endpoint_is_loopback_and_carries_its_port() { + let endpoint = ProxyEndpoint { + host: "127.0.0.1", + port: 12345, + }; + assert_eq!(endpoint.url("/healthz"), "http://127.0.0.1:12345/healthz"); + assert_eq!(endpoint.url(""), "http://127.0.0.1:12345"); + } +} diff --git a/desktop/src-tauri/src/exit.rs b/desktop/src-tauri/src/exit.rs new file mode 100644 index 00000000000..aba0aab4b39 --- /dev/null +++ b/desktop/src-tauri/src/exit.rs @@ -0,0 +1,756 @@ +//! Who is allowed to end the process, and what has to happen first. +//! +//! Three gestures arrive looking like an exit: closing the window, the platform's own quit gesture +//! (Cmd+Q on macOS, Alt+F4 on Windows, the window manager's close on Linux), and the tray's Quit +//! item. Only the last one means "end the runtime". Until this module existed the shell had no +//! `ExitRequested` handler, so the quit gesture went straight to `RunEvent::Exit`, which called +//! `CommandChild::kill()` — a SIGKILL on Unix — on a keystroke the user reads as "hide". +//! +//! Tauri separates a user gesture from a programmatic exit: `RunEvent::ExitRequested` carries +//! `code: None` for the gesture and `Some(_)` for `AppHandle::exit` or `AppHandle::restart`. What +//! it cannot tell apart is the tray's Quit from an update's restart, and those end differently. +//! [`ExitReason`] records which one asked. macOS needs one more thing on top, because its menu Quit +//! never raises the event at all; see [`crate::menu`]. +//! +//! Everything that stops the runtime funnels through one phase here — the tray's Quit, the tray's +//! Stop, an update, and a window close on a session with no tray. Two of them running at once is +//! two stops racing over one child, so a second one waits rather than starting its own. +//! +//! A drain that does not complete is **not** recorded as a drain. A quit may still proceed on one, +//! because refusing to close is the worse answer and a standing runtime is recoverable. A restart +//! may not: coming back onto a runtime that was never stopped puts the user on the old version +//! while they believe they are on the new one. + +use crate::{ + proxy::ProxyClient, runtime_stop, sidecar, tray_availability::TrayAvailability, window, + AppState, +}; +use std::sync::{Mutex, MutexGuard, PoisonError}; +use tauri::{AppHandle, ExitRequestApi, Manager}; +use tokio::time::Instant; + +/// Why the process has been asked to end. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ExitReason { + /// The tray's Quit item, or a window close on a session with no tray to hide into. + UserQuit, + /// An installed update restarting the app. It drains exactly as a quit does and then comes + /// back, which is why it is a coordinated restart rather than an exception to the quit rule. + CoordinatedRestart, +} + +/// How far the one drain sequence has got. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ExitPhase { + /// Nothing is in flight. + Idle, + /// A runtime is being started. An exit arriving now is held until the child exists and is + /// recorded, because the alternative is a process nobody owns and nobody will stop. + Spawning, + /// The runtime is being stopped without ending the app: the tray's Stop. + Stopping, + /// The drain that ends the app is running. + Draining, + /// The runtime this app owned is confirmed stopped, or was never ours to stop. + Drained, + /// The stop was refused, or the runtime still answered after the deadline. + DrainFailed, + /// Who owns the runtime could not be established, so nothing was stopped and nothing may be + /// replaced on the assumption that it was. + OwnershipUnknown, +} + +/// What the event loop should do with an exit request. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ExitDecision { + /// Nothing asked the app to end and there is a tray to come back from: hide instead. + Hide, + /// Hold the exit and drain for this reason; the exit is requested again when the drain reports. + Drain(ExitReason), + /// Something else is already draining. Hold the exit and let that one finish. + Wait, + /// The drain has reported success. Let the process end. + Proceed, + /// The drain did not complete, and this reason is one that may not proceed on that. + Refuse, +} + +/// What a drain established. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DrainVerdict { + /// The runtime this app owned is gone, or there was never one of ours to stop. + Drained, + /// The stop was refused, or the endpoint still answered after the deadline. + Failed, + /// The process answering could not be identified, so nothing was stopped. + OwnershipUnknown, +} + +/// Whether an update may start replacing files. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RestartReadiness { + /// The runtime is confirmed stopped. The install may proceed. + Ready, + /// The drain did not complete, so the install must not start. + DrainFailed, + /// Who owns the runtime could not be established. + OwnershipUnknown, + /// Something else is already ending or stopping the runtime. + Busy, +} + +impl RestartReadiness { + pub fn describe(self) -> &'static str { + match self { + Self::Ready => "the runtime is stopped", + Self::DrainFailed => "the runtime did not stop", + Self::OwnershipUnknown => "the running proxy could not be identified", + Self::Busy => "the app is already stopping its runtime", + } + } +} + +/// Decide what an exit request means. +/// +/// `reason` is what the app itself asked for and is `None` for a bare user gesture. +/// `hides_to_tray` is D6: on a session with no usable tray there is nowhere to hide, so a close is +/// a quit and takes the same graceful drain rather than leaving a running process unreachable. +pub fn decide(phase: ExitPhase, reason: Option, hides_to_tray: bool) -> ExitDecision { + match phase { + ExitPhase::Spawning | ExitPhase::Stopping | ExitPhase::Draining => ExitDecision::Wait, + ExitPhase::Drained => ExitDecision::Proceed, + // A quit that could not drain still closes the app: refusing to close is the worse answer + // and the runtime is recoverable. A restart is a different judgement — it would come back + // attached to a runtime that was never stopped, under a user who believes they upgraded. + ExitPhase::DrainFailed | ExitPhase::OwnershipUnknown => match reason { + Some(ExitReason::CoordinatedRestart) => ExitDecision::Refuse, + _ => ExitDecision::Proceed, + }, + ExitPhase::Idle => match reason { + Some(reason) => ExitDecision::Drain(reason), + None if hides_to_tray => ExitDecision::Hide, + None => ExitDecision::Drain(ExitReason::UserQuit), + }, + } +} + +struct Inner { + phase: ExitPhase, + reason: Option, + hides_to_tray: bool, + /// An exit that arrived while a runtime was being started or stopped, and still has to happen. + deferred: bool, +} + +/// The exit sequence's state, managed by the app. +pub struct ExitCoordinator { + inner: Mutex, +} + +impl ExitCoordinator { + pub fn new() -> Self { + Self { + inner: Mutex::new(Inner { + phase: ExitPhase::Idle, + reason: None, + // Until the probe answers, assume only what the platform guarantees. Assuming a + // tray that turns out not to exist is the exact failure D6 is about. + hides_to_tray: TrayAvailability::assumed().hides_to_tray(), + deferred: false, + }), + } + } + + fn inner(&self) -> MutexGuard<'_, Inner> { + self.inner.lock().unwrap_or_else(PoisonError::into_inner) + } + + /// Record the session's tray verdict once the probe has answered and an icon exists. + pub fn set_tray(&self, tray: TrayAvailability) { + self.inner().hides_to_tray = tray.hides_to_tray(); + } + + pub fn decision(&self) -> ExitDecision { + let inner = self.inner(); + decide(inner.phase, inner.reason, inner.hides_to_tray) + } + + /// Record why the app is ending, without starting anything. The first reason wins. + pub fn claim(&self, reason: ExitReason) { + let mut inner = self.inner(); + inner.reason.get_or_insert(reason); + } + + /// Take ownership of the drain, and with it the reason the app is ending. + /// + /// Claiming the reason and moving out of `Idle` is one step on purpose. Split apart, a Quit + /// that claimed first could still be overtaken by an update that started the drain, and the app + /// would restart under a user who asked it to stop. `fallback` is only used when nothing has + /// claimed a reason yet. + /// + /// While a runtime is being started or stopped the answer is "not yet": the reason is recorded + /// and the drain is handed to whichever of [`ExitCoordinator::finish_spawn`] or + /// [`ExitCoordinator::finish_stop`] is holding the phase. + pub fn claim_drain(&self, fallback: ExitReason) -> Option { + let mut inner = self.inner(); + match inner.phase { + ExitPhase::Idle => { + let reason = *inner.reason.get_or_insert(fallback); + inner.phase = ExitPhase::Draining; + Some(reason) + } + ExitPhase::Spawning | ExitPhase::Stopping => { + inner.reason.get_or_insert(fallback); + inner.deferred = true; + None + } + // A failed drain is a terminal failure, not work in flight, and retrying it is the + // recovery: the update stayed pending, so the next attempt runs the stop again. Without + // this the first refusal would be permanent until the app was restarted by hand — which + // is the one thing a user with a runtime that would not stop cannot easily do. + ExitPhase::DrainFailed | ExitPhase::OwnershipUnknown => { + let reason = *inner.reason.get_or_insert(fallback); + inner.phase = ExitPhase::Draining; + Some(reason) + } + ExitPhase::Draining | ExitPhase::Drained => None, + } + } + + /// Record what the drain established. A failure is not a drain. + pub fn finish_drain(&self, verdict: DrainVerdict) { + self.inner().phase = match verdict { + DrainVerdict::Drained => ExitPhase::Drained, + DrainVerdict::Failed => ExitPhase::DrainFailed, + DrainVerdict::OwnershipUnknown => ExitPhase::OwnershipUnknown, + }; + } + + /// Reserve the right to start a runtime. False once something else owns the phase. + /// + /// The reservation exists instead of holding the lock across the spawn. Holding it would make + /// the main thread's exit handler wait on process creation, so a wedged spawn would be a Quit + /// that never responds. An exit arriving in between is deferred rather than lost — which is the + /// thing that must not happen, because a quit that reads "we own nothing" leaves the child it + /// just missed running forever. + pub fn begin_spawn(&self) -> bool { + self.begin(ExitPhase::Spawning) + } + + /// Release the spawn reservation, handing back a reason that arrived meanwhile. + pub fn finish_spawn(&self) -> Option { + self.finish(ExitPhase::Spawning) + } + + /// Reserve the runtime for a stop that does not end the app. + pub fn begin_stop(&self) -> bool { + self.begin(ExitPhase::Stopping) + } + + /// Release the stop, handing back a reason that arrived meanwhile. + pub fn finish_stop(&self) -> Option { + self.finish(ExitPhase::Stopping) + } + + fn begin(&self, phase: ExitPhase) -> bool { + let mut inner = self.inner(); + if inner.phase != ExitPhase::Idle { + return false; + } + inner.phase = phase; + true + } + + fn finish(&self, phase: ExitPhase) -> Option { + let mut inner = self.inner(); + if inner.phase != phase { + return None; + } + if inner.deferred { + let reason = *inner.reason.get_or_insert(ExitReason::UserQuit); + inner.phase = ExitPhase::Draining; + inner.deferred = false; + return Some(reason); + } + inner.phase = ExitPhase::Idle; + None + } + + #[cfg(test)] + fn phase(&self) -> ExitPhase { + self.inner().phase + } + + #[cfg(test)] + fn hides_to_tray(&self) -> bool { + self.inner().hides_to_tray + } +} + +impl Default for ExitCoordinator { + fn default() -> Self { + Self::new() + } +} + +/// The platform's quit gesture, and what closing the window means. +/// +/// It is not a request to end. D2 makes it mean the same thing on all three platforms: hide where +/// there is a tray to come back from, and a graceful quit where there is not. Closing the window +/// arrives here too: one decision point, so the two gestures cannot drift apart. +pub fn gesture(app: &AppHandle) { + let Some(coordinator) = app.try_state::() else { + return; + }; + match coordinator.decision() { + ExitDecision::Hide => hide_windows(app), + ExitDecision::Drain(reason) => start_drain(app, reason), + ExitDecision::Wait | ExitDecision::Proceed | ExitDecision::Refuse => {} + } +} + +/// Ask the app to end for a stated reason. This is the only way the shell ends itself. +pub fn request(app: &AppHandle, reason: ExitReason) { + if let Some(coordinator) = app.try_state::() { + coordinator.claim(reason); + } + app.exit(0); +} + +/// Stop the runtime without ending the app: the tray's Stop item. +/// +/// It takes the same phase the quit path takes, so pressing Stop twice, or Stop and then Quit, or +/// Stop during an update, is one execution rather than two racing over one child. Unlike a quit it +/// returns the coordinator to idle, because the app is still running and may start a runtime again. +pub fn request_stop(app: &AppHandle) { + let app = app.clone(); + tauri::async_runtime::spawn(async move { + let Some(claimed) = app + .try_state::() + .map(|coordinator| coordinator.begin_stop()) + else { + return; + }; + if !claimed { + return; + } + let verdict = drain_current(&app).await; + if verdict == DrainVerdict::Drained { + if let Some(state) = app.try_state::() { + state.release(); + } + crate::tray::set_owned(&app, false); + } else { + crate::logging::log_once("the runtime could not be stopped", verdict.describe()); + } + let deferred = app + .try_state::() + .and_then(|coordinator| coordinator.finish_stop()); + if let Some(reason) = deferred { + drain_now(&app, reason); + } + }); +} + +impl DrainVerdict { + pub fn describe(self) -> &'static str { + match self { + Self::Drained => "the runtime is stopped", + Self::Failed => "the stop was refused or the runtime still answered", + Self::OwnershipUnknown => "the running proxy could not be identified", + } + } +} + +/// Handle `RunEvent::ExitRequested`. +pub fn on_exit_requested(app: &AppHandle, code: Option, api: &ExitRequestApi) { + // `AppHandle::restart` documents that `prevent_exit` is ignored for its own exit code, so a + // restart cannot be held here even to drain. The update path therefore drains before it + // restarts, and this branch only records the reason so nothing reads the restart as a quit. + if code == Some(tauri::RESTART_EXIT_CODE) { + if let Some(coordinator) = app.try_state::() { + coordinator.claim(ExitReason::CoordinatedRestart); + } + return; + } + let Some(coordinator) = app.try_state::() else { + return; + }; + match coordinator.decision() { + ExitDecision::Hide => { + api.prevent_exit(); + hide_windows(app); + } + ExitDecision::Wait => api.prevent_exit(), + ExitDecision::Refuse => api.prevent_exit(), + ExitDecision::Drain(reason) => { + api.prevent_exit(); + start_drain(app, reason); + } + ExitDecision::Proceed => {} + } +} + +/// Drain and then ask to end again. +pub fn start_drain(app: &AppHandle, reason: ExitReason) { + let Some(coordinator) = app.try_state::() else { + return; + }; + let Some(reason) = coordinator.claim_drain(reason) else { + return; + }; + drain_now(app, reason); +} + +/// Run the drain for a reason the coordinator has already moved to draining for. +pub fn drain_now(app: &AppHandle, reason: ExitReason) { + let app = app.clone(); + tauri::async_runtime::spawn(async move { + finish_and_exit_after(&app, reason).await; + }); +} + +async fn finish_and_exit_after(app: &AppHandle, reason: ExitReason) { + let verdict = drain_current(app).await; + if let Some(coordinator) = app.try_state::() { + coordinator.finish_drain(verdict); + } + match (reason, verdict) { + (ExitReason::UserQuit, _) => app.exit(0), + (ExitReason::CoordinatedRestart, DrainVerdict::Drained) => { + app.restart(); + } + (ExitReason::CoordinatedRestart, _) => { + crate::logging::log_once("the update restart was refused", verdict.describe()); + } + } +} + +/// Prepare for an update's restart: confirm ownership, drain, and confirm the child is gone. +/// +/// This is awaited rather than fired and forgotten, because the pinned updater's Windows install +/// ends the process itself. A restart asked for after `install` returns is a restart that never +/// happens there, so the stop has to be finished before the installer is started at all. +pub async fn prepare_restart(app: &AppHandle) -> RestartReadiness { + let Some(reason) = app + .try_state::() + .and_then(|coordinator| coordinator.claim_drain(ExitReason::CoordinatedRestart)) + else { + return RestartReadiness::Busy; + }; + if reason != ExitReason::CoordinatedRestart { + // A quit claimed the exit first. It owns the drain now, and the update does not install + // into an app that is on its way out. + drain_now(app, reason); + return RestartReadiness::Busy; + } + let verdict = drain_current(app).await; + if let Some(coordinator) = app.try_state::() { + coordinator.finish_drain(verdict); + } + match verdict { + DrainVerdict::Drained => RestartReadiness::Ready, + DrainVerdict::Failed => RestartReadiness::DrainFailed, + DrainVerdict::OwnershipUnknown => RestartReadiness::OwnershipUnknown, + } +} + +/// Come back, once the installer has finished and returned. +pub fn complete_restart(app: &AppHandle) -> ! { + app.restart() +} + +/// Stop the runtime this app owns and confirm it is gone. +/// +/// Ownership is re-established here rather than read off a flag. A flag set when the child was +/// spawned says nothing about the process answering the endpoint now: the child can have exited and +/// a service can have taken the port back. Sending an owner's stop to that listener is sending it +/// to somebody else's runtime, so the pid is checked first and a listener that cannot be identified +/// is left alone. +/// +/// The stop itself is the bundled `ocx stop` (D4), not a management call from inside this process. +/// The CLI's stop owns the receipt-backed teardown, the drain, the Windows respawn verification and +/// the client-configuration restore, and an in-process endpoint cannot own its own teardown: launchd +/// and systemd can terminate the request handler during self-unload. Nothing kills the child either +/// — the original path did, with `CommandChild::kill()`, a SIGKILL on Unix that cut off exactly the +/// work the CLI's stop exists to finish. +pub async fn drain_current(app: &AppHandle) -> DrainVerdict { + let Some((proxy, child_pid, watch)) = app + .try_state::() + .map(|state| (state.proxy(), state.child_pid(), state.watch.clone())) + else { + return DrainVerdict::OwnershipUnknown; + }; + let Some(proxy) = proxy else { + // Nothing resolved, so there is nothing of ours listening anywhere. + return DrainVerdict::Drained; + }; + let Some(child_pid) = child_pid else { + // This app never started a runtime, so it does not stop one. + return DrainVerdict::Drained; + }; + match confirm(&proxy, child_pid, &watch).await { + Ownership::Gone => DrainVerdict::Drained, + Ownership::Foreign => DrainVerdict::Drained, + Ownership::Unknown => DrainVerdict::OwnershipUnknown, + Ownership::Ours => { + let deadline = Instant::now() + runtime_stop::DEADLINE; + let result = runtime_stop::run(app, deadline).await; + if result.is_stopped() { + DrainVerdict::Drained + } else { + // The CLI's own outcome and exit code, carried rather than reinterpreted. A stop + // that did not end in exit 0 with the runtime down is a stop that did not happen. + crate::logging::log_once("the bundled stop did not complete", &result.describe()); + DrainVerdict::Failed + } + } + } +} + +enum Ownership { + /// The process answering is the child this app started. + Ours, + /// Something else holds the port. + Foreign, + /// Nothing is listening, and the child has reported its own exit. + Gone, + /// The listener could not be identified. + Unknown, +} + +async fn confirm(proxy: &ProxyClient, child_pid: u32, watch: &sidecar::SidecarWatch) -> Ownership { + match proxy.identify().await { + Ok(identity) if identity.pid == child_pid => Ownership::Ours, + Ok(_) => Ownership::Foreign, + Err(error) if error.is_unreachable() => { + // Nothing is listening. That is only proof the child is gone if the child said so. + if watch.exit().is_some() { + Ownership::Gone + } else { + Ownership::Unknown + } + } + Err(_) => Ownership::Unknown, + } +} + +fn hide_windows(app: &AppHandle) { + for window in app.webview_windows().values() { + window::hide(window); + } +} + +#[cfg(test)] +mod tests { + use super::{ + decide, DrainVerdict, ExitCoordinator, ExitDecision, ExitPhase, ExitReason, + RestartReadiness, + }; + use crate::tray_availability::TrayAvailability; + + #[test] + fn a_bare_gesture_hides_when_there_is_a_tray_to_come_back_from() { + assert_eq!(decide(ExitPhase::Idle, None, true), ExitDecision::Hide); + } + + #[test] + fn a_bare_gesture_quits_through_the_drain_when_there_is_no_tray() { + assert_eq!( + decide(ExitPhase::Idle, None, false), + ExitDecision::Drain(ExitReason::UserQuit) + ); + } + + #[test] + fn an_explicit_quit_drains_even_though_a_tray_exists() { + assert_eq!( + decide(ExitPhase::Idle, Some(ExitReason::UserQuit), true), + ExitDecision::Drain(ExitReason::UserQuit) + ); + } + + #[test] + fn every_in_flight_phase_holds_the_exit() { + for phase in [ + ExitPhase::Spawning, + ExitPhase::Stopping, + ExitPhase::Draining, + ] { + assert_eq!( + decide(phase, Some(ExitReason::UserQuit), true), + ExitDecision::Wait + ); + assert_eq!(decide(phase, None, false), ExitDecision::Wait); + } + } + + #[test] + fn only_a_reported_drain_lets_the_process_end() { + assert_eq!( + decide(ExitPhase::Drained, Some(ExitReason::UserQuit), true), + ExitDecision::Proceed + ); + } + + #[test] + fn a_quit_tolerates_a_failed_drain_and_a_restart_refuses_it() { + for phase in [ExitPhase::DrainFailed, ExitPhase::OwnershipUnknown] { + assert_eq!( + decide(phase, Some(ExitReason::UserQuit), true), + ExitDecision::Proceed + ); + assert_eq!( + decide(phase, Some(ExitReason::CoordinatedRestart), true), + ExitDecision::Refuse + ); + } + } + + #[test] + fn a_failed_drain_is_not_recorded_as_a_drain() { + let coordinator = ExitCoordinator::new(); + coordinator.claim_drain(ExitReason::CoordinatedRestart); + coordinator.finish_drain(DrainVerdict::Failed); + assert_eq!(coordinator.phase(), ExitPhase::DrainFailed); + // The restart that asked for it does not get to proceed on that. + assert_eq!(coordinator.decision(), ExitDecision::Refuse); + } + + #[test] + fn an_unidentified_runtime_is_its_own_state() { + let coordinator = ExitCoordinator::new(); + coordinator.claim_drain(ExitReason::CoordinatedRestart); + coordinator.finish_drain(DrainVerdict::OwnershipUnknown); + assert_eq!(coordinator.phase(), ExitPhase::OwnershipUnknown); + assert_eq!(coordinator.decision(), ExitDecision::Refuse); + } + + #[test] + fn a_refused_restart_can_be_tried_again() { + let coordinator = ExitCoordinator::new(); + coordinator.claim_drain(ExitReason::CoordinatedRestart); + coordinator.finish_drain(DrainVerdict::Failed); + // The update stayed pending, so pressing Install again runs the stop again rather than + // finding the app permanently unable to try. + assert_eq!( + coordinator.claim_drain(ExitReason::CoordinatedRestart), + Some(ExitReason::CoordinatedRestart) + ); + assert_eq!(coordinator.phase(), ExitPhase::Draining); + coordinator.finish_drain(DrainVerdict::Drained); + assert_eq!(coordinator.decision(), ExitDecision::Proceed); + } + + #[test] + fn a_successful_drain_is_not_re_entered() { + let coordinator = ExitCoordinator::new(); + coordinator.claim_drain(ExitReason::UserQuit); + coordinator.finish_drain(DrainVerdict::Drained); + assert_eq!(coordinator.claim_drain(ExitReason::UserQuit), None); + } + + #[test] + fn the_first_claimed_reason_wins_the_drain() { + let coordinator = ExitCoordinator::new(); + coordinator.claim(ExitReason::UserQuit); + assert_eq!( + coordinator.claim_drain(ExitReason::CoordinatedRestart), + Some(ExitReason::UserQuit) + ); + assert_eq!(coordinator.phase(), ExitPhase::Draining); + } + + #[test] + fn only_one_caller_owns_the_drain() { + let coordinator = ExitCoordinator::new(); + assert_eq!( + coordinator.claim_drain(ExitReason::UserQuit), + Some(ExitReason::UserQuit) + ); + assert_eq!(coordinator.claim_drain(ExitReason::UserQuit), None); + coordinator.finish_drain(DrainVerdict::Drained); + assert_eq!(coordinator.phase(), ExitPhase::Drained); + assert_eq!(coordinator.claim_drain(ExitReason::UserQuit), None); + } + + #[test] + fn a_stop_and_a_spawn_both_hold_the_runtime_alone() { + for begin in [ExitPhase::Spawning, ExitPhase::Stopping] { + let coordinator = ExitCoordinator::new(); + let started = match begin { + ExitPhase::Spawning => coordinator.begin_spawn(), + _ => coordinator.begin_stop(), + }; + assert!(started); + assert!(!coordinator.begin_spawn()); + assert!(!coordinator.begin_stop()); + assert_eq!(coordinator.decision(), ExitDecision::Wait); + } + } + + #[test] + fn a_quit_during_a_stop_is_deferred_rather_than_lost() { + let coordinator = ExitCoordinator::new(); + assert!(coordinator.begin_stop()); + assert_eq!(coordinator.claim_drain(ExitReason::UserQuit), None); + assert_eq!(coordinator.phase(), ExitPhase::Stopping); + assert_eq!(coordinator.finish_stop(), Some(ExitReason::UserQuit)); + assert_eq!(coordinator.phase(), ExitPhase::Draining); + assert_eq!(coordinator.finish_stop(), None); + } + + #[test] + fn a_quit_during_a_spawn_is_deferred_rather_than_lost() { + let coordinator = ExitCoordinator::new(); + assert!(coordinator.begin_spawn()); + assert_eq!(coordinator.decision(), ExitDecision::Wait); + assert_eq!(coordinator.claim_drain(ExitReason::UserQuit), None); + assert_eq!(coordinator.finish_spawn(), Some(ExitReason::UserQuit)); + assert_eq!(coordinator.phase(), ExitPhase::Draining); + } + + #[test] + fn a_deferred_update_restart_keeps_its_own_reason() { + let coordinator = ExitCoordinator::new(); + assert!(coordinator.begin_spawn()); + assert_eq!( + coordinator.claim_drain(ExitReason::CoordinatedRestart), + None + ); + assert_eq!( + coordinator.finish_spawn(), + Some(ExitReason::CoordinatedRestart) + ); + } + + #[test] + fn an_undrained_runtime_is_never_reported_as_ready_to_install_over() { + assert_eq!(RestartReadiness::Ready.describe(), "the runtime is stopped"); + for refused in [ + RestartReadiness::DrainFailed, + RestartReadiness::OwnershipUnknown, + RestartReadiness::Busy, + ] { + assert_ne!(refused, RestartReadiness::Ready); + assert!(!refused.describe().is_empty()); + } + } + + #[test] + fn the_tray_verdict_replaces_the_platform_assumption() { + let coordinator = ExitCoordinator::new(); + assert_eq!( + coordinator.hides_to_tray(), + TrayAvailability::assumed().hides_to_tray() + ); + coordinator.set_tray(TrayAvailability::Unavailable); + assert!(!coordinator.hides_to_tray()); + assert_eq!( + coordinator.decision(), + ExitDecision::Drain(ExitReason::UserQuit) + ); + coordinator.set_tray(TrayAvailability::Available); + assert_eq!(coordinator.decision(), ExitDecision::Hide); + } +} diff --git a/desktop/src-tauri/src/first_run.rs b/desktop/src-tauri/src/first_run.rs new file mode 100644 index 00000000000..3fc7616b93f --- /dev/null +++ b/desktop/src-tauri/src/first_run.rs @@ -0,0 +1,111 @@ +use std::fs; +use tauri::{AppHandle, Manager}; +use tauri_plugin_autostart::ManagerExt; + +/// Marker file recording that the one-time Start at Login default has already been applied. +const MARKER: &str = "start-at-login-claimed"; + +/// Marker file recording that the login item names the launch-origin argument. +const ORIGIN_MARKER: &str = "start-at-login-origin-flag"; + +/// What the one-time Start at Login decision did on this launch. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum StartAtLogin { + /// This installation had already decided, so whatever the user set is left alone. + AlreadyDecided, + /// Turned on for the first time on this installation. + Enabled, + /// The default could not be applied. The app still starts and the tray item still toggles it. + Unavailable, +} + +impl StartAtLogin { + pub fn describe(self) -> &'static str { + match self { + Self::AlreadyDecided => "already decided on this installation, left as the user set it", + Self::Enabled => "turned on for this installation", + Self::Unavailable => "could not be registered; the tray item still toggles it", + } + } +} + +/// Turn Start at Login on once, the first time this installation runs. +/// +/// A menu bar app that is not running has no menu bar item. Leaving autostart off by default +/// therefore means that after the next reboot an installed app is simply absent, with nothing on +/// screen to explain why — which is not a neutral default for an app whose main surface *is* the +/// menu bar. +/// +/// This runs exactly once per installation. The marker is written **before** the login item is +/// touched, and is never removed, so a user who turns Start at Login back off keeps it off: the +/// next launch sees the marker and does nothing. Writing afterwards instead would mean that a +/// failed or partial enable retries on every launch, and would eventually flip the setting back on +/// under a user who had deliberately turned it off in between. +/// +/// No failure stops the app. Not being able to write a marker or register a login item is not a +/// reason to refuse to start, and the user can still toggle the menu item. What has changed is that +/// the outcome is returned rather than swallowed: D7's startup sequence reports this decision as +/// one of its named states, so a registration that did not happen is visible instead of silent. +pub fn apply_start_at_login_default(app: &AppHandle) -> StartAtLogin { + let Ok(dir) = app.path().app_config_dir() else { + return StartAtLogin::Unavailable; + }; + let marker = dir.join(MARKER); + if marker.exists() { + return StartAtLogin::AlreadyDecided; + } + if fs::create_dir_all(&dir).is_err() { + return StartAtLogin::Unavailable; + } + if fs::write(&marker, b"").is_err() { + return StartAtLogin::Unavailable; + } + if app.autolaunch().is_enabled().unwrap_or(false) { + return StartAtLogin::AlreadyDecided; + } + match app.autolaunch().enable() { + Ok(()) => StartAtLogin::Enabled, + Err(_) => StartAtLogin::Unavailable, + } +} + +/// Rewrite an existing login item so a launch from it can be recognised as one. +/// +/// The autostart entry is written once, carrying whatever arguments the plugin was configured with +/// at the time. An installation that registered before the launch-origin argument existed has an +/// entry without it, and a bare launch carries nothing else that distinguishes login from manual — +/// so D7's hidden login start would quietly never happen for exactly the users who already had +/// autostart on. Re-registering rewrites the entry with the current arguments. +/// +/// It runs once, behind its own marker, and only where autostart is already on. It never turns the +/// setting on and never turns it off; the worst case is an entry that keeps its old arguments and a +/// login launch that shows its window, which is the visible failure rather than the silent one. +pub fn adopt_launch_origin_argument(app: &AppHandle) { + let Ok(dir) = app.path().app_config_dir() else { + return; + }; + let claimed = dir.join(ORIGIN_MARKER); + if claimed.exists() { + return; + } + if fs::create_dir_all(&dir).is_err() { + return; + } + // Unlike the default above, the marker is written *after* the work, and that difference is + // deliberate. Writing first exists there to stop a failed enable from flipping a setting the + // user turned off. Here there is no setting to flip: the rewrite only ever runs on an entry + // that is already enabled, so retrying after a transient registry, LaunchAgent or desktop-file + // error is free — and claiming the marker first would suppress the migration permanently and + // leave a login launch showing its window forever. + match app.autolaunch().is_enabled() { + Ok(true) => { + if app.autolaunch().enable().is_err() { + return; + } + } + // Nothing registered to migrate. A later enable writes the current arguments anyway. + Ok(false) => {} + Err(_) => return, + } + let _ = fs::write(&claimed, b""); +} diff --git a/desktop/src-tauri/src/formatting.rs b/desktop/src-tauri/src/formatting.rs new file mode 100644 index 00000000000..5f940b68fed --- /dev/null +++ b/desktop/src-tauri/src/formatting.rs @@ -0,0 +1,79 @@ +pub fn tokens(value: Option) -> String { + abbreviate(value, true) +} + +pub fn count(value: Option) -> String { + abbreviate(value, false) +} + +pub fn cost(value: Option) -> String { + let Some(value) = value else { + return "—".into(); + }; + if value < 1_000.0 { + return format!("${value:.2}"); + } + format!("${}", abbreviate_float(value, false)) +} + +fn abbreviate(value: Option, integer: bool) -> String { + let Some(value) = value else { + return "—".into(); + }; + if !integer && value < 10_000 { + return format!("{value}"); + } + if integer && value < 1_000 { + return format!("{value}"); + } + abbreviate_float(value as f64, integer) +} + +fn abbreviate_float(value: f64, integer: bool) -> String { + let units = [ + (1_000_000_000_000.0, "T"), + (1_000_000_000.0, "B"), + (1_000_000.0, "M"), + (1_000.0, "K"), + ]; + for (threshold, suffix) in units { + if value >= threshold * 0.9995 { + let scaled = value / threshold; + let decimals = if integer || scaled >= 100.0 { + 0 + } else if scaled >= 10.0 { + 1 + } else { + 2 + }; + let rendered = format!("{scaled:.decimals$}"); + return format!( + "{}{}", + rendered.trim_end_matches('0').trim_end_matches('.'), + suffix + ); + } + } + format!("{value:.0}") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn token_boundaries_match_swift_formatting() { + assert_eq!(tokens(Some(999_600)), "1M"); + assert_eq!(tokens(Some(1_234)), "1K"); + assert_eq!(tokens(Some(2_401_634_303)), "2B"); + assert_eq!(tokens(None), "—"); + } + + #[test] + fn counts_and_costs_have_expected_precision() { + assert_eq!(count(Some(9_999)), "9999"); + assert_eq!(count(Some(12_345)), "12.3K"); + assert_eq!(cost(Some(12.345)), "$12.35"); + assert_eq!(cost(Some(1_234.0)), "$1.23K"); + } +} diff --git a/desktop/src-tauri/src/identity.rs b/desktop/src-tauri/src/identity.rs new file mode 100644 index 00000000000..9deb1c32e0a --- /dev/null +++ b/desktop/src-tauri/src/identity.rs @@ -0,0 +1,111 @@ +//! This installation's own identity. +//! +//! The shared service install state records who owns the running proxy, and the claim names the +//! owning *installation* rather than the user or the machine. So the app has to hold a value of its +//! own to compare against, which is this: an opaque id written once into the app's config directory +//! and never rewritten. +//! +//! Two records rather than one is D3, and its cost is recorded there. An id stored only in the +//! shared record would be whoever wrote it last, which gives a reinstalled app no way to tell its +//! own prior consent from another installation's. The price is that a reinstall which keeps this +//! directory keeps its consent, and one that loses it has to ask again. + +use std::{ + fs, + io::{ErrorKind, Write}, + path::Path, +}; +use tauri::{AppHandle, Manager}; +use uuid::Uuid; + +/// The file holding this installation's id, in the app's own config directory. +const FILE: &str = "install-id"; + +pub fn install_id(app: &AppHandle) -> Option { + let directory = app.path().app_config_dir().ok()?; + install_id_in(&directory) +} + +/// Read this installation's id, minting it the first time. +/// +/// The mint is exclusive and the value is read back afterwards, so two launches racing each other +/// both answer to the id that won rather than to two different ones. Two ids would be two +/// installations as far as the recorded claim is concerned, and the second would find a claim that +/// is not its own and ask again for consent the user had already given. +pub fn install_id_in(directory: &Path) -> Option { + let path = directory.join(FILE); + if let Some(existing) = read(&path) { + return Some(existing); + } + fs::create_dir_all(directory).ok()?; + match mint(&path) { + Ok(()) => {} + // The file is there and says nothing: a blank or truncated write from an interrupted first + // run. An empty id matches nothing, so every comparison against the recorded claim would + // quietly be false and the app would ask for consent it already had. Replace it. + Err(ErrorKind::AlreadyExists) => { + if read(&path).is_none() { + fs::write(&path, Uuid::new_v4().to_string()).ok()?; + } + } + Err(_) => return None, + } + read(&path) +} + +fn mint(path: &Path) -> Result<(), ErrorKind> { + fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(path) + .and_then(|mut file| file.write_all(Uuid::new_v4().to_string().as_bytes())) + .map_err(|error| error.kind()) +} + +fn read(path: &Path) -> Option { + let value = fs::read_to_string(path).ok()?; + let trimmed = value.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_owned()) +} + +#[cfg(test)] +mod tests { + use super::{install_id_in, FILE}; + use std::fs; + + fn scratch(name: &str) -> std::path::PathBuf { + let directory = + std::env::temp_dir().join(format!("ocx-identity-{name}-{}", std::process::id())); + let _ = fs::remove_dir_all(&directory); + directory + } + + #[test] + fn the_id_is_minted_once_and_then_read_back() { + let directory = scratch("mint"); + let first = install_id_in(&directory).expect("an id"); + assert!(!first.is_empty()); + assert_eq!(install_id_in(&directory).as_deref(), Some(first.as_str())); + let _ = fs::remove_dir_all(&directory); + } + + #[test] + fn two_installations_do_not_share_an_id() { + let one = scratch("one"); + let two = scratch("two"); + assert_ne!(install_id_in(&one), install_id_in(&two)); + let _ = fs::remove_dir_all(&one); + let _ = fs::remove_dir_all(&two); + } + + #[test] + fn a_blank_record_is_replaced_rather_than_answered_with() { + let directory = scratch("blank"); + fs::create_dir_all(&directory).unwrap(); + fs::write(directory.join(FILE), " \n").unwrap(); + let minted = install_id_in(&directory).expect("an id"); + assert!(!minted.trim().is_empty()); + assert_eq!(install_id_in(&directory).as_deref(), Some(minted.as_str())); + let _ = fs::remove_dir_all(&directory); + } +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs new file mode 100644 index 00000000000..d24fefba790 --- /dev/null +++ b/desktop/src-tauri/src/lib.rs @@ -0,0 +1,236 @@ +mod auth; +mod endpoint; +mod exit; +mod first_run; +mod formatting; +mod identity; +mod logging; +// macOS only: it exists to replace one item in a menu no other platform installs. Compiling it +// elsewhere would leave its contents unreachable, which -D warnings rejects. +#[cfg(target_os = "macos")] +mod menu; +mod ownership; +mod proxy; +mod resolve; +mod runtime_stop; +mod sidecar; +mod startup; +mod tray; +mod tray_availability; +mod updater; +mod widget; +mod window; + +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Mutex, MutexGuard, PoisonError, +}; +use tauri::{Manager, WebviewUrl, WebviewWindowBuilder}; +use tauri_plugin_autostart::MacosLauncher; +use tauri_plugin_shell::process::CommandChild; + +pub struct AppState { + /// Absent until the startup sequence has resolved a home and a port. Nothing guesses an + /// endpoint any more, so there is no client to hand out before that. + proxy: Mutex>, + child: Mutex>, + /// The pid of the child this app started, if it started one. + child_pid: Mutex>, + /// Whether the process answering the endpoint has been confirmed to be that child. + /// + /// Durable consent and current process ownership are different facts. Consent is a recorded + /// claim that survives restarts; this is a statement about the process on the other end of the + /// endpoint right now, and it has to be re-established whenever the endpoint or the answering + /// process can have changed. Carrying a bool across an attach is how a retry that lands on a + /// foreign runtime would still send it an owner's stop. + confirmed: AtomicBool, + /// The consumed spawn event stream of the child, if this app started one. + pub watch: sidecar::SidecarWatch, +} + +impl AppState { + pub fn new() -> Self { + Self { + proxy: Mutex::new(None), + child: Mutex::new(None), + child_pid: Mutex::new(None), + confirmed: AtomicBool::new(false), + watch: sidecar::SidecarWatch::default(), + } + } + + fn slot(lock: &Mutex) -> MutexGuard<'_, T> { + lock.lock().unwrap_or_else(PoisonError::into_inner) + } + + pub fn proxy(&self) -> Option { + Self::slot(&self.proxy).clone() + } + + /// Point at a runtime. Nothing is owned until it is confirmed again. + pub fn attach(&self, proxy: proxy::ProxyClient) { + self.confirmed.store(false, Ordering::Release); + *Self::slot(&self.proxy) = Some(proxy); + } + + pub fn owns_runtime(&self) -> bool { + self.confirmed.load(Ordering::Acquire) + } + + pub fn child_pid(&self) -> Option { + *Self::slot(&self.child_pid) + } + + /// Confirm that the instance answering is the child this app started. + /// + /// This is the only thing that grants ownership. A spawn records a pid; it does not record that + /// the pid is what holds the port, because between the two the child can exit and a service can + /// take the port back. + pub fn confirm_ownership(&self, identity: proxy::RuntimeIdentity) -> bool { + let ours = self.child_pid() == Some(identity.pid); + self.confirmed.store(ours, Ordering::Release); + ours + } + + pub fn adopt(&self, child: CommandChild) { + *Self::slot(&self.child_pid) = Some(child.pid()); + *Self::slot(&self.child) = Some(child); + // Spawned, not yet confirmed: the health probe is what establishes that this pid is the + // one answering. + self.confirmed.store(false, Ordering::Release); + } + + /// Let go of a runtime that has already been drained. + /// + /// Dropping the handle does not signal the process — the shell plugin installs no `Drop` — so + /// this releases ownership without reintroducing the `kill()` that D2 removed. + pub fn release(&self) { + self.confirmed.store(false, Ordering::Release); + let _ = Self::slot(&self.child_pid).take(); + let _ = Self::slot(&self.child).take(); + } +} + +impl Default for AppState { + fn default() -> Self { + Self::new() + } +} + +#[tauri::command] +fn show_dashboard(app: tauri::AppHandle) { + if let Some(window) = app.get_webview_window("main") { + window::show(&window); + } +} + +#[tauri::command] +fn hide_dashboard(app: tauri::AppHandle) { + if let Some(window) = app.get_webview_window("main") { + window::hide(&window); + } +} + +/// Everything the startup sequence has said so far, including the states it has already finished. +/// +/// The page asks for this when it loads rather than relying only on the event stream: the first +/// states finish in milliseconds and an event emitted before the listener exists is simply gone. +#[tauri::command] +fn startup_snapshot(app: tauri::AppHandle) -> Option { + app.try_state::() + .map(|startup| startup.latest()) +} + +/// The named states the startup sequence moves through, in order. +/// +/// The page asks for them instead of restating them, so a state added in the shell appears in the +/// UI and one removed cannot leave a row behind. +#[tauri::command] +fn startup_phases() -> Vec { + startup::phase_list() +} + +/// Run the startup sequence again. A run already in flight is left alone. +#[tauri::command] +fn retry_startup(app: tauri::AppHandle) { + startup::begin(&app); +} + +pub fn run() { + let builder = tauri::Builder::default() + .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { + if let Some(window) = app.get_webview_window("main") { + window::show(&window); + } + })) + .plugin(tauri_plugin_opener::init()) + .plugin(tauri_plugin_process::init()) + // The argument is what makes a login launch recognisable. Nothing else in a bare launch + // distinguishes it from a person opening the app, and D7 needs the difference. + .plugin(tauri_plugin_autostart::init( + MacosLauncher::LaunchAgent, + Some(vec![startup::AUTOSTART_FLAG]), + )) + .plugin(tauri_plugin_shell::init()) + .plugin(tauri_plugin_updater::Builder::new().build()); + + // macOS is the one platform where the event loop cannot enforce D2 on its own: Tauri's default + // menu carries a predefined Quit wired to Cocoa's terminate:, and the pinned tao raises no + // cancellable event for it. Replacing that one item is what lets Cmd+Q mean hide. + #[cfg(target_os = "macos")] + let builder = builder + .menu(menu::build) + .on_menu_event(|app, event| menu::on_event(app, event.id().as_ref())); + + builder + .invoke_handler(tauri::generate_handler![ + show_dashboard, + hide_dashboard, + startup_snapshot, + startup_phases, + retry_startup + ]) + .setup(|app| { + app.manage(AppState::new()); + app.manage(updater::PendingUpdate(Mutex::new(None))); + app.manage(tray::TrayState::default()); + app.manage(exit::ExitCoordinator::new()); + app.manage(startup::Startup::new()); + + // D7: the window is created and shown before anything is registered, resolved, probed + // or started, so every state below has somewhere to be reported. A login launch stays + // hidden until the tray verdict, because R1 shows it after all when there turns out to + // be nowhere to hide. + let window = + WebviewWindowBuilder::new(app, "main", WebviewUrl::App("index.html".into())) + .title("OpenCodex") + .inner_size(1100.0, 720.0) + .visible(false) + .user_agent(&window::webview_user_agent()) + .on_navigation(window::navigation_allowed(app.handle().clone())) + .build()?; + window::configure(&window); + if startup::LaunchOrigin::detect() == startup::LaunchOrigin::User { + window::show(&window); + } else { + window::set_tray_policy(app.handle(), false); + } + + startup::begin(app.handle()); + + if !cfg!(debug_assertions) { + updater::start_background_checks(app.handle().clone()); + } + Ok(()) + }) + .build(tauri::generate_context!()) + .expect("error while building OpenCodex desktop shell") + .run(|app, event| { + // Window close and the platform quit gesture arrive here as an exit request, and until + // this handler existed they went straight through to a SIGKILL of the runtime. D2 makes + // them hide; only the tray's Quit, and an update's coordinated restart, get past. + if let tauri::RunEvent::ExitRequested { code, api, .. } = event { + exit::on_exit_requested(app, code, &api); + } + }); +} diff --git a/desktop/src-tauri/src/logging.rs b/desktop/src-tauri/src/logging.rs new file mode 100644 index 00000000000..a3b71fb5f47 --- /dev/null +++ b/desktop/src-tauri/src/logging.rs @@ -0,0 +1,14 @@ +use std::{ + collections::HashSet, + sync::{Mutex, OnceLock}, +}; + +pub fn log_once(scope: &str, message: &str) { + static LOGGED: OnceLock>> = OnceLock::new(); + let logged = LOGGED.get_or_init(|| Mutex::new(HashSet::new())); + if let Ok(mut logged) = logged.lock() { + if logged.insert(format!("{scope}: {message}")) { + eprintln!("{scope}: {message}"); + } + } +} diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs new file mode 100644 index 00000000000..8d5174cf6fa --- /dev/null +++ b/desktop/src-tauri/src/main.rs @@ -0,0 +1,5 @@ +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +fn main() { + opencodex_desktop_lib::run(); +} diff --git a/desktop/src-tauri/src/menu.rs b/desktop/src-tauri/src/menu.rs new file mode 100644 index 00000000000..58bc4346613 --- /dev/null +++ b/desktop/src-tauri/src/menu.rs @@ -0,0 +1,120 @@ +//! The macOS application menu. +//! +//! Tauri installs a default menu when the app sets none, and that menu's Quit is a predefined item +//! wired straight to Cocoa's `terminate:`. The pinned tao implements only +//! `applicationWillTerminate`, never the cancellable `applicationShouldTerminate`, so a Cmd+Q +//! through that item reaches `RunEvent::Exit` without ever raising `RunEvent::ExitRequested`. +//! Nothing can hold it, which means D2's rule — the quit gesture hides, only the tray's Quit ends +//! the app — cannot be enforced from the event loop alone on macOS. The one item is replaced here +//! with an ordinary item on the same accelerator, routed through the same gesture path as closing +//! the window. +//! +//! The rest is reproduced rather than mutated: `Menu::default` is not decomposable, and dropping it +//! would take Cut, Copy, Paste and Select All with it — which the startup diagnostic needs the user +//! to be able to use. This mirrors `tauri::menu::Menu::default` for the pinned version, minus that +//! item. + +/// The id of the replacement Quit item. Nothing else in the app uses it, so a menu event carrying +/// it is unambiguously this one. +pub const QUIT_ID: &str = "app-menu-quit"; + +pub fn build(app: &tauri::AppHandle) -> tauri::Result> { + use tauri::menu::{ + AboutMetadata, Menu, MenuItem, PredefinedMenuItem, Submenu, HELP_SUBMENU_ID, + WINDOW_SUBMENU_ID, + }; + + let package = app.package_info(); + let config = app.config(); + let about = AboutMetadata { + name: Some(package.name.clone()), + version: Some(package.version.to_string()), + copyright: config.bundle.copyright.clone(), + authors: config + .bundle + .publisher + .clone() + .map(|publisher| vec![publisher]), + ..Default::default() + }; + + // Labelled as a quit because that is the gesture the user is making. What it means here is + // D2's answer to that gesture: the window goes away and the runtime keeps serving. + let quit = MenuItem::with_id( + app, + QUIT_ID, + format!("Quit {}", package.name), + true, + Some("CmdOrCtrl+Q"), + )?; + + Menu::with_items( + app, + &[ + &Submenu::with_items( + app, + package.name.clone(), + true, + &[ + &PredefinedMenuItem::about(app, None, Some(about))?, + &PredefinedMenuItem::separator(app)?, + &PredefinedMenuItem::services(app, None)?, + &PredefinedMenuItem::separator(app)?, + &PredefinedMenuItem::hide(app, None)?, + &PredefinedMenuItem::hide_others(app, None)?, + &PredefinedMenuItem::separator(app)?, + &quit, + ], + )?, + &Submenu::with_items( + app, + "File", + true, + &[&PredefinedMenuItem::close_window(app, None)?], + )?, + &Submenu::with_items( + app, + "Edit", + true, + &[ + &PredefinedMenuItem::undo(app, None)?, + &PredefinedMenuItem::redo(app, None)?, + &PredefinedMenuItem::separator(app)?, + &PredefinedMenuItem::cut(app, None)?, + &PredefinedMenuItem::copy(app, None)?, + &PredefinedMenuItem::paste(app, None)?, + &PredefinedMenuItem::select_all(app, None)?, + ], + )?, + &Submenu::with_items( + app, + "View", + true, + &[&PredefinedMenuItem::fullscreen(app, None)?], + )?, + &Submenu::with_id_and_items( + app, + WINDOW_SUBMENU_ID, + "Window", + true, + &[ + &PredefinedMenuItem::minimize(app, None)?, + &PredefinedMenuItem::maximize(app, None)?, + &PredefinedMenuItem::separator(app)?, + &PredefinedMenuItem::close_window(app, None)?, + ], + )?, + &Submenu::with_id_and_items(app, HELP_SUBMENU_ID, "Help", true, &[])?, + ], + ) +} + +/// Route an application-menu event. +/// +/// Only the replacement Quit is ours. Tray menu events are handled by the tray's own handler and +/// carry different ids, so an id that is not [`QUIT_ID`] is left alone. +pub fn on_event(app: &tauri::AppHandle, id: &str) { + if id == QUIT_ID { + crate::exit::gesture(app); + } +} diff --git a/desktop/src-tauri/src/ownership.rs b/desktop/src-tauri/src/ownership.rs new file mode 100644 index 00000000000..a27bbf6a8e2 --- /dev/null +++ b/desktop/src-tauri/src/ownership.rs @@ -0,0 +1,249 @@ +//! Who owns the running proxy, as the shared service install state records it. +//! +//! The rule is not this lane's to invent. `src/service/state.ts` defines the claim — an owner, an +//! opaque install id naming the owning installation, and a consent generation — and +//! `ownershipGrantedTo` defines the comparison an installation applies to its own locally stored +//! install id. This is that comparison, and the three-valued reading it is applied to, so the shell +//! reaches the same verdict the CLI does instead of a weaker one of its own. +//! +//! What the shell deliberately does not do is read the record itself. Resolving a claim means +//! reading every state path and failing closed on an unreadable one, on a corrupt anchor record and +//! on paths that name different owners; absence is the only thing that means nobody owns the +//! runtime. Reimplementing that here is how `discovery.rs` ended up asking a weaker liveness +//! question than the one core already answered. The bundled CLI answers it: see [`resolve`]. +//! +//! The types below are the CLI's own answer as it will arrive on the wire, field for field, so the +//! contract that lands fills a hole rather than reshaping this file. + +use serde::Deserialize; +use tauri::AppHandle; + +/// Who a claim names. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Owner { + Cli, + Desktop, +} + +/// A recorded ownership claim. +#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Claim { + pub owner: Owner, + pub install_id: String, + /// Moves once per grant, and never back: the recorded ceiling survives a release so a later + /// grant cannot reuse a number an app-local record may still be holding. + /// + /// The record accepts any non-negative integer, and this accepts the ones it can represent. A + /// generation outside that range fails to parse, which makes the whole answer unreadable and + /// so refuses a takeover — the safe direction, and unreachable in practice by a counter that + /// moves by one per grant. + pub consent_generation: u64, +} + +/// What the recorded state says, in the CLI's own three answers. +#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] +#[serde(tag = "kind", rename_all = "lowercase")] +pub enum Recorded { + /// No claim. The CLI install that registered the service owns the runtime, which is also what + /// every record written before the field existed says. + None, + /// A claim, whoever it names. + Owned { ownership: Claim }, + /// The claim could not be read for a decision. This is not "nobody owns it": an unreadable + /// path, a corrupt anchor record and paths naming different owners all land here. + Unknown { reason: String }, +} + +/// The comparison `ownershipGrantedTo` defines: same owner, same install id. +/// +/// True means this installation already holds consent. False against a recorded claim means a +/// different installation owns the runtime and consent has to be asked again. No claim means the +/// CLI install still owns it. The generation is not part of the comparison. +pub fn granted_to(claim: Option<&Claim>, owner: Owner, install_id: &str) -> bool { + matches!(claim, Some(claim) if claim.owner == owner && claim.install_id == install_id) +} + +/// What this installation should do about consent. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Consent { + /// This installation already holds consent. Asked once, owned permanently — so a second launch + /// does not ask again. + Held, + /// Nothing is recorded, so the CLI install still owns the runtime. This is the first discovery + /// of an existing installation, and the one time the user is asked. + AskFirstTime, + /// A claim exists and it is not ours: another installation, or the CLI explicitly. + AskAgain, + /// The record could not be read for a decision, so nothing is taken over on a guess. + Refuse, +} + +pub fn consent(recorded: &Recorded, install_id: &str) -> Consent { + match recorded { + Recorded::Unknown { .. } => Consent::Refuse, + Recorded::None => Consent::AskFirstTime, + Recorded::Owned { ownership } => { + if granted_to(Some(ownership), Owner::Desktop, install_id) { + Consent::Held + } else { + Consent::AskAgain + } + } + } +} + +/// Read the recorded claim through the bundled CLI. +/// +/// Empty on purpose. Lane A publishes the machine-readable resolve the shell drives, and this is +/// the one call site that changes when it lands: it has to return the CLI's own answer, including +/// its refusals, rather than a verdict computed here. Until then the answer is *unavailable*, which +/// is not [`Recorded::None`] — the shell has not been told that nobody owns the runtime, it has not +/// asked — so no takeover is attempted and nothing is recorded. +pub fn resolve(_app: &AppHandle) -> Option { + None +} + +/// One line for the startup state and for the diagnostic. +pub fn describe(recorded: Option<&Recorded>, install_id: Option<&str>) -> String { + let installation = match install_id { + Some(id) => format!("installation {id}"), + None => "installation id unavailable".to_owned(), + }; + let verdict = match (recorded, install_id) { + (None, _) => { + "recorded owner not read: the bundled CLI's resolve contract has not landed".to_owned() + } + (Some(Recorded::Unknown { reason }), _) => { + format!("recorded owner could not be read ({reason}), so nothing is claimed") + } + (Some(_), None) => { + "recorded owner read, but this installation has no id to compare".to_owned() + } + (Some(recorded), Some(id)) => match (consent(recorded, id), recorded) { + (Consent::Held, Recorded::Owned { ownership }) => format!( + "this installation owns the runtime (consent generation {})", + ownership.consent_generation + ), + (Consent::AskFirstTime, _) => "the CLI install owns the runtime".to_owned(), + (Consent::AskAgain, _) => "another installation owns the runtime".to_owned(), + _ => "recorded owner could not be read, so nothing is claimed".to_owned(), + }, + }; + format!("{installation}; {verdict}") +} + +#[cfg(test)] +mod tests { + use super::{consent, describe, granted_to, Claim, Consent, Owner, Recorded}; + + fn owned(owner: Owner, install_id: &str, generation: u64) -> Recorded { + Recorded::Owned { + ownership: Claim { + owner, + install_id: install_id.to_owned(), + consent_generation: generation, + }, + } + } + + fn claim(owner: Owner, install_id: &str) -> Claim { + Claim { + owner, + install_id: install_id.to_owned(), + consent_generation: 1, + } + } + + #[test] + fn the_comparison_is_the_owner_and_the_install_id_together() { + let ours = claim(Owner::Desktop, "abc"); + assert!(granted_to(Some(&ours), Owner::Desktop, "abc")); + assert!(!granted_to(Some(&ours), Owner::Desktop, "def")); + assert!(!granted_to(Some(&ours), Owner::Cli, "abc")); + assert!(!granted_to(None, Owner::Desktop, "abc")); + } + + #[test] + fn the_generation_is_not_part_of_the_comparison() { + let mut later = claim(Owner::Desktop, "abc"); + later.consent_generation = 9; + assert!(granted_to(Some(&later), Owner::Desktop, "abc")); + } + + #[test] + fn consent_is_asked_once_and_then_held() { + assert_eq!( + consent(&owned(Owner::Desktop, "abc", 1), "abc"), + Consent::Held + ); + assert_eq!(consent(&Recorded::None, "abc"), Consent::AskFirstTime); + } + + #[test] + fn a_claim_that_is_not_ours_asks_again() { + assert_eq!( + consent(&owned(Owner::Desktop, "other", 2), "abc"), + Consent::AskAgain + ); + assert_eq!( + consent(&owned(Owner::Cli, "abc", 1), "abc"), + Consent::AskAgain + ); + } + + #[test] + fn an_unreadable_record_refuses_instead_of_reading_as_unowned() { + let unknown = Recorded::Unknown { + reason: "a service state path could not be read".to_owned(), + }; + assert_eq!(consent(&unknown, "abc"), Consent::Refuse); + assert!(describe(Some(&unknown), Some("abc")).contains("could not be read")); + } + + #[test] + fn the_wire_shape_is_the_one_the_cli_records() { + let resolution: Recorded = serde_json::from_str( + r#"{"kind":"owned","ownership":{"owner":"desktop","installId":"abc","consentGeneration":3}}"#, + ) + .expect("the recorded resolution"); + assert_eq!(resolution, owned(Owner::Desktop, "abc", 3)); + assert_eq!(consent(&resolution, "abc"), Consent::Held); + assert!(describe(Some(&resolution), Some("abc")).contains("consent generation 3")); + assert_eq!( + serde_json::from_str::(r#"{"kind":"none"}"#).expect("no claim"), + Recorded::None + ); + assert_eq!( + serde_json::from_str::(r#"{"kind":"unknown","reason":"why"}"#) + .expect("a refusal"), + Recorded::Unknown { + reason: "why".to_owned() + } + ); + } + + #[test] + fn the_description_separates_not_asked_from_nobody_owns_it() { + let not_asked = describe(None, Some("abc")); + let unowned = describe(Some(&Recorded::None), Some("abc")); + assert!(not_asked.contains("abc")); + assert_ne!(not_asked, unowned); + assert!(describe(None, None).contains("unavailable")); + } + + #[test] + fn a_generation_this_cannot_represent_is_not_read_as_a_claim() { + // Refusing beats granting on a number we cannot compare, and the claim is what a takeover + // would be authorised against. + assert!(serde_json::from_str::( + r#"{"kind":"owned","ownership":{"owner":"desktop","installId":"abc","consentGeneration":-1}}"# + ) + .is_err()); + assert!(serde_json::from_str::( + r#"{"kind":"owned","ownership":{"owner":"cli","installId":"abc","consentGeneration":"3"}}"# + ) + .is_err()); + } +} diff --git a/desktop/src-tauri/src/proxy.rs b/desktop/src-tauri/src/proxy.rs new file mode 100644 index 00000000000..fc45d820256 --- /dev/null +++ b/desktop/src-tauri/src/proxy.rs @@ -0,0 +1,277 @@ +use crate::{auth::Auth, endpoint::ProxyEndpoint}; +use reqwest::{redirect, Client, Method, StatusCode}; +use serde_json::Value; +use std::{ + sync::{Arc, Mutex, MutexGuard, PoisonError}, + time::Duration, +}; +use tokio::time::{timeout_at, Instant}; + +/// Which instance answered, taken from the unauthenticated health body. +/// +/// The management token is the admin credential for this machine's proxy. Sending it to whatever +/// happens to hold the port is the thing to avoid, so identity is established first — from a +/// response that needs no credential to read — and the credential follows only if the answer is the +/// instance the shell decided to trust. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct RuntimeIdentity { + pub pid: u32, + pub port: u16, +} + +/// The instance this client is bound to, and the binding it was bound under. +/// +/// The generation moves every time the shell binds to a runtime. A request authorised under an +/// earlier binding is not authorised under this one, which is what stops an in-flight management +/// call from landing on a runtime the shell rebound to in between. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct RuntimeBinding { + pub identity: RuntimeIdentity, + pub generation: u64, +} + +#[derive(Clone)] +pub struct ProxyClient { + client: Client, + endpoint: ProxyEndpoint, + auth: Auth, + binding: Arc>>, + generations: Arc>, +} + +#[derive(Debug)] +pub enum ProxyError { + Unreachable, + Unauthorized, + Http(StatusCode), + Decode(reqwest::Error), + /// The listener answered, but not as the instance this client is bound to — a foreign service + /// on the port, or a different process than the one the shell confirmed. + Foreign, +} + +impl ProxyError { + /// Whether nothing is listening on the endpoint at all. + /// + /// This is the only error that says anything about the process behind the port. A timeout, an + /// unauthorized reply or a body that will not parse all mean the listener answered or might + /// still be there, and a stop that reads any of them as "gone" reports a drain that did not + /// happen. + pub fn is_unreachable(&self) -> bool { + matches!(self, Self::Unreachable) + } +} + +/// Read an identity out of a health body. +/// +/// The marker is required: a 200 from something else on the port is not this proxy. The port is +/// required to be the one addressed, so a body describing a different listener cannot authorise a +/// credential for this one. +pub fn identity_from(body: &Value, addressed_port: u16) -> Option { + if body.get("service").and_then(Value::as_str) != Some("opencodex") { + return None; + } + let pid = u32::try_from(body.get("pid").and_then(Value::as_u64)?).ok()?; + let port = u16::try_from(body.get("port").and_then(Value::as_u64)?).ok()?; + if port != addressed_port { + return None; + } + Some(RuntimeIdentity { pid, port }) +} + +impl ProxyClient { + pub fn new(endpoint: ProxyEndpoint, auth: Auth) -> Result { + Ok(Self { + client: Client::builder() + .timeout(Duration::from_secs(4)) + .user_agent(Auth::user_agent()) + // The admin token attached to these requests is for the loopback endpoint and + // nowhere else. Two defaults would carry it off that endpoint, so both are turned + // off here rather than re-checked anywhere in the request path. + // + // A redirect is the first: the pinned client does not treat this custom credential + // header as sensitive, so it would follow the hop to wherever it pointed. + .redirect(redirect::Policy::none()) + // System proxy resolution is the second: reqwest honours system proxy + // configuration by default, which would route the credential through whatever + // proxy the machine declares and put another process between the shell and its + // own runtime. + .no_proxy() + .build()?, + endpoint, + auth, + binding: Arc::new(Mutex::new(None)), + generations: Arc::new(Mutex::new(0)), + }) + } + + pub fn endpoint(&self) -> ProxyEndpoint { + self.endpoint + } + + fn slot(lock: &Mutex) -> MutexGuard<'_, T> { + lock.lock().unwrap_or_else(PoisonError::into_inner) + } + + /// Bind this client to an instance, and return the binding it is now on. + pub fn bind(&self, identity: RuntimeIdentity) -> RuntimeBinding { + let mut generations = Self::slot(&self.generations); + *generations += 1; + let binding = RuntimeBinding { + identity, + generation: *generations, + }; + *Self::slot(&self.binding) = Some(binding); + binding + } + + pub fn binding(&self) -> Option { + *Self::slot(&self.binding) + } + + /// Ask the endpoint who it is, without sending anything secret. + pub async fn identify(&self) -> Result { + let response = self.send(&Method::GET, "/healthz", None).await?; + let body = decode(response).await?; + identity_from(&body, self.endpoint.port).ok_or(ProxyError::Foreign) + } + + pub async fn is_alive(&self) -> Result { + self.get("/healthz").await + } + + /// A health probe that cannot outlive the caller's deadline. + /// + /// The client's own timeout is per request and knows nothing about the budget the caller is + /// working to. A probe started a moment before a deadline would otherwise overrun it by that + /// whole timeout, which is how a stated 30-second startup ceiling quietly becomes 34. + /// `None` means the deadline arrived first. + pub async fn alive_within(&self, deadline: Instant) -> Option> { + timeout_at(deadline, self.is_alive()).await.ok() + } + + pub async fn companion_settings(&self) -> Result { + self.get("/api/companion/settings").await + } + + pub async fn usage_summary(&self) -> Result { + self.get("/api/usage?range=7d").await + } + + pub async fn usage_today(&self) -> Result { + self.get("/api/usage?range=today").await + } + + pub async fn startup_health(&self) -> Result { + self.get("/api/startup-health").await + } + + pub async fn quotas(&self) -> Result { + self.get("/api/provider-quotas").await + } + + pub async fn timeline(&self, query: &str) -> Result { + self.get(&format!("/api/usage/timeline?{query}")).await + } + + async fn get(&self, path: &str) -> Result { + self.request(Method::GET, path).await + } + + async fn request(&self, method: Method, path: &str) -> Result { + let response = self.send(&method, path, None).await?; + if response.status() == StatusCode::UNAUTHORIZED { + let token = self.authorised_token().await?; + let response = self.send(&method, path, Some(token)).await?; + return decode(response).await; + } + decode(response).await + } + + /// The management token, but only for the instance this client is bound to. + /// + /// The binding is re-confirmed here rather than trusted from when it was made: between then and + /// now the child can have exited and something else can hold the port. A request is therefore + /// bound to a pid, a port and the generation the shell authorised, and a mismatch is refused + /// instead of being sent the credential. + async fn authorised_token(&self) -> Result { + let Some(binding) = self.binding() else { + return Err(ProxyError::Unauthorized); + }; + let identity = self.identify().await?; + if identity != binding.identity { + return Err(ProxyError::Foreign); + } + if self.binding() != Some(binding) { + return Err(ProxyError::Foreign); + } + self.auth.token().ok_or(ProxyError::Unauthorized) + } + + async fn send( + &self, + method: &Method, + path: &str, + token: Option, + ) -> Result { + let mut request = self.client.request(method.clone(), self.endpoint.url(path)); + if let Some(value) = token { + request = request.header("X-OpenCodex-API-Key", value); + } + request.send().await.map_err(|error| { + if error.is_connect() { + ProxyError::Unreachable + } else { + ProxyError::Decode(error) + } + }) + } +} + +async fn decode(response: reqwest::Response) -> Result { + if response.status() == StatusCode::UNAUTHORIZED { + return Err(ProxyError::Unauthorized); + } + if !response.status().is_success() { + return Err(ProxyError::Http(response.status())); + } + response.json().await.map_err(ProxyError::Decode) +} + +#[cfg(test)] +mod tests { + use super::{identity_from, RuntimeIdentity}; + use serde_json::json; + + #[test] + fn a_health_body_without_the_marker_is_not_this_proxy() { + let body = json!({ "status": "ok", "pid": 42, "port": 10100 }); + assert!(identity_from(&body, 10100).is_none()); + let foreign = json!({ "service": "something-else", "pid": 42, "port": 10100 }); + assert!(identity_from(&foreign, 10100).is_none()); + } + + #[test] + fn the_body_has_to_describe_the_listener_that_was_addressed() { + let body = json!({ "service": "opencodex", "pid": 42, "port": 10101 }); + assert!(identity_from(&body, 10100).is_none()); + } + + #[test] + fn a_complete_body_identifies_the_instance() { + let body = json!({ "service": "opencodex", "version": "2.61.0", "pid": 42, "port": 10100 }); + assert_eq!( + identity_from(&body, 10100), + Some(RuntimeIdentity { + pid: 42, + port: 10100 + }) + ); + } + + #[test] + fn a_body_missing_the_instance_facts_identifies_nothing() { + assert!(identity_from(&json!({ "service": "opencodex", "port": 10100 }), 10100).is_none()); + assert!(identity_from(&json!({ "service": "opencodex", "pid": 42 }), 10100).is_none()); + } +} diff --git a/desktop/src-tauri/src/resolve.rs b/desktop/src-tauri/src/resolve.rs new file mode 100644 index 00000000000..8172006d0ff --- /dev/null +++ b/desktop/src-tauri/src/resolve.rs @@ -0,0 +1,329 @@ +//! What the bundled CLI says about this machine's runtime. +//! +//! D5: the shell stops resolving the configuration home, the port and liveness itself. It used to, +//! in a file called `discovery.rs` that read `runtime-port.json`, fell back to 10100 and started on +//! that port — so a user with a configured `config.port` was started somewhere else. The tuned probe +//! budgets it should have been using exist because a shell-side reimplementation answered "nobody is +//! listening" twice and started duplicate proxies. This asks instead. +//! +//! Liveness has three answers and the third one is the point. `live` means attach. `absent-proven` +//! means every recorded and configured endpoint was definitively dead, and only that authorises +//! starting a runtime. Anything else is unknown, and the CLI exits 1 rather than putting absence on +//! the wire. Everything that can go wrong on this side — a missing binary, a timeout, output that +//! will not parse, a schema this shell does not know — folds into the same unknown, because the one +//! reading that must never happen is "the resolve failed, so nobody must be listening". + +use crate::endpoint::ProxyEndpoint; +use serde::Deserialize; +use std::path::PathBuf; +use tauri::AppHandle; +use tauri_plugin_shell::ShellExt; +use tokio::time::{timeout_at, Instant}; + +/// The wire version this shell understands. A document announcing anything else is unknown. +pub const SCHEMA: &str = "ocx-resolve/1"; + +/// The CLI's liveness verdict. Only two reach the wire; the third exits 1. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum Status { + Live, + AbsentProven, +} + +#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Liveness { + pub status: Status, + pub pid: Option, + pub port: Option, + /// The bind address that answered. Absent on a proven absence, because nothing answered. + pub hostname: Option, + pub version: Option, + pub role: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Port { + /// The port a client should use: the live listener's, or the configured one. + pub effective: u16, + /// What a start would prefer. + pub configured: u16, +} + +#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Resolved { + pub schema: String, + pub cli_version: String, + pub config_home: String, + pub port: Port, + pub liveness: Liveness, +} + +impl Resolved { + pub fn endpoint(&self) -> ProxyEndpoint { + ProxyEndpoint { + host: "127.0.0.1", + port: self.port.effective, + } + } + + pub fn home(&self) -> PathBuf { + PathBuf::from(&self.config_home) + } +} + +/// What the shell got back. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Resolution { + /// The CLI produced a verdict this shell trusts. + Answered(Box), + /// It did not, for whatever reason. Never read as absence. + Unknown(String), +} + +impl Resolution { + pub fn resolved(&self) -> Option<&Resolved> { + match self { + Self::Answered(resolved) => Some(resolved.as_ref()), + Self::Unknown(_) => None, + } + } + + pub fn reason(&self) -> Option<&str> { + match self { + Self::Unknown(reason) => Some(reason), + Self::Answered(_) => None, + } + } +} + +/// Whether the shell may start a runtime of its own. +/// +/// Proven absence and nothing else. `live` means attach to what is there, and unknown means refuse: +/// a resolution that could not be trusted must never read as "nobody is listening", which is the +/// reading that puts a second proxy next to the one already running. +pub fn may_start(resolution: &Resolution) -> bool { + matches!( + resolution + .resolved() + .map(|resolved| resolved.liveness.status), + Some(Status::AbsentProven) + ) +} + +/// What the shell may do with a listener the CLI found alive. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum LiveVerdict { + /// Nothing is listening; this verdict does not apply. + NotLive, + /// It is a proxy, on an address this shell can reach. Attach as a guest. + Attach, + /// Something is listening and this shell cannot use it. Never a reason to start a second one. + Unusable(String), +} + +/// Whether the address the CLI reported is one this shell can reach on loopback. +/// +/// The shell speaks to loopback and nothing else — that is what makes sending the management token +/// to it safe. A proxy bound to either loopback spelling, or to every interface, is reachable at +/// 127.0.0.1. One bound to the IPv6 loopback or to a specific external address is not, and +/// addressing 127.0.0.1 anyway would turn a running proxy into a health wait that times out. +pub fn loopback_reachable(hostname: Option<&str>) -> bool { + matches!( + hostname, + None | Some("127.0.0.1") | Some("localhost") | Some("0.0.0.0") + ) +} + +/// Read a live verdict. +/// +/// Liveness answers "is something there", and core's predicate accepts a connected client's +/// listener on purpose so duplicate-start avoidance can see it. This shell needs the management +/// plane, so it has to discriminate on the role the CLI carried: a client listener serves machine +/// routes, not `/api/*`, and attaching to it would report Ready against an endpoint the dashboard +/// and the tray cannot use. +pub fn live_verdict(resolution: &Resolution) -> LiveVerdict { + let Some(resolved) = resolution.resolved() else { + return LiveVerdict::NotLive; + }; + if resolved.liveness.status != Status::Live { + return LiveVerdict::NotLive; + } + if resolved.liveness.role.as_deref() == Some("client") { + return LiveVerdict::Unusable( + "a connected client is listening on this port, not a proxy this app can manage".into(), + ); + } + if !loopback_reachable(resolved.liveness.hostname.as_deref()) { + return LiveVerdict::Unusable(format!( + "the runtime is bound to {} and this app only speaks to loopback", + resolved + .liveness + .hostname + .as_deref() + .unwrap_or("an unknown address") + )); + } + LiveVerdict::Attach +} + +/// Read one resolve document, refusing anything that is not exactly one. +/// +/// The CLI puts the document on stdout and its human output on stderr, so stdout is parsed whole. +/// A non-zero exit is the CLI's own refusal — including the exit 1 it uses for unknown liveness and +/// for a config it will not guess at — and is carried through rather than reinterpreted here. +pub fn read(exit_code: Option, stdout: &[u8], stderr: &[u8]) -> Resolution { + if exit_code != Some(0) { + let detail = String::from_utf8_lossy(stderr); + let detail = detail.trim(); + let code = exit_code + .map(|code| code.to_string()) + .unwrap_or_else(|| "no exit code".to_owned()); + return Resolution::Unknown(if detail.is_empty() { + format!("the bundled CLI could not resolve the runtime (exit {code})") + } else { + format!("the bundled CLI could not resolve the runtime (exit {code}): {detail}") + }); + } + let text = String::from_utf8_lossy(stdout); + let resolved: Resolved = match serde_json::from_str(text.trim()) { + Ok(resolved) => resolved, + Err(error) => { + return Resolution::Unknown(format!( + "the bundled CLI's resolve output could not be read ({error})" + )) + } + }; + if resolved.schema != SCHEMA { + return Resolution::Unknown(format!( + "the bundled CLI answered with schema {} and this app understands {SCHEMA}", + resolved.schema + )); + } + Resolution::Answered(Box::new(resolved)) +} + +/// Ask the bundled CLI, under the caller's deadline. +pub async fn run(app: &AppHandle, deadline: Instant) -> Resolution { + let command = match app.shell().sidecar("ocx") { + Ok(command) => command.args(["resolve", "--json"]), + Err(error) => { + return Resolution::Unknown(format!("the bundled CLI could not be started ({error})")) + } + }; + match timeout_at(deadline, command.output()).await { + Ok(Ok(output)) => read(output.status.code(), &output.stdout, &output.stderr), + Ok(Err(error)) => { + Resolution::Unknown(format!("the bundled CLI could not be run ({error})")) + } + Err(_) => Resolution::Unknown( + "the bundled CLI did not answer before the startup deadline".to_owned(), + ), + } +} + +#[cfg(test)] +mod tests { + use super::{ + live_verdict, loopback_reachable, may_start, read, LiveVerdict, Resolution, Status, SCHEMA, + }; + + const LIVE: &str = r#"{"schema":"ocx-resolve/1","cliVersion":"2.61.0","configHome":"/h", + "port":{"effective":10100,"configured":10100,"source":"runtime-record"}, + "liveness":{"status":"live","pid":42,"port":10100,"source":"runtime-record","version":"2.61.0"}}"#; + const ABSENT: &str = r#"{"schema":"ocx-resolve/1","cliVersion":"2.61.0","configHome":"/h", + "port":{"effective":10100,"configured":10100,"source":"config"}, + "liveness":{"status":"absent-proven","pid":null,"port":null,"source":null}}"#; + + #[test] + fn a_live_verdict_is_read_whole() { + let resolution = read(Some(0), LIVE.as_bytes(), b""); + let resolved = resolution.resolved().expect("a document"); + assert_eq!(resolved.schema, SCHEMA); + assert_eq!(resolved.liveness.status, Status::Live); + assert_eq!(resolved.liveness.pid, Some(42)); + assert_eq!(resolved.endpoint().port, 10100); + assert_eq!(resolved.home().display().to_string(), "/h"); + assert_eq!(live_verdict(&resolution), LiveVerdict::Attach); + assert!(!may_start(&resolution)); + } + + #[test] + fn only_a_proven_absence_authorises_a_start() { + let resolution = read(Some(0), ABSENT.as_bytes(), b""); + assert_eq!( + resolution.resolved().map(|r| r.liveness.status), + Some(Status::AbsentProven) + ); + assert!(may_start(&resolution)); + assert_eq!(live_verdict(&resolution), LiveVerdict::NotLive); + } + + #[test] + fn a_connected_client_is_live_but_not_a_runtime_to_attach_to() { + let client = LIVE.replace( + r#""version":"2.61.0""#, + r#""version":"2.61.0","role":"client""#, + ); + let resolution = read(Some(0), client.as_bytes(), b""); + assert!(matches!( + live_verdict(&resolution), + LiveVerdict::Unusable(_) + )); + // Live and unusable is still live: it is never a reason to start a second one. + assert!(!may_start(&resolution)); + } + + #[test] + fn only_a_loopback_bind_is_addressed_as_loopback() { + for reachable in [None, Some("127.0.0.1"), Some("localhost"), Some("0.0.0.0")] { + assert!(loopback_reachable(reachable), "{reachable:?}"); + } + for elsewhere in [Some("::1"), Some("192.168.1.10"), Some("example.internal")] { + assert!(!loopback_reachable(elsewhere), "{elsewhere:?}"); + } + let bound = LIVE.replace(r#""pid":42"#, r#""pid":42,"hostname":"::1""#); + let resolution = read(Some(0), bound.as_bytes(), b""); + assert!(matches!( + live_verdict(&resolution), + LiveVerdict::Unusable(_) + )); + assert!(!may_start(&resolution)); + } + + #[test] + fn the_clis_own_refusal_is_unknown_and_never_authorises_a_start() { + // Exit 1 is what the CLI uses for unknown liveness and for a config it will not guess at. + let resolution = read(Some(1), b"", b"resolve: liveness is unknown"); + assert!(matches!(resolution, Resolution::Unknown(_))); + assert!(resolution.reason().unwrap().contains("liveness is unknown")); + assert!(!may_start(&resolution)); + assert_eq!(live_verdict(&resolution), LiveVerdict::NotLive); + } + + #[test] + fn everything_that_can_go_wrong_here_folds_into_unknown() { + for (code, out) in [ + (Some(64), &b""[..]), + (None, &b""[..]), + (Some(0), &b"not json"[..]), + (Some(0), &b"{}"[..]), + ] { + let resolution = read(code, out, b""); + assert!(matches!(resolution, Resolution::Unknown(_)), "{code:?}"); + assert!(!may_start(&resolution)); + } + } + + #[test] + fn a_schema_this_app_does_not_know_is_unknown() { + let future = LIVE.replace("ocx-resolve/1", "ocx-resolve/2"); + let resolution = read(Some(0), future.as_bytes(), b""); + assert!(matches!(resolution, Resolution::Unknown(_))); + assert!(resolution.reason().unwrap().contains("ocx-resolve/2")); + assert!(!may_start(&resolution)); + } +} diff --git a/desktop/src-tauri/src/runtime_stop.rs b/desktop/src-tauri/src/runtime_stop.rs new file mode 100644 index 00000000000..c4099b8304b --- /dev/null +++ b/desktop/src-tauri/src/runtime_stop.rs @@ -0,0 +1,306 @@ +//! Stopping a runtime through the bundled CLI. +//! +//! D4: the shell drives the real `ocx stop` as a child process, so the receipt-backed teardown, the +//! drain, the Windows respawn verification and the client-configuration restore all run exactly as +//! they do from a terminal. An in-process management call cannot own that teardown — launchd and +//! systemd can terminate the request handler during self-unload, and the Windows respawn window can +//! only be verified after the process exits — so the shell reads the run's result instead of +//! performing it. +//! +//! The result is a document, not a guess. `ocx stop --json` puts one summary on stdout and its +//! human output on stderr, and this consumes the outcome and the exit code rather than inferring +//! either. A stop that did not end in exit 0 with the runtime down is a stop that did not happen. + +use serde::Deserialize; +use std::time::Duration; +use tauri::AppHandle; +use tauri_plugin_shell::ShellExt; +use tokio::time::{timeout_at, Instant}; + +/// The wire version this shell understands. +pub const SCHEMA: &str = "ocx-stop/1"; + +/// How long the stop may take. +/// +/// The CLI's stop drains in-flight requests, restores client configuration and verifies the Windows +/// respawn window, so this is generous on purpose: it bounds a hang, it does not pace a healthy +/// stop. Overrunning it is a failure, not a stop, because the caller's next step is to end the app +/// or replace the files the runtime is serving out of. +pub const DEADLINE: Duration = Duration::from_secs(30); + +/// The outcomes the CLI can report. An outcome this shell does not know fails to parse, which is +/// the same answer as a stop that did not happen. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum Outcome { + Stopped, + NotRunning, + HistoryIncomplete, + HistoryDeferred, + Failed, +} + +impl Outcome { + pub fn as_str(self) -> &'static str { + match self { + Self::Stopped => "stopped", + Self::NotRunning => "not-running", + Self::HistoryIncomplete => "history-incomplete", + Self::HistoryDeferred => "history-deferred", + Self::Failed => "failed", + } + } +} + +/// How the proxy half of the stop ended. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum Proxy { + Stopped, + StoppedOrphan, + NotRunning, + StopFailed, + OwnershipRefused, + UnresolvablePid, + Respawned, + Unknown, +} + +impl Proxy { + pub fn as_str(self) -> &'static str { + match self { + Self::Stopped => "stopped", + Self::StoppedOrphan => "stopped-orphan", + Self::NotRunning => "not-running", + Self::StopFailed => "stop-failed", + Self::OwnershipRefused => "ownership-refused", + Self::UnresolvablePid => "unresolvable-pid", + Self::Respawned => "respawned", + Self::Unknown => "unknown", + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StopSummary { + pub schema: String, + /// Strict exit-code view: true only for exit 0. + pub ok: bool, + pub outcome: Outcome, + pub exit_code: i32, + /// True when this stop left no proxy of this home running by its own paths. + pub runtime_down: bool, + pub proxy: Proxy, + pub message: String, +} + +/// What the shell concluded. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum StopResult { + /// The CLI reported a clean stop and a runtime that is down. + Stopped(StopSummary), + /// It reported anything else, or the run could not be read at all. + Failed(String), +} + +impl StopResult { + pub fn is_stopped(&self) -> bool { + matches!(self, Self::Stopped(_)) + } + + pub fn describe(&self) -> String { + match self { + Self::Stopped(summary) => summary.message.clone(), + Self::Failed(reason) => reason.clone(), + } + } +} + +/// Read one stop summary. +/// +/// Five facts have to hold together, and no four of them are enough. +/// +/// The process has to have exited 0, and the document has to say so too: `ok` is the strict +/// exit-code view and `exitCode` is the number behind it, so 1, 79 and 80 are refusals however the +/// rest of the document reads. Reading only the document would take a run's word for its own exit +/// status; reading only the status would accept a summary that disagrees with it. And `runtimeDown` +/// is the CLI's own statement that no proxy of this home is left running — a service that failed +/// while the proxy happened to stop satisfies that and not the others, and it is exactly the case +/// that may respawn the runtime a moment later. +/// +/// The fifth is that the document agrees with itself. The CLI's own summarizer cannot emit a +/// `failed` outcome beside a `stopped` proxy, but a reader that assumes that is trusting a +/// document to be self-consistent rather than checking. Only the two shapes that mean a runtime is +/// down are accepted, and an outcome or a proxy state this shell does not know fails to parse — +/// which is the same answer as a stop that did not happen. +pub fn read(exit_code: Option, stdout: &[u8], stderr: &[u8]) -> StopResult { + let text = String::from_utf8_lossy(stdout); + let summary: StopSummary = match serde_json::from_str(text.trim()) { + Ok(summary) => summary, + Err(error) => { + let detail = String::from_utf8_lossy(stderr); + let detail = detail.trim(); + let code = exit_code + .map(|code| code.to_string()) + .unwrap_or_else(|| "no exit code".to_owned()); + return StopResult::Failed(if detail.is_empty() { + format!("the bundled CLI's stop output could not be read (exit {code}: {error})") + } else { + format!("the bundled CLI's stop output could not be read (exit {code}): {detail}") + }); + } + }; + if summary.schema != SCHEMA { + return StopResult::Failed(format!( + "the bundled CLI answered with schema {} and this app understands {SCHEMA}", + summary.schema + )); + } + let agrees = matches!( + (summary.outcome, summary.proxy), + (Outcome::Stopped, Proxy::Stopped) + | (Outcome::Stopped, Proxy::StoppedOrphan) + | (Outcome::NotRunning, Proxy::NotRunning) + ); + if exit_code != Some(0) + || !summary.ok + || summary.exit_code != 0 + || !summary.runtime_down + || !agrees + { + return StopResult::Failed(format!( + "{} (outcome {}, proxy {}, exit {}, process exit {})", + summary.message, + summary.outcome.as_str(), + summary.proxy.as_str(), + summary.exit_code, + exit_code + .map(|code| code.to_string()) + .unwrap_or_else(|| "none".to_owned()) + )); + } + StopResult::Stopped(summary) +} + +/// Run the bundled `ocx stop --json`, under the caller's deadline. +pub async fn run(app: &AppHandle, deadline: Instant) -> StopResult { + let command = match app.shell().sidecar("ocx") { + Ok(command) => command.args(["stop", "--json"]), + Err(error) => { + return StopResult::Failed(format!("the bundled CLI could not be started ({error})")) + } + }; + match timeout_at(deadline, command.output()).await { + Ok(Ok(output)) => read(output.status.code(), &output.stdout, &output.stderr), + Ok(Err(error)) => StopResult::Failed(format!("the bundled CLI could not be run ({error})")), + Err(_) => { + StopResult::Failed("the bundled CLI did not finish stopping before the deadline".into()) + } + } +} + +#[cfg(test)] +mod tests { + use super::{read, Outcome, Proxy, StopResult, SCHEMA}; + + fn document(ok: bool, outcome: &str, exit: i32, down: bool, proxy: &str) -> String { + format!( + r#"{{"schema":"ocx-stop/1","ok":{ok},"outcome":"{outcome}","exitCode":{exit}, + "runtimeDown":{down},"service":"absent","proxy":"{proxy}", + "sharedTeardown":"restored","message":"a message"}}"# + ) + } + + #[test] + fn a_clean_stop_with_the_runtime_down_is_the_only_success() { + let ok = document(true, "stopped", 0, true, "stopped"); + let result = read(Some(0), ok.as_bytes(), b""); + assert!(result.is_stopped()); + match result { + StopResult::Stopped(summary) => { + assert_eq!(summary.schema, SCHEMA); + assert_eq!(summary.outcome, Outcome::Stopped); + assert_eq!(summary.proxy, Proxy::Stopped); + assert!(summary.runtime_down); + } + StopResult::Failed(reason) => panic!("{reason}"), + } + // Nothing was running is equally a runtime that is down. + assert!(read( + Some(0), + document(true, "not-running", 0, true, "not-running").as_bytes(), + b"" + ) + .is_stopped()); + } + + #[test] + fn a_non_zero_exit_is_never_folded_into_success() { + // 79 and 80 report a proxy that went down with an obligation still owed. The runtime may + // be down, but the run did not succeed, and an update must not install over it. + for (outcome, exit) in [ + ("history-incomplete", 79), + ("history-deferred", 80), + ("failed", 1), + ] { + let document = document(false, outcome, exit, true, "stopped"); + let result = read(Some(exit), document.as_bytes(), b""); + assert!(!result.is_stopped(), "{outcome}"); + assert!(result.describe().contains(outcome)); + } + } + + #[test] + fn the_process_status_and_the_document_have_to_agree() { + let clean = document(true, "stopped", 0, true, "stopped"); + // A run that exited non-zero is a refusal even when its summary reads clean: taking the + // document's word for its own exit status is taking one claim as evidence of itself. + assert!(!read(Some(1), clean.as_bytes(), b"").is_stopped()); + assert!(!read(None, clean.as_bytes(), b"").is_stopped()); + // And a summary that contradicts its own exit code is not a stop either. + let contradictory = document(true, "stopped", 1, true, "stopped"); + assert!(!read(Some(0), contradictory.as_bytes(), b"").is_stopped()); + } + + #[test] + fn a_document_that_contradicts_itself_is_not_a_stop() { + // The CLI's summarizer cannot emit this, and the reader does not assume that. + let mixed = document(true, "failed", 0, true, "respawned"); + assert!(!read(Some(0), mixed.as_bytes(), b"").is_stopped()); + let orphan = document(true, "stopped", 0, true, "stopped-orphan"); + assert!(read(Some(0), orphan.as_bytes(), b"").is_stopped()); + // An outcome or a proxy state this shell does not know is not read at all. + let future = document(true, "stopped", 0, true, "stopped") + .replace("\"proxy\":\"stopped\"", "\"proxy\":\"parked\""); + assert!(!read(Some(0), future.as_bytes(), b"").is_stopped()); + } + + #[test] + fn a_runtime_still_up_is_a_failure_however_the_exit_reads() { + for proxy in [ + "respawned", + "stop-failed", + "ownership-refused", + "unresolvable-pid", + ] { + let document = document(true, "stopped", 0, false, proxy); + assert!( + !read(Some(0), document.as_bytes(), b"").is_stopped(), + "{proxy}" + ); + } + } + + #[test] + fn output_that_cannot_be_read_is_a_failure_not_a_stop() { + assert!(!read(Some(0), b"", b"boom").is_stopped()); + assert!(!read(Some(0), b"not json", b"").is_stopped()); + assert!(!read(None, b"", b"").is_stopped()); + let future = + document(true, "stopped", 0, true, "stopped").replace("ocx-stop/1", "ocx-stop/2"); + let result = read(Some(0), future.as_bytes(), b""); + assert!(!result.is_stopped()); + assert!(result.describe().contains("ocx-stop/2")); + } +} diff --git a/desktop/src-tauri/src/sidecar.rs b/desktop/src-tauri/src/sidecar.rs new file mode 100644 index 00000000000..2a1da6e90c7 --- /dev/null +++ b/desktop/src-tauri/src/sidecar.rs @@ -0,0 +1,226 @@ +//! Starting and watching the runtime this app owns. +//! +//! The spawn event stream used to be discarded into `_events`, which is why a sidecar that exited +//! immediately — a binary built for a CPU instruction set this machine does not have, a port +//! already taken, a corrupt install — presented as the same generic health failure as a slow start. +//! The child's exit code and its last output were both available and both thrown away. They are +//! consumed here instead, and they are what the startup diagnostic is made of. +//! +//! Stopping it is not here. D4 gives that to the bundled `ocx stop`, which owns the receipt-backed +//! teardown this process cannot perform on itself; see `runtime_stop.rs`. + +use crate::endpoint::ProxyEndpoint; +use std::{ + collections::VecDeque, + sync::{Arc, Mutex}, +}; +use tauri::{async_runtime::Receiver, AppHandle, Manager}; +use tauri_plugin_shell::{ + process::{CommandChild, CommandEvent}, + ShellExt, +}; +/// How much sidecar output the diagnostic keeps. Enough to carry a stack trace or a startup +/// refusal, bounded so a chatty runtime cannot grow the buffer for the life of the process. +const MAX_LINES: usize = 40; + +/// How the sidecar process ended. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct SidecarExit { + pub code: Option, + pub signal: Option, +} + +impl SidecarExit { + pub fn describe(&self) -> String { + match (self.code, self.signal) { + (Some(code), _) => format!("exit code {code}"), + (None, Some(signal)) => format!("terminated by signal {signal}"), + (None, None) => "exited without reporting a code".to_owned(), + } + } +} + +/// One thing the spawned child told us. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum SidecarEvent { + Line(String), + Exited(SidecarExit), +} + +#[derive(Default)] +struct WatchInner { + lines: VecDeque, + exit: Option, +} + +impl WatchInner { + fn record(&mut self, event: SidecarEvent) { + match event { + SidecarEvent::Line(line) => { + let line = line.trim_end().to_owned(); + if line.is_empty() { + return; + } + if self.lines.len() == MAX_LINES { + self.lines.pop_front(); + } + self.lines.push_back(line); + } + SidecarEvent::Exited(exit) => self.exit = Some(exit), + } + } +} + +/// The consumed spawn event stream of the child this app started. +#[derive(Clone, Default)] +pub struct SidecarWatch { + inner: Arc>, +} + +impl SidecarWatch { + pub fn record(&self, event: SidecarEvent) { + if let Ok(mut inner) = self.inner.lock() { + inner.record(event); + } + } + + pub fn exit(&self) -> Option { + self.inner.lock().ok().and_then(|inner| inner.exit) + } + + pub fn lines(&self) -> Vec { + self.inner + .lock() + .map(|inner| inner.lines.iter().cloned().collect()) + .unwrap_or_default() + } + + /// Forget the previous attempt so a retry's diagnostic describes the retry. + pub fn reset(&self) { + if let Ok(mut inner) = self.inner.lock() { + inner.lines.clear(); + inner.exit = None; + } + } + + /// Drain the spawn event stream into this record for as long as the child lives. + pub fn follow(&self, mut events: Receiver) { + let watch = self.clone(); + tauri::async_runtime::spawn(async move { + while let Some(event) = events.recv().await { + if let Some(event) = translate(event) { + watch.record(event); + } + } + }); + } +} + +fn translate(event: CommandEvent) -> Option { + match event { + CommandEvent::Stdout(bytes) | CommandEvent::Stderr(bytes) => Some(SidecarEvent::Line( + String::from_utf8_lossy(&bytes).into_owned(), + )), + CommandEvent::Error(message) => Some(SidecarEvent::Line(format!("error: {message}"))), + CommandEvent::Terminated(payload) => Some(SidecarEvent::Exited(SidecarExit { + code: payload.code, + signal: payload.signal, + })), + _ => None, + } +} + +/// Start the bundled runtime and begin consuming what it says. +/// +/// The port is still passed explicitly. D5 hands that resolution to the bundled CLI so a user on a +/// custom `config.port` is not started on a different one; this is the call site that changes when +/// lane A's resolve verb lands, and nothing else here depends on where the number came from. +pub fn start( + app: &AppHandle, + endpoint: ProxyEndpoint, + watch: &SidecarWatch, +) -> Result { + let gui_dist = app + .path() + .resource_dir() + .map_err(|error| error.to_string())? + .join("gui") + .join("dist"); + let command = app + .shell() + .sidecar("ocx") + .map_err(|error| error.to_string())? + .args(["start", "--port", &endpoint.port.to_string()]) + .env("OPENCODEX_GUI_DIST", gui_dist); + let (events, child) = command.spawn().map_err(|error| error.to_string())?; + watch.follow(events); + Ok(child) +} + +#[cfg(test)] +mod tests { + use super::{SidecarEvent, SidecarExit, SidecarWatch, MAX_LINES}; + + #[test] + fn the_exit_code_survives_the_event_stream() { + let watch = SidecarWatch::default(); + watch.record(SidecarEvent::Line("listening on 10100".into())); + watch.record(SidecarEvent::Exited(SidecarExit { + code: Some(1), + signal: None, + })); + assert_eq!(watch.exit().and_then(|exit| exit.code), Some(1)); + assert_eq!(watch.lines(), vec!["listening on 10100".to_owned()]); + } + + #[test] + fn output_is_bounded_and_keeps_the_end() { + let watch = SidecarWatch::default(); + for index in 0..(MAX_LINES + 5) { + watch.record(SidecarEvent::Line(format!("line {index}"))); + } + let lines = watch.lines(); + assert_eq!(lines.len(), MAX_LINES); + assert_eq!(lines.first().unwrap(), "line 5"); + assert_eq!(lines.last().unwrap(), &format!("line {}", MAX_LINES + 4)); + } + + #[test] + fn blank_output_is_not_recorded_and_a_reset_forgets_the_attempt() { + let watch = SidecarWatch::default(); + watch.record(SidecarEvent::Line(" \n".into())); + assert!(watch.lines().is_empty()); + watch.record(SidecarEvent::Line("boom".into())); + watch.record(SidecarEvent::Exited(SidecarExit { + code: None, + signal: Some(9), + })); + watch.reset(); + assert!(watch.lines().is_empty()); + assert!(watch.exit().is_none()); + } + + #[test] + fn an_exit_reads_as_a_code_a_signal_or_neither() { + assert_eq!( + SidecarExit { + code: Some(2), + signal: None + } + .describe(), + "exit code 2" + ); + assert_eq!( + SidecarExit { + code: None, + signal: Some(9) + } + .describe(), + "terminated by signal 9" + ); + assert_eq!( + SidecarExit::default().describe(), + "exited without reporting a code" + ); + } +} diff --git a/desktop/src-tauri/src/startup.rs b/desktop/src-tauri/src/startup.rs new file mode 100644 index 00000000000..bcc08ba2279 --- /dev/null +++ b/desktop/src-tauri/src/startup.rs @@ -0,0 +1,876 @@ +//! The startup sequence, as named states inside a window the user can already see. +//! +//! Everything below used to run inside `setup()` before any window existed, and the window was +//! then created hidden. That ordering is why a failed start had no surface: the spawn event stream +//! was discarded, so the child's exit code was gone, and a run of probes that time out rather than +//! refuse takes over a minute with nothing on screen to explain it. D7 inverts it. The window is +//! created and shown first, and the sequence runs inside it as named states under one overall +//! deadline, with a retry, the child's exit code and a diagnostic the user can copy. +//! +//! Registration comes first, before the runtime is touched at all. The order looks backwards until +//! you follow the failing case: a login launch starts hidden, and if the tray were installed only +//! after a successful start then a start that failed would leave a running process with no window +//! and no icon — invisible. The app establishes its own surface, then deals with the runtime. +//! +//! A launch that came from login autostart starts hidden, and that is the only difference — except +//! where there is no usable tray to hide into, which is R1 and lives in [`shows_window`]. + +use crate::{ + auth::Auth, + endpoint::ProxyEndpoint, + first_run::{self, StartAtLogin}, + identity, ownership, + proxy::{ProxyClient, RuntimeIdentity}, + resolve, + sidecar::{self, SidecarWatch}, + tray_availability::{self, TrayAvailability}, + AppState, +}; +use serde::Serialize; +use std::{ + path::PathBuf, + sync::{ + atomic::{AtomicBool, Ordering}, + Mutex, MutexGuard, PoisonError, + }, +}; +use tauri::{AppHandle, Emitter, Manager}; +use tokio::time::{sleep, Duration, Instant}; + +/// The event the bootstrap page listens on. +pub const PHASE_EVENT: &str = "startup-phase"; + +/// One deadline for the whole sequence. +/// +/// Per-step budgets were what produced the unbounded case: a two-second attach loop whose probes +/// each cost a four-second client timeout, followed by twenty more waits, adds up to something no +/// single number in the code admitted to. One ceiling over the whole run is a promise that can be +/// read — and every probe under it is bounded by the remaining time rather than by its own +/// timeout, because otherwise the last probe overruns the ceiling by the whole client timeout. +pub const DEADLINE: Duration = Duration::from_secs(30); + +const POLL: Duration = Duration::from_millis(250); + +/// Where the launch came from. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LaunchOrigin { + /// A person opened the app. + User, + /// The login item started it. + Autostart, +} + +/// The argument the autostart registration passes back to us. Nothing else supplies it, so its +/// presence is the launch origin. +pub const AUTOSTART_FLAG: &str = "--autostart"; + +impl LaunchOrigin { + pub fn from_args(mut args: impl Iterator) -> Self { + if args.any(|argument| argument == AUTOSTART_FLAG) { + Self::Autostart + } else { + Self::User + } + } + + pub fn detect() -> Self { + Self::from_args(std::env::args()) + } +} + +/// Whether this launch shows its window. +/// +/// D7 shows it always and exempts a login launch, which starts hidden. D6 shows it wherever there +/// is no usable tray. A no-tray login launch satisfies both rules and they disagree, so R1 settles +/// it: tray availability wins. Starting hidden is a property of having somewhere to be hidden in, +/// not of how the process was started. +pub fn shows_window(origin: LaunchOrigin, tray: TrayAvailability) -> bool { + !tray.is_available() || origin == LaunchOrigin::User +} + +/// A named state of the startup sequence. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Phase { + Registering, + Resolving, + Probing, + Attaching, + Starting, + Waiting, + Ready, + Failed, +} + +/// Every phase, in the order they run. The bootstrap page derives its checklist from this rather +/// than restating it, so a phase cannot exist in one place and be missing from the other. +pub const PHASES: [Phase; 8] = [ + Phase::Registering, + Phase::Resolving, + Phase::Probing, + Phase::Attaching, + Phase::Starting, + Phase::Waiting, + Phase::Ready, + Phase::Failed, +]; + +impl Phase { + /// The stable identifier the bootstrap page keys on. + pub fn id(self) -> &'static str { + match self { + Self::Registering => "registering", + Self::Resolving => "resolving", + Self::Probing => "probing", + Self::Attaching => "attaching", + Self::Starting => "starting", + Self::Waiting => "waiting", + Self::Ready => "ready", + Self::Failed => "failed", + } + } + + pub fn label(self) -> &'static str { + match self { + Self::Registering => "Registering the tray and the login item", + Self::Resolving => "Resolving the configuration home and port", + Self::Probing => "Looking for a runtime that is already listening", + Self::Attaching => "Attaching to the runtime that answered", + Self::Starting => "Starting the bundled runtime", + Self::Waiting => "Waiting for the runtime to report healthy", + Self::Ready => "Ready", + Self::Failed => "OpenCodex could not start its runtime", + } + } + + pub fn is_terminal(self) -> bool { + matches!(self, Self::Ready | Self::Failed) + } +} + +/// One phase, as the bootstrap page sees it. +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PhaseInfo { + pub id: &'static str, + pub label: &'static str, + pub terminal: bool, +} + +/// The phase list the page renders. Derived from [`PHASES`] so the two cannot drift. +pub fn phase_list() -> Vec { + PHASES + .iter() + .map(|phase| PhaseInfo { + id: phase.id(), + label: phase.label(), + terminal: phase.is_terminal(), + }) + .collect() +} + +/// What the bootstrap page is told. +/// +/// It carries the phases already finished, not just the current one. An event emitted before the +/// page's listener exists is gone, and the early phases finish in milliseconds, so a page that +/// reconstructed history from events alone would show a run in progress with nothing behind it. +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Progress { + pub phase: &'static str, + pub label: &'static str, + pub detail: Option, + pub completed: Vec<&'static str>, + pub failed_phase: Option<&'static str>, + pub elapsed_ms: u64, + pub dashboard: Option, + pub diagnostic: Option, + pub can_retry: bool, +} + +impl Progress { + fn new(phase: Phase, elapsed_ms: u64) -> Self { + Self { + phase: phase.id(), + label: phase.label(), + detail: None, + completed: Vec::new(), + failed_phase: None, + elapsed_ms, + dashboard: None, + diagnostic: None, + can_retry: phase == Phase::Failed, + } + } +} + +/// Where the sequence is pointed, once the CLI has said. +#[derive(Clone)] +struct Target { + endpoint: ProxyEndpoint, + home: PathBuf, +} + +/// What registering established about this installation. +#[derive(Clone, Debug)] +pub struct Registration { + pub login: StartAtLogin, + /// This installation's own id, and what the recorded runtime owner says about it. + pub identity: String, +} + +struct Live { + latest: Progress, + reported: Vec<&'static str>, +} + +/// The sequence's managed state: the latest thing it said, what it has already finished, and +/// whether it is running, so a retry cannot start a second run alongside the first. +pub struct Startup { + live: Mutex, + running: AtomicBool, + /// The outcome of the one-time registration, once it has happened. + registered: Mutex>, +} + +impl Startup { + pub fn new() -> Self { + Self { + live: Mutex::new(Live { + latest: Progress::new(Phase::Registering, 0), + reported: Vec::new(), + }), + running: AtomicBool::new(false), + registered: Mutex::new(None), + } + } + + fn live(&self) -> MutexGuard<'_, Live> { + self.live.lock().unwrap_or_else(PoisonError::into_inner) + } + + fn registration(&self) -> Option { + self.registered + .lock() + .unwrap_or_else(PoisonError::into_inner) + .clone() + } + + fn remember_registration(&self, registration: Registration) { + *self + .registered + .lock() + .unwrap_or_else(PoisonError::into_inner) = Some(registration); + } + + /// The whole state of the run so far, which is what the page asks for when it loads. + pub fn latest(&self) -> Progress { + self.live().latest.clone() + } + + fn restart(&self) { + let mut live = self.live(); + live.reported.clear(); + live.latest = Progress::new(Phase::Registering, 0); + } + + fn publish(&self, progress: &mut Progress, failed_in: Option) { + let mut live = self.live(); + if !live.reported.contains(&progress.phase) + && progress.phase != Phase::Ready.id() + && progress.phase != Phase::Failed.id() + { + live.reported.push(progress.phase); + } + progress.completed = live + .reported + .iter() + .copied() + .filter(|id| *id != progress.phase) + .collect(); + progress.failed_phase = failed_in.map(Phase::id); + live.latest = progress.clone(); + } +} + +impl Default for Startup { + fn default() -> Self { + Self::new() + } +} + +/// Run the sequence, unless it is already running. This is also the retry. +pub fn begin(app: &AppHandle) { + let Some(startup) = app.try_state::() else { + return; + }; + if startup + .running + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + return; + } + startup.restart(); + let app = app.clone(); + tauri::async_runtime::spawn(async move { + run(&app).await; + if let Some(startup) = app.try_state::() { + startup.running.store(false, Ordering::Release); + } + }); +} + +async fn run(app: &AppHandle) { + let started = Instant::now(); + let deadline = started + DEADLINE; + let Some(watch) = app.try_state::().map(|state| state.watch.clone()) else { + return; + }; + report(app, started, Phase::Registering, None); + let registration = register(app, deadline).await; + report( + app, + started, + Phase::Registering, + Some(format!( + "{}; {}", + registration.login.describe(), + registration.identity + )), + ); + + report(app, started, Phase::Resolving, None); + // D5: the shell no longer resolves the home, the port or liveness. It asks the bundled CLI, + // which owns the tuned probe budgets that exist because a shell-side reimplementation answered + // "nobody is listening" twice and started duplicate proxies. The call is inside the sequence, so + // a CLI that is missing or slow has a state, a diagnostic and a retry rather than a guess. + let resolution = resolve::run(app, deadline).await; + let Some(answer) = resolution.resolved() else { + // Fail-closed. A resolution that could not be trusted is not an absence, and nothing below + // may read it as one. + fail( + app, + started, + None, + ®istration, + &watch, + Phase::Resolving, + resolution + .reason() + .unwrap_or("the runtime could not be resolved") + .to_owned(), + ); + return; + }; + let endpoint = answer.endpoint(); + let target = Target { + endpoint, + home: answer.home(), + }; + let proxy = match ProxyClient::new(endpoint, Auth::new(answer.home())) { + Ok(proxy) => proxy, + Err(error) => { + fail( + app, + started, + Some(&target), + ®istration, + &watch, + Phase::Resolving, + error.to_string(), + ); + return; + } + }; + if let Some(state) = app.try_state::() { + state.attach(proxy.clone()); + } + report( + app, + started, + Phase::Resolving, + Some(format!( + "{} with a configuration home of {}, resolved by the bundled CLI {}", + target.endpoint.url(""), + target.home.display(), + answer.cli_version + )), + ); + + report( + app, + started, + Phase::Probing, + Some(match answer.liveness.status { + resolve::Status::Live => "a runtime is already listening".to_owned(), + resolve::Status::AbsentProven => { + "no runtime is listening, and that absence was proven".to_owned() + } + }), + ); + match resolve::live_verdict(&resolution) { + resolve::LiveVerdict::Attach => { + report( + app, + started, + Phase::Attaching, + Some("a runtime was already listening, so this app is a guest on it".to_owned()), + ); + if bind(app, &proxy, deadline).await.is_none() { + fail( + app, + started, + Some(&target), + ®istration, + &watch, + Phase::Attaching, + "the runtime answered but did not identify itself, so this app did not attach" + .to_owned(), + ); + return; + } + finish(app, started, endpoint); + return; + } + // Something holds the port and this app cannot manage it. That is not an absence, so it + // does not authorise starting a second runtime beside it either. + resolve::LiveVerdict::Unusable(reason) => { + fail( + app, + started, + Some(&target), + ®istration, + &watch, + Phase::Attaching, + reason, + ); + return; + } + resolve::LiveVerdict::NotLive => {} + } + if !resolve::may_start(&resolution) { + // Only a proven absence authorises a start. Nothing else may fall through to one. + fail( + app, + started, + Some(&target), + ®istration, + &watch, + Phase::Probing, + "the runtime's liveness could not be established, so no runtime was started".to_owned(), + ); + return; + } + + // A retry must not leave a second proxy behind. A child that has not reported an exit is still + // out there, whatever the last run concluded, so the retry waits on that one rather than + // starting another and racing it for the port. + let owns_live_child = app + .try_state::() + .is_some_and(|state| state.owns_runtime()) + && watch.exit().is_none(); + if owns_live_child { + report( + app, + started, + Phase::Starting, + Some("the runtime this app started has not exited; waiting on it again".to_owned()), + ); + } else { + report(app, started, Phase::Starting, None); + watch.reset(); + match spawn_runtime(app, endpoint, &watch) { + Some(Ok(())) => {} + Some(Err(error)) => { + fail( + app, + started, + Some(&target), + ®istration, + &watch, + Phase::Starting, + error, + ); + return; + } + // An exit is already in flight, so starting a runtime now would orphan it. + None => return, + } + } + + report(app, started, Phase::Waiting, None); + while Instant::now() < deadline { + if matches!(proxy.alive_within(deadline).await, Some(Ok(_))) { + if bind(app, &proxy, deadline).await.is_none() { + // Healthy is not the same as identified: a 200 with a body that does not carry the + // marker is something else holding the port, and the token is never sent to it. + fail( + app, + started, + Some(&target), + ®istration, + &watch, + Phase::Waiting, + "the runtime reported healthy but did not identify itself".to_owned(), + ); + return; + } + finish(app, started, endpoint); + return; + } + // A child that has already exited will never answer, so the deadline is not worth waiting + // out. This is the case the discarded event stream used to hide behind a generic timeout. + if let Some(exit) = watch.exit() { + fail( + app, + started, + Some(&target), + ®istration, + &watch, + Phase::Waiting, + format!("the runtime {}", exit.describe()), + ); + return; + } + sleep(POLL).await; + } + fail( + app, + started, + Some(&target), + ®istration, + &watch, + Phase::Waiting, + format!( + "the runtime did not report healthy within {} seconds", + DEADLINE.as_secs() + ), + ); +} + +/// Establish the app's own surface: the tray verdict, the tray, and the login item. +/// +/// It happens once per process. A retry re-runs the runtime half of the sequence, and running this +/// half again would build a second tray icon with its own refresh loop and its own menu handlers — +/// the failure would look like the app duplicating itself every time the user pressed Retry. +async fn register(app: &AppHandle, deadline: Instant) -> Registration { + if let Some(done) = app + .try_state::() + .and_then(|startup| startup.registration()) + { + return done; + } + + // The probe blocks on a session-bus round trip, so it does not belong on an async worker — and + // it is bounded by the sequence's own deadline, because a bus that never answers would + // otherwise leave the page in this state with a retry that could do nothing about it. + let tray = match tokio::time::timeout_at( + deadline, + tauri::async_runtime::spawn_blocking(tray_availability::detect), + ) + .await + { + Ok(Ok(tray)) => tray, + _ => TrayAvailability::assumed(), + }; + + // Before the tray, so its Start at Login checkbox reads the state this leaves behind rather + // than the state from before first run. + let login = first_run::apply_start_at_login_default(app); + first_run::adopt_launch_origin_argument(app); + + // The verdict is published only once an icon actually exists. Announcing a tray and then + // failing to install it would hide the window into nothing, which is the exact stranding D6 + // exists to prevent. + let verdict = if tray.is_available() && install_tray(app, deadline).await { + TrayAvailability::Available + } else { + TrayAvailability::Unavailable + }; + if let Some(coordinator) = app.try_state::() { + coordinator.set_tray(verdict); + } + + if let Some(window) = app.get_webview_window("main") { + if shows_window(LaunchOrigin::detect(), verdict) { + crate::window::show(&window); + } + } + // This installation's own id, and what the recorded runtime owner says about it. The claim + // lives in the shared service install state and the CLI is what reads it; the comparison + // against our own id is the rule that record publishes. + let install_id = identity::install_id(app); + let registration = Registration { + login, + identity: ownership::describe(ownership::resolve(app).as_ref(), install_id.as_deref()), + }; + if let Some(startup) = app.try_state::() { + startup.remember_registration(registration.clone()); + } + registration +} + +/// Build the tray on the main thread, which is where GTK requires it on Linux. +async fn install_tray(app: &AppHandle, deadline: Instant) -> bool { + let handle = app.clone(); + let (sender, receiver) = tokio::sync::oneshot::channel(); + if app + .run_on_main_thread(move || { + let _ = sender.send(crate::tray::install(&handle).map_err(|error| error.to_string())); + }) + .is_err() + { + return false; + } + match tokio::time::timeout_at(deadline, receiver).await { + Ok(Ok(Ok(()))) => true, + Ok(Ok(Err(error))) => { + crate::logging::log_once("the tray could not be installed", &error); + false + } + _ => { + crate::logging::log_once( + "the tray could not be installed", + "the main thread did not answer", + ); + false + } + } +} +/// Start the runtime, unless an exit is already in flight. +/// +/// The coordinator reserves the spawn rather than holding its lock across it: holding it would put +/// process creation in front of the main thread's exit handler, so a wedged spawn would be a Quit +/// that never answers. A quit arriving in between is deferred until the child is ours and then +/// drains it, so it cannot observe "we own nothing" and leave a proxy running that nothing stops. +fn spawn_runtime( + app: &AppHandle, + endpoint: ProxyEndpoint, + watch: &SidecarWatch, +) -> Option> { + let coordinator = app.try_state::()?; + if !coordinator.begin_spawn() { + return None; + } + let outcome = match sidecar::start(app, endpoint, watch) { + Ok(child) => { + if let Some(state) = app.try_state::() { + state.adopt(child); + } + Ok(()) + } + Err(error) => Err(error), + }; + if let Some(reason) = coordinator.finish_spawn() { + // A quit landed while the child was being created. It is ours now, so it gets drained. + crate::exit::drain_now(app, reason); + return None; + } + Some(outcome) +} + +/// Establish which instance is answering, and whether it is the child this app started. +/// +/// The health body is unauthenticated and carries the marker, the pid and the port, so identity is +/// settled before any credential is sent. It is also the only thing that grants process ownership: +/// a spawn records a pid, and this is what says that pid is the one holding the port. An answer +/// that cannot be read leaves the app owning nothing, which is the safe way round — an owner's stop +/// sent to a listener that is not ours is a stop sent to somebody else's runtime. +/// +/// The answer is returned rather than swallowed, because a sequence that cannot identify what it is +/// talking to has not finished. Reporting Ready there would navigate the window to a dashboard the +/// shell cannot authenticate against, since the management token is only sent to a bound instance. +async fn bind(app: &AppHandle, proxy: &ProxyClient, deadline: Instant) -> Option { + let identity = match tokio::time::timeout_at(deadline, proxy.identify()).await { + Ok(Ok(identity)) => identity, + _ => return None, + }; + proxy.bind(identity); + if let Some(state) = app.try_state::() { + state.confirm_ownership(identity); + } + Some(identity) +} + +fn finish(app: &AppHandle, started: Instant, endpoint: ProxyEndpoint) { + // Ownership is whatever the confirmation above established, not whatever a spawn assumed. + crate::tray::set_owned( + app, + app.try_state::() + .is_some_and(|state| state.owns_runtime()), + ); + let dashboard = endpoint.url("/#/usage"); + let mut progress = Progress::new(Phase::Ready, elapsed(started)); + progress.dashboard = Some(dashboard.clone()); + emit(app, progress, None); + if let Some(window) = app.get_webview_window("main") { + // justified: replacing the bootstrap page with the dashboard is how this window has always + // navigated, and the string is a URL this process resolved, not anything a page supplied. + let _ = window.eval(format!("window.location.replace({dashboard:?})")); + } +} + +#[allow(clippy::too_many_arguments)] +fn fail( + app: &AppHandle, + started: Instant, + target: Option<&Target>, + registration: &Registration, + watch: &SidecarWatch, + phase: Phase, + reason: String, +) { + let elapsed_ms = elapsed(started); + let mut progress = Progress::new(Phase::Failed, elapsed_ms); + progress.diagnostic = Some(diagnostic( + target.map(|target| (target.endpoint, target.home.clone())), + registration, + watch, + phase, + &reason, + elapsed_ms, + )); + progress.detail = Some(reason); + emit(app, progress, Some(phase)); +} + +/// The text the failure surface offers for copying. +/// +/// It names the state it stopped in, the endpoint and home it was using, how the child ended and +/// what the child last said. Those together are what separates "the port was taken" from "the +/// binary will not run on this CPU" from "the home is not the one the accounts are in", and none of +/// them were reachable from the generic health failure this replaces. +pub fn diagnostic( + target: Option<(ProxyEndpoint, PathBuf)>, + registration: &Registration, + watch: &SidecarWatch, + phase: Phase, + reason: &str, + elapsed_ms: u64, +) -> String { + let mut lines = vec![ + format!( + "OpenCodex desktop {} on {}", + env!("CARGO_PKG_VERSION"), + std::env::consts::OS + ), + format!("state: {}", phase.id()), + format!("reason: {reason}"), + format!("elapsed: {elapsed_ms}ms"), + ]; + match target { + Some((endpoint, home)) => { + lines.push(format!("endpoint: {}", endpoint.url(""))); + lines.push(format!("home: {}", home.display())); + } + None => lines.push("endpoint: not resolved".to_owned()), + } + lines.push(format!("start at login: {}", registration.login.describe())); + lines.push(format!("runtime ownership: {}", registration.identity)); + lines.push(match watch.exit() { + Some(exit) => format!("runtime process: {}", exit.describe()), + None => "runtime process: still running or never started".to_owned(), + }); + let output = watch.lines(); + if output.is_empty() { + lines.push("runtime output: none".to_owned()); + } else { + lines.push("runtime output:".to_owned()); + lines.extend(output.into_iter().map(|line| format!(" {line}"))); + } + lines.join("\n") +} + +fn report(app: &AppHandle, started: Instant, phase: Phase, detail: Option) { + let mut progress = Progress::new(phase, elapsed(started)); + progress.detail = detail; + emit(app, progress, None); +} + +fn emit(app: &AppHandle, mut progress: Progress, failed_in: Option) { + if let Some(startup) = app.try_state::() { + startup.publish(&mut progress, failed_in); + } + let _ = app.emit(PHASE_EVENT, progress); +} + +fn elapsed(started: Instant) -> u64 { + started.elapsed().as_millis() as u64 +} + +#[cfg(test)] +mod tests { + use super::{shows_window, LaunchOrigin, Phase, AUTOSTART_FLAG, DEADLINE, PHASES, POLL}; + use crate::tray_availability::TrayAvailability; + use tokio::time::Duration; + + #[test] + fn only_the_autostart_argument_marks_a_login_launch() { + let user = ["/Applications/OpenCodex.app".to_owned()]; + assert_eq!( + LaunchOrigin::from_args(user.into_iter()), + LaunchOrigin::User + ); + let login = [ + "/Applications/OpenCodex.app".to_owned(), + AUTOSTART_FLAG.to_owned(), + ]; + assert_eq!( + LaunchOrigin::from_args(login.into_iter()), + LaunchOrigin::Autostart + ); + } + + #[test] + fn a_manual_launch_always_shows_the_window() { + assert!(shows_window( + LaunchOrigin::User, + TrayAvailability::Available + )); + assert!(shows_window( + LaunchOrigin::User, + TrayAvailability::Unavailable + )); + } + + #[test] + fn a_login_launch_hides_only_where_there_is_a_tray_to_hide_in() { + assert!(!shows_window( + LaunchOrigin::Autostart, + TrayAvailability::Available + )); + assert!(shows_window( + LaunchOrigin::Autostart, + TrayAvailability::Unavailable + )); + } + + #[test] + fn registration_runs_before_the_runtime_is_touched() { + let order: Vec<&str> = PHASES.iter().map(|phase| phase.id()).collect(); + let registering = order.iter().position(|id| *id == "registering").unwrap(); + for later in ["resolving", "probing", "starting", "waiting"] { + assert!(registering < order.iter().position(|id| *id == later).unwrap()); + } + } + + #[test] + fn every_phase_has_a_distinct_identifier_and_a_label() { + let mut ids: Vec<&str> = PHASES.iter().map(|phase| phase.id()).collect(); + ids.sort_unstable(); + ids.dedup(); + assert_eq!(ids.len(), PHASES.len()); + assert!(PHASES.iter().all(|phase| !phase.label().is_empty())); + assert_eq!(PHASES.iter().filter(|phase| phase.is_terminal()).count(), 2); + assert!(PHASES.contains(&Phase::Ready)); + } + + #[test] + fn the_whole_sequence_is_bounded_well_under_the_minute_it_used_to_take() { + let budgets = [DEADLINE, POLL]; + assert!(budgets + .iter() + .all(|budget| *budget <= Duration::from_secs(45))); + assert!(POLL < DEADLINE); + } +} diff --git a/desktop/src-tauri/src/tray.rs b/desktop/src-tauri/src/tray.rs new file mode 100644 index 00000000000..57923e19340 --- /dev/null +++ b/desktop/src-tauri/src/tray.rs @@ -0,0 +1,384 @@ +use crate::{ + exit::{self, ExitReason}, + formatting, + proxy::ProxyClient, + updater, widget, window, +}; +use serde_json::Value; +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Mutex, +}; +use tauri::{ + menu::{CheckMenuItem, Menu, MenuItem, PredefinedMenuItem}, + tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, + AppHandle, Manager, Wry, +}; +use tauri_plugin_autostart::ManagerExt; +use tauri_plugin_opener::OpenerExt; + +pub struct TrayState { + pub menu: Mutex>, + pub installing: AtomicBool, +} + +#[derive(Clone)] +pub struct TrayMenu { + check_updates: MenuItem, + install_update: MenuItem, + stop: MenuItem, +} + +impl Default for TrayState { + fn default() -> Self { + Self { + menu: Mutex::new(None), + installing: AtomicBool::new(false), + } + } +} + +/// Build the tray. +/// +/// The proxy is not passed in. The tray is installed before a runtime has been resolved, so every +/// use reads the current client from the app instead of holding one that might not exist yet. +pub fn install(app: &AppHandle) -> tauri::Result<()> { + let open = MenuItem::with_id(app, "open-dashboard", "Open Dashboard", true, None::<&str>)?; + let browser = MenuItem::with_id(app, "open-browser", "Open in Browser", true, None::<&str>)?; + let login = CheckMenuItem::with_id( + app, + "start-at-login", + "Start at Login", + true, + app.autolaunch().is_enabled().unwrap_or(false), + None::<&str>, + )?; + // The tray is built before the startup sequence has decided anything, so nothing owns a + // runtime yet. Ownership arrives later and reaches this item through [`set_owned`]. + let owned = app + .try_state::() + .is_some_and(|state| state.owns_runtime()); + let stop = MenuItem::with_id(app, "stop-proxy", "Stop proxy", owned, None::<&str>)?; + let check_updates = MenuItem::with_id( + app, + "check-updates", + "Check for Updates…", + true, + None::<&str>, + )?; + let install_update = + MenuItem::with_id(app, "install-update", "Install update", false, None::<&str>)?; + let quit = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?; + let menu = Menu::with_items( + app, + &[ + &open, + &browser, + &PredefinedMenuItem::separator(app)?, + &login, + &stop, + &PredefinedMenuItem::separator(app)?, + &check_updates, + &install_update, + &PredefinedMenuItem::separator(app)?, + &quit, + ], + )?; + if let Ok(mut state) = app.state::().menu.lock() { + *state = Some(TrayMenu { + check_updates: check_updates.clone(), + install_update: install_update.clone(), + stop: stop.clone(), + }); + } + + let tray = TrayIconBuilder::with_id("main") + .icon(icon()) + .icon_as_template(true) + .menu(&menu) + .on_tray_icon_event(|tray, event| { + if let TrayIconEvent::Click { + button: MouseButton::Left, + button_state: MouseButtonState::Up, + .. + } = event + { + if let Some(window) = tray.app_handle().get_webview_window("main") { + window::show(&window); + } + } + }) + .on_menu_event(move |app, event| match event.id().as_ref() { + "open-dashboard" => { + if let Some(window) = app.get_webview_window("main") { + window::show(&window); + } + } + "open-browser" => { + let Some(endpoint) = app + .state::() + .proxy() + .map(|proxy| proxy.endpoint()) + else { + return; + }; + let _ = app + .opener() + .open_url(format!("{}#/usage", endpoint.url("/")), None::); + } + "start-at-login" => { + let enabled = app.autolaunch().is_enabled().unwrap_or(false); + if enabled { + let _ = app.autolaunch().disable(); + } else { + let _ = app.autolaunch().enable(); + } + } + "stop-proxy" => { + // Through the coordinator, not beside it: Stop pressed twice, Stop then Quit, and + // Stop during an update all have to be one execution over one child. + exit::request_stop(app); + } + "check-updates" => { + let app = app.clone(); + tauri::async_runtime::spawn(async move { + updater::check_and_show(&app).await; + }); + } + "install-update" => { + let app = app.clone(); + tauri::async_runtime::spawn(async move { + let update = app + .state::() + .0 + .lock() + .ok() + .and_then(|mut pending| pending.take()); + let Some(update) = update else { + return; + }; + let version = update.version.clone(); + let retry_update = update.clone(); + set_installing(&app, &version); + if let Err(error) = updater::install(&app, update).await { + if let Ok(mut pending) = + app.state::().0.lock() + { + *pending = Some(retry_update); + } + set_install_failed(&app, &version); + crate::logging::log_once("updater install failed", &error); + } + }); + } + // The only gesture that ends the app. It does not call `exit` itself: the coordinator + // holds the exit, drains an app-owned runtime and only then lets the process end. + "quit" => exit::request(app, ExitReason::UserQuit), + _ => {} + }) + .build(app)?; + + refresh(app, &tray); + let tray = tray.clone(); + let app = app.clone(); + tauri::async_runtime::spawn(async move { + let mut tick = 0; + loop { + tokio::time::sleep(std::time::Duration::from_secs(60)).await; + let Some(proxy) = app + .try_state::() + .and_then(|state| state.proxy()) + else { + continue; + }; + refresh_title(&tray, &proxy); + tick += 1; + if tick % 5 == 0 { + widget::refresh(&proxy); + } + } + }); + Ok(()) +} + +fn refresh(app: &AppHandle, tray: &tauri::tray::TrayIcon) { + let Some(proxy) = app + .try_state::() + .and_then(|state| state.proxy()) + else { + return; + }; + refresh_title(tray, &proxy); + widget::refresh(&proxy); +} + +/// Take a copy of the menu handles, holding the lock only for the copy. +/// +/// Every Tauri menu setter dispatches to the main thread and waits for it. The tray is built *on* +/// the main thread and takes this same mutex while doing so, so calling a setter with the lock held +/// is a cycle: a background update owns the mutex and waits for the main thread, and the main +/// thread waits for the mutex. The app would stop answering Quit. +fn menu_handles(app: &AppHandle) -> Option { + let state = app.try_state::()?; + let handles = state.menu.lock().ok()?; + handles.as_ref().cloned() +} + +/// Reflect who owns the runtime in the tray's Stop item. +pub fn set_owned(app: &AppHandle, owned: bool) { + if let Some(menu) = menu_handles(app) { + let _ = menu.stop.set_enabled(owned); + } +} + +pub fn show_update_available(app: &AppHandle, version: &str) { + if let Some(menu) = menu_handles(app) { + let _ = menu.install_update.set_text(updater::update_label(version)); + let _ = menu.install_update.set_enabled(true); + let _ = menu.check_updates.set_enabled(true); + let _ = menu.check_updates.set_text("Check for Updates…"); + } +} + +pub fn show_up_to_date(app: &AppHandle) { + if let Some(menu) = menu_handles(app) { + let _ = menu + .check_updates + .set_text(format!("Up to date (v{})", env!("CARGO_PKG_VERSION"))); + let _ = menu.check_updates.set_enabled(true); + let _ = menu.install_update.set_enabled(false); + } +} + +pub fn is_installing(app: &AppHandle) -> bool { + app.try_state::() + .is_some_and(|state| state.installing.load(Ordering::Acquire)) +} + +fn set_installing(app: &AppHandle, version: &str) { + if let Some(state) = app.try_state::() { + state.installing.store(true, Ordering::Release); + } + if let Some(menu) = menu_handles(app) { + let _ = menu + .install_update + .set_text(format!("Installing update v{version}…")); + let _ = menu.install_update.set_enabled(false); + let _ = menu.check_updates.set_enabled(false); + } +} + +fn set_install_failed(app: &AppHandle, version: &str) { + if let Some(state) = app.try_state::() { + state.installing.store(false, Ordering::Release); + } + show_update_available(app, version); +} + +fn refresh_title(tray: &tauri::tray::TrayIcon, proxy: &ProxyClient) { + let proxy = proxy.clone(); + let tray = tray.clone(); + tauri::async_runtime::spawn(async move { + let Ok(settings) = proxy.companion_settings().await else { + return; + }; + let Ok(usage) = proxy.usage_summary().await else { + return; + }; + let quotas = proxy.quotas().await.unwrap_or(Value::Null); + if let Some(title) = render_title(&settings, &usage, "as) { + let _ = tray.set_title(Some(&title)); + } + }); +} + +pub(crate) fn render_title(settings: &Value, usage: &Value, quotas: &Value) -> Option { + let metric = settings + .pointer("/settings/menuBarMetric") + .and_then(Value::as_str) + .unwrap_or("tokens"); + let summary = usage.get("summary").unwrap_or(usage); + let quota = quota_percent(quotas); + let value = match metric { + "requests" => formatting::count(summary.get("requests").and_then(Value::as_i64)), + "cost" => formatting::cost(summary.get("estimatedCostUsd").and_then(Value::as_f64)), + "quota" => format_percent(quota), + "none" => return None, + _ => formatting::tokens(summary.get("totalTokens").and_then(Value::as_i64)), + }; + let template = settings + .pointer("/settings/menuBarTemplate") + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()); + let rendered = template + .map(|value| { + value + .replace( + "{requests}", + &formatting::count(summary.get("requests").and_then(Value::as_i64)), + ) + .replace( + "{totalTokens}", + &formatting::tokens(summary.get("totalTokens").and_then(Value::as_i64)), + ) + .replace( + "{costUsd}", + &formatting::cost(summary.get("estimatedCostUsd").and_then(Value::as_f64)), + ) + .replace( + "{inputTokens}", + &formatting::tokens(summary.get("inputTokens").and_then(Value::as_i64)), + ) + .replace( + "{outputTokens}", + &formatting::tokens(summary.get("outputTokens").and_then(Value::as_i64)), + ) + .replace("{quotaPercent}", &format_percent(quota)) + }) + .unwrap_or(value); + let rendered = rendered.trim(); + if rendered.is_empty() { + None + } else if rendered.chars().count() > 24 { + Some(format!( + "{}…", + rendered.chars().take(23).collect::() + )) + } else { + Some(rendered.to_owned()) + } +} + +fn quota_percent(value: &Value) -> Option { + let reports = value.get("reports")?.as_array()?; + let mut values = Vec::new(); + for report in reports { + let Some(quota) = report.get("quota") else { + continue; + }; + for key in ["weeklyPercent", "monthlyPercent", "fiveHourPercent"] { + if let Some(value) = quota.get(key).and_then(Value::as_f64) { + values.push(value); + } + } + if let Some(windows) = quota.get("customWindows").and_then(Value::as_array) { + values.extend( + windows + .iter() + .filter_map(|window| window.get("percent").and_then(Value::as_f64)), + ); + } + } + values.into_iter().reduce(f64::min) +} + +fn format_percent(value: Option) -> String { + value + .map(|value| format!("{}%", value.round() as i64)) + .unwrap_or_else(|| "—".into()) +} + +fn icon() -> tauri::image::Image<'static> { + tauri::image::Image::from_bytes(include_bytes!("../icons/tray/icon.png")) + .expect("valid tray icon") +} diff --git a/desktop/src-tauri/src/tray_availability.rs b/desktop/src-tauri/src/tray_availability.rs new file mode 100644 index 00000000000..404c5b75e34 --- /dev/null +++ b/desktop/src-tauri/src/tray_availability.rs @@ -0,0 +1,131 @@ +//! Whether this session actually has a tray, as opposed to a tray backend that accepts an icon. +//! +//! `TrayIconBuilder::build` returning `Ok` proves nothing on Linux. The pinned backend creates an +//! AppIndicator and reports success without checking that anything will display it, so on stock +//! GNOME — which ships no AppIndicator extension — construction succeeds and no icon ever appears. +//! The shell's macOS-shaped assumptions then compound it: the window was created hidden and close +//! always hid, which leaves a running process with no way back in. +//! +//! So the question is asked of the session bus. Not whether the watcher exists — a watcher with no +//! host attached still accepts registrations and still draws nothing — but whether it reports a +//! host registered, which is the StatusNotifier specification's own answer to "is there somewhere +//! for an icon to appear". macOS and Windows have a status area that is always present and answer +//! without a probe. + +/// The result of asking whether this session can display a tray icon. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TrayAvailability { + /// There is somewhere for the icon to appear, so hiding to the tray is a real place to hide. + Available, + /// There is not. The window is shown on launch and closing it ends the app through the drain. + Unavailable, +} + +impl TrayAvailability { + pub fn is_available(self) -> bool { + matches!(self, Self::Available) + } + + /// Whether a window close or a platform quit gesture may hide the app instead of ending it. + pub fn hides_to_tray(self) -> bool { + self.is_available() + } + + /// What to assume before the probe has answered. + /// + /// The probe is asynchronous, and a window close can land before it returns. Assuming a tray + /// that turns out not to exist is the failure this whole module is about, so the platforms that + /// need a probe assume nothing until they have one. + pub fn assumed() -> Self { + if cfg!(target_os = "linux") { + Self::Unavailable + } else { + Self::Available + } + } +} + +/// The StatusNotifier watcher, and the property that says a host is attached to it. +#[cfg(target_os = "linux")] +pub const WATCHER_NAME: &str = "org.kde.StatusNotifierWatcher"; +#[cfg(target_os = "linux")] +pub const WATCHER_PATH: &str = "/StatusNotifierWatcher"; +#[cfg(target_os = "linux")] +pub const HOST_REGISTERED: &str = "IsStatusNotifierHostRegistered"; + +/// How long the session-bus probe may take. +#[cfg(target_os = "linux")] +const PROBE_TIMEOUT_MS: u64 = 750; + +/// Read a host-registered answer as an availability verdict. +/// +/// `None` means the question could not be asked at all — no session bus, no watcher on it, no +/// reply, a malformed one. That is deliberately folded into the same answer as a watcher with no +/// host, because the two are indistinguishable from here and the safe response to both is +/// identical: show the window and let close mean close. Guessing the other way strands the user. +pub fn from_host_registered(registered: Option) -> TrayAvailability { + match registered { + Some(true) => TrayAvailability::Available, + Some(false) | None => TrayAvailability::Unavailable, + } +} + +#[cfg(not(target_os = "linux"))] +pub fn detect() -> TrayAvailability { + // macOS and Windows both have a status area that is always there, so the answer is known + // without asking anything. It still goes through the same reading so there is one place where + // an availability verdict is produced. + from_host_registered(Some(true)) +} + +#[cfg(target_os = "linux")] +pub fn detect() -> TrayAvailability { + from_host_registered(host_registered()) +} + +#[cfg(target_os = "linux")] +fn host_registered() -> Option { + use dbus::blocking::{stdintf::org_freedesktop_dbus::Properties, Connection}; + use std::time::Duration; + + let connection = Connection::new_session().ok()?; + let watcher = connection.with_proxy( + WATCHER_NAME, + WATCHER_PATH, + Duration::from_millis(PROBE_TIMEOUT_MS), + ); + // A watcher nobody owns makes this call fail rather than answer, which is the same verdict. + watcher.get(WATCHER_NAME, HOST_REGISTERED).ok() +} + +#[cfg(test)] +mod tests { + use super::{from_host_registered, TrayAvailability}; + + #[test] + fn only_a_registered_host_is_an_available_tray() { + assert_eq!( + from_host_registered(Some(true)), + TrayAvailability::Available + ); + assert_eq!( + from_host_registered(Some(false)), + TrayAvailability::Unavailable + ); + assert_eq!(from_host_registered(None), TrayAvailability::Unavailable); + } + + #[test] + fn hiding_is_only_offered_where_the_icon_would_be_drawn() { + assert!(TrayAvailability::Available.hides_to_tray()); + assert!(!TrayAvailability::Unavailable.hides_to_tray()); + } + + #[test] + fn nothing_is_assumed_on_the_platform_that_needs_a_probe() { + assert_eq!( + TrayAvailability::assumed().is_available(), + !cfg!(target_os = "linux") + ); + } +} diff --git a/desktop/src-tauri/src/updater.rs b/desktop/src-tauri/src/updater.rs new file mode 100644 index 00000000000..456ffe76c5c --- /dev/null +++ b/desktop/src-tauri/src/updater.rs @@ -0,0 +1,144 @@ +use crate::{exit::RestartReadiness, logging, tray}; +use std::sync::Mutex; +use tauri::{AppHandle, Manager}; +use tauri_plugin_updater::{Update, UpdaterExt}; + +pub struct PendingUpdate(pub Mutex>); + +/// The manifest key a Linux install must resolve, or None to keep the updater's default +/// os-arch key (linux-x86_64, windows-x86_64, darwin-*). +/// +/// A deb install cannot apply the AppImage payload: the updater validates the downloaded +/// bytes as a real .deb before installing through package-manager elevation, so it must +/// resolve the deb's own manifest key. The bundle type is patched into the binary at +/// packaging time, so the answer is embedded per artifact, not detected at runtime. The +/// AppImage keeps the default key, which is also what installs from releases before the +/// deb target existed already resolve. +#[cfg(any(target_os = "linux", test))] +pub fn linux_updater_target( + bundle: Option, +) -> Option<&'static str> { + match bundle { + Some(tauri_utils::config::BundleType::Deb) => Some("linux-x86_64-deb"), + _ => None, + } +} + +#[cfg(target_os = "linux")] +fn configured_updater_target() -> Option<&'static str> { + linux_updater_target(tauri_utils::platform::bundle_type()) +} + +#[cfg(not(target_os = "linux"))] +fn configured_updater_target() -> Option<&'static str> { + None +} + +pub async fn check(app: &AppHandle) -> Result, String> { + let mut builder = app.updater_builder(); + if let Some(target) = configured_updater_target() { + builder = builder.target(target); + } + builder + .build() + .map_err(|error| error.to_string())? + .check() + .await + .map_err(|error| error.to_string()) +} + +pub async fn install(app: &AppHandle, update: Update) -> Result<(), String> { + // Download and verify first, and separately from installing. The pinned updater checks the + // release signature inside `download`, so these bytes are the ones the key signed; nothing has + // been replaced yet, and a failure here costs only the download. + let package = update + .download(|_, _| {}, || {}) + .await + .map_err(|error| error.to_string())?; + + // Then stop the runtime, and confirm it stopped, *before* anything is replaced. Asking for the + // restart after `install` is the shape that does not work: the pinned Windows installer hands off + // to the installer process and ends this one, so the call after it is never reached and the + // update would replace files under a runtime that is still serving. R2 still holds — this is a + // coordinated restart and not a quit — but the coordination has to finish first. + let readiness = crate::exit::prepare_restart(app).await; + if readiness != RestartReadiness::Ready { + return Err(format!( + "the update was downloaded but not installed: {}", + readiness.describe() + )); + } + + update.install(package).map_err(|error| error.to_string())?; + // Only reached where the installer returns. On Windows it does not. + crate::exit::complete_restart(app) +} + +pub fn update_label(version: &str) -> String { + format!("Install update v{version}") +} + +pub fn start_background_checks(app: AppHandle) { + tauri::async_runtime::spawn(async move { + tokio::time::sleep(std::time::Duration::from_secs(30)).await; + loop { + check_and_show(&app).await; + tokio::time::sleep(std::time::Duration::from_secs(6 * 60 * 60)).await; + } + }); +} + +pub async fn check_and_show(app: &AppHandle) { + if tray::is_installing(app) { + return; + } + match check(app).await { + Ok(Some(update)) => { + if tray::is_installing(app) { + return; + } + let version = update.version.clone(); + if let Ok(mut pending) = app.state::().0.lock() { + *pending = Some(update); + } + tray::show_update_available(app, &version); + } + Ok(None) => { + if let Ok(mut pending) = app.state::().0.lock() { + *pending = None; + } + tray::show_up_to_date(app); + } + Err(error) => logging::log_once("updater check failed", &error), + } +} + +#[cfg(test)] +mod tests { + use super::{linux_updater_target, update_label}; + use tauri_utils::config::BundleType; + + #[test] + fn formats_update_menu_label() { + assert_eq!(update_label("2.62.0"), "Install update v2.62.0"); + } + + #[test] + fn deb_installs_resolve_their_own_updater_key() { + assert_eq!( + linux_updater_target(Some(BundleType::Deb)), + Some("linux-x86_64-deb") + ); + } + + #[test] + fn appimage_installs_keep_the_default_updater_key() { + assert_eq!(linux_updater_target(Some(BundleType::AppImage)), None); + } + + #[test] + fn unbundled_builds_keep_the_default_updater_key() { + // Dev builds and any format without a patcher entry resolve the default key. + assert_eq!(linux_updater_target(None), None); + } +} diff --git a/desktop/src-tauri/src/widget.rs b/desktop/src-tauri/src/widget.rs new file mode 100644 index 00000000000..a2e38cfc78e --- /dev/null +++ b/desktop/src-tauri/src/widget.rs @@ -0,0 +1,523 @@ +#[cfg(target_os = "macos")] +mod macos { + use crate::{ + proxy::{ProxyClient, ProxyError}, + tray, + }; + use serde::Serialize; + use serde_json::{json, Value}; + use std::{ + fs, + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, + }; + use uuid::Uuid; + + #[derive(Debug, Serialize, serde::Deserialize, Clone, PartialEq)] + #[serde(rename_all = "camelCase")] + struct Today { + requests: Option, + total_tokens: Option, + estimated_cost_usd: Option, + } + + #[derive(Debug, Serialize, serde::Deserialize, Clone, PartialEq)] + #[serde(rename_all = "camelCase")] + struct Quota { + provider_label: String, + window_label: String, + percent: Option, + reset_at: Option, + } + + #[derive(Debug, Serialize, serde::Deserialize, Clone, PartialEq)] + #[serde(rename_all = "camelCase")] + struct Series { + id: String, + points: Vec, + } + + #[derive(Debug, Serialize, serde::Deserialize, Clone, PartialEq)] + #[serde(rename_all = "camelCase")] + struct Chart { + start: f64, + bucket_seconds: i64, + style: String, + series: Vec, + } + + #[derive(Debug, Serialize, serde::Deserialize, Clone, PartialEq)] + #[serde(rename_all = "camelCase")] + struct Snapshot { + schema_version: i64, + state: String, + state_title: String, + detail: Option, + endpoint_display: String, + menu_title: Option, + today: Option, + quotas: Vec, + chart: Option, + last_updated: Option, + generated_at: f64, + } + + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + enum ErrorKind { + Unreachable, + Unauthorized, + Http, + Decode, + /// The port answered, but as something other than the runtime this shell is bound to. + Foreign, + } + + fn state_for_error( + kind: ErrorKind, + detail: Option, + ) -> (&'static str, &'static str, Option) { + match kind { + ErrorKind::Unreachable => ( + "unreachable", + "Stopped", + Some("The proxy is not running.".into()), + ), + ErrorKind::Unauthorized => ( + "unauthorized", + "Needs API key", + Some("This proxy requires an API key.".into()), + ), + // A runtime this app did not start is a different event from a fault, so it does not + // borrow the vocabulary of one. "degraded" would claim the proxy is misbehaving and + // "unreachable" would claim nothing is there; a user who started the runtime from npm + // or the CLI themselves would read either as a defect in a setup that is working. + // The widget has no red for this: `tone` in `app/Sources/OpenCodexWidget/Views.swift` + // maps a state it does not know to the neutral secondary colour, which is the right + // signal for "serving, just not ours". + ErrorKind::Foreign => ( + "foreign", + "External runtime", + Some("This port is served by a runtime this app did not start.".into()), + ), + ErrorKind::Http | ErrorKind::Decode => ("degraded", "Degraded", detail), + } + } + + fn proxy_error(error: &ProxyError) -> (ErrorKind, Option) { + match error { + ProxyError::Unreachable => (ErrorKind::Unreachable, None), + ProxyError::Unauthorized => (ErrorKind::Unauthorized, None), + ProxyError::Http(status) => (ErrorKind::Http, Some(format!("HTTP {status}"))), + ProxyError::Decode(error) => (ErrorKind::Decode, Some(error.to_string())), + ProxyError::Foreign => (ErrorKind::Foreign, None), + } + } + + fn number(value: Option<&Value>) -> Option { + value.and_then(Value::as_f64) + } + + fn integer(value: Option<&Value>) -> Option { + value.and_then(Value::as_i64) + } + + fn reset_at(value: Option<&Value>) -> Option { + let value = number(value)?; + Some(if value >= 1_000_000_000_000.0 { + value / 1000.0 + } else { + value + }) + } + + fn quotas(value: &Value) -> Vec { + let Some(reports) = value.get("reports").and_then(Value::as_array) else { + return Vec::new(); + }; + let mut rows = Vec::new(); + for report in reports { + let provider_label = report + .get("label") + .or_else(|| report.get("provider")) + .and_then(Value::as_str) + .unwrap_or("unknown") + .to_owned(); + let Some(quota) = report.get("quota") else { + continue; + }; + let mut push = |percent: Option<&Value>, window_label: &str, reset: Option<&Value>| { + if percent.is_some() || reset.is_some() { + rows.push(Quota { + provider_label: provider_label.clone(), + window_label: window_label.to_owned(), + percent: number(percent), + reset_at: reset_at(reset), + }); + } + }; + push( + quota.get("fiveHourPercent"), + "5h", + quota.get("fiveHourResetAt"), + ); + push( + quota.get("weeklyPercent"), + "week", + quota.get("weeklyResetAt"), + ); + push( + quota.get("monthlyPercent"), + "month", + quota.get("monthlyResetAt"), + ); + if let Some(windows) = quota.get("customWindows").and_then(Value::as_array) { + for window in windows { + let label = window + .get("label") + .and_then(Value::as_str) + .unwrap_or("window"); + push(window.get("percent"), label, window.get("resetAt")); + } + } + } + rows + } + + fn chart(value: &Value, settings: &Value) -> Option { + let start = number(value.get("start"))?; + let bucket_seconds = integer(value.get("bucketSeconds"))?; + let settings = settings.get("settings").unwrap_or(settings); + let style = settings + .get("chartStyle") + .and_then(Value::as_str) + .unwrap_or("line") + .to_owned(); + let series = value + .get("series") + .and_then(Value::as_array)? + .iter() + .take(6) + .filter_map(|item| { + Some(Series { + id: item.get("id")?.as_str()?.to_owned(), + points: item + .get("points")? + .as_array()? + .iter() + .filter_map(Value::as_f64) + .collect(), + }) + }) + .collect(); + Some(Chart { + start, + bucket_seconds, + style, + series, + }) + } + + fn timeline_query(settings: &Value) -> String { + let settings = settings.get("settings").unwrap_or(settings); + let get = |key: &str, fallback: &str| { + settings + .get(key) + .and_then(Value::as_str) + .unwrap_or(fallback) + .to_owned() + }; + let hours = settings + .get("chartHours") + .and_then(Value::as_i64) + .unwrap_or(24); + let bucket_minutes = settings + .get("bucketMinutes") + .and_then(Value::as_i64) + .unwrap_or(60); + let metric = get("tokenMetric", "total"); + let aggregation = get("aggregation", "sum"); + let grouping = get("chartGrouping", "model"); + let mut query = format!( + "hours={hours}&bucketMinutes={bucket_minutes}&metric={metric}&aggregation={aggregation}&grouping={grouping}" + ); + if let Some(models) = settings.get("models").and_then(Value::as_array) { + let models = models + .iter() + .filter_map(Value::as_str) + .collect::>() + .join(","); + if !models.is_empty() { + query.push_str("&models="); + query.push_str(&models); + } + } + query + } + + fn snapshot_path() -> PathBuf { + let home = std::env::var_os("HOME") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".")); + home.join("Library/Containers/com.opencodex.desktop.widget/Data/Library/Application Support/OpenCodex/snapshot.json") + } + + fn now_seconds() -> f64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs_f64() + } + + fn without_generated_at(snapshot: &Snapshot) -> Snapshot { + let mut snapshot = snapshot.clone(); + snapshot.generated_at = 0.0; + snapshot + } + + fn write_if_changed( + path: &std::path::Path, + previous: Option<&Snapshot>, + snapshot: &Snapshot, + ) -> std::io::Result { + if previous.map(without_generated_at).as_ref() == Some(&without_generated_at(snapshot)) { + return Ok(false); + } + let Some(directory) = path.parent() else { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "snapshot path has no parent", + )); + }; + fs::create_dir_all(directory)?; + let bytes = serde_json::to_vec(snapshot).map_err(std::io::Error::other)?; + let temporary = directory.join(format!(".snapshot-{}.tmp", Uuid::new_v4())); + fs::write(&temporary, bytes)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&temporary, fs::Permissions::from_mode(0o600))?; + } + fs::rename(temporary, path)?; + Ok(true) + } + + fn make_snapshot( + proxy: &ProxyClient, + settings: &Value, + health: &Value, + today: Option<&Value>, + quota_value: Option<&Value>, + timeline_value: Option<&Value>, + ) -> Snapshot { + let endpoint = proxy.endpoint(); + let detail = { + let parts = [health.get("status"), health.get("protection")] + .into_iter() + .filter_map(|value| value.and_then(Value::as_str)) + .filter(|part| !part.is_empty() && *part != "none") + .collect::>(); + (!parts.is_empty()).then(|| parts.join(" · ")) + }; + let today_snapshot = today + .and_then(|value| value.get("summary").or(Some(value))) + .map(|summary| Today { + requests: integer(summary.get("requests")), + total_tokens: integer(summary.get("totalTokens")), + estimated_cost_usd: number(summary.get("estimatedCostUsd")), + }); + let quotas_value = quota_value.unwrap_or(&Value::Null); + let menu_title = tray::render_title(settings, today.unwrap_or(&Value::Null), quotas_value); + let chart = timeline_value.and_then(|value| chart(value, settings)); + Snapshot { + schema_version: 1, + state: "running".into(), + state_title: "Running".into(), + detail, + endpoint_display: format!("{}:{}", endpoint.host, endpoint.port), + menu_title, + today: today_snapshot, + quotas: quotas(quotas_value), + chart, + last_updated: timeline_value.map(|_| now_seconds()), + generated_at: now_seconds(), + } + } + + pub async fn write(proxy: ProxyClient) { + let health = match proxy.startup_health().await { + Ok(value) => value, + Err(error) => { + let (kind, detail) = proxy_error(&error); + let (state, state_title, detail) = state_for_error(kind, detail); + let snapshot = Snapshot { + schema_version: 1, + state: state.into(), + state_title: state_title.into(), + detail, + endpoint_display: format!( + "{}:{}", + proxy.endpoint().host, + proxy.endpoint().port + ), + menu_title: None, + today: None, + quotas: Vec::new(), + chart: None, + last_updated: None, + generated_at: now_seconds(), + }; + let path = snapshot_path(); + let previous = fs::read(&path) + .ok() + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()); + if let Err(error) = write_if_changed(&path, previous.as_ref(), &snapshot) { + crate::logging::log_once("widget snapshot write failed", &error.to_string()); + } + crate::logging::log_once("widget snapshot health failed", state); + return; + } + }; + let settings = proxy + .companion_settings() + .await + .unwrap_or_else(|_| json!({ "settings": {} })); + let today = proxy.usage_today().await.ok(); + let quota_value = proxy.quotas().await.ok(); + let timeline_value = proxy.timeline(&timeline_query(&settings)).await.ok(); + let snapshot = make_snapshot( + &proxy, + &settings, + &health, + today.as_ref(), + quota_value.as_ref(), + timeline_value.as_ref(), + ); + let path = snapshot_path(); + let previous = fs::read(&path) + .ok() + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()); + if let Err(error) = write_if_changed(&path, previous.as_ref(), &snapshot) { + crate::logging::log_once("widget snapshot write failed", &error.to_string()); + } + } + + pub fn refresh(proxy: &ProxyClient) { + let proxy = proxy.clone(); + tauri::async_runtime::spawn(async move { write(proxy).await }); + } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn serialization_uses_swift_field_names() { + let snapshot = Snapshot { + schema_version: 1, + state: "running".into(), + state_title: "Running".into(), + detail: Some("ok".into()), + endpoint_display: "127.0.0.1:10100".into(), + menu_title: Some("2K".into()), + today: Some(Today { + requests: Some(2), + total_tokens: Some(1234), + estimated_cost_usd: Some(0.12), + }), + quotas: vec![Quota { + provider_label: "OpenAI".into(), + window_label: "week".into(), + percent: Some(10.0), + reset_at: Some(1.0), + }], + chart: Some(Chart { + start: 1.0, + bucket_seconds: 3600, + style: "line".into(), + series: vec![Series { + id: "openai/gpt".into(), + points: vec![1.0, 2.0], + }], + }), + last_updated: Some(2.0), + generated_at: 3.0, + }; + assert_eq!( + serde_json::to_string(&snapshot).unwrap(), + r#"{"schemaVersion":1,"state":"running","stateTitle":"Running","detail":"ok","endpointDisplay":"127.0.0.1:10100","menuTitle":"2K","today":{"requests":2,"totalTokens":1234,"estimatedCostUsd":0.12},"quotas":[{"providerLabel":"OpenAI","windowLabel":"week","percent":10.0,"resetAt":1.0}],"chart":{"start":1.0,"bucketSeconds":3600,"style":"line","series":[{"id":"openai/gpt","points":[1.0,2.0]}]},"lastUpdated":2.0,"generatedAt":3.0}"# + ); + } + + #[test] + fn error_state_mapping_covers_every_kind() { + assert_eq!( + state_for_error(ErrorKind::Unreachable, None).0, + "unreachable" + ); + assert_eq!( + state_for_error(ErrorKind::Unauthorized, None).0, + "unauthorized" + ); + assert_eq!( + state_for_error(ErrorKind::Http, Some("HTTP 500".into())).0, + "degraded" + ); + assert_eq!( + state_for_error(ErrorKind::Decode, Some("bad".into())).0, + "degraded" + ); + assert_eq!(state_for_error(ErrorKind::Foreign, None).0, "foreign"); + } + + #[test] + fn a_foreign_runtime_is_not_reported_as_a_failure() { + // The mapping is the whole point of the variant. Folding it into either neighbour + // tells a user whose own CLI or npm runtime holds the port that something is broken, + // and the widget is the one surface where that claim is read without any context. + assert_eq!(proxy_error(&ProxyError::Foreign).0, ErrorKind::Foreign); + let (state, title, detail) = state_for_error(ErrorKind::Foreign, None); + assert_eq!(state, "foreign"); + assert_eq!(title, "External runtime"); + assert!(detail.unwrap().contains("did not start")); + } + + #[test] + fn write_if_changed_ignores_generated_at() { + let path = std::env::temp_dir().join(format!("ocx-widget-{}.json", std::process::id())); + let snapshot = Snapshot { + schema_version: 1, + state: "running".into(), + state_title: "Running".into(), + detail: None, + endpoint_display: "127.0.0.1:10100".into(), + menu_title: None, + today: None, + quotas: Vec::new(), + chart: None, + last_updated: None, + generated_at: 1.0, + }; + assert!(write_if_changed(&path, None, &snapshot).unwrap()); + let mut changed = snapshot.clone(); + changed.generated_at = 2.0; + assert!(!write_if_changed(&path, Some(&snapshot), &changed).unwrap()); + let _ = fs::remove_file(path); + } + + #[test] + fn chart_series_are_truncated_to_six() { + let series = (0..8) + .map(|index| json!({ "id": index.to_string(), "points": [1] })) + .collect::>(); + let value = json!({ "start": 1, "bucketSeconds": 60, "series": series }); + let result = chart(&value, &json!({ "settings": { "chartStyle": "line" } })).unwrap(); + assert_eq!(result.series.len(), 6); + } + } +} + +#[cfg(target_os = "macos")] +pub(crate) use macos::refresh; + +#[cfg(not(target_os = "macos"))] +pub(crate) fn refresh(_: &crate::proxy::ProxyClient) {} diff --git a/desktop/src-tauri/src/window.rs b/desktop/src-tauri/src/window.rs new file mode 100644 index 00000000000..81969f7189e --- /dev/null +++ b/desktop/src-tauri/src/window.rs @@ -0,0 +1,160 @@ +use crate::{auth::Auth, exit, AppState}; +use tauri::{AppHandle, Manager, Url, WebviewWindow, WindowEvent}; + +pub fn webview_user_agent() -> String { + let platform = if cfg!(target_os = "macos") { + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko)" + } else if cfg!(target_os = "windows") { + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko)" + } else { + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko)" + }; + format!("{platform} {}", Auth::user_agent()) +} + +/// Decide what closing this window means, at the moment it is closed. +/// +/// The answer is not known when the window is built: on Linux it depends on a session-bus probe +/// that the startup sequence runs afterwards. So it is read here rather than captured. With a tray +/// a close hides and the runtime keeps serving; without one there is nowhere to hide, so D6 makes +/// the close a quit — and it takes the same graceful drain the tray's Quit does. +pub fn configure(window: &WebviewWindow) { + let window_for_close = window.clone(); + window.on_window_event(move |event| { + if let WindowEvent::CloseRequested { api, .. } = event { + api.prevent_close(); + exit::gesture(window_for_close.app_handle()); + } + }); +} + +/// Where this window may navigate. +/// +/// The loopback endpoint is read from the app rather than captured, because the window now exists +/// before anything has been resolved. Until it has, an http target is refused outright instead of +/// being handed to the browser: nothing should be navigating anywhere yet, and opening an +/// unresolved address in the user's browser is a worse answer than doing nothing. +pub fn navigation_allowed(app: AppHandle) -> impl Fn(&Url) -> bool { + move |url| { + if is_app_origin(url) { + return true; + } + if url.scheme() == "about" && url.as_str() == "about:blank" { + return true; + } + let endpoint = app + .try_state::() + .and_then(|state| state.proxy()) + .map(|proxy| proxy.endpoint()); + if let Some(endpoint) = endpoint { + if url.scheme() == "http" && url.host_str() == Some(endpoint.host) { + return url.port_or_known_default() == Some(endpoint.port); + } + if matches!(url.scheme(), "http" | "https") { + let _ = tauri_plugin_opener::open_url(url.as_str(), None::<&str>); + } + } + false + } +} + +/// The bundled `frontendDist` origin. +/// +/// Tauri serves it as `tauri://localhost` on macOS and Linux, and as `http://tauri.localhost` on +/// Windows, where WebView2 has no custom-scheme support. Without that second spelling the window's +/// first navigation to its own page on Windows falls through to the branch that hands a URL to the +/// external browser. +/// +/// It is that one host and nothing near it. `https` is not the scheme the pinned Tauri serves the +/// app over, and a port means something else is answering rather than the app — neither localhost +/// generally, nor a name that merely ends in it, is this origin. +fn is_app_origin(url: &Url) -> bool { + match url.scheme() { + "tauri" => true, + "http" => url.host_str() == Some("tauri.localhost") && url.port().is_none(), + _ => false, + } +} + +pub fn show(window: &WebviewWindow) { + let _ = window.show(); + let _ = window.set_focus(); + apply_tray_policy(window.app_handle(), true); +} + +pub fn hide(window: &WebviewWindow) { + let _ = window.hide(); + apply_tray_policy(window.app_handle(), false); +} + +#[cfg(target_os = "macos")] +fn apply_tray_policy(app: &AppHandle, visible: bool) { + let policy = if visible { + tauri::ActivationPolicy::Regular + } else { + tauri::ActivationPolicy::Accessory + }; + let _ = app.set_dock_visibility(visible); + let _ = app.set_activation_policy(policy); +} + +#[cfg(not(target_os = "macos"))] +fn apply_tray_policy(_app: &AppHandle, _visible: bool) {} + +pub fn set_tray_policy(app: &AppHandle, visible: bool) { + apply_tray_policy(app, visible); +} + +#[cfg(test)] +mod tests { + use super::{is_app_origin, webview_user_agent}; + use tauri::Url; + + fn url(value: &str) -> Url { + Url::parse(value).expect("a url") + } + + #[test] + fn the_app_origin_is_allowed_by_both_spellings_on_every_platform() { + // The custom scheme everywhere, and the http spelling WebView2 needs on Windows. The + // second is not gated on the platform: the origin is the app's wherever it is served. + assert!(is_app_origin(&url( + "tauri://localhost/index.html?port=10100" + ))); + assert!(is_app_origin(&url( + "http://tauri.localhost/index.html?port=10100" + ))); + } + + #[test] + fn nothing_near_that_origin_is_that_origin() { + for value in [ + // Not the scheme the pinned Tauri serves the app over. + "https://tauri.localhost/index.html", + // A port means something else is answering. + "http://tauri.localhost:8080/", + // Neither localhost generally nor a name that merely contains it. + "http://localhost/", + "http://127.0.0.1/", + "http://evil.tauri.localhost/", + "http://tauri.localhost.example.com/", + "file:///C:/index.html", + ] { + assert!(!is_app_origin(&url(value)), "{value}"); + } + } + + #[test] + fn webview_user_agent_marks_the_desktop_shell() { + let user_agent = webview_user_agent(); + assert!(user_agent.starts_with("Mozilla/5.0 ")); + assert!(user_agent.contains("OpenCodexDesktop/")); + if cfg!(target_os = "macos") { + assert!(user_agent.contains("(Macintosh; Intel Mac OS X 10_15_7)")); + } else if cfg!(target_os = "windows") { + assert!(user_agent.contains("(Windows NT 10.0; Win64; x64)")); + } else { + assert!(user_agent.contains("(X11; Linux x86_64)")); + } + } +} diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json new file mode 100644 index 00000000000..83b2dc18886 --- /dev/null +++ b/desktop/src-tauri/tauri.conf.json @@ -0,0 +1,72 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "OpenCodex", + "version": "2.61.0", + "identifier": "com.opencodex.desktop", + "build": { + "frontendDist": "../ui", + "devUrl": "http://localhost:1420" + }, + "app": { + "withGlobalTauri": true, + "security": { + "csp": "default-src 'self'; connect-src 'self' http://127.0.0.1:*; style-src 'self' 'unsafe-inline'; script-src 'self'" + } + }, + "bundle": { + "active": true, + "targets": "all", + "createUpdaterArtifacts": true, + "externalBin": [ + "binaries/ocx" + ], + "resources": { + "resources/gui/dist": "gui/dist" + }, + "icon": [ + "icons/icon.icns", + "icons/icon.ico", + "icons/icon.png" + ], + "macOS": { + "minimumSystemVersion": "13.0", + "files": { + "PlugIns/OpenCodexWidget.appex": "widget/OpenCodexWidget.appex" + }, + "dmg": { + "appPosition": { + "x": 180, + "y": 170 + }, + "applicationFolderPosition": { + "x": 480, + "y": 170 + } + } + }, + "windows": { + "webviewInstallMode": { + "type": "downloadBootstrapper" + }, + "wix": { + "language": "en-US" + } + }, + "linux": { + "deb": { + "depends": [] + }, + "appimage": { + "bundleMediaFramework": false + } + } + }, + "plugins": { + "updater": { + "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEFDNzZCMDg0NkVCRUJGODEKUldTQnY3NXVoTEIyckhRUXJMOXJRUDR0aHQ2L3pLVHVweXFSc1lzS24vWDNiSUJ5MXJIZmpyb2sK", + "endpoints": [ + "https://github.com/lidge-jun/opencodex/releases/latest/download/latest.json" + ] + } + } +} diff --git a/desktop/ui/index.html b/desktop/ui/index.html new file mode 100644 index 00000000000..f42074944ce --- /dev/null +++ b/desktop/ui/index.html @@ -0,0 +1,57 @@ + + + + + + OpenCodex + + + +
+

OpenCodex

+

Starting OpenCodex…

+

+
    + +
    + + + diff --git a/desktop/ui/main.js b/desktop/ui/main.js new file mode 100644 index 00000000000..6431ac08230 --- /dev/null +++ b/desktop/ui/main.js @@ -0,0 +1,154 @@ +// The bootstrap page is the startup surface. It does not probe anything itself: the shell owns the +// sequence, its deadline and its diagnostic, and this page renders what it is told. The phase list +// is asked for rather than written here, so a state added in the shell appears without a second +// edit — and one removed cannot leave a row behind. +// +// What each row shows comes from the shell too, including the states already finished. Rebuilding +// that history from events would be wrong: the first states finish in milliseconds, so a page whose +// listener attached a moment late would show a run in progress with nothing behind it. +// +// Nothing here uses alert, confirm or prompt. The embedded webview implements none of the +// WKUIDelegate panel methods on macOS, so a platform dialog is silently declined and the user sees +// nothing at all. Every message this page has goes into the page — including its own failures, +// because a surface that cannot report is the problem this file exists to fix. + +const bridge = window.__TAURI__; +const invoke = bridge && bridge.core && bridge.core.invoke; +const listen = bridge && bridge.event && bridge.event.listen; + +// The shell owns the sequence and its deadline. The page has no deadline of its own: an invoke +// whose command never answers returns a promise that neither settles nor rejects, and the page +// then keeps its initial markup forever - the headline still says the run is starting, the +// checklist is empty, and the only thing on screen is a Retry button with an empty diagnostic. +// That is indistinguishable from a hung product. Bounding the handshake turns the silence into a +// failure the page can report and the user can copy. +const HANDSHAKE_DEADLINE_MS = 5000; + +function withDeadline(work, what) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error('the shell did not answer ' + what + ' within ' + HANDSHAKE_DEADLINE_MS + ' ms')); + }, HANDSHAKE_DEADLINE_MS); + Promise.resolve(work).then( + (value) => { clearTimeout(timer); resolve(value); }, + (error) => { clearTimeout(timer); reject(error); }, + ); + }); +} + +const headline = document.querySelector("#headline"); +const detail = document.querySelector("#detail"); +const phaseList = document.querySelector("#phases"); +const failure = document.querySelector("#failure"); +const retry = document.querySelector("#retry"); +const copy = document.querySelector("#copy"); +const copyState = document.querySelector("#copyState"); +const diagnostic = document.querySelector("#diagnostic"); + +const MARKS = { done: "✓", failed: "✕", active: "…", pending: "·" }; + +let phases = []; + +function render(progress) { + const completed = new Set((progress && progress.completed) || []); + const failedPhase = (progress && progress.failedPhase) || null; + const current = progress && progress.phase; + phaseList.replaceChildren(); + for (const phase of phases) { + let state = "pending"; + if (phase.id === failedPhase) { + state = "failed"; + } else if (phase.id === current) { + state = "active"; + } else if (completed.has(phase.id)) { + state = "done"; + } + const row = document.createElement("li"); + row.dataset.state = state; + const mark = document.createElement("span"); + mark.className = "mark"; + mark.textContent = MARKS[state]; + const label = document.createElement("span"); + label.textContent = phase.label; + row.append(mark, label); + phaseList.append(row); + } +} + +function apply(progress) { + if (!progress) return; + headline.textContent = progress.label; + detail.textContent = progress.detail || ""; + const failed = progress.phase === "failed"; + failure.hidden = !failed; + retry.disabled = !progress.canRetry; + if (failed) { + diagnostic.value = progress.diagnostic || ""; + copyState.textContent = ""; + } + render(progress); +} + +function reportPageFailure(message, error) { + const cause = error && error.message ? error.message : String(error); + headline.textContent = "OpenCodex could not read its own startup state."; + detail.textContent = message; + failure.hidden = false; + retry.disabled = false; + diagnostic.value = [message, cause].join("\n"); +} + +async function copyDiagnostic() { + const text = diagnostic.value; + if (!text) return; + try { + await navigator.clipboard.writeText(text); + copyState.textContent = "Copied to the clipboard."; + return; + } catch { + // A webview without clipboard access is the reason the text is on screen in the first place. + } + diagnostic.focus(); + diagnostic.select(); + let copied = false; + try { + copied = document.execCommand("copy"); + } catch { + copied = false; + } + copyState.textContent = copied + ? "Copied to the clipboard." + : "The text above is selected — copy it with your keyboard."; +} + +retry.addEventListener("click", async () => { + if (!invoke) return; + copyState.textContent = ""; + retry.disabled = true; + try { + await invoke("retry_startup"); + } catch (error) { + reportPageFailure("The retry could not be sent to the shell.", error); + } +}); +copy.addEventListener("click", copyDiagnostic); + +async function start() { + if (!invoke || !listen) { + headline.textContent = "This page is the OpenCodex desktop shell's startup surface."; + detail.textContent = "Open it from the OpenCodex app."; + return; + } + try { + phases = (await withDeadline(invoke("startup_phases"), "startup_phases")).filter((phase) => !phase.terminal); + render(null); + // The listener goes on before the snapshot is read, so a transition landing between the two is + // delivered rather than lost. + await withDeadline(listen("startup-phase", (event) => apply(event.payload)), "the startup-phase subscription"); + apply(await withDeadline(invoke("startup_snapshot"), "startup_snapshot")); + } catch (error) { + reportPageFailure("The startup surface could not reach the shell.", error); + } +} + +start(); diff --git a/devlog/_fin/260725_macos_menubar_app/003_design_read.md b/devlog/_fin/260725_macos_menubar_app/003_design_read.md index f3ec2d33856..0a6d4802489 100644 --- a/devlog/_fin/260725_macos_menubar_app/003_design_read.md +++ b/devlog/_fin/260725_macos_menubar_app/003_design_read.md @@ -113,7 +113,7 @@ column. ├──────────────────────────────────────┤ │ LAST 7 DAYS │ range echoed from the response │ REQUESTS TOKENS COST │ micro labels, 10px, letterspaced -│ 1,746 12.4M $8.21 │ tabular-nums, 13px +│ 1,746 12M $8.21 │ tabular-nums, 13px │ ▁▂▃▅▂▁▃ │ 7d usage trend from usage.days[] ├──────────────────────────────────────┤ │ OpenAI ▓▓▓▓▓░░░░░ 44% │ quota rows, one per provider @@ -170,7 +170,7 @@ Live data reaches `requests: 232507`, `totalTokens: 36536664705`, `estimatedCostUsd: 34018.25`. Rules: - Counts: `1,746` → `12.4K` → `1.2M` (3 significant figures, SI suffix at 10 000). -- Tokens: always suffixed (`12.4M`, `36.5B`). +- Tokens: always suffixed with integer values (`12M`, `37B`). - Cost: `$8.21` below 1 000, `$34.0K` above. - All numerics use `tabular-nums` so digits do not reflow while polling. - Timestamps normalize by magnitude: values below `1e12` are seconds, at or above are diff --git a/devlog/_fin/260725_macos_menubar_app/010_phase1_core.md b/devlog/_fin/260725_macos_menubar_app/010_phase1_core.md index f6f8c854cbe..1dded9edf28 100644 --- a/devlog/_fin/260725_macos_menubar_app/010_phase1_core.md +++ b/devlog/_fin/260725_macos_menubar_app/010_phase1_core.md @@ -399,7 +399,7 @@ seconds and anthropic milliseconds both resolve to sane 2026 dates · `ProxySett decodes without a `defaultProvider` field and `ProxyConfigSummary` supplies it. `FormattingTests`: the `002` magnitudes (`232507`, `36536664705`, `34018.25`) render as -`232K`, `36.5B`, `$34.0K` · `nil` renders `—` · zero renders `0`, not `—`. +`232K`, `37B`, `$34.0K` · `nil` renders `—` · zero renders `0`, not `—`. ## `app/.gitignore` diff --git a/devlog/_fin/260725_macos_menubar_app/020_phase2_ui.md b/devlog/_fin/260725_macos_menubar_app/020_phase2_ui.md index 28fbf346144..95126dceb3c 100644 --- a/devlog/_fin/260725_macos_menubar_app/020_phase2_ui.md +++ b/devlog/_fin/260725_macos_menubar_app/020_phase2_ui.md @@ -184,7 +184,7 @@ shown as selectable text — displayed, never executed (`002` §3). Three columns from `/api/usage?range=7d`: REQUESTS, TOKENS, COST. Labels in `Theme.micro` uppercase with 0.5pt tracking; values in `Theme.numeric`. All values -through `Format` (`010`), so `36536664705` becomes `36.5B` and `nil` becomes `—`. +through `Format` (`010`), so `36536664705` becomes `37B` and `nil` becomes `—`. **The range label is rendered from the response, not the request.** `002` §3 records that `parseRange` silently falls back to `30d` for any unrecognized value, so a UI that diff --git a/devlog/_fin/260725_macos_menubar_app/051_feature_summary.md b/devlog/_fin/260725_macos_menubar_app/051_feature_summary.md new file mode 100644 index 00000000000..3de9b2b95e0 --- /dev/null +++ b/devlog/_fin/260725_macos_menubar_app/051_feature_summary.md @@ -0,0 +1,12 @@ +# 051 — Feature summary + +The macOS companion now shares the proxy's canonical usage accounting across the menu bar +app, widget, and dashboard Usage companion section. The proxy owns the +`/api/usage/timeline` and `/api/companion/settings` contracts; `ocx companion` provides +matching read/write controls with `show`, `set`, and `reset` subcommands. + +The menu bar app renders a settings-driven title, today metrics, model/account/provider +sections, and a timeline chart. It writes a privacy-safe snapshot for the WidgetKit +companion, which supports small, medium, and large families and links back to Usage. +The default menu bar headline is total tokens; the dashboard can switch it to requests, +cost, quota, or icon-only display. diff --git a/devlog/_fin/260921_brand_icon_and_menu_bar_mark/000_plan.md b/devlog/_fin/260921_brand_icon_and_menu_bar_mark/000_plan.md new file mode 100644 index 00000000000..35d6b14a3be --- /dev/null +++ b/devlog/_fin/260921_brand_icon_and_menu_bar_mark/000_plan.md @@ -0,0 +1,95 @@ +# Brand icon and menu bar mark + +The shipped app icon is not the product's mark. `desktop/src-tauri/icons/*` derives from an empty +rounded-square ring that came in with the Tauri template, and the vector source written for it in +#5329 reproduced that ring faithfully — the measurement was right and the subject was wrong. + +Two consequences, both visible on a Mac today. The artwork covers 21.4% of the 1024px canvas and +has transparent corners, so macOS 26/27 classifies it as a uniquely shaped icon, strips it onto a +default grey tile and scales it down; Finder shows a grey square with a small black ring in it. +And the menu bar carries the same ring, so nothing on screen says which product this is. + +The real mark already exists in the repository. `assets/logo-light.png` is the mark on +transparency at 512px and `gui/public/favicon.png` is its app-icon composition at 128px: a light +squircle behind a dark six-lobed cloud that holds a `>` and a `_`, flanked by `{` and `}`, inside a +dashed orbit with a dot at top and bottom. Neither has a vector source, and `gui/src/icons.tsx` +holds only 24x24 line icons, so there is nothing to reuse — the vector has to be produced. + +## Where the geometry comes from + +The silhouette is measured, not redrawn. `assets/logo-light.png` is pure black with a shaped alpha +channel, so the outline is the alpha channel: upsample it 4x to 2048px, threshold at alpha 110, +trace with potrace, and map the result back into the 512-unit source space. That yields exactly +nine subpaths — cloud, two braces, four orbit arcs, two dots — and re-rendering them at 512px +disagrees with the thresholded source in **188 of 262144 pixels (0.072%)**, which is antialiasing +rather than a different shape. + +The prompt glyphs cannot be traced. In the source they are engraved: alpha 217+ against a 206 body, +with a lit rim along one edge. Composited at 512px that reads as depth; at 128px and below it reads +as nothing, and an app icon spends most of its life at 32px. Thresholding the emboss produces a +ragged chevron because the lit edge falls below the threshold asymmetrically. + +They are redrawn as flat geometry on the measured centreline instead: + +| glyph | measurement (512 source space) | drawn as | +| --- | --- | --- | +| `>` | rows 214-238 give the upper arm centreline slope 0.5625; rows 254-278 give the lower arm slope -0.5833; the two meet at (214.6, 244); tips at y 196.5 and 292.5 | polyline `193.4 206.5 -> 214.6 244 -> 193.4 281.5`, stroke 22, round cap and join | +| `_` | x 253-324.5, y 271-293.5, ends semicircular | rect 71 x 22.5, rx 11.25 at (253.5, 271) | + +Checked against the source: the chevron's predicted horizontal cross-section is 25.3px against 25px +measured, and the underscore's cap curvature lands within one pixel at both ends. + +## Shape of the change + +The glyphs are a **mask** rather than a lighter fill. Cutting them out of the mark makes the +backdrop show through, which is the flat reading of an engraved groove, and it is also what gives +the menu bar template real holes instead of a black blob. + +The backdrop is a **full-bleed opaque square**, not a pre-rounded tile. Apple's current app icon +guidance asks for a square, unmasked, full-bleed 1024px source and applies the rounded-rectangle +mask and material itself; a baked corner fights that and shows as jagged edges. The 824px inner +tile with a transparent margin is the pre-Tahoe recipe, and the transparent margin is precisely +what triggers today's grey fallback. + +Files: + +- `desktop/src-tauri/icons/icon.svg` — replaced. Full-bleed `#fcfcfc` backdrop, mark in `#2c2c2c`, + glyphs cut by `mask#prompt`, mark placed by `translate(2 26) scale(2)` so the orbit centre sits on + the canvas centre and the ink keeps the 77% coverage the favicon composition uses. +- `desktop/src-tauri/icons/tray/icon.svg` — new. Same curves, no backdrop, black fill, orbit and + dots dropped because at 22pt a dashed circle resolves into grey specks. viewBox is the ink bounds + of what is left plus 6%, so the glyph fills the menu bar height rather than the source margin. +- `desktop/scripts/generate-icons.ts` — `render()` takes a source, and the run emits + `tray/icon.png` at 44px (22pt at @2x) alongside the existing seventeen. Both `icons` and + `icons:check` cover it. +- `tests/ci-workflows/build-desktop-icon-set.test.ts` — two additions. The tray raster has to be the + size the generator declares and the generator has to actually render and report it, and the tray + source has to carry the app icon's mask verbatim, wire it onto the mark, and draw distinct curves + that all appear in `icon.svg`. + + Subset alone was too weak, and a review caught it: every interesting way of breaking the tray + removes something, so a strict subset stays a subset. Dropping the mask, deleting the underscore + or repeating a brace in place of the cloud each ship a black blob with green CI. Each of those, + plus removing the generator's tray render and removing its `produced.push`, was applied and run: + all five turn the suite red at 6 pass / 1 fail, and the restored tree is 7 pass / 0 fail. + +Nothing in `desktop/src-tauri/src/tray.rs` changes: it already builds the tray with +`.icon_as_template(true)`, and the asset it includes is the file being replaced. + +## Acceptance + +1. `cd desktop && bun run icons:check` reports every generated artifact matching the source, tray included. +2. `tests/ci-workflows/build-desktop-icon-set.test.ts` passes, and its drift guard fails when the + tray source is perturbed. +3. `icon.png` is fully opaque, and `tray/icon.png` is 44x44 with no non-black opaque pixel. +4. The change lands on `dev`, and a locally built and installed app shows the mark in Finder, the + Dock and the menu bar. + +## Recorded results + +- silhouette trace vs source alpha: 188 / 262144 px (0.072%). +- `icon.png` opaque coverage: 21.4% before, 100.0% after. +- `tray/icon.png`: 44x44, 759 pixels with alpha above zero — 498 fully opaque and 261 antialiased + — and no pixel with alpha whose colour is anything but black, which is what a template image has + to be. Both prompt glyphs are transparent holes rather than white fill. +- `cd desktop && bun run icons` regenerated 18 artifacts; `bun run icons:check` reported 18 matching. Both are desktop package scripts and fail from the repository root. diff --git a/devlog/_fin/260921_brand_icon_and_menu_bar_mark/010_favicons.md b/devlog/_fin/260921_brand_icon_and_menu_bar_mark/010_favicons.md new file mode 100644 index 00000000000..6e09e5c5973 --- /dev/null +++ b/devlog/_fin/260921_brand_icon_and_menu_bar_mark/010_favicons.md @@ -0,0 +1,54 @@ +# The dashboard and documentation favicons + +The desktop app now renders its icon and its menu bar image from one traced vector. The two +favicons the product serves are still hand-made rasters with no source, and one of them is broken +in a way the file itself does not show. + +`docs-site/public/favicon.png` is the dark variant of the mark: white on transparency, 192px, +36225 of 36864 pixels carrying some alpha but every one of them RGB `(255,255,255)`, exactly one +fully opaque pixel, corner `(255,255,255,3)`. Composited on white it is a white square. A browser +tab strip is light by default and Starlight names the favicon unconditionally, so the documentation +site effectively has no favicon in light mode. `favicon.ico` beside it carries the same artwork at +16, 32 and 48, also with no fully opaque pixel. + +`gui/public/favicon.png` is the light composition and looks right at 128px, but it is a bitmap no +source can regenerate, and it is the shaded artwork rather than the flat mark: its engraved prompt +all but disappears at 16 and 32. Rendering the vector at 128 differs from it in 68% of pixels. That +is a visible simplification, not only a deduplication, and it is the same trade the app icon made. + +This unit covers the favicons and nothing else. Starlight's `logo-light.png` and `logo-dark.png` +stay independent 512px rasters — they are the brand artwork the vector was traced from, not +derived assets. `og.png` also stays, and carries a separate pre-existing defect worth its own +scope: `docs-site/astro.config.mjs` declares it 1200x630 while the committed file is 1536x1024. + +## Shape of the change + +- `scripts/lib/icon-render.ts` — new. The renderer, the RGBA re-encode and the ICO packer move here + out of `desktop/scripts/generate-icons.ts`, which keeps its size tables and imports them. Two + generators sharing one renderer is the point; a second copy of the PNG re-encode would be a + second place for the alpha bug to come back. +- `scripts/brand-favicons.ts` — new. Renders `desktop/src-tauri/icons/icon.svg` into + `gui/public/favicon.png` (128), `docs-site/public/favicon.png` (192) and + `docs-site/public/favicon.ico` (16, 32, 48) — the names, sizes and formats the two sites already + reference, so no page or config changes. `--check` regenerates into scratch and compares bytes, + the same contract the desktop set has. +- `package.json` — `favicons` and `favicons:check`. +- `tests/ci-workflows/brand-favicons.test.ts` — new, registered in `scripts/test-layout/layout.json` + and `tests/fixtures/test-layout-expected.json`. Asserts the declared sizes match what the two + sites ask for, that each committed favicon is that size with its alpha channel intact, that the + ICO carries exactly the declared sizes as embedded PNGs, and that both favicons read on a light + tab. + + That last one is why the test decodes pixels. An opaque corner alone is not enough: a plain white + square has an opaque corner and is still invisible. So it requires an opaque light corner **and** + at least 10% of the image to be opaque pixels whose luminance differs from that corner by more + than 64 — the mark actually being there. + +## Acceptance + +1. `bun run favicons:check` reports every favicon matching the source. +2. The new test fails on both ways of being invisible, checked by applying each: the + white-on-transparent artwork this replaces gives 3 pass / 1 fail, and a solid `#fcfcfc` square + gives 3 pass / 1 fail. The generated favicons give 4 pass / 0 fail. +3. `bun run privacy:scan`, `bun run structure:check` and the two test-layout guards stay green. + diff --git a/devlog/_fin/260921_brand_icon_and_menu_bar_mark/020_closure.md b/devlog/_fin/260921_brand_icon_and_menu_bar_mark/020_closure.md new file mode 100644 index 00000000000..858cadede98 --- /dev/null +++ b/devlog/_fin/260921_brand_icon_and_menu_bar_mark/020_closure.md @@ -0,0 +1,66 @@ +# Outcome + +Three changes landed on `dev`, in this order: + +| commit | pull request | change | +| --- | --- | --- | +| `a2d35a609e` | #5355 | the app icon and the menu bar mark, traced from the brand artwork | +| `5794348b0d` | #5356 | the alpha channel every generated icon needs | +| `917d690ecb` | #5361 | the dashboard and documentation favicons, from the same vector | + +## Verified on screen + +- **Finder and Dock.** Built locally with `bun run build:local`, signed with the Developer ID + identity the installed app already carried, installed to `/Applications` and relaunched. Finder + shows the mark on the system rounded rectangle: macOS masks the full-bleed square itself, which + is what the square, unmasked source is for. The widget extension still registers with + `pluginkit` under `com.opencodex.desktop.widget`, so replacing the bundle did not cost it. +- **Menu bar.** The status item renders the template mark with both prompt glyphs as holes, + tinted by macOS, next to the usage label. Both that and the Finder icon are recorded in + `assets/pr-screenshots/app-icon-finder-menubar.png`. +- **Browser tabs.** The two favicons were served over loopback HTTP and opened in a browser. Both + read as the mark on a light tile at tab size. This is the claim `010_favicons.md` makes, checked + in a tab rather than in a composite. `assets/pr-screenshots/favicon-browser-tab.png` is the tab + strip itself, not a rendering of one. + +## The defect the build caught + +`bun run build:local` failed on the first head with +`error: proc macro panicked ... icon .../icons/icon.png is not RGBA`. The new backdrop is opaque, +and librsvg drops the alpha channel when nothing in a render is transparent; `generate_context!` +rejects a window icon that is not RGBA. Fifteen of the sixteen rasters were affected — only the +tray image, which has real transparency, kept its alpha. + +Nothing in the repository could have seen it. The icon tests read dimensions and container +structure, and no test or hosted job builds the Tauri bundle. The generator now re-encodes, and +the colour type of every committed raster is asserted rather than trusted. + +## CI at the head + +`917d690ecb`, read at the exact SHA. Green: all four test shards, `gates`, `desktop shell`, +`macos 2/2`, `macos widget + bundle`, `docs site build`, `docker smoke`, `storage policy`, +`api usage`, all three `npm-global` legs, all three keyring legs, the three service legs. +Skipped, and named rather than counted: `macos control`, `structure gate`, the Windows shard +matrix placeholder. + +The first attempt had one failure, and it did not belong to this unit: `macos 1/2`, on +`tests/server/memory-watchdog.test.ts` -- +*serializes only an allowlisted Bun runtime provenance, omitting it otherwise (#848)* -- at 47.4s +against its own 20s timeout, with 13436 pass, 12 skip, 1 fail on that leg. That test makes eight +full `/api/system/memory` route calls and its own comment records the route costing roughly +600ms per read on shared runners, so it is timing fragile by construction. It passed at +`64b0eca2b0`, which already contained #5355 and #5356, and `917d690ecb` adds only favicon bytes +and a favicon generator. It also failed at `07e2ac9b41`, before any of this landed, and a focused +local run finishes in 377ms. + +Re-running that job at the same SHA turned it green, and the aggregate `ci` check with it, so +exact-head CI for `917d690ecb` is green. The flake is real and still there: the fix is to stop the +test paying for eight route snapshots, not to widen the timeout again. That is separate scope. + +## Left deliberately + +- `docs-site/src/assets/logo-light.png` and `logo-dark.png` stay as they are. They are the artwork + the vector was traced from, not derived assets. +- `og.png` stays, and carries a pre-existing mismatch worth its own scope: the configuration + declares 1200x630 and the committed file is 1536x1024. + diff --git a/devlog/_fin/260921_cross_path_contract/000_plan.md b/devlog/_fin/260921_cross_path_contract/000_plan.md new file mode 100644 index 00000000000..c9ab2d7f078 --- /dev/null +++ b/devlog/_fin/260921_cross_path_contract/000_plan.md @@ -0,0 +1,113 @@ +# Cross-path contract gaps before the next release + +Status: open. Target branch for every lane: `dev`. + +The batch that landed on 2026-09-20 fixed several defects one path at a time. The +audit that followed found the same shape repeating: a policy is correct where it +was written and absent one wrapper away, or a guard that protects a real hazard +also refuses the supported case. This unit closes that class before the release +rather than adding features. + +Each lane is one branch, ordered commits, and one pull request to `dev`. No +stacked child pull requests, no native stacks. A lane owns its files; where two +lanes touch the same subsystem the split is written below so the merge is a union +and not a conflict. + +## L1 — one replay refusal on all three HTTP surfaces + +`src/lib/upstream-retry.ts` answers an ambiguous connection loss with +`upstream_reset_replay_refused` and no `Retry-After`, which means "this may +already have executed, do not send it again". `src/server/chat-native.ts` and +`src/server/responses/passthrough-error.ts` recognise that. The translated Chat +wrapper in `src/server/chat-completions.ts` does not: it preserves only the cyber +policy code and `model_not_found`, assigns `upstreamCode` just when +`classifyError` produced no code, and then adds a default `Retry-After: 2`. A +refusal to replay leaves the proxy as an ordinary rate limit that clients retry. + +Carry the replay verdict as a property of the result the three wrappers share, so +no wrapper re-derives it from a status code. Then decide the status deliberately: +the widely used Python SDK retries 429 by default, so preserving the code while +dropping `Retry-After` does not by itself stop a resend. The acceptance evidence +is the number of physical upstream sends observed through a client with retries +enabled, not a single `fetch`. + +## L2 — the Chat translation inbound loses developer position + +Outbound keeps a `developer` message where the conversation put it +(`src/adapters/openai-chat/messages.ts`). Inbound does not: +`src/chat/inbound.ts` routes both `system` and `developer` into +`systemParts` and joins them into `body.instructions`, so +`U1 → A1 → D2 → U2` becomes `instructions: D2` with `U1 → A1 → U2`. Position +is gone before any adapter sees it, and no outbound fix can restore it. + +This is not a rare internal path. Combo, policy, synthetic effort rows and several +preprocessing routes translate, so the same transcript behaves differently once a +routing feature is on. The Claude inbound already models this correctly by keeping +a mid-conversation instruction as a developer input item +(`src/claude/inbound.ts`, `src/responses/parser.ts`). Reuse that representation +for the mid-conversation case only; a leading system block keeps its current +treatment. + +## L3 — an explicit developer-role setting is ignored natively + +`foldDeveloperRoleToSystem` decides the role on the translated path. The native +Chat passthrough (`src/adapters/openai-chat/passthrough.ts`) forwards the +caller's `messages` untouched and never reads it, so an operator who recorded +"this destination rejects `developer`" still sends `developer` there. Honour the +explicit setting on both paths and leave the unset default alone: the existing +native test that preserves caller messages stays green. + +## L4 — the paginated-history transition, past "enable succeeded" + +The provider-table transition on a paginated `openai` home now completes. The +remaining risk is the state after it. Acceptance is destination preservation, not +a successful sync: existing conversations must not resume against the default +OpenAI endpoint, new conversations must use the injected provider and catalog, +restore must return operator-owned settings and remove only what this project +owns, a user-owned root override must not be taken over, and an admission-token +home must still be refused rather than reported as supported. + +## L5 — tool constraints survive response repair + +`createGrokResponsesSparseTerminalBlockRewrite` rebuilds a terminal output from +collected `output_item.done` events and receives a budget but not this request's +tool selection. The undeclared-tool guard answers a different question — whether a +name was declared — so a request with `tool_choice: none` or a narrowed allow-list +can still receive a call the repair put back. Pass the request scope into the +repair and enforce it there. Keep the failure narrow: one forbidden call must not +discard the ordinary text that accompanied it. The empty-catalog case belongs to +the same rule — compatibility is judged on the final request and the final +response, after every removal, rename and translation. + +## L6 — a client integration that writes a store nobody reads + +A newer client release reads its provider list from a different file than the one +this exporter writes, and the legacy import does not run again once the new file +exists, so an apply that reports success produces no models. Support the store the +running client actually reads, including catalog refresh and disable, or report +the write as ineffective. Deleting the new file to re-trigger a migration is not a +supported remedy. The verification unit is "the client requests the intended +provider", not "the file was written". + +## L7 — one developer-role policy in both documents and the code + +`structure/providers/chat-compat.md` states the role is forwarded as itself on +every destination; `docs-site` states an unset setting sends `system`; the two +code paths differ again. Whoever fixes this area next picks one of them and +reintroduces the regression. Make the three agree after L2 and L3 settle, and +derive the statement from the code where a test can hold it. + +## L8 — one resend budget per logical request + +The ambiguous-resend gate landed with one operator grant per request. The +composition still needs evidence: first send, reset, replacement, disconnect after +`response.created`, then the combo candidate, credential refresh and 429 legs. +Observe two separate numbers — physical sends, and sends of a turn that may already +have executed. The neighbouring retry issues are not closed by this lane and stay +open with their remaining scope recorded. + +## Out of scope + +A lenient finish for a text-only stream with no terminal event is existing +compatibility behaviour with its own regression coverage. Turning every EOF into an +error would be a policy change, not a fix, and is not part of this unit. diff --git a/devlog/_fin/260921_cross_path_contract/010_lane_boundaries.md b/devlog/_fin/260921_cross_path_contract/010_lane_boundaries.md new file mode 100644 index 00000000000..ae2d37c9f3d --- /dev/null +++ b/devlog/_fin/260921_cross_path_contract/010_lane_boundaries.md @@ -0,0 +1,70 @@ +# Lane ownership, ordering and acceptance shape + +The first audit round of `000_plan.md` returned blocking findings: the lanes were +described by symptom without an owned-file set, two lanes overlapped on the send +path, one lane depended on two others without saying so, and two lanes named an +acceptance unit that no test can observe. This document answers those and is the +binding half of the unit. + +## Owned files + +A lane changes files in its own row. A file in another row is read-only for it. +Anything outside every row is open, but a second lane touching it has to say so in +its pull request. + +| Lane | Owns | +|---|---| +| L1 | `src/server/chat-completions.ts` error path, `src/server/chat-native.ts` error path, `src/server/responses/passthrough-error.ts`, the replay-verdict carrier it extracts, and tests for those | +| L2 | `src/chat/inbound.ts`, `src/responses/parser.ts` where the Chat path needs it, and its own tests | +| L3 | `src/adapters/openai-chat/passthrough.ts`, `src/adapters/openai-chat/messages.ts` role selection, and its own tests | +| L4 | `src/codex/history-provider.ts`, `src/codex/inject.ts`, `tests/codex-integration/*` | +| L5 | `src/server/grok-responses-snapshot-repair.ts`, `src/server/responses-undeclared-tool-guard.ts`, `src/server/responses/passthrough-dispatch.ts` call sites, and its own tests | +| L6 | `src/clients/config-export/`, `src/integrations/registry.ts` entry for that client, and its own tests | +| L7 | `structure/providers/chat-compat.md`, `docs-site` provider reference, and the generated binding check | +| L8 | `src/lib/request-execution-budget.ts`, `src/lib/request-resend-gate.ts`, and send-count tests | + +L1 and L8 both live near the send path and are split by question. L1 owns what the +client is told when a replay is refused — code, status, retry header, and the +carrier that stops each wrapper re-deriving it. L8 owns how many sends one logical +request may make and which leg may spend the shared reserve. L8 does not change an +error body; L1 does not change an allowance. + +## Ordering + +L7 lands last. It writes down the single developer-role policy, and that policy is +not settled until L2 fixes where the message sits and L3 fixes which role it +carries. Until both are on `dev`, L7 keeps its branch rebased and its pull request +open. Every other lane is independent and merges in whatever order its evidence +arrives. + +## Acceptance that a test can hold + +Static reading is how a lane reviews itself; hosted CI on the exact head is what +decides. A lane whose acceptance sentence names something no job can observe has +to restate it: + +- L1 and L8 count sends against a recorded fetch, so the number is an assertion and + not an inference. L1 additionally asserts the response the client receives. +- L4 drives a temporary home with fixtures: enable, create, resume, restore. It + asserts the destination recorded for an existing conversation and for a new one, + and it asserts the refusal that an admission-token home still receives. The + refusal path already has coverage; the transition and the post-transition + destinations are the new part. +- L6 cannot prove what a third-party client does at runtime. Its assertion is that + the file the current client release reads carries the intended provider after + enable and refresh, and carries nothing after disable — with the client's own + published schema quoted in the pull request as the reason that file is the one + that matters. If the lane cannot establish the schema, it reports the write as + ineffective instead, which is the honest half of the original instruction. +- L5 asserts on the block rewrite directly: `tool_choice: none`, a narrowed + allow-list, an empty catalog after normalisation, and a forbidden call arriving + beside ordinary text, which must survive. +- L7 asserts that the documented default is derived from the code, so a default + change fails a check rather than only a review. + +## Baseline + +The lanes are cut from `dev` after the September 20 batch, so the paginated +transition and the per-request resend gate are already present. A lane that finds +its premise already satisfied says so in its pull request and narrows to the part +that is not, rather than reimplementing what landed. diff --git a/devlog/_fin/260921_cross_path_contract/090_outcome.md b/devlog/_fin/260921_cross_path_contract/090_outcome.md new file mode 100644 index 00000000000..250d3754a85 --- /dev/null +++ b/devlog/_fin/260921_cross_path_contract/090_outcome.md @@ -0,0 +1,43 @@ +# Outcome + +All eight lanes are on `dev`. The unit closes here; what each lane actually +changed is below, with the parts that were narrowed or left open named rather +than implied. + +| Lane | Landed as | What it changed | +|---|---|---| +| L1 | `556b670251` | The ambiguous-resend refusal now carries the same code and the same retry policy on the translated Chat wrapper, the native Chat route and Responses, from one shared verdict instead of three re-derivations from a status code | +| L2 | `0ab648b4e3` | A mid-conversation instruction keeps its slot through the Chat translation inbound instead of being folded into `instructions`; a leading system block is unchanged | +| L3 | `6e0e912ebd` | A recorded `foldDeveloperRoleToSystem` decides the role on the native Chat route as well; the unset default and the caller-message preservation test are untouched | +| L4 | `a6e8df4ec1` | The paginated-history transition is judged by conversation destination — enable, new conversation, resume, restore — rather than by a successful sync, and an admission-token home is still refused | +| L5 | `b20acc79d2` | The request's tool selection is enforced after a sparse-terminal repair, so a forbidden call cannot re-enter the terminal output, and ordinary text beside it survives | +| L6 | `5d3f5db84a` | The integration writes the provider store the client actually reads, with enable, refresh and disable symmetric, and reports an ineffective write instead of success when the store's schema is not one it knows | +| L7 | `34332bb785` | One developer-role policy across the contract document, the configuration reference and its translations, derived from the adapter so a changed default fails a check | +| L8 | `3a0718c81c` | One resend allowance per logical request across the composed recovery legs, with the physical send count and the possibly-executed send count observed separately | + +## What this unit did not close + +`#5348` is closed because its acceptance is on `dev`. The neighbouring retry +issues about a WebSocket failure stage and single-key 429 handling are not +resolved by L8 and stay open with their remaining scope recorded on the issues +themselves. + +## What the audit round changed + +The first reviewer pass returned blocking findings and two mistaken ones. The +mistaken pair read a stale checkout and reported L4's refusal and L8's gate as +still absent; both had landed the day before, so those lanes narrowed to the +part that was missing instead of reimplementing what was there. The real +findings — no owned-file set, L1 and L8 overlapping on the send path, L7 +depending on L2 and L3 without an order, and two acceptance sentences no job +could observe — are answered in `010_lane_boundaries.md` and were followed. + +## Friction worth remembering + +Two lanes conflicted on `scripts/test-layout/layout.json` and +`tests/fixtures/test-layout-expected.json` because each appended its own +registration line. The resolution is always the union; the file is a registry, +not a narrative. One lane's own tests failed for the two familiar reasons: a +test asserting a field name the type does not carry, and a fixture that set an +unbound fingerprint beside `canApply`, which the parser refuses by contract. +Both were fixed by deriving from the source rather than restating it. diff --git a/devlog/_fin/260921_cross_path_contract/100_verification.md b/devlog/_fin/260921_cross_path_contract/100_verification.md new file mode 100644 index 00000000000..535cefe5026 --- /dev/null +++ b/devlog/_fin/260921_cross_path_contract/100_verification.md @@ -0,0 +1,48 @@ +# Verification on the landed tip + +The eight lanes were re-read on `dev` after they landed, from source rather +than from commit messages, to check that each acceptance is actually present. +Six hold as written. Three carry a boundary that the plan did not state, and +they are written down here because each of them is the kind of thing a later +reader would otherwise discover as a surprise. + +**The replay refusal is identical on all three surfaces.** The translated Chat +wrapper, the native Chat route and the Responses error path each preserve +`upstream_reset_replay_refused`, drop `Retry-After`, and send +`x-should-retry: false`. That last header is what makes the intent legible to a +client whose default is to retry a 429. + +**The Chat inbound keeps a mid-conversation instruction in place, with one +ordering rule.** Leading system text still becomes `instructions`. A developer +message that arrives inside an open tool-call batch is held until the batch +closes rather than being spliced between a call and its result, because a +transcript that interleaves them is not one any destination accepts. Outside a +batch the message stays exactly where it arrived. + +**The native Chat route honours an explicit setting only.** `true` rewrites the +developer role to system; `false` and unset return the caller's messages +untouched, which keeps the passthrough contract that route exists for. + +**The repair enforces the request's tool selection on what it reconstructs.** +The rebuilt terminal output excludes a forbidden call and keeps the ordinary +text that arrived beside it. Raw `output_item.done` blocks are not rewritten by +the repair — policing the raw stream belongs to the undeclared-tool guard, and +splitting it that way keeps one owner per question. + +**The client store is shared by enable, refresh and disable.** An unknown schema +is reported rather than merged into. When this project's own block is still in +the legacy file, refresh refuses and disable cleans that file first: the +alternative is a block left in one file while another is written, which is the +state that made the original report hard to diagnose. + +**The resend grant is one shared ledger entry.** Derived and combo budgets +inherit it rather than opening their own, so composing recovery legs cannot +multiply the allowance. The ceiling itself stays operator-configurable; what is +fixed is that there is one of it per logical request. + +## Issue dispositions + +`#5348` is closed: its acceptance is on `dev`. `#4191` and `#5180` stay open +with their remaining scope recorded on the issues — a dead mid-turn transport +still has no fallback, and a provider 429 on a single key still has no cooldown +policy. Neither is what a per-request resend budget decides. diff --git a/devlog/_fin/260921_remaining_seams/000_plan.md b/devlog/_fin/260921_remaining_seams/000_plan.md new file mode 100644 index 00000000000..84aa1d2a45e --- /dev/null +++ b/devlog/_fin/260921_remaining_seams/000_plan.md @@ -0,0 +1,104 @@ +# The seams the first batch left open + +Status: open. Target branch for every lane: `dev`. + +The cross-path unit fixed each contract where it was written. Re-reading the +integrated tree found four places where those fixed policies stop one layer +short: an endpoint the refusal never reached, a name that changes between the +check and the thing checked, a match that accepts more than it was given, and a +pair of builders that read different halves of the same provider setting. + +Each lane is one branch, ordered commits, and one pull request to `dev`. + +## Owned files + +| Lane | Owns | +|---|---| +| N1 | `src/server/claude-messages.ts` error path and its tests | +| N2 | `src/server/responses-request-tool-scope.ts`, the scope call site in `src/server/responses/passthrough-delivery.ts`, `src/responses/muse-tool-name-alias.ts` where identity is carried, and the composition test | +| N3 | `src/adapters/openai-chat/passthrough.ts`, the shared wire policy it and `src/adapters/openai-chat.ts` both call, and its tests | +| N4 | `src/integrations/merge.ts` selector parsing and its tests | + +N2 owns the scope module outright; N1 and N3 do not touch it. + +## N1 — the refusal stops at the Claude Messages wrapper + +`src/server/chat-completions.ts`, `src/server/chat-native.ts` and +`src/server/responses/passthrough-error.ts` now agree: an ambiguous connection +loss answers with `upstream_reset_replay_refused`, no `Retry-After`, and +`x-should-retry: false`. `src/server/claude-messages.ts` rebuilds the error +envelope itself. It keeps only the message string, runs the generic +`resolveClientRetryAfter`, and emits an Anthropic error with `Content-Type` and +`Retry-After` — so the refusal reaches the caller as an ordinary retryable +rate limit. Anthropic's own client reads `x-should-retry` before the status +code, so the header is the part that actually stops the resend. + +Read the shared verdict here rather than re-deriving it from the message text, +and carry the code, the header and the suppressed `Retry-After` through. Two +behaviours must survive: the transient-5xx to 529 mapping the Claude client +depends on for backoff, and an ordinary provider 429, which keeps the retry +policy it has today. The acceptance is the header and code observed at +`/v1/messages`, not the internal Responses result. + +## N2 — identity has to survive the rename + +A long client tool name is sent upstream under a short alias, and +`tool_choice` is rewritten to that alias with it. On the way back the payload +rewrites restore the client name first, and only then does the snapshot repair +check the call against the scope built from the outbound body, which still +spells the selector as the alias. The restored name is not the alias, so an +allowed call is removed from the reconstructed terminal output and the turn ends +incomplete. + +The same module accepts too much in the other direction. A call is matched by +any of its spellings — bare name, `namespace__name`, `namespace.name` — against +a set holding the selector's spellings, so `alpha.lookup` and `beta.lookup` +both offer bare `lookup` and match each other. The selection set is a set of +strings, so a `custom` and a `function` tool of the same name are not separated +either; the fix is to distinguish a verified conversion from a coincidence of +names, not to refuse every kind mismatch. + +Carry the correspondence between the original identity, the wire alias and the +restored identity from the request, and have the scope read that correspondence. +`src/responses/namespace-tool-compat.ts` already reasons about selector kinds +and dotted-alias ambiguity; reuse it rather than growing a second, looser name +set. The regression test has to run the real order — a tool name past the length +limit, a named or allowed-tools selector, alias on the way out, restore on the +way back, sparse terminal reconstruction — and end with the original name and +call id intact. Keep the negative case: a tool the request did not select is +still refused after restoration. + +## N3 — two builders, one provider setting + +The translated builder turns `reasoningWireFormat: "gateway-object"` with an +effort of `none` into the gateway's object form, and omits the effort entirely +for a tool-bearing request when the model is listed in +`omitReasoningEffortWithToolsModels`. The native Chat passthrough reads neither, +so the same provider and model behave differently depending on whether a routing +feature sent the request through translation. + +Apply the explicit settings through one small policy both builders call, after +the provider is resolved. Do not route native requests through translation to +get it: the native path exists to preserve Chat-only fields such as `n`, audio +and logprobs, and losing those is a worse regression than the one being fixed. +An unset setting keeps today's native behaviour. The test compares the final +request body captured on both paths for the same input, not the status code. + +## N4 — a selector path must not change meaning + +The integration merge grammar gained a conjunction form, `[field=value,field=value]`, +because one field is not always an identity. The single-criterion form allows a +comma inside the value, so a path already written into an ownership record — for +example one whose value itself contains `,` and `=` — can parse as a conjunction +under the new rule and select a different element. + +No record in that shape has been found, so this is a migration hazard rather +than a reported loss. Close it deliberately: version the grammar, structure the +selector, or define an escape, and cover it with a test that reads a record +written under the older rule and asserts it still names the same element. + +## Out of scope + +The paginated-history work and the client provider store landed and are not +reopened here. The two retry issues left open after the first batch keep their +recorded scope; neither is a lane in this unit. diff --git a/devlog/_fin/260921_remaining_seams/090_outcome.md b/devlog/_fin/260921_remaining_seams/090_outcome.md new file mode 100644 index 00000000000..6ecdae83453 --- /dev/null +++ b/devlog/_fin/260921_remaining_seams/090_outcome.md @@ -0,0 +1,38 @@ +# Outcome + +All four lanes are on `dev`, and the seams they were opened for are closed. + +| Lane | Landed as | What it changed | +|---|---|---| +| N1 | `2cc11b780a` | The routed Claude Messages error path now carries the replay-refusal code, `x-should-retry: false` and no `Retry-After`, so a refusal no longer reaches that endpoint as an ordinary retryable rate limit. The transient-5xx to 529 mapping the client relies on for backoff and the policy for a genuine provider 429 are unchanged | +| N2 | `ebaf78a46c` | The scope reconstructs each exact outbound identity through the namespace and wire aliases before judging a call, so an allowed call restored from its alias survives sparse-terminal reconstruction. Authorization keys carry kind, namespace and name, so a shared bare name no longer grants another namespace or another kind | +| N3 | `03ab5bfb11` | `applyExplicitChatReasoningWirePolicy` owns the gateway-object form and the tool-bearing effort omission, and both the translated and native builders call it. It is a no-op when neither setting is recorded, and the native path still copies its preserved Chat-only fields verbatim | +| N4 | `bd4822bcea` | A conjunction selector now needs an explicit marker, so a legacy single-criterion value containing a comma keeps its old meaning. The whole recorded path is validated before traversal, and an unreadable selector stays attached to the file its record names as unsafe rather than moving the operation elsewhere | + +## Found while waiting + +A manual full-matrix run on one lane's branch exposed a Windows-only defect that +pull-request CI never ran: the desktop release-asset test took a basename with +`path.split("/")`, which is not a separator on Windows, so it compared whole +`C:\…` paths against asset names and five shards failed. Fixed in +`e2453085b9` by asking the platform for the last segment. The release matrix +would have hit it. + +## Verification + +Each lane merged on its own exact head with every requested job green. The four +landed seams were then re-read on the integrated tip and all five acceptance +statements hold at source level. + +Two CI conditions shaped the pace and are worth recognising next time. Duplicate +runs for one head appear regularly and one of them is cancelled by concurrency; +a cancellation is not evidence in either direction, and the remedy is to re-run +the failed jobs of the real matrix rather than to read the rollup. Hosted macOS +capacity was saturated for much of this batch, with single jobs queued for over +an hour, which is why the last two merges trailed the rest. + +## Not in this unit + +The two retry issues left open after the first batch keep their recorded scope: +a mid-turn transport death still has no fallback, and a provider 429 on a single +key still has no cooldown policy. Neither is decided by anything landed here. diff --git a/devlog/_plan/260920_desktop_app_stabilization/000_local_build.md b/devlog/_plan/260920_desktop_app_stabilization/000_local_build.md new file mode 100644 index 00000000000..db1792aa6f1 --- /dev/null +++ b/devlog/_plan/260920_desktop_app_stabilization/000_local_build.md @@ -0,0 +1,52 @@ +# Desktop app stabilization — local build and conflict handling + +Status: OPEN. Opened against `dev` after the desktop stack landed as #5318 and the Claude Desktop +chain as #5319. This unit records what the first real local build found and what the app does when +it meets something already running. + +## The release profile could not build the app at all + +`cargo build --release` stopped at `ctor`, a transitive dependency of `tauri-utils`: + +``` +error[E0463]: can't find crate for `ctor_proc_macro` + --> ctor-0.8.0/src/lib.rs:244:9 +``` + +The same graph compiles in the dev profile. The difference is `[profile.release] strip = "symbols"`, +which cargo applies to build scripts and proc macros as well as to the crate being built. A proc +macro is a host dylib that rustc loads by symbol, so stripping it leaves a file rustc cannot read, +and the error names the macro rather than the profile that removed its symbols. + +`[profile.release.build-override] strip = false` restores it. The fix is one line plus the reason, +because the next person to read `can't find crate` will otherwise go looking at the dependency. + +This did not surface earlier because nothing had built the desktop app in release outside CI, and +CI's toolchain tolerated the stripped dylib. It is reproducible here on rustc 1.95.0. + +## Two installations at once + +The widget snapshot has two writers that target the same path: + +- `app/Sources/MenuBarCore/WidgetSnapshot.swift` builds it from + `~/Library/Containers/com.opencodex.desktop.widget/Data/Library/Application Support/OpenCodex/snapshot.json` +- `desktop/src-tauri/src/widget.rs:246` writes the same file from Rust + +Each deduplicates with its own in-process `lastWritten`, so two live writers do not settle: each +sees the other's file as changed, rewrites it, and calls `reloadTimelines`. The current build no +longer ships a standalone menu bar executable — `app/Package.swift` declares only the widget appex +and its test harness — so this is reachable only for a user who still has an earlier standalone +build installed. `tauri_plugin_single_instance` guards a second copy of the same bundle and cannot +see a different one. + +## Proxy ownership + +`spawned_by_us` in `desktop/src-tauri/src/lib.rs` records whether the app started the proxy, and +`tray.rs` enables **Stop proxy** from it. It is consumed with `swap(false)`, so the behaviour after +a stop-and-restart cycle needs checking rather than assuming. + +## Execution note + +This unit builds and installs locally at the maintainer's explicit request, which is a deliberate +exception to the no-local-build rule the surrounding batch worked under. Test suites are still not +run here. diff --git a/devlog/_plan/260920_desktop_app_stabilization/010_roadmap.md b/devlog/_plan/260920_desktop_app_stabilization/010_roadmap.md new file mode 100644 index 00000000000..af51c49d63e --- /dev/null +++ b/devlog/_plan/260920_desktop_app_stabilization/010_roadmap.md @@ -0,0 +1,69 @@ +# Roadmap — six items, four work phases + +Status: LOCKED at wp1. Each later phase consumes one decade doc below and revalidates it at its +own P. The evidence in [000_local_build.md](000_local_build.md) is the ground truth; this file +turns it into an order of work. + +## What the local build and run actually showed + +Nothing about the usage feature was missing. `gui/dist` was five days old, so the bundle the +service served predated #5196 and could not contain the companion panel. `AGENTS.md` already says +the dashboard is served from `gui/dist` and lists `bun run build:gui`, so rebuilding after a +fast-forward was the existing procedure and skipping it was the mistake. After the rebuild the page +renders 74,974 requests, 18.66B tokens, 99% coverage, and a **menu bar and widget** section reading +"desktop app connected · just now". + +That reframes the work: five of the six items are real defects, and the sixth is the guard that +stops this particular mistake from being silent. + +## Order and why + +| Phase | Items | Why here | +| --- | --- | --- | +| wp2 | release profile, stale dist, updater-key exit | Nothing else can be built or verified until the release profile compiles; the dist guard belongs with it because both are "the build lied about its state". | +| wp3 | Claude Desktop first-party reachability | Independent of the build, and already verified by hand, so it lands on its own evidence. | +| wp4 | SVG app icons, widget gallery verdict | Both need a signed-or-explained bundle, so they come after the build is trustworthy. | + +## 020 — release profile, stale dist, updater key + +`[profile.release] strip = "symbols"` is applied by cargo to build scripts and proc macros as well +as to the crate being built. A proc macro is a host dylib rustc loads by symbol, so stripping it +produces `can't find crate for ctor_proc_macro` — an error that names the macro and never mentions +the profile. `[profile.release.build-override] strip = false` is the fix, already committed with +its reason. + +The stale-dist guard reports rather than repairs. The dashboard is a served artifact, so the honest +signal is "the bundle you are looking at is older than the source that produced it", surfaced where +someone will read it. Rebuilding automatically at startup would make a serving process do a build, +which is the wrong trade for a proxy. + +The updater-key exit is smaller: a local build that produced both bundles should not end on a +failure line about a signing key it was never given. + +## 030 — Claude Desktop first-party reachability + +`resolveClaudeDesktopMode` returns `gateway` when a gateway apply marker exists, and an explicit +`desktopMode` wins over everything. Both rules are right on their own: neither should flip a +working install silently. Together they mean the help text calls first-party "(default)" while an +existing user can never arrive there without discovering `--first-party` unaided. + +The fix is not to change the resolution. It is to make the choice visible at the moment an apply +happens, so a gateway apply says what it chose, that first-party exists, and how to switch. + +## 040 — SVG app icons and the widget verdict + +Icons today are a raster set with no vector source, so every size is an independent artifact that +can drift. One SVG source with a generation step makes the sizes derived rather than restated — +the same principle the test-layout registries follow. + +The widget question is answered with evidence, not hope. The bundle carries +`PlugIns/OpenCodexWidget.appex` and the app is ad-hoc signed +(`Identifier=opencodex_desktop-b89067d97e1c189c`, `flags=0x20002(adhoc,linker-signed)`), so the +phase records whether the widget appears in the gallery under that signing and, if it does not, +what specifically rejects it. + +## Constraints carried through every phase + +Stacked PRs, all pushes `--no-verify`, CI tracked after the fact rather than waited on. No local +test suite. Builds and real launches are the verification, because this unit exists precisely +because a build that was never run locally was assumed to work. diff --git a/devlog/_plan/260920_desktop_app_stabilization/011_acceptance.md b/devlog/_plan/260920_desktop_app_stabilization/011_acceptance.md new file mode 100644 index 00000000000..e25669f18ff --- /dev/null +++ b/devlog/_plan/260920_desktop_app_stabilization/011_acceptance.md @@ -0,0 +1,49 @@ +# Acceptance evidence per phase + +The roadmap says what each phase does. This says what closes it, in terms of evidence that a +passing command or a rendered page does not by itself provide. + +## wp2 — release profile, stale dist, updater key + +**Release profile.** `cargo build --release` completes for the desktop crate. The regression is not +a test that runs cargo; it is an assertion that the release profile carries a build-override which +does not strip, because the failure mode is a profile setting and the symptom appears in an +unrelated crate. A test that only built something would pass on a machine whose rustc tolerates a +stripped proc-macro dylib, which is exactly how this reached `dev`. + +**Stale dist.** The check compares the newest source timestamp under `gui/src` against the built +bundle and reports when the bundle is older. It closes when a deliberately stale bundle produces +the report and a fresh one does not. Reporting is the contract: the proxy must not start a build. + +**Updater key.** A local bundle build that produced its artifacts ends by naming them, and the +missing updater key is stated as a skipped signing step rather than a failure. It closes when the +command's exit status reflects whether the bundles exist. + +## wp3 — Claude Desktop first-party reachability + +Closes when an apply that resolves to gateway says so, names first-party as the alternative, and +gives the exact command that switches. The resolution rules stay as they are: neither an explicit +`desktopMode` nor an existing apply marker may be overridden silently, because a working install +must not flip underneath its user. + +The evidence is the apply output on a machine that already carries a gateway marker — this one. +A unit test asserting the string is not sufficient on its own, because the defect was that the +help text and the resolved behaviour disagreed, and only running the real path shows which wins. + +## wp4 — SVG icons and the widget verdict + +**Icons.** One SVG source exists and every raster size is generated from it by a committed script. +It closes when regenerating produces byte-identical output for unchanged input, so the sizes are +derived rather than restated. + +**Widget.** The verdict is recorded either way. If the widget appears in the gallery under ad-hoc +signing, that is the finding. If it does not, the phase records what rejects it — the specific +system log line or the signing requirement — rather than reporting an absence. An unverified +"should work" closes nothing. + +## What none of these accept + +A green pull request is not evidence for any item here, because every one of them was invisible to +CI. The release profile failed only on a toolchain CI does not use, the stale bundle is a runtime +artifact CI rebuilds, the mode disagreement needs an existing install, and the widget needs a real +login session. Each phase therefore carries a local run alongside its hosted check. diff --git a/devlog/_plan/260920_desktop_app_stabilization/020_build_state_guards.md b/devlog/_plan/260920_desktop_app_stabilization/020_build_state_guards.md new file mode 100644 index 00000000000..fe67b3411a8 --- /dev/null +++ b/devlog/_plan/260920_desktop_app_stabilization/020_build_state_guards.md @@ -0,0 +1,59 @@ +# wp2 — the build telling the truth about its own state + +Two defects and one rough edge, all of the same shape: the build produced a state nobody could see +from its output. + +## 1. The release profile could not compile the app (landed) + +`cargo build --release` stopped at `ctor` with `can't find crate for ctor_proc_macro`. The dev +profile compiled the identical graph. `[profile.release] strip = "symbols"` is applied by cargo to +build scripts and proc macros as well as to the crate under build, and a proc macro is a host dylib +rustc loads by symbol, so stripping it leaves a file rustc cannot read. The error names the macro +and never mentions the profile that removed its symbols. + +`[profile.release.build-override] strip = false` in `desktop/src-tauri/Cargo.toml`, with the reason +in a comment because the next reader of that error will otherwise go looking at the dependency. +Verified by `cargo build --release -p ctor` failing before and passing after, and by the full +`tauri build` reaching both bundles afterwards. + +## 2. A stale dashboard bundle was invisible (landed) + +`gui/dist` was five days old, so the served page predated #5196 and could not contain the usage +companion panel. Nothing failed — the proxy answered and the page loaded, and the feature simply was +not in the bundle, which reads as the feature being broken. + +`src/server/gui-freshness.ts` compares the newest mtime under `gui/src` with the served bundle and +`ocx status` prints the rebuild command beside the dashboard URL. It reports and never rebuilds: a +proxy compiling a frontend at startup trades silent staleness for a slow, surprising start. + +Unknown is not stale, because a packaged install ships no `gui/src` and a missing bundle is a +separate condition. `node_modules` is skipped so a dependency install cannot make sources look +newer than they are. Four regressions in `tests/server/server-gui-bundle-freshness.test.ts` hold those +cases, and the live check was confirmed by touching a source file and watching the warning appear +and then disappear after a rebuild. + +## 3. A local build ends on a failure after succeeding (this phase) + +`createUpdaterArtifacts` is true and `plugins.updater.pubkey` is set in `tauri.conf.json`, so Tauri +always builds the updater archive and then refuses to finish without `TAURI_SIGNING_PRIVATE_KEY`: + +``` +Finished 2 bundles at: .../OpenCodex.app, .../OpenCodex_2.61.0_aarch64.dmg +A public key has been found, but no private key. Make sure to set TAURI_SIGNING_PRIVATE_KEY +Error failed to build app +``` + +Both bundles exist at that point. The command still exits non-zero, so a developer building locally +sees a failure for a signing step they were never meant to perform, and a script wrapping the build +cannot distinguish this from a real failure. + +The release path must keep failing here: an unsigned updater artifact shipped to users is worse +than a failed release. So the fix is not to relax the check but to give the local build a path that +does not ask for the artifact at all — an explicit script that builds the app and dmg without +updater artifacts, documented beside the existing release instructions. + +### Acceptance + +A local build command produces `OpenCodex.app` and the dmg and exits zero without a signing key. +The release instructions still describe the signed path, and nothing weakens the requirement that a +published updater artifact is signed. diff --git a/devlog/_plan/260920_desktop_app_stabilization/030_icons_and_widget.md b/devlog/_plan/260920_desktop_app_stabilization/030_icons_and_widget.md new file mode 100644 index 00000000000..6ea275bebe6 --- /dev/null +++ b/devlog/_plan/260920_desktop_app_stabilization/030_icons_and_widget.md @@ -0,0 +1,114 @@ +# wp4 — one vector source for the icons, and a verdict on the widget + +## Why the icon set needed a source + +`desktop/src-tauri/icons/` carried eighteen raster files and no vector. Every size was an +independent artifact: nothing tied `Square107x107Logo.png` to `icon.png`, nothing could tell +whether one of them had been hand-edited, and adding a platform size meant drawing it again. The +`.icns` and `.ico` containers hid the problem further, because a wrong member inside them is not +visible in a diff at all. + +The fix is a single `icon.svg` plus `desktop/scripts/generate-icons.ts`, exposed as +`bun run icons` and `bun run icons:check`. Fifteen PNGs render through `rsvg-convert`, the +`.icns` is assembled by `iconutil` from its ten members, and the `.ico` is written directly with +six PNG-embedded entries (16, 32, 48, 64, 128, 256). `--check` regenerates into a temporary +directory and compares byte for byte, so a hand-edited PNG fails instead of silently disagreeing +with the source. + +## The geometry was measured, not redrawn + +A redrawn mark would have been a different icon wearing the same name. The shape in `icon.png` +was measured instead: it spans 58..453 on both axes, the stroke is 48 wide, and the outer corner +turns at radius 135. A centred stroke therefore sits at `x=82 y=82 w=348 h=348` with +`stroke-width=48`, and the corner radius was swept to find the closest match. `rx=127` reproduces +the original to within **430 of 262144 pixels at 512×512 — 0.164%**, which is antialiasing along +the curve rather than a changed silhouette. + +The mark stays pure black on transparency. Both macOS and Windows composite it over their own +backgrounds, so a baked background would appear as a card on one of the two. + +## The widget question + +`OpenCodexWidget.appex` is bundled, and the acceptance note requires a verdict either way rather +than an absence. + +**The extension registers, and that part is settled.** `pluginkit` lists it from the installed +application with the parent bundle resolved and no disabled or ignored marker: + +``` +com.opencodex.desktop.widget(2.61.0) + SDK = com.apple.widgetkit-extension + Parent Bundle = /Applications/OpenCodex.app + Parent Name = OpenCodex + Platform = macOS +``` + +That record is structurally identical to a system widget queried the same way, so the earlier +working hypothesis — that ad-hoc signing keeps the extension from being adopted at all — is wrong +and is recorded here as wrong. Registration is not the obstacle. + +**And it does not appear in the gallery.** The gallery was opened on this machine and checked: +OpenCodex is not among the offered widgets. No `OpenCodexWidget` process has ever run here +either, so nothing has asked the extension for a timeline. Registration and adoption are two +different things, and only the first of them holds. + +**What the signing state actually costs.** The host bundle carries the linker-signed placeholder: + +``` +host app Identifier = opencodex_desktop-b89067d97e1c189c + flags = 0x20002(adhoc,linker-signed) + Info.plist = not bound + Sealed Resources = none +appex Identifier = com.opencodex.desktop.widget + flags = 0x2(adhoc) +``` + +The host's `CFBundleIdentifier` is `com.opencodex.desktop`, but its *signed* identity is the +placeholder, its `Info.plist` is not bound into the signature, and it seals no resources. Locally +that is tolerated because the machine built the bundle itself. A distributed copy has no sealed +host for the system to validate the extension's containment against, and nothing binds the +declared identifier to the signed one. + +**The verdict, then:** the extension is registered and the gallery does not offer it. The host +bundle is the thing that fails a requirement — its signed identity is not the identity it +declares, and it seals nothing — so nothing downstream can establish that this extension belongs +to `com.opencodex.desktop`. Until the release pipeline signs the host with a Developer ID +identity, the widget ships but cannot be added. That is the finding; it is not worked around here, +and no part of the icon work depends on it. + +## What the icon check does and does not cover + +`bun run icons:check` compares all seventeen generated artifacts — fifteen PNGs, the `.ico` and +the `.icns` — byte for byte against a fresh render. It needs `rsvg-convert` and `iconutil`, and +when `iconutil` is missing it now says the `.icns` was not compared and fails, rather than +reporting a pass over a file it never looked at. + +That check does not run in CI, and claiming otherwise would be the easy lie here. The renderer is +not pinned, so two machines with different librsvg builds produce different bytes with nothing +wrong; asserting byte identity on a hosted runner would be asserting the runner's renderer +version. What CI runs instead is `tests/ci-workflows/build-desktop-icon-set.test.ts`, which needs +no renderer at all and reads its expectations out of the generator: every declared size committed +at exactly that size, the `.ico` directory carrying exactly the packed sizes with each payload a +real PNG of its declared dimension, the `.icns` walking cleanly end to end with one image member +per declared entry, and nothing hand-added beside the generated set. It was driven red on a +resized raster and on a stray file before being trusted. + +So the split is: shape is enforced everywhere, byte identity is enforced wherever the toolchain +exists. + +## Files + +- `desktop/src-tauri/icons/icon.svg` — new, the single source. +- `desktop/scripts/generate-icons.ts` — new, renderer and `--check` verifier. +- `desktop/package.json` — `icons` and `icons:check` scripts. +- Seventeen regenerated raster artifacts under `desktop/src-tauri/icons/`. +- `tests/ci-workflows/build-desktop-icon-set.test.ts` — new, the renderer-free structural guard, + registered in `scripts/test-layout/layout.json` and `tests/fixtures/test-layout-expected.json`. + +## Acceptance + +`bun run icons:check` passes on the committed tree over all seventeen artifacts, +`build-desktop-icon-set.test.ts` passes and has been shown to fail on a wrong-sized raster and on +a stray file, `bun run build:local` produces a bundle whose `Contents/Resources/icon.icns` is the +generated one, and the widget verdict is an observation of the gallery rather than an inference +from registration. diff --git a/devlog/_plan/260920_desktop_app_stabilization/040_widget_never_offered.md b/devlog/_plan/260920_desktop_app_stabilization/040_widget_never_offered.md new file mode 100644 index 00000000000..062fbfa4d27 --- /dev/null +++ b/devlog/_plan/260920_desktop_app_stabilization/040_widget_never_offered.md @@ -0,0 +1,215 @@ +# wp5 — the widget registered, and offered nothing + +## The symptom, and why it was not a signing problem + +The extension installs, `pluginkit` lists it beside the system widgets, and the gallery does not +show it. The obvious reading was signing: a locally built host is ad-hoc signed, so of course the +system will not adopt its extension. That reading was wrong, and following it would have produced +a signing change that fixed nothing, because the released build is signed and notarized and the +widget is missing there too. + +The actual defect is in the binary. `app/Package.swift` forced the executable's entry point: + +```swift +.unsafeFlags(["-Xlinker", "-e", "-Xlinker", "_NSExtensionMain"]), +``` + +and `app/Sources/OpenCodexWidget/main.swift` held nothing but a comment explaining that the entry +was handled by that flag. So `OpenCodexWidgetBundle` — which `Views.swift` defines correctly, +with a display name, a description and three supported families — was never referenced by +anything, and no code ever handed it to the extension host. + +Read off the shipped bundle: + +``` +LC_MAIN entryoff -> _NSExtensionMain +nm: SnapshotProvider present, OpenCodexWidgetBundle absent +Info.plist: NSExtensionPointIdentifier = com.apple.widgetkit-extension + NSExtensionPrincipalClass = (absent) +``` + +That combination is exactly consistent with the symptom. `pluginkit` registers from the +Info.plist, which is complete, so registration succeeds. `NSExtensionMain` then looks for an +`NSExtensionPrincipalClass`, which a SwiftUI widget does not declare because Xcode's `@main` on +the `WidgetBundle` is what connects it instead. Nothing errors. The gallery simply has no +configuration to offer. + +## The fix, and the wrong turn on the way to it + +The first attempt was to delete the linker override and call the bundle from `main.swift`. That +made the bundle's symbols appear in the binary and did not work either — it replaced a silent +failure with a loud one. Every launch died: + +``` +EXC_BREAKPOINT (SIGTRAP) + ExtensionFoundation closure #1 in ... _EXRunningExtension._shared + ExtensionFoundation MainActor.assumeIsolated + ExtensionFoundation _EXExtension.bootstrap(with:) + WidgetKit + OpenCodexWidget main +chronod: [com.opencodex.desktop::com.opencodex.desktop.widget] query failed - will try lazy + reload later +``` + +Seventeen crash reports accumulated in `~/Library/Logs/DiagnosticReports` while the gallery stayed +empty, because `chronod` asks the extension for its descriptors and the extension never survives +long enough to answer. + +**The extension needs both halves of what Xcode does, and each is useless alone.** `@main` on the +`WidgetBundle` is what keeps it in the binary; `-e _NSExtensionMain` is what makes the process +start as an extension rather than as a program. The original code had the second without the +first, this branch briefly had the first without the second, and only both together produce a +widget the system will talk to. With both in place the crash reports stop at zero and `chronod` +processes the extension normally. + +`tests/clients/desktop-widget-entry.test.ts` asserts both, plus that no `main.swift` has come back +to compete with `@main`, and that the bundle carries a widget with a display name rather than an +empty body — the same failure by a third route. + +The deployment target moved to macOS 14 at the same time, which drops the per-declaration +`@available(macOS 14, *)` guards and puts the binary's `minos` at 14.0, matching every working +widget on the machine this was measured on. + +## The sandbox is not optional + +While narrowing this down, the extension was rebuilt without `com.apple.security.app-sandbox` to +test whether the sandbox was implicated. It is required, and the system says so plainly: + +``` +pkd: Ignoring mis-configured plugin at [.../OpenCodexWidget.appex]: plug-ins must be sandboxed +``` + +An unsandboxed extension is not rejected at launch — it is never registered at all, so it vanishes +from `pluginkit` entirely. That also settles the snapshot path: the host writes into +`~/Library/Containers/com.opencodex.desktop.widget/Data/...` precisely because the extension reads +its own container, and that arrangement has to stay. + +## What the public record says about this failure + +The `_EXRunningExtension` crash is not unique to this repository, and finding the precedent +changed how much of the fix is guesswork. A forensic report on macOS 26.5 with Swift 6.3.2 +describes the same trap from the same cause — a widget extension assembled from a SwiftPM +`.executableTarget` and wrapped into an `.appex` by hand — and records that neither Info.plist +shape avoids it, because SwiftPM has no app-extension target and therefore never applies the +entry-point setup Xcode's WidgetKit template provides. That project's resolution was to stop +using SwiftPM for the extension and build a real Xcode app-extension target instead. + +Two other projects keep SwiftPM and supply the missing pieces by hand, which is the route taken +here: the linker entry (`-Xlinker -e -Xlinker _NSExtensionMain`) and the compiler's +extension-only mode (`-application-extension`, which is what Xcode spells +`APPLICATION_EXTENSION_API_ONLY`). Both are now set, and the extension launches and answers +`chronod` without a crash report. + +Three things the same record settles that were open questions here: + +- **Ad-hoc signing does not prevent gallery appearance.** Developer ID and notarization matter for + Gatekeeper, not for gallery mechanics. The containing app does have to be launched once after + installation, which is what makes the first-run behaviour in this branch load-bearing for more + than the menu bar. +- **App Groups do not work under ad-hoc signing**, and the documented fallback is exactly what + this repository already does — the host writes into the extension's own container. +- **`CFBundleVersion` must match between host and extension** or WidgetKit rejects timeline + reloads. Verified on the installed bundle: both read 2.61.0. + +If the gallery still refuses this extension after the entry point and the extension-only build, +the remaining known cause is the Xcode app-extension target itself, and that is a larger change +than this unit: it means adding an Xcode project for the widget and building it with +`xcodebuild` rather than `swift build`. + +## The signing defect underneath it + +Fixing the entry point does not make a *released* widget adoptable on someone else's machine, +because the release pipeline would not sign it. + +`.github/workflows/release.yml` ran `build-widget.sh` with no `env:` block. `MACOS_SIGN_IDENTITY` +was set one step later, on the Tauri build, which never reads it. So the script took its +`codesign --force --sign -` branch, and the bundler does not re-sign anything under `PlugIns/` — +its nested-code walker handles `.framework`, `.xpc` and `.app`, not `.appex`. + +**This has not harmed a release yet, and the reason matters.** No release has ever published a +macOS application: the last three carry no desktop assets at all, and the signing secrets did not +exist until after the most recent one was cut. `MACOS_SIGN_IDENTITY` reads a secret that was not +there, so the real-signing branch has never executed and the Developer ID path in the Tauri step +has never executed either. The bug is a mine rather than a crater — the next release is the first +one that would step on it. Saying otherwise would be inventing a history this repository does not +have. + +**Signing one path is also not enough.** A bundler that did not place a file does not sign it, and +picking binaries by file extension misses the ones that have none. The durable form of the check +is to find Mach-O files by their magic bytes and require every one of them to carry the release +identity, rather than naming the paths that are expected to exist. + +Three changes: + +- The certificate is imported into a temporary keychain in a step **before** the widget build, and + the keychain is deleted in an `always()` step so it cannot outlive a failed job. +- The widget build receives `MACOS_SIGN_IDENTITY`, and `build-widget.sh` now signs with + `--options runtime` as well as `--timestamp`, both of which notarization requires. +- A step after the widget build asserts the result rather than printing it: strict verification, + the configured team identifier, the runtime flag, and a secure timestamp. Without a configured + team it says so and skips, so a fork's build still works and still cannot pretend to be signed. + +This half cannot be proven here. It needs maintainer-held credentials, and the proof is a +notarized artifact installed on a machine that did not build it, launched once, with the gallery +then checked. That is recorded as the outstanding verification rather than claimed. + +## The menu bar had the same shape of problem + +Start at Login was purely opt-in. Nothing enabled it on first run, so an install left the user +with a menu bar item only for as long as the app happened to be running — and a menu bar app that +is not running has no menu bar item. After a reboot the app was simply absent. + +`first_run::apply_start_at_login_default` enables it once per installation, keyed on a marker in +the app config directory, and runs before `tray::install` so the tray checkbox reads the state it +leaves behind. The marker is written before the login item is touched and is never removed, so a +user who turns the setting off keeps it off. Writing afterwards would let a failed enable retry +every launch and eventually flip the setting back under someone who had deliberately disabled it. + +The marker distinguishes a fresh install from a user who opted out, but it cannot distinguish +either from an install that predates the marker. The desktop shell and the widget both landed the +same day this was written and no release tag contains them, so there is no such population; if +that changes, this needs a migration rather than a marker. + +## What was verified here + +Rebuilt, installed to `/Applications`, and launched: + +``` +LC_MAIN entryoff 5656 -> _main (was _NSExtensionMain) +nm: _$s15OpenCodexWidget0abC6BundleV4bodyQrvpQOMQ present +pluginkit: com.opencodex.desktop.widget re-registered, parent bundle resolved +~/Library/Application Support/com.opencodex.desktop/start-at-login-claimed written +~/Library/LaunchAgents/OpenCodex.plist created +``` + +So the entry point is connected and the login item is registered, both on a real install rather +than in a test double. + +## Verdict: it appears + +The gallery was opened on this machine after the fix and OpenCodex is in it, between OKX and +PASS, with all three declared families rendering real data rather than placeholders: + +``` +com.opencodex.desktop::com.opencodex.desktop.widget:OpenCodexWidget:systemSmall +com.opencodex.desktop::com.opencodex.desktop.widget:OpenCodexWidget:systemMedium +com.opencodex.desktop::com.opencodex.desktop.widget:OpenCodexWidget:systemLarge + "OpenCodex — Proxy status, today's usage, and quota at a glance." +``` + +Small shows the token count for the day, medium adds requests, cost and the account quota rows, +large adds the 24-hour per-model timeline. The list icon is the mark generated from `icon.svg`. + +That settles the whole question the acceptance note left open, and it settles it the right way +round: the extension was never rejected by signing or by the sandbox. It had no widget in it, and +then it had one that could not start. Both are fixed, and the fix is a SwiftPM configuration +rather than the Xcode app-extension target the public record recommends — so the cheaper route +does work, provided all three of `@main`, the `_NSExtensionMain` entry and +`-application-extension` are present. + +## Acceptance + +The entry-point half is closed by the gallery observation above. The signing half closes when a +release build's extension reports the team identifier, the runtime flag and a timestamp, and a +clean install on a machine that did not build it offers the widget. That second half needs a real +release and is recorded as outstanding. diff --git a/devlog/_plan/260920_desktop_app_stabilization/050_landing.md b/devlog/_plan/260920_desktop_app_stabilization/050_landing.md new file mode 100644 index 00000000000..4e6135b9d70 --- /dev/null +++ b/devlog/_plan/260920_desktop_app_stabilization/050_landing.md @@ -0,0 +1,66 @@ +# wp5 — landing the stack + +## Shape + +Four pull requests, each based on the one below it, all ultimately targeting `dev`: + +| PR | branch | what it carries | +|---|---|---| +| #5327 | `codex/260920-app-stabilization` | release profile, stale-dist report, `build:local`, the lockfile and test-layout repairs | +| #5328 | `codex/260920-claude-desktop-mode-visibility` | the first-party reachability message | +| #5329 | `codex/260920-app-icons` | one SVG source, the generator, the renderer-free CI guard | +| #5339 | `codex/260920-widget-entry` | the widget entry point, the login-item default, release signing | + +They merge bottom-up. After each one lands, the next is retargeted to `dev` and its exact head is +read again, because a squash merge rewrites the parent and the child's base disappears. + +## Two repairs in here are not ours + +`dev` was already red when this stack was cut, in two independent places, and both were fixed +here because every branch cut from `dev` inherits them. + +`tests/providers/stepfun-provider.test.ts` landed with no entry in either inventory and no regex +seed that resolves its name, so the membership oracle failed on `dev` and on everything branched +from it. Registering it under `providers` restores the gate for everyone. + +`macos widget + bundle` failed with *A public key has been found, but no private key*. The job is +an unsigned build by design, so the key is correctly absent — but the committed config sets +`bundle.createUpdaterArtifacts` and `plugins.updater.pubkey`, so `tauri build` writes the updater +archive and then refuses to finish. That half is #5338's, which turns the artifact off for that one +invocation; this stack does not duplicate it. + +Fixing the build revealed the rest of the job, which had never run. Its first assertion looked for +`Contents/MacOS/OpenCodex` — `productName` — while the bundle carries `opencodex-desktop`, the +crate name. That half landed separately as #5351, and better than the version written here: it +reads `CFBundleExecutable` out of the bundle instead of restating the name, so the check follows +the config rather than drifting from it. This stack's copy was dropped in favour of it. + +What remains here is the assertion with no equivalent: that the WidgetBundle is actually linked +into the extension. The appex builds, signs and registers identically with the bundle dropped by +the linker, so nothing else in this job would have noticed the defect that shipped. + +Three of this stack's incidental repairs turned out to be running in parallel with the +maintainer's own: the StepFun layout registration (#5335), the widget job's updater override +(#5338), and this executable assertion (#5351). Each was dropped here once the other landed. The +pattern is worth noting for the next batch — a repair found while passing through is worth +checking against open pull requests before it is written. + +## What closes this + +Each merge reads the exact head's check runs rather than a rollup, distinguishes a job the event +requested from one it skipped, and treats a missing, skipped, or cancelled job as not a pass. The +last merge is followed by reading `dev`'s own push run, because five of the eight defects found in +this unit were invisible until two changes met. + +## Deliberately not changed here + +Review asked for the public macOS install guidance to move with the release path, since +`README.md`, `guides/desktop-app.md` and `guides/macos-menu-bar.md` all tell the reader the app is +ad-hoc signed and not notarized, while this stack makes a real release refuse to run without a +Developer ID and the full notarization credential set. + +Those pages are accurate today and will stop being accurate at the next release, not at this +merge. No release has ever published a macOS application, so rewriting them now would describe an +artifact nobody can download and would leave the Gatekeeper walkthrough — still correct for a +locally built app — reading as though it were obsolete. The pages move with the first notarized +artifact, which is also when someone can check the instructions against a real download. diff --git a/devlog/_plan/260920_meaning_preservation_batch/000_plan.md b/devlog/_plan/260920_meaning_preservation_batch/000_plan.md new file mode 100644 index 00000000000..5003dc1a5a1 --- /dev/null +++ b/devlog/_plan/260920_meaning_preservation_batch/000_plan.md @@ -0,0 +1,73 @@ +# Meaning preservation and request-scoped safety batch + +Status: OPEN. Opened against `main` 2.60.0 (`7c625fc9755c9824653ab944190e243091a2c85c`) and the +current `dev` head. This unit covers the first six items of the post-2.60.0 assessment: the two +safety fixes and the four meaning-preservation defects. Scope beyond those six is explicitly out. + +## Why these six + +The assessment scored meaning preservation lowest of the six axes. The shared failure mode is that +a request arrives carrying an explicit constraint — which tools may be called, how a tool is +declared, an attached document, where an instruction sits in the conversation — and the proxy +returns a normal HTTP success after silently dropping it. A test that only asserts a successful +tool call or a 200 response cannot see any of them. + +The governing rule for every item in this unit: + +> On a supported path, preserve it. On an unsupported path, refuse it or apply the conversion +> policy the operator chose. Never drop it quietly and return as if the request was honored. + +## Delivery topology + +Each lane delivers **one branch with ordered commits and one pull request** against `dev`. No +GitHub native stack and no chain of child pull requests. Where an existing contributor pull +request already covers part of a lane's scope, the lane carries that work into its own branch with +a `Co-authored-by` trailer naming the original author, and the superseded pull request is closed by +the coordinator only after the lane lands. Carrying without the trailer is not acceptable: +`missing_coauthor_credit` in `.github/scripts/pr-carry-attribution.cjs` exists because +`CREDITS.md` already lists 27 landings that lost their author. + +## Lanes + +### Lane A — meaning preservation on the request path + +| Item | Contract to restore | +| --- | --- | +| #5211 | Caller-specified `allowed_tools` and `parallel_tool_calls: false` survive the Chat Completions path from inbound parse to the actual outbound request. | +| #5210 | Tool declaration `strict` and `allowed_callers` reach destinations that support them; an unsupported destination refuses rather than silently widening the declaration. | +| #5212 | Inline document bytes survive the inbound parse into the internal representation and outbound, so a title-only forward is never reported as a success. | +| #5213 | A `developer` message keeps its chronological position. Role conversion to `system` is a separate, explicitly recorded decision with its own acceptance, not a side effect of placement. | + +#5237 is a correct narrow fix for the #5213 position problem and is not the whole of role +preservation. Lane A carries it with attribution and keeps position and role as two distinct +acceptance conditions. + +### Lane B — request-scoped transport and managed-write safety + +| Item | Contract to restore | +| --- | --- | +| #5087 | DNS pinning and transport selection are decided by whether a proxy actually applies to *this request*, not by whether one is configured. Scheme mismatch, `NO_PROXY` and DNS failure must not produce an unintended unpinned direct connection. | +| #5241 | A managed configuration write never follows a terminal symlink to another file, including under `apply`, `refresh`, `disable` and `restore` races. | + +Both existing pull requests are authored by the same contributor and are carried with attribution. + +## Regression discipline + +Behaviour changes in `src/` need focused regressions next to the existing tests for that subsystem. +Before any push, check the union-defect classes `AGENTS.md` records, because exact-head CI cannot +see a defect that exists only in the union of two branches: + +- the file-size ratchet only moves downward, so a new case goes in a sibling file registered in + both `scripts/test-layout/layout.json` and `tests/fixtures/test-layout-expected.json`; +- anything exhaustive over a union — locale catalogs, `satisfies Record`, hand-written + rosters, counts in generated documentation — must be derived rather than restated. + +The 2.60.0 release was blocked by exactly this class: #5239 tightened Fernet validation while an +older case in another domain directory still minted its fixture the loose way. + +## Execution constraints + +No local suites, individual tests, typecheck, build, install or live `ocx` execution. Verification +is static source review plus exact-head hosted CI. Branch pushes use `--no-verify`. Only the +coordinator merges and closes issues. Public artifacts stay English and name no other repository or +model. diff --git a/devlog/_plan/260920_meaning_preservation_batch/010_lane_a.md b/devlog/_plan/260920_meaning_preservation_batch/010_lane_a.md new file mode 100644 index 00000000000..a0892345d86 --- /dev/null +++ b/devlog/_plan/260920_meaning_preservation_batch/010_lane_a.md @@ -0,0 +1,100 @@ +# Lane A — meaning preservation on the request path + +Status: OPEN. Branch `codex/260920-lane-a-meaning-preservation`, cut from `origin/dev` +`0613aaec17`. One branch, five ordered commits, one pull request against `dev`. + +## What each commit restores + +### #5211 — tool choice policy on the Chat Completions path + +Two constraints reached the parser and were dropped on the way out, both under a normal 200. + +A `tool_choice` of type `allowed_tools` is a record, is not `type: "function"`, and carries no +`function` member, so it fell past every branch of `toolChoiceToResponses` and `body.tool_choice` +was never assigned. Chat nests the subset under `allowed_tools` and names each entry under a +member keyed by its own type; the Responses shape `mapToolChoice` reads carries `mode` and +`tools` on the choice itself with a flat `name`. Both levels are now flattened. An entry that +cannot be named is refused rather than skipped, because skipping one widens the subset. + +`parallel_tool_calls` had three provider states and two branches, in two places. The unset state +is the default for every provider that never configured the knob, and it dropped the caller's own +explicit `false` — on the translated path and, from a second copy of the same branch, on the +native Chat passthrough. The decision now lives in `src/adapters/openai-chat/parallel-tool-calls.ts`, +which both builders read. An explicit `true` still omits the key, matching the configured opt-out. + +The passthrough half was found by adversarial review, not by the original report. + +### #5210 — tool declaration fields on the outbound adapters + +`strict` was kept deliberately by the Messages inbound and forwarded by the OpenAI Chat adapter, +and dropped by Anthropic — the target that defines it. It is now emitted when it is explicitly +`true`. An unstated `strict` stays absent, because the inbound records it as `false` and a +`false` on the wire cannot be told apart from silence. + +`allowed_callers` had no carrier at all. It now rides `OcxTool.allowedCallers` from the Messages +inbound, through the Responses tool schema — where an undeclared key is stripped, which is why it +never reached `buildTools` — to the Anthropic wire. The OpenAI Chat and Gemini builders have no +counterpart and refuse with a 400 rather than rebuild the declaration without the fence. The +unrestricted `["direct"]` default is not a restriction. + +Gemini's `functionCallingConfig.mode: "VALIDATED"` was plumbed to the wire compiler but only +reachable by matching a model name. A caller-declared strict tool now selects it in place of the +absent-choice default; `NONE`, `ANY` and a forced-name choice are never overwritten. + +### #5213 — developer message position, then role + +Delivered as two commits because they are two acceptance conditions. + +Position carries #5237 by Yum-wu with a `Co-authored-by` trailer. The upstream branch had the +right idea and a broken patch (a stray `];` and an assertion that put the deferred reminder +before the tool result), so the change was reimplemented and the attribution kept. One +destination already had chronological placement, keyed to a model id and a registry entry; that +is a property of prompt-prefix caching rather than of that destination, so it is now universal +and the model/registry test is gone. + +Role is separate. `developer` is part of the Chat Completions role set and is now forwarded as +sent. A destination that genuinely rejects it sets `foldDeveloperRoleToSystem`, which converts +the role in place and never moves the message, so the placement contract holds on both paths. + +### #5212 — inline document bytes + +Both inbound parsers reduced an attachment to its name before any adapter ran. +`OcxContentPart` gains a document member carrying the media type and the base64 payload; +Anthropic emits the document block, OpenAI Chat the file part, Gemini `inline_data`. + +Widening that union is the hazard, so the part also carries the marker every text-only consumer +already falls back to, which keeps a wire with no document representation byte-identical to +before. Six consumers needed more than the fallback: `ollama-native` and the Cursor tool-result +decoder would have read a nonexistent `imageUrl`, and the Kiro, Devin, Cursor and coding-agent +text serializers would have produced an empty turn. All were found by adversarial review. + +The untranslated-media refusal is narrowed only where a converter actually builds the part: +user content on the Chat projection, user and developer messages on the Responses one. A file in +a tool output, a system message or an assistant message is still refused. The scanner and the +decoder share one predicate, so a request cannot be exempted in one and reduced to a marker in +the other. + +## Known remaining gap + +Tool-result documents keep the #939 marker. The Responses tool-output vocabulary has no file +block and every adapter's tool-result path flattens to text, so carrying bytes there is a +separate change rather than a half-done one. + +## Union-defect check before push + +- File-size ratchet: `src/adapters/openai-chat.ts` was the only capped file in the touch set + (cap 822). The `parallel_tool_calls` decision moved to a sibling module and the file is 811 + lines. No cap was raised. +- `PROVIDER_CONFIG_FIELD_POLICY` in `src/server/auth-cors.ts` is + `satisfies Record`, so `foldDeveloperRoleToSystem` is classified + there and in `providerConfigSchema`. +- Every new test file is registered in both `scripts/test-layout/layout.json` and + `tests/fixtures/test-layout-expected.json`, which the layout guard asserts are equal. +- No count is restated: the provider reference tables gained a row rather than a number. + +## Verification + +Static source review plus exact-head hosted CI. Local suites, individual tests, typecheck, +build, install and live `ocx` execution were NOT RUN, per the lane constraints. Adversarial +source review ran on every commit and produced the passthrough, Kiro/Devin/Cursor/coding-agent, +role-aware-refusal and base64-predicate findings listed above. diff --git a/devlog/_plan/260920_meaning_preservation_batch/010_phase2.md b/devlog/_plan/260920_meaning_preservation_batch/010_phase2.md new file mode 100644 index 00000000000..a6a6a2ff041 --- /dev/null +++ b/devlog/_plan/260920_meaning_preservation_batch/010_phase2.md @@ -0,0 +1,84 @@ +# Phase 2 — the ten consolidation bundles + +Status: OPEN. Phase 1 (items 1-6) runs in lanes A and B under [000_plan.md](000_plan.md). This file +opens items 7-16 of the post-2.60.0 assessment. These are **not ten new pull requests**. Each bundle +is a unit of existing issues and pull requests to reuse, with only the shared part reviewed +together. + +## Delivery topology is unchanged + +One branch, ordered commits, one pull request to `dev` per lane. No native stack, no child pull +request chain. Carried contributor work needs a `Co-authored-by` trailer in a branch commit; +superseded pull requests are closed by the coordinator only after the lane lands. + +## Why these groupings and not one bundle per lane + +Bundles 7 and 14 both want the same substrate. Item 7 divides a failure into pre-header, +headers-only, protocol prelude, semantic output, side effect and terminal, and decides resend +permission per stage. Item 14 wants a logical request to attempt to physical send to terminal +record with one cause dictionary. Two lanes defining that separately would typecheck on each branch +and contradict each other in the merge — the exact class that blocked 2.60.0. They stay in one lane. + +Bundles 8 and 9 are the same question asked twice: an observation attributed to an account or +credential generation must not survive its replacement. A refusal learned from the previous account +and a warm cache binding dropped on a threshold hint are the same attribution defect at different +layers. + +Bundle 13 consumes the request-scoped route decision that lane B is building for #5087. Starting it +before lane B lands would fork that authority, so it is scheduled after. + +## Lanes + +| Lane | Bundles | Existing items to reuse | +| --- | --- | --- | +| C | 7 retry stage table, 14 one event model | #4942, #4989, #5245, #2366, #3748, #3983, #5063; issues #4191, #5180 | +| D | 8 account and credential generation, 9 cache affinity and diagnostics | #5214, #5145, #5229, #5209, #4793; issues #3375, #5178, #3433, #3765 | +| E | 10 Devin output budget, 11 adapter queue memory, 12 per-key permission | #5189, #5182; issues #5190, #5049 | +| F (after B) | 13 per-provider egress, 15 CodeBuddy and native wire | #3901, #5148, #5147, #5188; issues #2894, #5146, #5097, #5096 | +| G (after B) | 16 onboarding, update and screen consolidation | #5016, #4560, #5068; issues #2811, #5215, #5216 | + +## Ownership boundaries between concurrent lanes + +These exist because the lanes share a checkout-independent surface and would otherwise collide. + +- Lane C owns send accounting: `sendCount`, request-wide send budget and the stage and cause + vocabulary. Lanes D and E consume it and do not redefine it. +- Lane D owns #4793 and every per-model cache view. Lane C derives cache projections from the + recorder without editing that surface. +- Lane E owns the adapter event queue budget and the Devin and coding-agent limits. It does not + touch retry classification. +- A collision that cannot be resolved inside these boundaries goes to the coordinator rather than + being settled unilaterally in one branch. + +## Acceptance that is easy to fake and must not be + +Each bundle has a completion condition that a passing request does not demonstrate. + +- 7: an uncertain resend after output or a side effect is never automatically permitted; 429, quota, + policy refusal and ciphertext refusal stay distinguishable; the provider's stated reason and the + actual send count agree, with no duplicated parent and child counter. +- 8: a refusal or capability observation from a replaced account does not transfer; a cancelled + request's late refresh does not overwrite another request's binding; false, unknown and absent + stay distinct. +- 9: passing a threshold alone does not drop a warm binding, while real exhaustion does; input + change, account change and transformation change are distinguishable; a prefix fingerprint never + becomes a public or durable correlation key. +- 10: with the caller omitting a limit, the configured effective output cap reaches the wire, and an + explicit small cap survives; the history ceiling keeps its own meaning. +- 11: slow consumers, one large event, accumulated coalescing and a cancel race all stay bounded + with no unreleased counter, and a normal long stream is not capped by total length. +- 12: an alias, combo child, fallback or compact route cannot reach a forbidden model or provider; + filtering `/models` is not completion; an inference key never gains management authority. + +## Release shape + +The first stabilization release carries items 1-6 plus only the small, reproduced fixes from 8, 10 +and 11. Items 7, 9, 12, 13 and 14 form the second group. Items 15 and 16 are the optional extension +and do not precede the fidelity work. A new large control plane, a full manager rewrite and a +multi-tenant conversion stay out of this stabilization and are not closed as unwanted. + +## Execution constraints + +Unchanged from phase 1: no local suites, individual tests, typecheck, build, install or live `ocx` +execution; verification is static source review plus exact-head hosted CI; pushes use +`--no-verify`; only the coordinator merges and closes issues. diff --git a/devlog/_plan/260920_meaning_preservation_batch/020_incident_5261.md b/devlog/_plan/260920_meaning_preservation_batch/020_incident_5261.md new file mode 100644 index 00000000000..400c8c79a4f --- /dev/null +++ b/devlog/_plan/260920_meaning_preservation_batch/020_incident_5261.md @@ -0,0 +1,48 @@ +# Incident lane — Codex sign-in lockout after an applied integration (#5261) + +Status: OPEN, dispatched out of band and ahead of the phase 2 bundles. This is a user-reported +incident, not a roadmap item. + +## What was reported + +A Windows 11 user on 2.59.0 configured the Codex integration, repeatedly failed to add an account +pool, then could not call any model from Codex. After restarting, Codex would no longer sign in at +all: the client showed only "Unable to load sign-in requirements" and a Retry button. The reporter's +own analysis is that every Codex request was being routed to the local proxy on port 10100 and hung +there. + +## Why this outranks a model-routing bug + +The damaging part is not the failed inference. It is that the user is locked out of Codex sign-in +while the integration stays applied. A reboot does not clear an applied integration, so the lockout +survives it, and the failure surface offers no recovery the user can act on — the only visible +control is Retry against the endpoint that is failing. A user in that state cannot reach the +product that would let them undo the change. + +## What the lane must establish from source, not assume + +1. What the Codex integration apply path actually writes, and whether its scope covers only + inference or also the authentication and sign-in bootstrap. +2. What happens to those requests when the proxy is absent or hung: a bounded timeout, a fail-open + path, or an indefinite wait. +3. Whether Windows can reach a state where the integration stays applied while the service is not + running, including after a reboot. +4. Whether a recovery path exists that does not require the proxy to be running, and whether a user + in the failure state can discover it. +5. The account-pool add failure that triggered the sequence, recorded precisely rather than assumed + to share a cause. +6. Whether anything already on `dev` since 2.59.0 changes this path. + +## Fix standard + +Having the integration applied must not be able to lock a user out of Codex sign-in. Either the +authentication and sign-in bootstrap stays off the proxy path, or an unavailable proxy produces a +detectable failure and a discoverable recovery. Whichever holds, it is fixed by a regression that +simulates the dead-proxy state. + +## Execution constraint specific to this lane + +The incident is a configuration change that locked a user out. The lane therefore may not run the +proxy, start or restart the service, or modify any credential or configuration file on the working +machine; doing so would reproduce the damage locally. Verification is static source review plus +exact-head hosted CI. diff --git a/devlog/_plan/260920_meaning_preservation_batch/020_lane_c.md b/devlog/_plan/260920_meaning_preservation_batch/020_lane_c.md new file mode 100644 index 00000000000..a6c9b2edaa5 --- /dev/null +++ b/devlog/_plan/260920_meaning_preservation_batch/020_lane_c.md @@ -0,0 +1,121 @@ +# Lane C — retry stage table (7) and one event model (14) + +Status: OPEN. Branch `codex/260920-lane-c-retry-event-model`, cut from `dev` at +`b9483b3b510f9a8d99d465282517f7bf91678de3`. One branch, ordered commits, one pull request to +`dev`, per [010_phase2.md](010_phase2.md). + +## What this lane fixes first + +Bundles 7 and 14 want the same substrate, so the branch defines it before touching anything that +consumes it. `src/lib/request-failure-model.ts` is now the single statement of three things: + +- **Stage** — how far a failed exchange got, ordered by what the DOWNSTREAM CLIENT observed: + `pre-header`, `headers-only`, `protocol-prelude`, `semantic-output`, `side-effect`, `terminal`. + Ordering by client observation rather than by upstream progress is deliberate: the question the + table answers is whether a resend can duplicate something the caller already saw. +- **Cause** — one closed dictionary, with `rate-limit`, `quota-exhausted`, `policy-refusal` and + `ciphertext-refusal` as four separate members because their remedies are four different actions. + `parameter-rejected` is separate from `policy-refusal` for the same reason: the same content + succeeds once the parameter changes, and `payload-too-large` is separate from `payload-rejected` + because a smaller rebuild succeeds where no repair helps the other. +- **Resend permission** — derived from three small per-member facts, not written out as a + stage-by-cause matrix. A 6×14 matrix is a restatement that has to be re-derived by hand whenever + a member is added, and the cell nobody revisited is how two individually correct branches merge + into a wrong table. That is the class that blocked 2.60.0. + +A stage is how far the observable progression got, not which events happened to arrive. A turn that +settled carrying no output — an empty completion, a 4xx error body — did not reach `terminal`; it +stalled at `protocol-prelude`, because the caller saw no answer. `terminal` means the answer was +delivered, which is why it is both last and refused. Commitment is a named per-stage fact rather +than a rank comparison, so a stage added later cannot default into permission. + +`refused-ambiguous` forbids an AUTOMATIC resend. It does not forbid a narrowly scoped, explicitly +opted-in recovery that a maintainer reasoned about and bounded. That distinction is what separates +a sanctioned single-shot rebuild from a retry loop that fires because a counter had room, and it is +why this table can be honest about the recoveries the proxy already performs. + +Funding follows the disposition rather than the permission, for the same reason. The opt-in reset +replay, the bounded empty-completion rebuild and the transient 5xx ladder are all refused +automatically and all really send, so all three still name the allowance they draw on. Keying +funding on permission would leave exactly those paths unfunded, which is how a per-layer counter +comes back. + +Two classifications were corrected during review after being checked against what the code actually +does rather than against what the recovery kind is called. `transient-5xx` covers a status set that +mixes a 503 the origin declined with a 500 it may already have run, so it classifies as +`upstream-fault` and the table never claims the resend was provably safe. `console-go-upload-retry` +replays a byte-identical body that the gateway accepts seconds later, so nothing about the payload +was wrong and it classifies as `upstream-declined`. + +## No second store + +The durable shapes stay `PersistedUsageAttempt`, `PersistedRequestSpend` and +`PersistedUsageEntry` in `src/usage/log.ts`, joined by `addFinalRequestLog()`. That join is already +the one place a logical request id, its attempts, their physical `sendCount` and the terminal +outcome meet, so this lane derives from it rather than growing a parallel history. The new module +declares no record type and holds no state; both of its imports are types and are erased at +runtime, so it stays a leaf. + +## Restatements removed + +Two live instances of the union-defect class, both found while fixing the substrate: + +- `AttemptRecoveryKind` was written twice — as a union and as the read-back whitelist + `normalizedAttempt` filters against. A member added only to the union compiles, is written to + disk, and is dropped on the next read, so the row loses the field that says why it recovered. + Both vocabularies are now frozen rosters with the types derived from them. +- `recoveryClass()` in `src/server/request-metrics.ts` ended in `default: return "other"`, so a + recovery kind added later compiled cleanly and vanished into an unactionable bucket. It is now + total over the shared cause dictionary; a missing member is a typecheck failure. + +## Ownership + +This lane owns `sendCount`, the request-wide send budget, and the stage and cause vocabulary. +Lanes D and E consume them and do not redefine them. #4793 and every per-model cache view belong to +lane D; this branch edits neither and derives nothing from them. + +## Dispositions + +### Carried + +| Item | Disposition | +| --- | --- | +| #5245 (cmdy) | **Carried, narrowed.** Only an embedded `invalid_request_error` / `invalid_encrypted_content` is admitted through the gateway wrapper. The original reruns the whole opaque classifier on the embedded payload, which would also admit the code-less unverifiable-ciphertext wording, the #4469 caller mismatch and the two xAI decoder strings — identities accepted on evidence about how one specific upstream words its own rejection, which a gateway in between is not. A gateway envelope is now decided ONLY by its embedded payload: the pre-existing anchored-wording checks run on the whole message, and a gateway quotes the upstream's message inside its own, so a relayed caller mismatch would otherwise have satisfied the #4469 identity and gained a resend the strict check exists to withhold. Attribution is in the branch commit. | +| #4191 | **Addressed in part.** The WebSocket failure classifier now has a tested projection onto the shared stage and cause, so its four outcomes are stated in the same words as every other surface and the shared table independently reaches the transport's own no-replay-after-send verdict. The projection is not yet threaded into the durable record, and the SSE fallback the issue also asks for is a transport change; neither is in this branch. | +| #5180 | **Addressed in part.** `rate-limit` and `quota-exhausted` are separate causes with different resend decisions and different metric label values. The shared cooldown and `Retry-After` handling the issue also asks for are routing behaviour and are not in this branch. | + +### Deferred, with reasons + +| Item | Disposition | +| --- | --- | +| #4942 (FredAmartey) | **Deferred to a follow-up on this substrate.** The pre-header ambiguous-reset stage and its default refusal are now expressed in the shared table, which is what the PR's `replaySafe`/`replayResets` pair was duplicating. The PR itself is a 28-file transport change touching provider config, key failover and passthrough dispatch, and `dev` has moved under it around `request-execution-budget.ts` and `physical-send.ts`. Landing that reworked and unrun in a branch whose verification is static review would be a worse trade than deferring it. | +| #4989 | **Deferred to the same follow-up.** Its protocol-prelude state gate (`responseCreated && !outputCommitted && !terminal`) is exactly the `protocol-prelude` row of the shared table and is the correct model. It overlaps #4942 in `src/lib/upstream-retry.ts` and `passthrough-dispatch.ts`, and the two must not each buy an independent replacement send for one logical request, so they belong in one reworked change rather than two. | +| #2366 (chilung-cgu) | **Deferred.** Its `StreamTimeline`, `FailureSide` and seven-stage `FailureStage` are good source material and store nothing in parallel, but they are a second stage vocabulary. Reconciling them with the one landed here is a rewrite of the PR, not a carry, and it is better done once the substrate is on `dev`. | +| #3748 (yansigit) | **Deferred as implemented.** It adds an authoritative SQLite failure ledger beside the usage ledger, which is the parallel store this lane exists to avoid. The derived equivalent is to group recorder terminals by a versioned fingerprint of closed cause plus provider and model class. Its API also accepts a free-text `signature`, and regex redaction cannot prove content was removed. | +| #3983 (yansigit) | **Deferred.** Content-free and durable-store-free, but it emits through a second path independent of request recording. The derived form routes the same structural observations through the recorder and formats the debug ring from them. | +| #5063 (Vocllum) | **Deferred.** Sound retention work on the canonical ledger, and orthogonal to the stage and event model. It also changes GUI surface, which this branch cannot evidence. | +| GUI recovery-kind roster | **Deferred, and it is a real defect.** `gui/src/pages/Logs.tsx` declares its own `AttemptRecoveryKind` with nine of the durable thirteen members, so `key-401`, `oauth-account-429`, `opaque-blob-rejection` and `reasoning-effort-downgrade` have no localized label. Fixing it needs new strings across ten locale catalogs and a screenshot of the changed dialog, which a branch that may not build or run the GUI cannot produce. It should be one follow-up that derives the GUI union from the durable roster instead of restating it. | + +## Verification + +Static source review plus exact-head hosted CI, per the batch execution constraints. + +Checked statically on this branch: + +- every assertion in the two new test files was re-derived by hand from the declared tables, and + the only recovery kinds whose Prometheus class changes are `opaque-blob-rejection` + (`payload` to `ciphertext`) and `console-go-upload-retry` (`payload` to `transient`), both + corrections rather than side effects; +- every `satisfies Record` added here is total over its roster, and every value it + produces is a declared member of the target vocabulary; +- `scripts/test-layout/layout.json` and `tests/fixtures/test-layout-expected.json` agree + key-for-key, and both new test files sit in the domain they are registered to; +- no file this branch touches has a `tests/fixtures/file-size-baseline.json` cap, and the new test + cases went into a sibling file rather than into `responses-opaque-blob-recovery.test.ts`, which + sits 148 lines under the new-file threshold; +- `src/server/index.ts` is untouched; it has one line of headroom against its cap. + +NOT RUN on this branch, by instruction: `bun run test`, any individual `bun test` file, +`bun run typecheck`, `bun run build:gui`, `bun install`, `bun run structure:check`, +`bun run privacy:scan`, and any live `ocx` execution. None of these may be recorded as passing. +Hosted CI at the exact head is the only execution evidence for this branch. diff --git a/devlog/_plan/260920_meaning_preservation_batch/020_lane_g.md b/devlog/_plan/260920_meaning_preservation_batch/020_lane_g.md new file mode 100644 index 00000000000..1d38c8531ef --- /dev/null +++ b/devlog/_plan/260920_meaning_preservation_batch/020_lane_g.md @@ -0,0 +1,120 @@ +# Lane G — onboarding, update and screen improvements as one flow + +Status: OPEN. Branch `codex/260920-lane-g-onboarding-update`, cut from `origin/dev` +`043aa435ff`. One branch, ordered commits, one pull request against `dev`. + +Roadmap item 16 asks for connect → confirm → change → check state → recover as a single user +flow, built on the server-owned preview that landed in #5185 and #5197. Six targets were named: +#5016, #4560, #5068, #2811, #5215 and #5216. + +## The finding that shapes the flow + +The dashboard is served by the proxy. `startServer` binds the listener and that same listener +serves `gui/dist`, so when the proxy stops there is no surface left to render a recovery panel. +The state #5261 was reported in — injected routing pointing Codex's own built-in provider at a +dead loopback port — is therefore a state the dashboard cannot be part of getting out of. + +That is not a reason to leave the failure state undesigned. It moves where the design has to +land: + +- **Recovery belongs to the surfaces that survive the proxy.** #5267 already placed it there: + `ocx status` names `ocx restore` when the proxy is down and the routing is ours, the routing + marker in `config.toml` now reads `(undo: ocx restore)`, and the troubleshooting page covers + the manual edit for someone without the CLI. +- **The dashboard's job is disclosure before the fact.** It is the only surface present at the + moment the integration is applied, and it is the one that will be gone if the proxy later + stops. Naming the offline undo path at apply time is what turns a lockout into an + inconvenience. + +## What each target needed, and what this lane did + +### #5216 — compaction panel (delivered) + +Two strings described behaviour the code does not have. The panel decided combo-ness by testing +a `combo/` prefix, so a combo reached through an alias was described as an ordinary provider and +none of its targets were named. It now asks what the selection resolves to, keyed by the public +model id the server already computes, read through `parseComboList` — the same reader the combo +workspace uses, so the selector rule is not written down twice. + +The warning claimed a covered compaction goes to every target including failover targets. +`core-combo.ts` dispatches one target per loop iteration, returns as soon as one responds, and +advances only after a retryable failure. An operator reading the old text would budget fan-out +cost and latency for something that never happens. + +### #5215 — hand-copied registry values (delivered) + +Thirteen presets restate a byte ceiling and a row ceiling in eight guides, checked by nobody. +Each value is now read from that preset's `modelDiscovery`. + +Two details worth keeping: sections are located by brand name plus the presence of a `KiB`/`MiB` +token rather than by a translated phrase, because a restated anchor is the same hand-copied value +the guard exists to remove; and the byte ceiling is compared as an exact token set, so a stale +number left beside the current one fails instead of passing on a substring. + +The guard immediately earned its keep. `structure/ops/docs-and-release.md` asserted in prose that +the guides carry the same limits. That was false when it was written: the Korean guide had no +Featherless section, so it documented twelve of the thirteen limited presets. The section is +added and the prose is replaced by a description of what is actually asserted. + +### #4560 and #5068 — the two workspace pull requests (analysed, not merged) + +The instruction was to review the actual difference and consolidate only duplicated screens. They +are not the same feature and must not be treated as one. + +- 46 files and 39 files, intersecting in 31. Only **nine** of those 31 are byte-identical. +- **#4560** is the UI foundation: responsive grid, dual collapsible rails, unified filter, + Cockpit Tools import, the quota-analysis regression suite and its layout registrations. +- **#5068** is the pool follow-up: generic pool enablement and strategy persistence, strategy + preview, per-account quota refresh, remaining-token estimates calibrated from request logs, + plan badges, switch notifications, modal focus trapping. + +Three findings decide the sequencing, and none of them is "they overlap": + +1. **#5068 removes behaviour #4560 keeps.** Its `ProviderAccountCard.tsx` drops the Grok coupon + badge and the `ProviderAccountQuota` fallback. Landing #5068 after #4560 would silently + revert them. +2. **#5068 changes an email-masking decision.** `account-quota-analysis.ts` adds `rawEmail` and + prefers an unmasked value, where #4560 deliberately uses the management API's projected + email. That is a privacy boundary, not a display preference, and it needs explicit review + against the `emailMaskingEnabled` policy before either version lands. +3. **#5068 cannot land as it stands.** It folds the collapsed-sidebar CSS into + `gui/src/styles.css`, which carries a committed cap of 2,958 lines; its head is 3,186. The + ratchet only moves downward, so the remedy is the move #4560 already makes — a sibling + `sidebar-collapsed.css` — not a new number. + +Both are 28 commits behind `dev` and conflict on all ten locale catalogs through #5197, and +#4560 additionally conflicts on the two test-layout registries. Neither is a rebase this lane +could carry without absorbing the privacy decision above, so the differential is recorded here +for the coordinator to sequence rather than half-landed. + +### #5016 and #2811 — the Codex CLI update manager (not started here) + +#5016 is phase 2 of #2811 and is an open contributor pull request carrying its own plan/apply +engine. Phase 3 is the dashboard integration. Both are left to their own lane: carrying an +unlanded engine and building its surface in the same branch would put the authorization boundary +#5016 is built around under review twice. + +## Remaining scope, stated rather than closed + +The one src/ defect this lane identified and did not fix: `codexStatus` in +`src/server/management/native-integration-routes.ts` derives `state` from +`config.clientIntegrations?.codex` alone. It reports desired configuration, not what is +currently applied — it does not read `config.toml`, the routing kind, or the catalog pointer, +all of which `src/codex/injected-marker.ts` already exposes predicates for. So the dashboard +cannot answer "what is applied right now", which is half of the completion condition, and it +names no undo path at apply time, which is the disclosure the #5261 state needs. + +The bounded shape of that fix: report the applied routing state from the file rather than from +intent, carry the undo command beside it, and render both on the Codex tab. It is a change to a +DTO that every native client shares plus ten locale catalogs, so it is its own commit set rather +than an addendum to a documentation lane. + +## Verification + +Static source review plus exact-head hosted CI. Local suites, individual tests, typecheck, +build, install and live `ocx` execution were NOT RUN. + +Both delivered changes were simulated statically against the real files before commit rather +than assumed: the discovery-limit guard was run as a Python transcription of its own logic over +all eight guides, which is how the Korean gap surfaced and how the byte and row values were +confirmed to already agree with the registry everywhere else. diff --git a/devlog/_plan/260920_meaning_preservation_batch/020_lane_h_codex_signin_lockout.md b/devlog/_plan/260920_meaning_preservation_batch/020_lane_h_codex_signin_lockout.md new file mode 100644 index 00000000000..fb06afae186 --- /dev/null +++ b/devlog/_plan/260920_meaning_preservation_batch/020_lane_h_codex_signin_lockout.md @@ -0,0 +1,89 @@ +# Lane H — Codex sign-in lockout behind a stopped proxy (#5261) + +Status: OPEN. Base is `origin/dev` at `b9483b3b51`. One branch, ordered commits, one pull +request to `dev`, matching the topology in [010_phase2.md](010_phase2.md). + +This lane is not a consolidation bundle. It is incident response to a user report, and it +was scheduled ahead of the phase 2 lanes because the reported failure ends with the user +unable to sign in to Codex at all. + +## What was reported + +A Windows 11 user on 2.59.0 configured opencodex, repeatedly failed to add accounts to the +pool, then found Codex could no longer call models. After a restart Codex would not sign in, +showing only a retry. Three screenshots on the issue show the cause on their machine: the +root override in `~/.codex/config.toml` pointing at `http://127.0.0.1:10100/v1` with the +proxy process gone, and a `model_catalog_json` naming a catalog file that no longer existed. + +## What the source says + +Four independent reads of `dev` agreed on the following. No proxy was started and no config +was touched to establish any of it. + +1. The default loopback injection does not add a provider. It sets the codex-rs root key + `openai_base_url`, which redirects Codex's own built-in `openai` provider + (`src/codex/inject.ts:102`), plus `experimental_realtime_ws_base_url` and + `model_catalog_json`. It is written with an atomic replace into `$CODEX_HOME/config.toml` + (`src/codex/inject.ts:591`), so it survives a reboot. +2. No auth, token or sign-in endpoint is separately redirected at the proxy. The redirect is + the built-in provider's base URL, and Codex has no second endpoint to fall back to. +3. There is no liveness precondition on the write (`src/codex/inject.ts:190`) and no + fail-open path back to the real upstream anywhere in the runtime. +4. Applying the integration does not install a service; that is a separate + `ocx service install` (`src/cli/init.ts:226`, `src/cli/init.ts:255`). The Windows + scheduled task carries a logon trigger and no boot trigger + (`src/service/windows-taskxml.ts:225`). Injection present with nothing listening is + therefore an ordinary post-reboot state, not a corruption. +5. The shim runs `ocx ensure` with output discarded and `|| true`, then launches the real + Codex regardless (`src/codex/shim-templates.ts:134`), so a failed auto-start is silent. + It is also CLI-only (`src/codex/autostart-health.ts`), so it never covered the reporter, + who was in the Codex app. +6. Recovery already existed and already worked offline: `ocx restore` needs no proxy, no + management API and no network (`src/cli/dispatch.ts:198`). It was simply not discoverable. + `ocx status` on a dead proxy offered only ways to restart it (`src/cli/index.ts:1586`), + the injected config named no command, and no troubleshooting page covered the state. + +## The account-pool failures are a second cause + +They began the session but are not the lockout. The pool is served by the management API, so +both `ocx account login openai` and the dashboard roster need a live proxy +(`src/cli/runtime-api.ts:68`). The browser flow additionally needs the fixed callback port +1455, which cannot move (`src/oauth/callback-server.ts:137`), and the Windows browser launch +swallows its own failure (`src/lib/open-url.ts:20`). The dashboard keeps the last good rows +after a failed refresh (`gui/src/hooks/useCodexAccountPool.ts:325`), which is why a new +account can be absent while older ones still show. Documented, not changed, in this lane. + +## What this lane changes + +The direction taken is the second of the two the incident allows. Excluding sign-in from the +proxy path is not expressible: `openai_base_url` is one key for one built-in provider, and +when the proxy is down no scoping helps. So the failure is made detectable and the recovery +discoverable. + +1. Routing markers name their own undo: `# Auto-injected by opencodex (undo: ocx restore)`. + Ownership is matched as a substring everywhere, so older markers keep working, and an + in-place rewrite refreshes the line so existing installs gain it on the next start. +2. `ocx status` on a dead proxy over routing we own now says sign-in fails too, and names + the command that does not need the proxy back. +3. A troubleshooting page for the state, including the manual edit and the warning against + dropping the catalog pointer alone. +4. Regression tests that reconstruct the reported config and run recovery with nothing + listening. + +## What it does not close + +- The shim still discards `ocx ensure` failures, and remains CLI-only, so the Codex app is + still not covered by auto-start at all. +- Nothing revalidates `model_catalog_json` after injection. The inject-time chooser refuses a + missing owned catalog (`src/codex/inject/config-toml.ts:597`), but a file removed later + leaves a pointer that makes Codex fail to load its config. +- Windows autostart has no boot trigger, so the post-reboot gap is unchanged. + +Each is a separate change with its own risk, and none of them is what locks the user out on +its own. The issue stays open for them. + +## Verification + +Static source review and exact-head hosted CI only. No local suite, typecheck, build, install, +service action or `ocx` invocation was used to establish any claim above, because the incident +itself is a configuration change that locked a user out. diff --git a/devlog/_plan/260920_meaning_preservation_batch/030_lane_b_transport_write_safety.md b/devlog/_plan/260920_meaning_preservation_batch/030_lane_b_transport_write_safety.md new file mode 100644 index 00000000000..c992b81e129 --- /dev/null +++ b/devlog/_plan/260920_meaning_preservation_batch/030_lane_b_transport_write_safety.md @@ -0,0 +1,66 @@ +# Lane B — request-scoped transport and managed-write safety + +Status: PR #5264 open against `dev` at exact head `235525b52a`, hosted CI +in flight. One branch, two ordered commits plus this progress record, per the +batch topology. + +## Scope + +- #5087 — DNS pinning and transport selection now follow whether a proxy + actually applies to the request, not whether one is configured. +- #5241 — a managed configuration write never follows a terminal symlink to + another file, including apply/refresh/disable/restore and their races. + +Both items carry the existing contributor pull requests by luvs01 with a +`Co-authored-by` trailer on each branch commit. The original pull requests +stay open for the coordinator. + +## Branch + +`codex/260920-lane-b-transport-write-safety`. Commits in order: + +1. `fix(transport): decide DNS pinning by whether the proxy applies to the request` +2. `fix(integrations): reject symlinked managed write targets` + +## Review findings on current dev (beyond the carried diffs) + +- #5087's carried model counted a non-SOCKS `ALL_PROXY` for `http:` targets on + POSIX only. The repository's own provider-outbound e2e drives that exact + request through the proxy and runs green on the Windows shard too, so the + platform gate was dropped: `ALL_PROXY` counts for `http:` on every CI + platform. A present-but-unusable scheme-matched variable now fails closed + instead of falling through to `ALL_PROXY`. +- The Mihomo IPv6 fake-IP gate keeps its stricter documented condition + (scheme-matched variable or SOCKS5 `ALL_PROXY`, non-SOCKS `ALL_PROXY` + never counts) via a dedicated `schemeMatchedProxyFor`, so the documented + and tested #3462 behaviour is byte-identical. Moving it to the new snapshot + would have contradicted the provider docs in every shipped locale. +- The carried #5087 diff had no regression for the DNS-failure degradation + branch. Added both directions: a mismatched proxy surfaces the DNS error + instead of degrading to an unpinned fetch; a scheme-matched proxy keeps the + degradation. +- #5241's carried diff re-exported the new primitive from `src/config.ts`, + which sits exactly at its file-size ratchet cap. The re-export was dropped; + the only consumer imports the leaf directly. +- #5241's carried tests covered apply (at rest and swap-during-write) and the + Cline pair boundary. Added disable and restore refusing a symlinked target + with the linked file byte-identical. Refresh shares the apply observation + and write path, so it inherits the same refusals. + +## Decision-function reach (#5087) + +Every `providerOutboundGet/Post` caller — provider discovery, the +model-catalog gather modules, quota probes, ollama show, and the management +model-refresh routes — funnels through the single changed decision. The main +inference dispatch (`providerFetch`) and OAuth token exchange +(`src/oauth/*`, bare global fetch) do not use the DNS-pinned transport today +and are unchanged. + +## Verification + +- Local suites, focused tests, typecheck, builds and live runs: NOT RUN (lane + rule). Verification is static source review plus exact-head hosted CI. +- Union-defect sweep: no capped file touched (`src/config.ts` left + byte-identical), no new test files (layout inventories unchanged), nothing + exhaustive over a union restated (locale catalogs, rosters and generated + counts untouched). diff --git a/devlog/_plan/260920_meaning_preservation_batch/030_lane_c2.md b/devlog/_plan/260920_meaning_preservation_batch/030_lane_c2.md new file mode 100644 index 00000000000..c07447cf53f --- /dev/null +++ b/devlog/_plan/260920_meaning_preservation_batch/030_lane_c2.md @@ -0,0 +1,220 @@ +# Lane C2 — telemetry projections on the landed vocabulary + +Status: OPEN. Branch `codex/260920-lane-c2-telemetry-projections`, cut from `dev` at +`043aa435ff8f86095f55cbe08f74d45b9858da59`, which is where lane C's stage, cause and resend +vocabulary landed ([020_lane_c.md](020_lane_c.md)). One branch, ordered commits, one pull request +to `dev`. + +## What this lane fixes + +The completion condition for bundle 14 is that the UI, the durable log and Prometheus agree on +logical and physical counts and on terminal classification. They did not, and the disagreement was +not subtle: + +- Three surfaces classified a terminal three different ways. The durable row carried + `terminalStatus` and `closeReason`; the exporter kept its own private `classifyResult`; the + dashboard read the numeric HTTP status and nothing else. A turn cut short by + `max_output_tokens` is durably `status: 200, terminalStatus: "incomplete"`, which the exporter + reported as `incomplete` and the dashboard rendered as a green 200. The metric said incident and + the operator saw success, for the same request. +- The dashboard showed no physical send count at all. `sendCount` and `spend` were never rendered, + so an attempt that sent three times appeared as one row with nothing to say otherwise. +- The dashboard's recovery-kind union had drifted to nine of the durable thirteen, so `key-401`, + `oauth-account-429`, `opaque-blob-rejection` and `reasoning-effort-downgrade` all reached the + operator as "Unknown recovery reason" — four real causes rendered as the absence of one. + +The fix is one classifier in `src/usage/request-outcome.ts` that the exporter imports, the +management payload already carries, and the dashboard calls. Agreement is structural rather than a +rule someone maintains. The data was never missing: `requestLogDto` spreads the whole durable +entry, so the page only had to declare the fields and stop reinventing the precedence. + +The exporter's result label set **is** the shared vocabulary rather than a copy of it. Restating +those four strings is what let the two drift while both looked correct. + +### The dashboard may only reach a contract leaf + +Adversarial review caught this before the first push and it is worth recording, because the +mistake is invisible from the backend side. The dashboard is a separate TypeScript project with +`erasableSyntaxOnly`, and a **type-only** import still pulls the imported file's entire import +graph into that project. Importing the recovery roster from `src/usage/log.ts` therefore dragged +`node:fs`, `node:crypto` and the config barrel into the browser build, where a parameter property +in `src/config/atomic-write.ts` does not compile. "It is only a type import" is not a defence. + +So the names a browser legitimately needs now live in `src/usage/telemetry-contract.ts`, which has +no imports at all and must keep none. `src/usage/log.ts` re-exports them so every existing importer +keeps its path, and `src/lib/request-failure-model.ts` stopped depending on the ledger module as a +side effect. Three cases hold the boundary: the page must not name `src/usage/log`, the contract +must have no imports, and the outcome module must reach nothing but the contract. + +The same review caught the send total disagreeing in the other direction. An earlier draft reported +`max(sends, reserved)`, on the reasoning that a budget charge with no attempt row behind it is +still a send that left. That is true, and it still made the dashboard say four where the exporter, +summing the same attempts the recorder summed, said three. Two defensible formulas are two answers; +the surfaces now read the recorded totals and recompute nothing, and a case asserts the exporter's +`opencodex_physical_sends_total` equals what the dashboard shows. + +## Dispositions, updated + +### Who is credited, and why only one + +Attribution follows what was actually taken, not what was read. Only #2366's work is carried here, +so only its author carries a `Co-authored-by` trailer, and that trailer sits in a branch commit so +it survives the squash. The other three were analysed in depth and their designs informed the +deferral reasons below, but no line of their work is in this branch; crediting them would claim a +landing that did not happen and would make the contributor graph say something false. + +A note on the gate, because getting this right took two attempts. `pr-carry-attribution.cjs` looks +for a carry verb and reads the pull request numbers in the eighty characters after it. A first +draft of the description read "#2366's rehydration half is carried and ... ; #3748 is blocked", +which put #3748 inside that window and asked for a trailer naming an author whose work is +deliberately absent. Rewording split the sentences — but it also moved #2366 out of every window, +so the check went green by having nothing left to check. A gate that passes because the trigger was +removed is not evidence. The provenance sentence now names #2366 after the verb, in the commit +itself, so the check resolves the author and matches the trailer instead of skipping. + +### #2366 (chilung-cgu) — partially carried + +**Carried:** the rehydration and UI-projection half. The durable terminal facts now reach an +operator instead of stopping at the API boundary. + +**Not carried, and why:** `FailureSide` and the seven-member `FailureStage` are a second +attribution vocabulary beside the one that just landed, and defining two is the exact class that +blocked 2.60.0. The PR also widens `transportPhase` and `terminalSource` from their existing closed +unions to arbitrary strings, which would let bounded upstream-controlled text into the durable row; +those validators stay. Copying the request-relative timeline into an attempt at finalization is +simply wrong — request-relative elapsed values do not become attempt-relative by being copied. + +**Next step, specified:** persist `failureStage?: RequestFailureStage` and +`failureCause?: RequestFailureCause` on `PersistedUsageAttempt`, projected onto the entry, with +`resendPermission` computed at read time and never persisted. That is the smallest durable record +that makes a failure attributable, and it is the prerequisite for #3748 below. It is not in this +branch because it is new classification logic on the finalization path, and a branch whose only +verification is static review plus hosted CI should not add a new derivation and the surface that +consumes it in the same change. + +### #3748 (yansigit) — still deferred, reason updated + +The earlier reason was the parallel SQLite store. That still holds, but the blocking reason today is +narrower and more useful: **the recorder does not yet record why a request finally failed.** +`causeForRecoveryKind` answers why a *recovery* was attempted, which is a different question — a +request that failed without any recovery, or that recovered and then failed for another reason, +has no cause to group by. A derived failure ledger therefore cannot compute a grouping key today +without reading `errorCode` or `upstreamError`, which are open strings. + +The design is otherwise settled and should be built once the field above exists: group failed rows +scanned through the existing `scanUsageLedgerCooperatively` by a versioned fingerprint over closed +vocabularies only — cause, status class, inbound protocol, terminal status, close reason, transport +phase, terminal source — with fixed tuple positions so a missing field cannot collide structurally. +No provider, no model, no account label, no free-text signature. First-seen, last-seen and count +fall out of the scan; no second timestamp list is retained. + +Two parts of the original are not derivable from request history at all: the mutable +`monitoring/dispatched/fixed/ignored` remediation status and its free-text notes. Those are +operator state, not event history, and need their own owner rather than being presented as a +derived ledger. + +### #3983 (yansigit) — still deferred as an emission path, reason updated + +The earlier reason was "a second emission path". The updated reason is stronger: the path is not +ephemeral. `emitDebugLine` writes the in-process ring **and** stderr, and stderr is redirected to +the service log under both launchd and systemd, so an installed service gets a durable per-event +record with its own retention, sequencing, request identity and masking — beside the ledger and +sourced from something other than it. + +Two further facts: four of its eighteen files no longer apply, including +`run-turn-execution.ts` where carrying it literally would regress the current send-budget +accounting; and its per-content HMAC is a process-global random key, so equality of every prompt, +tool name and error message is correlatable for the process lifetime. + +**The useful half, specified:** a bounded normalized summary on the attempt — adapter events, +actually relayed events, semantic bytes, side-effect events, terminal events — counted where the +event is delivered rather than where it is read. That keeps the signals worth having (missing +terminal, adapter-to-relay loss, empty output, partial output size) and inherits the ledger's +normalization, masking and retention instead of inventing its own. + +### #5063 (Vocllum) — still deferred, reason updated + +The earlier reason was "a separate product slice with GUI surface". The updated reason is a +correctness one found while reviewing it against current `dev`: + +- Retention captures the file size, copies a retained suffix to a temp file and renames. A row + appended by **another process** between the size snapshot and the rename is silently dropped. + The PR's own "concurrent re-entrancy" test performs two sequential calls and says it cannot test + true concurrency. +- Both the temp-file fsync and the parent-directory fsync failures are swallowed, and replacement + proceeds anyway. There is no revision, inode or digest comparison immediately before the rename. +- It hand-rolls the copy rather than using `src/config/atomic-write.ts`. +- It does not clear the 2,000-entry request-log ring, so rows deleted from disk stay visible + through `/api/logs` until eviction or restart. +- `src/server/index.ts` is 892 lines against a 893-line cap, so the startup wiring cannot land as + written without offsetting a line elsewhere. + +Retention on the canonical ledger is the right architecture and its derived-index invalidation is +correct in shape. It needs a ledger-owner serialization and revision contract first. + +### The dashboard roster defect — fixed here + +Recorded in [020_lane_c.md](020_lane_c.md) as deferred because it needed strings across ten +catalogs and a screenshot. It is fixed in this branch because the agreement condition required +touching this page anyway. The union is now derived from the durable roster and a `satisfies` +clause makes the next added kind a typecheck failure rather than a silent fallback. + +**This trips the `missing_ui_screenshot` gate.** That gate fires on changed paths under `gui/`, +not on words in the description, and this lane may not build or run the GUI, so it cannot produce +the screenshot itself. A maintainer comment or the `gui-screenshot-waived` label is the documented +resolution; the visible change is four attempt rows that said "Unknown recovery reason" now naming +their cause, plus an outcome row and a send count in the detail dialog. + +## Retention and masking, stated in one place + +The instruction was to unify rather than add a fifth policy. The honest form of that today is to +record what already exists and bind projections to it, because every bound below is already +enforced and none of them needs a new owner: + +| Surface | Bound | +| --- | --- | +| Usage ledger rows (scanner) | 1 MiB per row, 1 MiB read chunks, 64 KiB boundary digest | +| Startup request-log hydration | 64 MiB read ceiling | +| Management usage snapshot | 64 MiB, 500,000 rows | +| Usage metadata strings | sanitized, capped at 64 characters | +| Upstream error text | `redactSecretString`, capped at 500 characters | +| Conversation correlation | rejects over 4,096 characters, persists a 32-hex digest | +| Debug ring | 2,000 lines, 16 KiB per line, 64 subscribers | +| Request-log ring | 2,000 entries plus app-owned-memory eviction | +| Routing history | rebuildable projection; pages capped, records over 1 MiB omitted | +| Email masking | masked unless `privacy.maskEmails: false` | + +The policy this lane adopts: **masking happens at capture and projection boundaries, retention +deletes canonical rows, and every derived surface inherits both.** A projection does not get its +own TTL, its own row cap or its own redaction pass. The failure projection specified above obeys +this by construction — it holds only aggregates and a scanner checkpoint, and discards them when +the source is replaced. + +## Issues + +#4191 and #5180 stay open and are not closed here. What narrowed: the dashboard now reports the +terminal classification and the send count the durable row always carried, so an operator can tell +an incomplete turn from a successful one without reading the ledger. What remains unchanged: the +WebSocket-to-SSE fallback for #4191, and the shared cooldown and `Retry-After` handling for #5180. + +## Verification + +Static source review plus exact-head hosted CI. + +NOT RUN on this branch, by instruction: `bun run test`, any individual `bun test` file, +`bun run typecheck`, `bun run build:gui`, `bun run lint:gui`, `bun install`, +`bun run structure:check`, `bun run privacy:scan`, and any live `ocx` execution. None of these may +be recorded as passing. + +Checked statically on this branch: + +- all eleven new label keys are present in all ten catalogs, and the catalog edits are purely + additive (+11 lines, 0 removed, per file); +- the ten catalogs are explicitly exempt from the file-size ratchet, for the reason the exemption + list gives: they grow by one line per UI string across every locale at once; +- no ratchet-capped file is touched by this branch; +- `scripts/test-layout/layout.json` and `tests/fixtures/test-layout-expected.json` agree key for + key, and the new test's regex seed resolves to the same domain it is registered to, which is the + oracle that failed lane C on its first push; +- no test restates a source constant: the outcome vocabulary, the recovery roster and the label + keys are all read from the modules that declare them. diff --git a/devlog/_plan/260920_meaning_preservation_batch/030_lane_h2_lockout_dead_ends.md b/devlog/_plan/260920_meaning_preservation_batch/030_lane_h2_lockout_dead_ends.md new file mode 100644 index 00000000000..d1c6f7c1d94 --- /dev/null +++ b/devlog/_plan/260920_meaning_preservation_batch/030_lane_h2_lockout_dead_ends.md @@ -0,0 +1,90 @@ +# Lane H2 — the remaining Codex lockout dead ends (#5261) + +Status: OPEN. Base is `origin/dev` at `043aa435ff`, after lane H landed as `9880c3cad2`. +One branch, ordered commits, one pull request to `dev`. + +Lane H removed the dead end a locked-out user hits: injected routing now names its own undo, +a dead proxy is told to say so, and there is a troubleshooting page. This lane takes the four +paths INTO that state which lane H listed as not closed. + +## Why these four belong together + +None of them locks anyone out alone. Each one removes a signal, and the incident is what +happens when all of them are missing at once: a proxy that stopped, an autostart that failed +silently, a reboot that restarted nothing, a catalog pointer left naming a deleted file, and an +account flow that looked like it was working. Every individual step had an explanation; the +user had no way to reach any of them. + +## 1. The shim hid autostart failure, and could prevent Codex launching + +The wrapper ran `ocx ensure` with both streams discarded and `|| true`, then launched Codex +regardless (`src/codex/shim-templates.ts:139`). A failed start was invisible. + +Ensure's own streams stay discarded rather than being let through. It prints progress and +warnings on exit-zero runs too, so a wrapper that leaked them would put noise in front of every +ordinary launch, which is how a diagnostic gets ignored. The exit status is the signal. + +PowerShell had the opposite defect in the same place: a throwing `ensure` escaped a `try/finally` +with no `catch`, so Codex never launched at all. The autostart helper was producing the exact +lockout it exists to prevent, and the previous test asserted that propagation as correct +behaviour. It now catches, reports, and hands over. + +Not closed: the Unix revision marker moved to 3 so installed Unix shims regenerate, but Windows +shims carry no revision marker and are excluded from obsolete-shim refresh +(`src/codex/shim.ts:892`). Existing Windows wrappers keep the old text until reinstalled. Giving +Windows a refresh path is its own change. + +## 2. The catalog pointer — my lane H note was wrong + +Lane H recorded that nothing revalidates `model_catalog_json` after injection. That is not true. +The chooser refuses a missing owned path and the caller strips the stale line +(`src/codex/inject.ts:327`), with end-to-end coverage already in place. + +The real gap is narrower and worse: that repair only reaches someone who runs opencodex again, +and the difficulty of this state is that Codex is the thing that stopped working, so nothing +prompts them to. A `model_catalog_json` naming a file that is gone does not degrade Codex, it +stops Codex loading its configuration at all — the same blank wall as dead routing, from a +different cause. So this lane adds detection, not repair, and `ocx status` now names the file +and both ways out. + +## 3. Reboot: stated, not faked + +Applying the integration does not install a service, and the Windows scheduled task a separate +install would create is logon-triggered (`src/service/windows-taskxml.ts:225`). Setup ended on a +success line without ever saying routing outlives the proxy. + +A BootTrigger is deliberately NOT the fix. The task runs as the interactive user +(`LogonType: InteractiveToken`), so before logon there is no session for it to run in; the +trigger would read like a fix and change nothing. Genuine pre-logon start means a different +principal and a different service backend, which is larger than this lane and is left open +rather than half-done. What was cheap and true on every platform is saying the dependency +exists, reusing the health model `ocx status` and `ocx doctor` already report. + +## 4. Account pool: a silence, not an error + +The URL launcher swallowed its own failure and returned nothing, so the Codex login route +answered identically whether a browser opened, failed, or was skipped. The CLI printed the URL +and polled, and a user whose machine could not launch a browser watched something that looked +like it was working. + +Launch failure is now reported and never fatal — the URL is still worth opening by hand and the +flow stays live. The CLI names the fixed callback port 1455, because ChatGPT supplies the +redirect URI and the flow cannot move to a free port, so `--device` is the way around it. + +Not closed: the dashboard keeps last-good rows after a failed refresh +(`gui/src/hooks/useCodexAccountPool.ts:325`), which is why a newly added account can be absent +while older rows still show. That is a GUI change, and the pull-request gate requires a +screenshot of a UI change, which cannot be produced under this lane's no-build constraint. It +is left for a lane that can build the GUI. + +## Verification + +Static source review and exact-head hosted CI only. NOT RUN, by lane constraint: local suite, +individual tests, typecheck, build, install, `ocx` execution, service start or restart, and any +change to credentials or configuration on the machine. The incident being fixed is a +configuration change that locked a user out. + +Checked before push: the file-size ratchet (no tracked file over cap; `codex-shim.test.ts` +sits close to its cap, so the new shim tests went to a sibling file registered in both +layout maps), and the restated-constant class that broke two lanes in this batch — the shim +test's private literal copies of the marker constants now come from the source module. diff --git a/devlog/_plan/260920_meaning_preservation_batch/050_lane_d_account_cache_generation.md b/devlog/_plan/260920_meaning_preservation_batch/050_lane_d_account_cache_generation.md new file mode 100644 index 00000000000..e4eac30867c --- /dev/null +++ b/devlog/_plan/260920_meaning_preservation_batch/050_lane_d_account_cache_generation.md @@ -0,0 +1,226 @@ +# Lane D — account/credential-generation evidence and cache affinity + +Status: branch pushed for hosted CI. One branch, ordered commits, one PR +against `dev`, per the batch topology. + +## Scope + +Bundle 8 — an observation attributed to an account or credential generation +must not survive its replacement: + +- #5214 (carried) — entitlement refreshes fenced behind native-main admission. +- #5145 (carried) — learned reasoning-effort refusals scoped to the credential + identity, snapshot v2, legacy destination-wide rows ignored. +- #5229 (carried) — Cursor live wire-spelling and Max-Mode evidence keyed by + destination+credential scope. +- Lane addition: Cursor and Devin live *rosters* (the cached model list + itself, not just the derived evidence) are now bound to an irreversible + credential fingerprint, including the stale fallback and the failure + cooldown's suppression. +- Lane addition: a cancelled data-plane request's late entitlement refresh no + longer commits — the /v1/models signal reaches the native-main token + refresh, which re-checks it before the auth.json write. + +Bundle 9 — a threshold hint is not exhaustion, and cache-loss causes must be +distinguishable: + +- #5209 (carried) — shared cache-affinity bindings retire on genuine 100% + exhaustion, not on the proactive auto-switch threshold. +- #4793 (carried) — per-model cache metrics on the Usage page (lane D owns + this surface). +- Lane addition: opt-in privacy-bounded cache diagnostic (#5178), one record + per finalized request in `cache-debug.jsonl`, so client prefix change, + account change and proxy transformation change are distinguishable without + retaining content or a durable correlation key. + +All four luvs01 pull requests and xdober's #4793 are carried with +`Co-authored-by` trailers on the branch commits. The original pull requests +stay open for the coordinator. + +## Branch + +`codex/260920-lane-d-account-cache-generation`, cut from `origin/dev` +(`b9483b3b51`). Commits in order: + +1. `fix(codex): preserve cache affinity across model detours` (carries #5209) +2. `fix(reasoning): scope learned reasoning-effort refusals to credential identity` (carries #5145) +3. `fix(cursor): isolate live roster and Max Mode evidence by account` (carries #5229) +4. `fix(codex): fence entitlement credential refreshes behind admission` (carries #5214) +5. `feat(usage): show cache metrics by model` (carries #4793) +6. `test(codex): move cache-affinity detour cases to a sibling under the file-size cap` +7. `fix(codex): bind Cursor and Devin live rosters to the observing credential` +8. `fix(codex): fence cancelled entitlement refreshes behind caller cancellation` +9. `feat(usage): opt-in privacy-bounded cache diagnostic (#5178)` + +## Review findings on current dev (beyond the carried diffs) + +- #5229 scoped the Cursor spelling/Max-Mode maps but not the roster cache + itself: `provider-models.ts` read and wrote the Cursor and Devin live model + lists by provider name alone, and the failure cooldown let one credential's + error suppress another's discovery. Qoder already had the correct pattern + (`authorityIdentity`); the lane extended it. +- The entitlement admission fence (#5214) covered lifecycle drains but not + caller cancellation: `/v1/models` never passed `req.signal`, and the + native-main refresh committed its late result without re-checking it. The + roster-cache publication needed no change — it is already fenced by + credential identity plus mutation epoch, which is the right boundary for a + shared flight. +- False/unknown/absent is covered on the entitlement path + (`model-entitlements.ts` keeps a `confirmed` bit separate from the model + set, and the public state is tri-state) and is pinned by existing tests in + `codex-model-entitlements.test.ts`. For usage telemetry the distinction + rides the existing provenance enum: a measured zero is `observed`, a + defaulted zero from a normalized wire is `synthesized`, and an absent + counter is `unknown` — an earlier revision of this branch changed + extraction to keep all-zero frames alive, and review rejected it because it + reclassified spend settlement for placeholder frames. +- Deliberately NOT generation-scoped: quota/rate-limit avoidance + (`health-store.ts`, `subagent-model-fallback.ts`). Those observations + describe the subscription, not the token generation; scoping them to a + credential refresh would re-hammer a known-drained account. The 401/403 + quarantine is already generation-fenced. Model static policy and live + health were not mixed in either direction. + +## Ownership boundaries respected + +- Lane C owns send accounting (`sendCount`, request-wide send budget, stage + and cause vocabulary). This lane consumes `loggedUsage`, provenance and + the affinity enums and does not redefine any of them. +- Lane D owns #4793 and the per-model cache view; the diagnostic reuses the + usage ledger's account label (salted, process-local) instead of inventing a + new identifier. + +## Adversarial review (pre-CI) and its dispositions + +- HIGH, fixed: the carried entitlement-admission test kept its tests-root + import paths after the domain move; all imports now resolve. +- HIGH, fixed: the diagnostic's block splitter aliased an array-valued + `instructions` field and would have mutated the live request body; it now + copies, pinned by a mutation regression test. +- MEDIUM, rejected with reason: persisted same-process equality tags were + called a correlation key. The issue being closed explicitly requests + process-scoped salted equality tags so two requests can be compared; the + key dies with the process, the file is owner-only, and retention is bounded + at 100 records. That is the requested design, not a breach of it. +- MEDIUM, accepted: the all-zero usage extraction change altered spend + settlement semantics for placeholder frames; reverted. The measured-zero + versus absent distinction needs no extraction change for any frame that + reports tokens. +- Also fixed: a trailing blank line flagged by `git diff --check`. + +## Exact-head CI at 55b512d2 and its dispositions + +Run 35492856534 failed `gates`, `test 1/4`, `test 4/4` and `macos 1/2`. Three +distinct causes, none of them a flake: + +- `gates` reported five typecheck errors. Two were mine and trivial: + `cache-diagnostic.ts` narrowed `draft.promptCacheKey` through optional + chaining and then read it again unguarded. Fixed by binding the inbound key + once. +- The other three were the interesting ones, and they are the union class this + batch keeps hitting. `catalog/effort.ts` and `catalog/build-entries.ts` cast + a partially populated ladder to `Array<{ effort?: string }>` and then push a + canonical `CODEX_REASONING_LEVELS` rung into it, which also carries + `description`. That has always been a type error; it was invisible because + `reasoning-effort.ts` → `providers/reasoning-metadata.ts` → + `providers/key-store.ts` → the `../config` barrel formed an import cycle, + and inside it the rung type degraded so the excess-property check never ran. + Carried #5145 breaks that cycle on purpose — its new `api-key-resolve.ts` is + a leaf module written so reasoning-metadata can import it without the barrel + — so the latent error surfaced on this branch first. Neither file is in this + lane's scope and neither is touched by its diff; the fix is the remedy + `AGENTS.md` prescribes for a restated shape: `reasoning-effort.ts` now + exports `CodexReasoningLevel`, and the three casts derive + `Array>` from it instead of restating a + narrower literal. Any lane that breaks this cycle would have hit the same + wall. +- `test 4/4` (`production adapter contract rejects omitted translator budgets + at typecheck`) spawns tsc over the project and asserts the valid fixture + exits zero. It was downstream of the same five errors and needs no change of + its own. +- `test 1/4` (`Cursor catalog discovery cooldown > second refresh during + cooldown does not re-invoke discovery`) was a real regression from this + lane. Scoping only the roster reads to the credential left the failure + cooldown provider-wide, so the branch had to require a credential-scoped + stale entry before honouring it — and a discovery that fails before caching + anything has no stale entry, which reopened the timeout storm #54 closed. + The fix moves the scope to where the observation actually belongs: a + discovery failure now records the credential that observed it, and + `isModelsFetchCoolingDown` suppresses only that credential. A failure + recorded without an identity stays credential-agnostic and suppresses + everyone, so plain-endpoint providers and the existing Qoder branch keep + their current behaviour unchanged. This is the same thesis as the rest of the + bundle: one account's 401 or 404 is not evidence about another account's + catalog. `cursor-roster-account-scope.test.ts` already pins both halves. +- `macos 1/2` carried the same shard failures as the Linux shards. + +The branch is now aligned on `dev` at `447ac22ca6` (lanes B and E landed). +Lane E's `run-turn-queue.ts` and `admission-model-scope.ts` do not overlap +this lane's surface; the merge was clean. + +Run 35496444256 at `6bd074e0` confirmed both fixes: `test 1/4` and `test 4/4` +passed, along with every other shard, both macOS shards, `structure gate`, +`docker smoke`, `docs site build`, `api usage`, `storage policy`, keyring on +all three platforms and `npm-global` on all three. Only `gates` still failed, +on three GUI assertions, all of them the same restated-literal class and all +introduced by the carried #4793 columns: + +- `usage-custom-range` listed the models-table headers as English literals and + omitted the `API list-price` column the page already renders, so the case + could not pass on any tree carrying both. The expectation now maps the + ordered column keys through the `en` catalog, which is where that copy lives. +- The French accidental-English guard and the zh-TW stale-placeholder guard + both flagged `usage.unavailable`, whose value is an em dash. Adding one more + allowlist entry would have been literal-for-literal, so both checks now + derive the rule from the value: with placeholders removed, a string carrying + no letters has nothing to translate and is identical in every locale by + construction. Keys that do carry letters, `uptime.hour` among them, stay + allowlisted and still fail if they go untranslated. + +## Verification + +### The macOS sideband failure was shard composition, not the relay + +`macos 2/2` then failed twice on `sideband GET /v1/live/{callId} relays the exact frame +ceiling bidirectionally`, and it is worth being precise about why, because retrying it would +not have helped and neither would touching its deadline. + +The case relays a 50 MiB WebSocket frame end to end against a hard 15s deadline. It is not in +`SERIAL_FULL_SUITE_FILES`, so it runs inside `bun test --shard=N/2` sharing one process with +the rest of that half. On `dev` at `043aa435f` it lands in shard 1 and its echo leg alone +takes **7.4s of the 15s budget**. This branch adds three test files in unrelated directories, +Bun repartitioned the halves, `tests/server/server-live.test.ts` moved to shard 2, and the +echo leg went past 15s on both attempts while the peer never received the frame +(`recv=13 progress=5 moving=no`). Nothing in this lane's diff touches the sideband relay, the +live route or WebSocket handling, and the delta between the run that passed every shard and +the run that failed this one is three GUI test files and a devlog page. + +So the test has been passing by accident: its result was a property of which half it drew. +The remedy is the mechanism the repository already has for this exact category — +`SERIAL_FULL_SUITE_FILES`, described in its own guard as quarantining *load-sensitive* files +into one-worker lanes. Adding `server/server-live.test.ts` there keeps the 15s deadline, keeps +the assertion, and keeps macOS in the matrix; it only stops the case from sharing a process. +It also takes the landmine out of the path of the next lane that adds a test file anywhere in +the tree. If the coordinator would rather own that change centrally, it is one line in +[scripts/test.ts](../../../scripts/test.ts) and can be lifted out of this branch. + +Per batch rules, no local suites, individual tests, typecheck, build, +install or live `ocx` execution. Verification is static source review plus +exact-head hosted CI. + +- NOT RUN: `bun run test`, focused `bun test`, `bun run typecheck`, + `bun run lint:gui`, `bun run build:gui`, `bun run privacy:scan`, + `bun run structure:check` (all forbidden locally; hosted CI decides). +- Static checks performed: file-size ratchet evaluated against + `tests/fixtures/file-size-baseline.json` (the carried routing test would + have grown 83 lines over its cap — moved to a registered sibling; all new + files are far below the 2000-line threshold); both layout registries carry + every new test file and parse as JSON; the ten GUI locale catalogs gained + identical keys (no hand-restated roster or count); the diagnostic module's + imports were walked for a `src/lab/` reach (none) and `responses/core.ts` + gains no runtime import of it. +- Focused regression tests added next to the existing subsystem tests: + roster credential binding (Cursor, Devin), cancelled-refresh fencing + (admission + main-account refresh), measured-zero survival (usage + passthrough), and the diagnostic itself (privacy, fingerprints, alias + rebinding, retention, tag independence from affinity-debug). diff --git a/devlog/_plan/260920_meaning_preservation_batch/050_lane_e.md b/devlog/_plan/260920_meaning_preservation_batch/050_lane_e.md new file mode 100644 index 00000000000..154a0a639e9 --- /dev/null +++ b/devlog/_plan/260920_meaning_preservation_batch/050_lane_e.md @@ -0,0 +1,128 @@ +# Lane E — output budgets, queue memory, per-key policy + +Status: OPEN. Branch `codex/260920-lane-e-budgets-key-policy` against `dev`, one pull request. +Covers phase 2 bundles 10, 11 and 12 from [010_phase2.md](010_phase2.md). + +## What each bundle turned out to be + +### 10 — Devin output budget and the history ceiling + +Two defects, not one. The adapter forwarded only a caller-supplied +`max_output_tokens`, and Codex never sends one, so every `devin/*` turn was capped at +the cloud-direct encoder's 8192 fallback however the provider was configured. The +escape hatch was closed too: no OAuth preset declares `defaultMaxOutputTokens` or +`modelMaxOutputTokens`, so the delete-when-preset-undefined branch in +`applyOAuthPresetCatalog` was the only branch either field ever took and a +hand-edited value was gone before the next startup finished. Both had to move, or +wiring the adapter alone would have been unreachable in practice. + +The resolver reads the caller's explicit value, then the configured per-model cap, +then the provider default, then nothing — leaving the encoder fallback. It never +reads `contextWindow` or `modelContextWindows`: CompletionConfiguration #2 is the +output cap and #3 is the context window, and collapsing them would ask Cognition to +generate a whole window of output. + +The history ceiling is the other half and stays a separate quantity. #5189 is +carried with attribution: it derives the coding-agent projected-history bound from +the declared context window in characters. That bounds replayed history memory; +nothing there decides how long a reply may run. + +No retry change was needed. Source review of `stated-reset-retry.ts` and +`upstream-retry.ts` confirms an upstream `incomplete / max_output_tokens` is a +successful streaming response that has already emitted events, so it matches none +of the replay conditions. The repeated identical attempts in #5190 are the client's. + +### 11 — adapter event queue memory + +PR #5182 had the right idea and the wrong number. Its 1 MiB aggregate default +aborts a legitimate turn: a synchronous producer fills the queue before its +consumer is scheduled, and the image loop does exactly that with over a million +one-character deltas that coalesce into roughly 1.2 MB of retained text. Its own CI +proved it, which is why it sits at `CHANGES_REQUESTED`. + +The work is carried with attribution and reshaped around two budgets rather than +one, because a stalled consumer and a malformed event are different failures and an +operator reading the terminal error should learn which happened. Accounting is now +exact by construction: each queued item records what it was charged, so a merge +pays only for appended text, a refused event is priced before anything is retained +and never charged, and the terminal record explaining a refusal is admitted past +the budget it reports but still charged and released. `retainedCodeUnits()` exposes +the counter so the regressions assert it reaches zero rather than inferring it from +an abort that happened to fire. + +Retention is measured by a bounded walk of own enumerable properties rather than a +per-variant table. A table would be exhaustive over `AdapterEvent`, which is the +union class `AGENTS.md` records: a member added on another branch would silently +stop being counted. + +### 12 — per-admission-key model and provider scope + +The security question is where the check goes, not what it compares. A scope +evaluated against the client's string authorizes one destination and reaches +another, because alias resolution, policy and combo selection, subagent fallback +and compaction override all rewrite that string. So the scope names destinations +and is applied to the resolved route. + +On the Responses path every route produced by the request — direct name, alias, +policy, combo child, shadow-intercept target and both subagent-fallback re-routes — +passes through one capture point, which is where the check sits. Chat and Messages +translate into that path; their native lanes and the compaction route send without +re-entering it, so each applies the same predicate itself. `/v1/models` filters by +the same predicate, and that filter is explicitly not the boundary. + +A malformed scope drops the key rather than degrading to `undefined` like every +other field on the record, because degrading a permission field reads as "allowed +everything". + +Out of scope and deliberately not started: Redis, a full multi-tenant conversion, +and any budget or RPM/TPM system. + +#### What the scope does not cover, stated rather than implied + +An adversarial review of the branch found authenticated data-plane endpoints that +spend provider quota without resolving a model through the router, so the scope +does not reach them: + +- `/v1/images/generations` and `/v1/images/edits`, +- `/v1/audio/transcriptions` and its streaming form, +- `/v1/live`, `/v1/realtime/calls` and the standalone realtime sockets, +- the non-account-qualified branch of `/v1/alpha/search`, which forwards the caller's + model to a search sidecar without routing it. + +The account-qualified search branch does route a model and is checked. The rest +need a destination definition this lane does not own — an image or audio endpoint +has a fixed-purpose model rather than a routed one — and inventing one here would +be the multi-tenant expansion this batch rules out. They are recorded so the +contract is not read as broader than it is. + +The review also found that resolving an OpenAI virtual model rewrites +`route.modelId` to the wire id after the initial check. That one was a real hole in +the stated contract and is fixed: the settled route is re-checked after +normalization, so the id that is billed is the id that was authorized. + +## Verification + +Static source review plus exact-head hosted CI. No local suite, individual test, +typecheck, build, install or live `ocx` execution was run — those are NOT RUN, not +passing. + +Union-defect classes checked before pushing. No file in the touched set carries a +`file-size-baseline.json` cap; the largest, `src/oauth/index.ts` and +`src/server/index/serve-options.ts`, stay under the 2000-line new-file threshold. +The two new test files are registered in both `scripts/test-layout/layout.json` and +`tests/fixtures/test-layout-expected.json`. Nothing here restates a count or +enumerates a union. + +## Ownership + +Lane E owns the adapter event queue budget and the Devin and coding-agent limits. +Retry classification — `sendCount`, the send budget, the stage and cause vocabulary — +is lane C's and is untouched. + +## Carried work + +- #5182 (luvs01) — adapter event queue backlog budget. +- #5189 (mdwsk88) — coding-agent projected-history ceiling. + +Both carry a `Co-authored-by` trailer in the branch commit. Neither original pull +request is closed here; the coordinator handles that after this lane lands. diff --git a/devlog/_plan/260920_meaning_preservation_batch/060_lane_f.md b/devlog/_plan/260920_meaning_preservation_batch/060_lane_f.md new file mode 100644 index 00000000000..e51bbedfc47 --- /dev/null +++ b/devlog/_plan/260920_meaning_preservation_batch/060_lane_f.md @@ -0,0 +1,246 @@ +# Lane F — per-provider egress, and the CodeBuddy/native-wire disposition + +Status: OPEN. PR #5289 against `dev` at exact head `40fe2ee7d8`, hosted CI green across the +whole matrix. One branch, ordered commits, one pull request. Covers phase 2 bundles 13 and 15 +from [010_phase2.md](010_phase2.md). + +Lane F was scheduled after lane B because bundle 13 consumes the request-scoped route decision +that lane B built for #5087. That landed as #5264 (`8e1fdea1`), so this lane reads +`effectiveProxyFor` as the authority for the global decision and does not restate it. + +## 13 — per-provider egress + +### What the bundle actually was + +Issue #2894 asks for two things and only one of them was missing. Global SOCKS5 already ships: +`socks5ProxyFromEnv`, `socks5Fetch` and the `configureSocks5Fetch` wrapper handle it, and +`applyProxyEnv` mirrors a configured value into `ALL_PROXY`. Rebuilding that was never in +scope. What was missing is the two-level model: a per-provider override with direct / inherit / +custom, so one upstream can exit through a regional proxy while another stays direct. + +### The decision function + +`src/lib/provider-egress.ts` resolves one route for one destination. It deliberately mirrors +#5087's shape: the question is never "is a proxy configured" but "does a proxy apply to THIS +request". Four states — inherit, direct, http(s) proxy, socks5 proxy — with +`providers..noProxy` applied to whichever route resolved, which is what lets a provider +exempt one destination from an inherited global proxy without owning a proxy of its own. + +Two divergences from the issue's sketch, both deliberate: + +- **An empty string is rejected, not read as DIRECT.** The issue lists `""` as a third spelling + of direct. A dashboard field the operator merely cleared would then silently switch a provider + from inheriting the global proxy to refusing it. The error names both real alternatives. +- **A malformed value throws rather than degrading.** Falling back to the global proxy sends a + credential out a route nobody chose; falling back to direct leaves a restricted network with + no exit. Both read as success at the call site, which is the defect class this batch exists + to remove. + +### How direct egress is expressed, and why that needed settling + +This was the one genuine unknown. #3901 refused the direct state outright with the message +"direct has no safe request-scoped transport on this runtime". That is correct about the +mechanism it rejected and wrong as a general claim. + +Bun's documented `proxy: false` connects directly regardless of `HTTP_PROXY`, `HTTPS_PROXY`, +`ALL_PROXY` **and** `NO_PROXY`. The same documentation states that `undefined`, `null` and +`""` all mean "no option given" and fall through to the environment, so none of them can +express direct egress — which is why the resolver emits the literal `false` and never an empty +string. + +`configuredOutboundFetch` had to learn the same distinction. It derived its SOCKS route with +`typeof explicitProxy === "string" ? … : socks5ProxyFromEnv()`, so a `false` fell into the +environment branch and a request pinned to direct egress would have been sent through the global +SOCKS proxy. It would have returned 200 by the wrong exit, which no status-code assertion can +see. That is now a regression. + +One path needs nothing from the runtime at all: on `providerOutboundRequest`, direct egress is +the DNS-pinned transport, which connects through `node:http` to an address this process +resolved and never reads the proxy environment. Discovery and quota therefore have direct +egress by construction rather than by flag. + +### Reach, stated as coverage rather than implied + +Honoured: the main inference dispatch (`providerFetch`), every +`providerOutboundGet`/`providerOutboundPost` caller (provider discovery, the model-catalog +gather, the management provider test, the Ollama show probe), and the seventeen API-key quota +probes in `vendor-probes-key.ts`. + +Refused rather than dropped: a caller-supplied `provider.fetch` executor owns its own routing, +so an explicit route throws instead of running the executor by a contradicting route. The +WebSocket upstream picks its proxy from the process environment when it dials, so an explicit +route serves those turns over HTTP/SSE and says so once per provider. + +**Not covered, and this is the honest limit of the change:** OAuth token exchange and refresh +under `src/oauth/`, the OAuth-backed quota probes in `vendor-probes-oauth.ts`, and the API-key +validation probes in `key-providers.ts`. All three reach fixed vendor endpoints from modules +that hold no provider config, and `validateApiKey` receives a derived `KeyLoginProvider` whose +caller builds the real provider record only afterwards. Threading provider config through those +call sites is a caller-contract change across roughly a dozen OAuth modules and is not attempted +here. The consequence is stated plainly in the provider guide and the transport inventory: a +provider pinned to its own proxy or to direct still refreshes credentials by the process-wide +route. #2894 therefore stays open for that half. + +Also uncovered and recorded: Cursor's default HTTP/2 transport, the coding-agent subprocess +providers whose scoped child environment omits proxy variables, and the Compatibility Lab pinned +sender. + +### Overlap with the #5049 router-bypass list + +Lane E recorded authenticated data-plane endpoints that spend provider quota without resolving a +model through the router. Every one of them is also outside this lane's egress reach, for the +same structural reason — no routed provider at the send — and the overlap is complete: +`/v1/images/generations`, `/v1/images/edits`, `/v1/audio/transcriptions` and its streaming +form, `/v1/live`, `/v1/realtime/calls`, the standalone realtime sockets, and the +non-account-qualified branch of `/v1/alpha/search`. + +### Credential handling + +A proxy URL routinely embeds `user:password@`. `proxy` is classified credential-bearing +alongside `apiKey`, so it never reaches the dashboard DTO and the editor may not write it; +`ocx config set` and the config file remain the way to set it. Log output keeps scheme, host +and port only. Nothing derived from the credential is emitted — the carried +`providerEgressRouteKey` FNV-1a digest over the full proxy URL was dropped rather than carried, +because a 32-bit digest over a known host is a guessable stand-in for the secret and a durable +correlation key for the account behind it, and it had no consumer. + +### A finding recorded rather than acted on + +Bun's documentation states it uses `ALL_PROXY` for `http:` and `https:` alike when the +scheme-specific variable is unset. `effectiveProxyFor` counts a non-SOCKS `ALL_PROXY` only for +`http:` targets. The divergence fails toward keeping the DNS-pinned transport, which is the safe +direction, and lane B reasoned about and tested that boundary explicitly. Changing it is lane +B's surface, not this one, so it is recorded here rather than altered. + +### Carried work + +#3901 (jingzxy) — per-provider HTTP proxy overrides. Carried with a `Co-authored-by` trailer on +both code commits. The branch was 289 `dev` commits behind and its `provider-outbound.ts` hunks +were written against the pre-#5264 `outboundProxyConfigured` shape, so the work was carried onto +the landed decision rather than replayed. Its management cases would also have pushed +`tests/server/management-provider-validation.test.ts` from 5,498 to 5,612 lines against a 5,506 +cap; those cases live in a registered sibling file instead. The original pull request stays open +for the coordinator. + +## 15 — CodeBuddy tool bridge and native wire: disposition + +Item 15 is delivered as a disposition, not an implementation. All three pull requests were +audited against current `dev` and none is carryable as it stands. Recording why is the +deliverable; carrying a defect with a `Co-authored-by` trailer on it would not be. + +No issue is closed by this lane. #5146, #5097 and #5096 stay open. + +### #5147 — account-roster discovery: blocked on an attribution defect + +The roster is read by running the vendor CLI, which answers for the account **signed in to that +CLI's home directory**. The result is then cached under a fingerprint of the **configured API +key**. Those are two different identities. The fingerprint isolates cache reuse between +configured keys, which is what it was designed for, but it does not make the roster belong to +the key it is filed under: with key B configured and account A signed in to the CLI, the proxy +advertises A's models as B's catalog. That is the same class of defect bundle 8 is about — an +observation outliving the identity it was made under — so carrying it into this batch would +contradict the batch. + +The rest of the pull request reviewed clean: the key is passed by environment rather than argv, +output is bounded at 512 KiB with an 8-second timeout, an explicit `liveModels: false` is +preserved, and a missing CLI warns and degrades to the static seed rather than crashing. The +defect is the binding, not the plumbing. + +### #5148 — capture-only tool bridge: conflicts, and coverage short of the bar + +The capture-only security boundary itself reviewed sound: the MCP server advertises and captures +but never resolves a call, no path traversal or execution route was found, and no secret reaches +the logs. The CLI does not execute tools and client approval is preserved. + +It does not apply to current `dev` — `src/adapters/coding-agent/turn.ts` conflicts and seven +touched files drifted since its merge base. More important for this batch, its tests do not +reach the acceptance bar set for item 15. Directly uncovered: a **successful** multi-call +assistant message, call-ID preservation across the capture boundary, bridge-specific reasoning +replay on the continuation turn, and an integrated abort that proves process-tree cleanup rather +than orphaning a child. Those four are exactly the cases a "the first tool call worked" test +cannot see, which is why they were named as the completion condition. + +Landing it would mean rebasing the adapter work and writing those four regressions. That is a +lane of its own, not a trailing commit on this one. + +### #5188 — Alibaba Token Plan default flip: evidence does not support it + +#5198 already landed the opt-in and declined the flip, and added a guard that fails if a +Responses wire default is declared for this entry without `preserveResponsesReasoningContent` +beside it. #5188 proposes exactly that declaration without that flag. + +The guard is not bureaucratic. The entry sets `preserveReasoningContentModels`, which the +**Chat** adapter reads; the Responses serializer reads a different flag this entry does not set, +so pinned models would replay continuations with blanked reasoning content — strictly less state +than they carry today. Z.AI and DeepSeek set both flags together and their entry comments say +why. + +The live evidence in #5097 covers a tool call and a continuation that replays +`custom_tool_call` and `custom_tool_call_output`. It does not assert that reasoning content +survived that continuation, which is the one thing the flip would change. The delegation's own +constraint applies: do not change inbound behaviour or international endpoints without evidence. +The opt-in stands; the flip waits for a replay that demonstrates reasoning preservation. + +## Verification + +Static source review plus exact-head hosted CI. No local suite, individual test, typecheck, +build, install, live `ocx` execution or service restart was run — those are **NOT RUN**, not +passing. + +Hosted CI at `40fe2ee7d8` is green: all four test shards, both macOS halves, `gates` +(typecheck, GUI tests, privacy scan, generated skill surface), the structure gate, docker smoke, +storage policy, api usage, the three `npm-global` smokes and the three keyring jobs. + +### What only CI could tell me, and what only review could + +Three defects reached a pushed head and were caught by adversarial review before CI ran, all in +the same seam and all invisible to a status-code assertion: + +1. The route was resolved when the fetch wrapper was built, but `dispatchOverride` can rebuild a + queued request against a different upstream host. A host-scoped `noProxy` decision could + therefore be applied to a host it was not decided for, sending a bearer out an excluded route. + The decision moved to `sendWithConnectionPolicy` — the same boundary and the same reason + #4992 records for the connection policy. +2. Refusing every `provider.fetch` as transport-owning was too broad. The xAI route installs a + wrapper on every request that only adds a generated request id, so an explicit route would + have thrown for one of the two providers #2894 names. +3. The executor handed to an override was itself unmarked, so an ordinary provider would have + been refused on every overridden path — after the attempt had already been recorded. None of + the regressions written to that point covered the production-shaped nested send; two do now. + +CI then found two more that review had cleared. The zod field schemas used +`z.unknown().superRefine(...)` without narrowing, so the parsed provider record carried +`proxy: unknown` and failed to satisfy `OcxProviderConfig` — four typecheck errors, and a +typecheck-based adapter contract test that asserts zero errors reported one. And the privacy +scan reads a URL userinfo pair as an address, so the fixtures that deliberately carry a +credential to prove it never reaches a log were read as one. They moved to the `.test` host the +scanner already allows for fixtures, with the assertions unchanged. Both are the reason this +lane treats hosted CI as the verification and static review as the preparation for it, rather +than the reverse — and the second one repeated itself in this very document, which first +described the defect by quoting the shape that caused it. + +Union-defect sweep before pushing: + +- **File-size ratchet.** No touched source file carries a cap. + `tests/server/management-provider-validation.test.ts` does (5,506) and is deliberately not + touched; the management egress cases are a registered sibling file. +- **Exhaustive over a union.** Adding `proxy` and `noProxy` to `OcxProviderConfig` makes + `PROVIDER_CONFIG_FIELD_POLICY` — declared `satisfies Record` — + fail to compile until both are classified. Both are, and the classification is asserted rather + than assumed. +- **Derived, not restated.** The tests import `PROVIDER_EGRESS_DIRECT`, + `MIN_BOUNDED_CODEX_WS_BUN_VERSION` and `CODEX_RESPONSES_HTTP_URL` from source instead of + repeating their values, and configuration validation calls the resolver instead of restating + what a valid proxy value is. No count in generated documentation was touched. +- **Test layout.** Four new test files, each registered in both `scripts/test-layout/layout.json` + and `tests/fixtures/test-layout-expected.json`. +- **Exact-list guards.** `tests/responses/responses-fetch-helpers-boundary.test.ts` pins the + runtime-import list of `fetch-helpers.ts` and needed the two modules this lane adds. It is the + restatement class in miniature, and it is the guard working as intended: the list is a + deliberate classification, so adding to it is a reviewed decision rather than a silent one. + +## Ownership + +Consumed and not redefined: lane C's send accounting, lane E's adapter event queue budget and +per-key model/provider scope (`src/server/admission-model-scope.ts`), and lane D's per-model +cache views. The `effectiveProxyFor` global decision belongs to lane B and is read, not changed. diff --git a/devlog/_plan/260920_meaning_preservation_batch/060_lane_i.md b/devlog/_plan/260920_meaning_preservation_batch/060_lane_i.md new file mode 100644 index 00000000000..bef4c28b42a --- /dev/null +++ b/devlog/_plan/260920_meaning_preservation_batch/060_lane_i.md @@ -0,0 +1,112 @@ +# Lane I — per-key scope on the unrouted data planes + +Status: OPEN. Branch `codex/260920-lane-i-key-scope-dataplane` against `dev`, one pull request, +ordered commits. Base is `origin/dev` at `043aa435ff`. + +Scope is one thing: the part of #5049 that #5265 (`447ac22ca6`) could not reach. That change put +the model and provider scope on the resolved route, at the single capture point every Responses +destination passes through, and Chat and Messages inherit it by translating into that path. Four +authenticated endpoints spend provider quota without ever resolving a model through the router, so +the predicate never saw them. + +## What each surface turned out to be + +### Images + +`handleImages` can settle on four different destinations, and only two of them use the model the +caller sent. The ChatGPT forward account and a keyed OpenAI provider relay the body verbatim; the +xAI Imagine bridge always runs `images.bridgeModel` on the configured xAI provider; the Antigravity +fallback always runs its own CCA image model. Judging the body would therefore have authorized one +thing and billed another on two of the four branches, which is the failure the landed design names. + +Each branch is checked as it is entered — the bridge and the Antigravity fallback before they +resolve a credential, the two relays before the forward probe lease is consumed or the keyed picker +commits a rotation. A refused request spends nothing and mutates nothing. + +### Audio and voice + +Transcription, the dictation socket, external voice call-create and the external sideband join all +resolve through `resolveAudioUpstream`, which already receives both the admission and the model the +upstream will run. One check on each of its two return paths covers all four endpoints. The forward +path releases its probe lease on refusal, mirroring the adjacent unusable-account branch. + +The native `/v1/live` and `/v1/realtime/calls` path does not share that resolver, so it needed its +own: `resolveLiveRelay` now takes the destination it is resolving for. The model is read where the +client states it — `session.model` in a JSON or multipart call-create, the `model` parameter of a +standalone socket query — and a join onto an existing call carries the default, because the call it +attaches to stated its model when it was created. + +### Search + +The brief expected the non-account-qualified branch to hand the caller's model to the sidecar. It +does not, and the distinction matters for where the check belongs. There are two unrouted branches, +not one: + +- the forward relay copies the caller's model to whichever ChatGPT account the upstream resolved, + and that account is billed for it; +- the sidecar fallback ignores the caller's model entirely and runs the backend and model the + operator configured, spending that backend's own credential. + +Both are destinations a scoped key must not reach, so both are checked — the first against the +resolved account and the caller's model, the second against the configured backend and the model +that backend runs. Exa has no provider entry, so its backend name is its destination. The +account-qualified branch keeps the single check #5265 gave it and is not judged twice. + +## Rules that fell out of the review + +A request that names no model has no destination a model list can allow: the relay would copy the +body and let the upstream pick. `UNNAMED_DESTINATION_MODEL` makes that explicit, so a key scoped by +model is refused rather than sent to a provider default. A key scoped only by provider is +unaffected, and a key with no scope at all reaches every surface exactly as before. + +No new policy system was introduced. `admissionScopeDenial` is a three-line composition of the +landed `resolveAdmissionModelScope`, `routeAllowedByScope` and `admissionModelDeniedResponse`, +shaped for handlers that return a `Response` rather than throwing into a route resolver. Every +refusal is the same 403 naming the caller's own selector, with the resolved destination left to the +server log. + +## Explicitly out of scope + +Redis, multi-tenancy, budgets and RPM/TPM ceilings. Nothing here reads or writes a credential, and +no new field is logged: a refusal carries the selector the caller already knows and no account +identifier, provider credential or request body. + +## Verification + +Static review against the `dev` source plus exact-head hosted CI. Per the lane instruction the +local suite, focused test files, `typecheck`, `build`, `install` and any running `ocx` were NOT +RUN; the file-size ratchet and the union-exhaustiveness classes were checked by reading the +baseline and the changed files instead. No touched file carries a baseline cap, and the largest, +`src/server/index/serve-options.ts`, stays well under the 2000-line threshold. The four new test +files are registered in both `scripts/test-layout/layout.json` and +`tests/fixtures/test-layout-expected.json`. + +## Remaining scope on #5049 + +The issue stays open for the coordinator to judge. This lane closes the four endpoints named in the +#5265 adversarial review and nothing beyond them. + +## What the first review round changed + +Three findings on PR #5290, all of them about a destination the first pass was willing to assume. + +The refusal marker was being used as a model id. A request that named no model was checked as the +literal `(unnamed)`, which the configuration schema accepts like any other string, so an operator +who copied it out of a refusal into `allowedModels` would have granted "whatever the upstream +picks". The absent model is now absent: the denial helper takes an undefined model id and refuses +any key carrying a model list, judging a provider-only scope on the provider alone. The marker is +message vocabulary and never reaches the comparison. + +A Realtime standalone socket was judged as the default. The external audio path read the `model=` +query only for the Frameless style — the one that rewrites its own query — while a +`realtime-standalone` socket forwards that parameter untouched. Both standalone styles now report +the model they forward, through the helper the native path already used. + +A join was authorized against an assumed model, and what to do about it depends on what each path +can know. The external path keeps a per-key call registry, so the model a call settles on is now +recorded in its `LiveCallBinding` and a rejoin is judged against it. The native compatibility path +records nothing about the calls it relays and does not gain a registry here — adding call ownership +to it is a different change from closing a scope hole — so a native join, and a native call-create +that sends no session model, name no destination and a key carrying a model list is refused. That +is a real restriction on model-scoped keys and it is written down beside the contract it +constrains, in `structure/data-planes/inbound-compat.md`, rather than left to be rediscovered. diff --git a/devlog/_plan/260920_regression_release/010_execution.md b/devlog/_plan/260920_regression_release/010_execution.md new file mode 100644 index 00000000000..39b8ca8e61c --- /dev/null +++ b/devlog/_plan/260920_regression_release/010_execution.md @@ -0,0 +1,90 @@ +# 2.60.0 regression and release execution + +Status: IN PROGRESS. This file records what was actually executed, with exact commits, runs and +dispositions. It is not a completion claim beyond the evidence listed here. + +## Candidate freeze + +| Branch | Before | After | +| --- | --- | --- | +| dev | `12cb129d424d1a67319c1e7a761ba5474fdc48a4` | `1b57a572182d9f6f76cb6169eec7b65495be5910` (2.61.0) | +| main | `134c92a01b120162f00c7275189cc47858720379` (2.59.0) | `7c625fc9755c9824653ab944190e243091a2c85c` (2.60.0) | +| preview | `48e1ddba0bb8da9ad39e32f8e20c1e4d7f1794da` (2.58.0) | `84c4f014c8da51ff50c3e8b64f2d82b9ee3792da` (2.60.0) | + +The released tree is `015c67c46aaf16d4319543c8941c6dbec9887ae6`, the `dev` head after the last +blocking repair and before the version pre-move. + +## Dev-tip regression failure and repair + +Run `35485314835` at `12cb129d42` failed on Linux `test 2/4` and macOS `macos 2/2` with the same +single case, `sanitizeEncryptedContentInPlace > plaintext parked in encrypted slots becomes +input_text; real blobs survive`, reporting `Expected: 2, Received: 3` at +`tests/codex-integration/multi-agent-compat.test.ts:1392`. Every other producer and both keyring +and docker legs passed, so the aggregate `ci` check failed on that one assertion. + +This is the merge-union class `AGENTS.md` describes. #5239 replaced `looksLikeBackendCiphertext` +with `isStructurallyValidFernetToken`, which requires canonical base64url, length at least 100, a +`0x80` version byte and a 16-byte-aligned ciphertext. The case predates that change and minted its +surviving blob as `"gAAAAAB".padEnd(120, "Qw1_-=")`, which embeds `=` mid-token and is therefore no +longer a valid token, so the sanitizer correctly lowered it as a third rewrite. #5239's own tests +were updated; this one in another domain directory was not. + +#5246 mints the blob with the `fernetFixture()` helper already defined in the same `describe` — +a 73-byte payload with `raw[0] = 0x80` encoding to a 100-character canonical token, giving +`decoded.length - 57 === 16`. The classifier is untouched, the rewrite count returns to 2 and the +surviving slot stays byte-identical. Merged to `dev` as `015c67c46aaf16d4319543c8941c6dbec9887ae6`. + +## Integration sequence + +1. #5242 archived the campaign unit to `_fin`; merged as `d3d637912ab5080e0d54ef60db273951746d0aa9`. +2. #5246 repaired the fixture; merged as `015c67c46aaf16d4319543c8941c6dbec9887ae6`. +3. `release/2.60.0` was cut at that head, before any version move. +4. `dev-version-bump.yml` was dispatched with `intended-version=2.60.0`. The first dispatch from + `dev` was refused by the workflow's default-ref guard; the dispatch from `main` + (`35486732967`) opened #5247, merged as `1b57a572182d9f6f76cb6169eec7b65495be5910`, so `dev` + outranks the release at 2.61.0. +5. #5249 promoted `release/2.60.0` to `main` as merge commit `7c625fc9755c9824653ab944190e243091a2c85c`. +6. #5250 brought `preview` onto the same tree as `84c4f014c8da51ff50c3e8b64f2d82b9ee3792da`; + `git diff` against `main` was empty before the merge and after it. + +The maintainer directed admin integration without waiting for per-pull-request CI on the repair and +promotion pull requests. That timing direction is recorded here rather than presented as a +completed per-PR pass. + +## Publication gates and full regression evidence + +`release.yml` refuses to publish without a successful push-event `ci.yml` run for the exact release +commit; a pull-request run does not qualify. The first dispatch (`35486936693`) failed on that gate +while the `main` push run was still queued. `service-lifecycle.yml` was already satisfied for the +same commit by run `35486928648`. + +The full regression from the request-time `main` baseline through the released candidate is push +run `35486928618` at `7c625fc9755c9824653ab944190e243091a2c85c`, conclusion SUCCESS. Every +event-requested producer passed: `changes`, all four Linux `test` shards, both macOS shards, +`storage policy`, `api usage`, `gates`, `structure gate`, `docker smoke`, `docs site build`, all +three `keyring` legs and all three `npm-global` legs. The nine-shard Windows matrix and +`macos control` are workflow-dispatch lanes that this event does not request, so their skipped +placeholders are applicability, not execution. + +## Publication + +Release run `35488151017` at the same commit completed SUCCESS with every step green, including +`Require successful Cross-platform CI for this commit`, `Require dev to be ready for this release`, +`Refuse a release the current tag set already outranks` and `Publish (or dry-run)` with +`dry-run=false`. + +npm accepted `+ @bitkyc08/opencodex@2.60.0` on dist-tag `latest` with public access and a signed +provenance statement recorded in the sigstore transparency log at index `2894025266`. Tarball +`bitkyc08-opencodex-2.60.0.tgz`, shasum `651613e7536c33be936ac387a0a9f38ff290c4cc`, 1436 files. + +Tag `v2.60.0` points at `7c625fc9755c9824653ab944190e243091a2c85c` and the GitHub release was +published at 2026-09-20T04:05:15Z, not a draft and not a prerelease. + +Registry propagation was still pending at the time of writing: npm reported "Your package is being +processed and may take a few minutes to become available", the workflow's bounded six-attempt smoke +ended `verification=pending`, and direct reads of +`https://registry.npmjs.org/@bitkyc08%2fopencodex` still showed `latest` at 2.59.0 about fifteen +minutes after publication. The publish itself is acknowledged and must not be republished; the +remaining check is a later registry read confirming `2.60.0` under `latest`. + +Local suites, typecheck, builds, installs and runtime execution were not run in this lane. diff --git a/devlog/_plan/260920_round2_followups/000_plan.md b/devlog/_plan/260920_round2_followups/000_plan.md new file mode 100644 index 00000000000..bc113d7d542 --- /dev/null +++ b/devlog/_plan/260920_round2_followups/000_plan.md @@ -0,0 +1,69 @@ +# Round 2 — what the first batch left behind + +Status: OPEN. The sixteen post-2.60.0 bundles and the #5261 incident work all landed on `dev` +today. This unit collects what that round deliberately left open, plus two things it caused. + +Everything here is either a defect a user hit, a remainder a lane recorded rather than hid, or a +red check on `dev`. Nothing is new scope invented for its own sake. + +## Priority 1 — a 2.60.0 regression that locks users out of the transition + +#5321: on a Codex home whose history has been migrated to paginated form, enabling the integration +in its authless provider-table shape is hard-refused with +`history_paginated_openai_requires_native_writer`. Nothing is written and the integration stays +disabled. Before 2.60.0 the same transition completed, with the history relabel standing down while +the routing and catalog half was still written. + +The guard itself is right. `src/codex/history-provider.ts` explains why: a provider-table +transition removes the root `openai_base_url`, and a row already paginated cannot be relabeled, so +standing it down would route an openai-tagged thread to Codex's own OpenAI endpoint. Refusing to +relabel is correct; refusing the entire transition and saying "do not retry" without naming a way +forward is what traps the user. + +The reporter found the only exits by reading the preflight source: delete the affected +conversations, or downgrade. On their home that meant deleting 173 sessions. Neither is a +remediation this project can ship as the supported answer. + +#4812 is the same guard family from the other side: `restore`, `stop` and `uninstall` also refuse +on paginated history, which leaves the CLI pointed at a dead proxy port. The two belong in one lane +because a fix that unblocks activation while leaving recovery blocked trades one trap for another. + +## Priority 2 — `dev` is red from the desktop landing + +The app stack brought a `macos widget + bundle` job. MenuBarCore's 118 tests, the dashboard build, +the sidecar preparation and the WidgetKit appex build all pass; `tauri build` then fails with +`A public key has been found, but no private key`. The updater public key is committed while the +private key is not a CI secret, so Tauri refuses to produce a signed update artifact. + +This is configuration, not code. The CI job's purpose is to prove the appex and the app bundle +build and that the widget is embedded, which does not require a signed updater artifact. Release +signing belongs to the release workflow, where the key can be held as a secret. + +## Remainders the first round recorded rather than hid + +| Item | What is left | +| --- | --- | +| #5292 | `gui/src/pages/Logs.tsx` restates the recovery-kind union with nine of thirteen members, so four durable kinds have no label. The fix derives the GUI union from the roster instead of restating it. | +| #5261 | Generic OAuth and key login still discard the browser launch result, and the dashboard account roster keeps last-good rows after a failed refresh. | +| #4191 | The WebSocket failure projection is not threaded into the durable record, and the SSE fallback the issue asks for is a transport change. | +| #5180 | The shared cooldown and `Retry-After` handling are routing behaviour and were not in the stage-table branch. | +| #4942 + #4989 | Both express rows of the landed stage table and overlap in `upstream-retry.ts` and `passthrough-dispatch.ts`. They must not each buy an independent replacement send for one logical request, so they are one reworked change. | +| #2366, #3748, #3983, #5063 | Deferred as implemented because each adds a parallel store or a second emission path. The derived forms read from the landed recorder instead. | + +## Lanes + +| Lane | Scope | +| --- | --- | +| R1 | #5321 and #4812 — the paginated-history guard, from both activation and recovery | +| R2 | the `macos widget + bundle` failure on `dev` | +| R3 | #5292 and the two #5261 remainders | +| R4 | #4942 and #4989 as one rework, plus the #4191 and #5180 remainders | +| R5 | the four telemetry pull requests as derived consumers of the recorder | + +## Execution constraints + +Unchanged. One branch, ordered commits, one pull request to `dev` per lane. No native stack — the +desktop chain proved why: squashing the bottom of one detaches every child and the remaining work +has to be reconstructed. Carried contributor work needs a `Co-authored-by` trailer. No local +suites, typecheck, builds, installs or live `ocx` execution; verification is static review plus +exact-head hosted CI. Only the coordinator merges and closes. diff --git a/devlog/_plan/260920_round2_followups/010_r1_paginated_history_guard.md b/devlog/_plan/260920_round2_followups/010_r1_paginated_history_guard.md new file mode 100644 index 00000000000..a3db4957992 --- /dev/null +++ b/devlog/_plan/260920_round2_followups/010_r1_paginated_history_guard.md @@ -0,0 +1,91 @@ +# R1 — the paginated-history guard, from activation and from recovery + +Scope: #5321 (activation) and #4812 (recovery). Branch `codex/260920-r1-paginated-history-guard`. + +## What the guard was actually protecting + +`preflightCodexHistoryInjection` returns `history_paginated_openai_requires_native_writer` when a +provider-table transition finds a thread row that is both `model_provider = 'openai'` and +`history_mode = 'paginated'`. The reasoning is sound. The transition takes the root +`openai_base_url` out, a paginated row cannot be relabeled, and Codex builds its provider map as +`merge_configured_model_providers(built_in_model_providers(openai_base_url), model_providers)`, so +without that root line the built-in `openai` entry is `api.openai.com`. The conversation would +resume outside the proxy. + +What made it a lockout is that 2.60.0 classified it alongside "something is wrong with this +store". `src/codex/inject.ts` refuses every reason that is not exactly `HISTORY_RELABEL_STANDS_DOWN`, +so nothing was written at all: no config, no profile, no `model_catalog_json`, integration +disabled. Before 2.60.0 the same home returned the plain stand-down, and the routing and catalog +half landed while the relabel stood down. + +## The state that was already in the tree + +The injector already builds the safe state for one routing form. `keepRootOverrideAlongsideTable` +keeps the marker-owned root override beside the provider table for client compaction, for exactly +this reason, and passes `resumeHistory: false` so the relabel never runs. Authless was excluded +deliberately — its point is `requires_openai_auth = false` — on the assumption that it could +always forward-tag resume history instead. On a paginated home that assumption is false, and the +refusal is where that showed up. + +So the fix is not a new mechanism. `src/codex/inject/paginated-openai-compat.ts` selects the +existing one from the preflight verdict rather than from the routing form: when the reason is the +paginated-openai code and the target can own a root key, retain the override, downgrade the reason +to the stand-down constant, and let the transition complete. The paginated row is never read or +written; it simply keeps resolving to this proxy. + +Two cases cannot reach that state, and both are honest outcomes rather than traps: + +- An admission-token form cannot use the root key at all, because Codex's built-in `openai` entry + carries no `x-opencodex-api-key` header. It keeps the refusal, and the message now names + `unauthenticatedLoopbackListener` and `syncResumeHistory` instead of "do not retry". +- A root line the user owns is left alone. The conversation follows the destination they chose, + which is the guarantee the injector already makes everywhere else about a line it does not own, + and the journal correctly records the line as not ours. + +## Where it had to live + +`src/codex/inject.ts` was at 984 of its 987-line ratchet cap, so the decision could not be +inlined. The new module costs the injector one import and one net line; the file now sits at +exactly 987. The refusal code became an exported constant in `src/codex/history-provider.ts` +because the same literal in two files is how the stand-down pair drifted the first time. + +## #4812, checked rather than assumed + +The recovery half is already closed on `dev`: `resolveRestoreHistoryDisposition` stands down on +`HISTORY_RELABEL_STANDS_DOWN` and removal retains the provider table. The new code cannot reach +restore at all — it is only set under `providerTableMode`, and restore preflights with +`providerTableMode = false`, whose row predicate is `model_provider = 'opencodex'`. + +Two things were still wrong on that side. `ocx restore --remove-codex-provider-table` existed but +appeared in no usage or help text, so the escape hatch was reachable only by reading the parser; +it is now in the command registry and top-level usage, bound by a test that reads the flag out of +`dispatch.ts` rather than restating it. And the public guide in all eight locales still said +restore and removal refuse on paginated history and that such a home cannot be uninstalled, which +has not been true since 2026-09-17. + +## Verification + +Static review plus exact-head hosted CI. Per the lane constraints, NOT RUN locally: `bun test`, +any individual test file, `bun run typecheck`, any build, any install, live `ocx`, service +restart, and credential or configuration changes. + +Regression coverage added: + +- `tests/codex-integration/history-paginated-openai-compat.test.ts` — the resolver itself: root + override retained and placed before the first table, CRLF preserved, a user-owned line left + untouched and not claimed, the admission-token refusal naming both remedies as keys that are + asserted to exist in `src/types/config.ts`, every other reason passing through unchanged, and a + source-oracle check that the refusal code is defined once. +- `tests/codex-integration/codex-inject-integration.test.ts` — the end-to-end regression, rewritten + from "refuses" to the full transition: config carries both the table and the marker-owned root + override, the rollout bytes and the thread row are unchanged, and `ocx restore` afterwards takes + the retained override back out. That last assertion is the one that keeps this from trading + #5321 for a new #4812. +- `tests/cli/cli-restore-back.test.ts` — the removal flag is discoverable in both help surfaces. + +## Not in this lane + +The other hard-refusal reasons on the recovery side still have no named repair command: a missing +state database with pending manifest entries, and a backup manifest that is unreadable, foreign, +or schema-invalid. Those are a different failure family from the guard and are left open rather +than folded in here. diff --git a/devlog/_plan/260920_round2_followups/020_r2_desktop_ci.md b/devlog/_plan/260920_round2_followups/020_r2_desktop_ci.md new file mode 100644 index 00000000000..47c658ef83e --- /dev/null +++ b/devlog/_plan/260920_round2_followups/020_r2_desktop_ci.md @@ -0,0 +1,54 @@ +# R2 — the `macos widget + bundle` failure on `dev` + +Status: the job had two independent defects stacked on top of each other. The second was +invisible until the first was fixed, because it lived in a step that had never once executed. + +## First layer: the verification build demanded the release key + +`bundle.createUpdaterArtifacts` is on and the updater public key is committed, so `tauri build` +concluded it had to emit a signed update artifact and stopped with `A public key has been found, +but no private key`. On macOS this bites even with `--bundles app`, because the macOS updater +artifact is derived from the `.app` itself. + +#5338 scoped the opt-out to the verification build with a `--config` override and left +`tauri.conf.json` alone, so release signing stays in `release.yml` where the secret lives. +`BundleConfig` carries `deny_unknown_fields`, so a misspelled override key fails the build +rather than silently reverting to signing — the override cannot rot into a no-op. + +## Second layer: the Verify step asserted a filename that never existed + +With the build green the Verify step ran for the first time and failed on its first line, +`test -x "$app/Contents/MacOS/OpenCodex"`, printing nothing because `test` is silent. + +Tauri renames the main binary only when `mainBinaryName` is set (`tauri-cli` +`src/interface/mod.rs`, with `rename_app` in `src/interface/rust/desktop.rs` a no-op +otherwise). This config does not set it, so the bundled executable keeps the Cargo bin name +`opencodex-desktop`. The job log had said so all along: `Built application at: +.../target/release/opencodex-desktop`. + +The fix reads `CFBundleExecutable` from the bundle's own `Info.plist`. `tauri-bundler` +`create_info_plist` writes that key from the same `main_binary_name()` that +`copy_binaries_to_bundle` uses for the filename, so the plist and the file on disk cannot +disagree. An empty value is rejected so a missing key cannot pass by testing the `MacOS` +directory. + +The other three assertions were checked against the same source and were already correct: +`Settings::copy_binaries` strips the `-` suffix so the sidecar lands as +`Contents/MacOS/ocx`, and `copy_custom_files_to_bundle` resolves `bundle.macOS.files` +relative to `Contents` and errors when the source is missing, so the appex is present with its +executable bit intact. + +## What this leaves open + +CI no longer exercises updater bundling at all. A regression there surfaces only during a +release. Two release-time backstops contain it — `collect-release-assets.ts` throws when the +macOS `app.tar.gz` is missing, and `updater-manifest.ts --require-all` refuses a partially +signed `latest.json` — and both are covered by `tests/ci-workflows/release-desktop-scripts.test.ts`. +What nothing covers is `tauri.conf.json` itself: no test reads it, so flipping +`createUpdaterArtifacts` off or mangling the `plugins.updater` block stays green everywhere +until a release runs. A static contract test over that file is the cheap follow-up. + +The macOS updater filename is also restated by hand in four places — +`collect-release-assets.ts`, `updater-manifest.ts`, the `release.yml` matrix, and +`structure/desktop-shell.md` — with nothing deriving one from another. The same contract test +should tie them together. diff --git a/devlog/_plan/260920_round2_followups/030_lane_r3.md b/devlog/_plan/260920_round2_followups/030_lane_r3.md new file mode 100644 index 00000000000..d38a851a40f --- /dev/null +++ b/devlog/_plan/260920_round2_followups/030_lane_r3.md @@ -0,0 +1,90 @@ +# R3 — the roster and login remainders + +Status: implemented, awaiting review. Scope was #5292 and the two #5261 remainders. + +## #5292 was already closed before this lane opened + +The plan's table says `gui/src/pages/Logs.tsx` restates the recovery-kind union with nine of +thirteen members. That was true when the table was written and stopped being true two hours +earlier: `555f0cacdf` (#5300, 18:41) replaced the copy with the durable roster, and the plan +commit landed at 20:48 from a snapshot taken before it. + +Current `dev` already has all of it. `Logs.tsx` imports `AttemptRecoveryKind` from +`src/usage/telemetry-contract.ts` and its label map closes with +`satisfies Record`, so a fourteenth kind is a typecheck failure +there rather than an "Unknown recovery reason". All ten catalogs carry all thirteen labels plus +the fallback, and `tests/usage/request-outcome-agreement.test.ts` holds both: the label map has +to cover every member of `ATTEMPT_RECOVERY_KIND_ROSTER`, and every key it names has to exist in +every catalog. Verified by reading the tree, not by rerunning the suite. + +Nothing was changed for it. The row is stale, not open. + +## #5261, remainder one: the two CLI logins that discarded the launch + +`src/oauth/login-cli.ts` called `void openUrl(...)` in both `handleOAuthLogin` and +`handleKeyLogin`. Each printed a URL, said it was opening a browser, and asked a question that +assumes it opened — indistinguishable from a login that is working. + +The part that made this more than a missing `console.warn`: `OAuthController.onAuth` returns +`void` and every one of the thirteen provider call sites invokes it as `ctrl.onAuth?.(...)` and +moves on. The launcher's answer therefore arrives after the flow has continued, and on a +callback-server provider `#waitForCallback` has already called `onManualCodeInput` by then. A +warning written at that moment lands on the line the user is typing on. + +Making `onAuth` awaitable would mean changing the controller contract and all thirteen call +sites, which is a much larger change than the defect deserves. Instead the launch reports itself +when it settles, and the two things that could collide with it wait on that report: the +manual-code prompt awaits it before asking, and the key login awaits it before it constructs a +reader at all. A polling provider that never prompts is still told before the login claims to +have worked. + +`BROWSER_LAUNCH_FAILED_HINT` in `src/cli/account-auth.ts` kept its ChatGPT-specific second line +and now derives its first from `BROWSER_LAUNCH_FAILED_NOTICE`, so the sentence has one home +across all three logins. + +The handlers took an optional deps object. The contract worth holding is an order, and an order +is only observable from something that records both events; spawning a launcher and attaching to +stdin to find that out would test the operating system. Production passes none of them. + +## #5261, remainder two: the roster that kept last-good rows silently + +`useCodexAccountPool` kept its rows after a failed read and also kept reporting `ready`. Keeping +the rows is right — blanking a populated pool because one 30s poll missed is its own defect — but +the surface then could not tell a list the server had just confirmed from one that predated a +failure. The reported shape: add an account, the read that would bring it over fails, and the +older accounts are on screen with the new one absent. + +`refreshFailed` sits beside `loadState` rather than inside it, for the same reason `refreshing` +already does. `loadState` answers what the surface can draw and a warm failure does not change +that answer; folding it in would mean either flashing the cold skeleton over good data or saying +nothing. A cold failure still replaces the surface with the error it already had, and the banner +only renders when rows survived, so an empty cold failure is never annotated instead of explained. + +## Verification + +Static review and hosted CI at the exact head. The lane ran no local suite, no individual test, +no typecheck, no build, no install, no `ocx`, and changed no credential or configuration — +recorded as NOT RUN. + +Checked by reading rather than running, because the ratchets are what a merge breaks: + +- No file this lane touches appears in `tests/fixtures/file-size-baseline.json`. The ten i18n + catalogs are in its `exempt` list. +- `tests/oauth/oauth-login-cli-browser-launch.test.ts` is registered in both + `scripts/test-layout/layout.json` and `tests/fixtures/test-layout-expected.json`. The gui + suite has no layout guard. +- The one new i18n key is in all ten catalogs, which `gui/tests/locale-parity.test.ts` and + `gui/tests/claude-desktop-locale.test.ts` both require. +- `CodexAccountLoadState` gained no member. `CodexAccountPoolController` gained one, and the + source-oracle roster in `gui/tests/codex-account-pool-controller.test.ts` names it. +- `CodexAccountPoolLoadStates` stopped restating the load-state union and derives it. + +## The GUI screenshot gate + +`enforce-target` requires a screenshot for a PR that touches `gui`. Producing one needs +`bun run build:gui` and a running proxy, both of which this lane is forbidden to do, so the pull +request says so and offers what can be checked instead: the rendered markup is asserted against a +mounted DOM in `gui/tests/codex-account-pool-stale-refresh.test.tsx` — the banner appears with +surviving rows, carries the catalog string, does not appear on a successful refresh, and does not +replace the cold error — and the new class reuses the existing `.pwi-auth-state` block with the +`--amber` pair already used elsewhere in the theme. diff --git a/devlog/_plan/260920_round2_followups/040_r4_retry_rework.md b/devlog/_plan/260920_round2_followups/040_r4_retry_rework.md new file mode 100644 index 00000000000..6022b10626a --- /dev/null +++ b/devlog/_plan/260920_round2_followups/040_r4_retry_rework.md @@ -0,0 +1,53 @@ +# R4 — #4942 and #4989 as one ambiguous-resend gate + +Status: OPEN. Branch `codex/260920-r4-retry-rework`, cut from `origin/dev` at `d6d87440b7`. + +## Why the two pull requests are one change + +#4942 (FredAmartey) replays a native Responses send whose connection died before any response +head, behind a per-provider opt-in. #4989 (lidge-jun) replaces a native Responses SSE stream that +died after the head while the body had carried only control events. Written apart they read as two +features. Against the stage table #5266 landed in `src/lib/request-failure-model.ts` they are one +row: a stage whose `stageCommitment` is `nothing-observed`, with a cause whose +`causeEvidence` is `unknown`. `resendPermission` answers `refused-ambiguous` for both, and +the module already names the only thing that may override it — "a narrowly scoped, explicitly +opted-in recovery that a maintainer reasoned about and bounded". + +Two overrides is one too many. #4942 spreads `replayResets: 2` into every dispatch leg of the +request and #4989 takes `Math.min(1, remaining)` of the transient budget at the stream boundary, +so one logical request that reset before the head and again after it would buy a replacement on +each. The rework gives the override a single per-request allowance and makes both stages claim +from it. + +## Shape + +- `src/lib/request-resend-gate.ts` — the one gate. Pure table lookup for the stages the caller + already observed something at, plus the operator override for the ambiguous row. It never + restates the table: stage, cause, permission and send class all come from + `request-failure-model.ts`, and the cause comes from the `AttemptRecoveryKind` that will be + recorded, so the reason in the log and the send it authorised cannot disagree. +- `src/lib/request-execution-budget.ts` — the allowance lives on the shared send ledger, which is + what a combo child inherits through `deriveRequestExecutionBudget`. Parent and child therefore + cannot each hold one. +- `src/server/responses/reset-replay.ts` — the provider opt-in and the body judgment from #4942, + plus the per-request authority both call sites use. +- `src/lib/upstream-retry.ts` — the pre-header claim, as a callback rather than a number. +- `src/server/responses/combo-stream-preflight.ts` — the preflight reports the stage it observed + instead of a boolean, so the gate rather than the preflight decides. + +## Stage classification at the stream boundary + +#4989 gated on `responseCreated && !outputCommitted && !terminal`. That is `protocol-prelude`. +A read error before any parsed event is `headers-only`, which the table gives the same +commitment and therefore the same answer; the rework admits it rather than refusing a row the +table permits. Everything else the preflight can see is `semantic-output` or `terminal`, and +those refuse regardless of cause. + +## In scope from the remainders + +#4191 and #5180 only to the extent the resend gate reaches them. Recorded in 050. + +## Verification + +Static review plus exact-head hosted CI. Local suites, individual tests, typecheck, build, +install and live `ocx` execution are NOT RUN by lane policy. diff --git a/devlog/_plan/260920_round2_followups/050_lane_r5.md b/devlog/_plan/260920_round2_followups/050_lane_r5.md new file mode 100644 index 00000000000..7343408d0cf --- /dev/null +++ b/devlog/_plan/260920_round2_followups/050_lane_r5.md @@ -0,0 +1,191 @@ +# Lane R5 — the four telemetry pull requests as derived consumers of the recorder + +Status: OPEN. Branch `codex/260920-r5-telemetry-derived`, rebased onto `dev` after `origin/dev` +advanced mid-lane. One branch, ordered commits, one pull request to `dev`. + +Lane C deferred #2366, #3748, #3983 and #5063 "as implemented", because each adds a parallel store +or a second emission path. [030_lane_c2.md](../260920_meaning_preservation_batch/030_lane_c2.md) +then specified the derived form for each. This lane builds those four forms. It adds no store: the +durable shapes stay `PersistedUsageAttempt` and `PersistedUsageEntry`, and every projection reads +them. + +## What landed, per pull request + +### #2366 (chilung-cgu) — durable failure attribution, in the landed vocabulary + +`failureStage` and `failureCause` now ride the attempt that ended a request and the logical row, +both closed roster members. `FailureSide` and the seven-member `FailureStage` are not here: two +attribution vocabularies for one question is the class that blocked 2.60.0. The PR's widening of +`transportPhase` and `terminalSource` to arbitrary strings is not here either; those validators +stay closed, and `terminalStatus` — which was a plain `string` — joined them, because it is now a +grouping-key slot and it is assembled from an upstream frame. + +The derivation reads only closed values. `errorCode` and `upstreamError` are excluded on purpose: +both carry upstream text, so a classification keyed on them is a different answer per provider and +per locale, and a key built from them cannot promise it carries no content. That exclusion is what +lets the pair be a Prometheus label and a fingerprint component with no masking pass. + +It runs at `addFinalRequestLog`, the one seam every request passes exactly once, and before the +attempt snapshot so the disk row and the live attempt carry the same pair. `addRequestLog` rebuilds +the persisted row field by field, so the pair is written there explicitly — a field omitted at that +line reaches `/api/logs` and never reaches `usage.jsonl`. + +**The resend verdict is not stored.** `/api/logs` computes `resendPermission` at read time for the +row and each attempt. The tables that decide it live in this build; a row written months ago must +not assert a permission the current tables refuse. + +**Known limit, recorded rather than hidden.** Only the attempt that ends a request, plus the one +sealed by a key-account rotation, carry attribution. The other intermediate finalizers — +`policy-fallback.ts` and five sites in `core-combo.ts` — still reach the ledger unattributed. Each +has different evidence in scope and a branch verified by static review alone should not add six new +classification call sites at once. The logical row is attributed in every case, which is what the +projection and the exporter read. + +**Second known limit.** A ciphertext or reasoning-parameter recovery that SUCCEEDED, followed by an +unrelated 400 on the same attempt, still reads as that recovery's cause. The rule is narrowed to +the last recorded kind on the matching status, and the proper fix — clearing recovery evidence on +success in `core-opaque-recovery.ts` — belongs in the recovery path, not the derivation. + +### #3748 (yansigit) — a failure grouping, not a second ledger + +`src/telemetry/` and its SQLite store are not built. Failed rows are grouped by a versioned +fingerprint over a fixed-arity tuple of closed roster members, folded during a scan of +`usage.jsonl` through the existing `scanUsageLedgerCooperatively`. The projection holds a count and +two timestamps per group; delete a ledger row and it leaves the grouping on the next rebuild. + +The free-text `signature` and its regex masking are replaced by construction rather than by a +better regex: an expression can only assert it removed what it matched, while a tuple whose every +slot comes from a frozen list has nothing to remove. Absent facts are explicit nulls in fixed +positions, because omitting them would let `[a, null, b]` and `[a, b]` collide. + +**A deliberate divergence from 030_lane_c2.md, flagged for the coordinator.** That document says +"No provider". The lane brief for R5 says the fingerprint is over "closed cause + provider + model +class". The brief is the later and more direct instruction, so `providerClass` is in the tuple — +resolved against the provider registry so it is a registry id or `null`, never the alias a user +typed. Model class is NOT in the tuple: no closed model-class vocabulary exists in this repository +and inventing one is the union-exhaustive hazard this batch exists to avoid. Removing +`providerClass` is one slot and a version bump if the coordinator prefers the C2 shape. + +The mutable `monitoring/dispatched/fixed/ignored` status and its notes are absent. They are +operator state; they cannot be reconstructed from immutable request rows, so presenting them as a +derived ledger would be a claim this projection cannot make. + +The reader is `GET /api/usage?failures=1` rather than a new route: it answers a different question +from the usage summary and costs a scan, so it is opt-in and no new CLI-parity surface appears. + +### #3983 (yansigit) — five counts on the attempt, no second emission path + +`emitDebugLine` writes the in-process ring AND stderr, and stderr is redirected to the service log +under launchd and systemd, so the PR's per-event lines would give an installed service a durable +per-event history beside the ledger. Its per-payload HMAC used a process-global random key, making +every repeated prompt fragment, tool name and error message correlatable for the process lifetime. + +Instead the attempt carries adapter events, relayed frames, semantic bytes, side effects and +terminal frames. Adapter events are counted at the existing adapter-parse seam; relayed frames +after a SUCCESSFUL `controller.enqueue`. Counting both at the reader would make them equal by +construction and erase the loss signal. The recorder is bound to the request's translator budget +and reaches the current attempt through a callback, so a mid-request attempt rotation credits the +live attempt rather than one already finalized. The debug ring now FORMATS one line per finalized +attempt from those counts, through `appendDebugLogLine` and never `emitDebugLine`. + +Adversarial review caught the case this design gets wrong on its own: a non-streaming turn delivers +one body and calls no per-frame recorder, so every buffered response would have persisted adapter +events with zero relayed ones — the loss signal, raised on every buffered request. The buffered +seam now records its delivery from the body it built. + +`run-turn-execution.ts` is untouched. Its accounting distinguishes adapters that report their own +physical sends, and the PR's unconditional pre-count would double-charge them. + +### #5063 (Vocllum) — retention with a revision contract + +`usageLedgerMaxBytes` is unset by default and unset means unlimited. When set, an append that +crosses it publishes the newest whole rows byte for byte through the shared atomic writer. + +The defect this closes: #5063 captured a size, copied a suffix and renamed over whatever was there, +so a row appended in between was silently dropped; its own concurrency test performed two +sequential calls and said it could not test concurrency. Two things close it. The append is +synchronous and the compaction runs inside the same call stack, so no in-process append can +interleave, and a second server on the same home cannot append at all — it is refused by the +existing ledger-owner lease, which is why the hook is installed after ownership. And +`validateBeforeRename` re-opens the target immediately before the rename and refuses unless +identity, size and revision metadata are byte-for-byte what was copied. A focused test drives that +exact window through an injected hook. + +Rows are copied and never parsed, which is what keeps a field a newer build wrote intact through a +compaction. The writer gained a streaming form so the retained span is not held in memory, and that +form fsyncs the temp before the rename and the parent directory after it. + +The invalidation half was missing from the original entirely. A compaction now discards the +2,000-entry Logs ring, the retained usage aggregate and failure projection, and the request-history +index — otherwise `/api/logs` keeps serving rows the ledger no longer has. + +**This does not close #5063.** The Usage-page control it also asks for is not here: this lane may +not build or run the GUI, so it cannot produce the screenshot the gate requires, and shipping an +unverifiable control is worse than shipping the policy it would set. The limit is settable in +`config.json` today and the configuration reference says so. Remaining scope: the dashboard +control, its management route, and the ten catalog strings. + +## The GUI screenshot gate + +This branch changes `gui/src/pages/Logs.tsx` and the ten locale catalogs, so `missing_ui_screenshot` +fires. It fires on changed paths under `gui/`, not on words in a description, and this lane may not +run `bun run build:gui`. A maintainer comment or the `gui-screenshot-waived` label is the documented +resolution. + +The evidence to judge it without the screenshot: the catalog edits are purely additive (+29 lines, +0 removed, in each of ten files, all exempt from the file-size ratchet), every new key exists in all +ten catalogs, and three `satisfies` clauses make a missing label a typecheck failure rather than a +silent fallback. The visible change is three rows added to the Logs detail dialog for a failed +request — the cause, the stage it reached and the resend verdict — and a named cause where the +attempt table previously led with a bare wire code. + +## Pre-existing defect found and deliberately not fixed here + +`src/config/atomic-write.ts` scrubs a failed temp through `effective.write(tmp, "")`, but the default +writer opens with `"wx"`, so that fallback always fails with `EEXIST` on an existing temp. It only +matters when `truncate` has also failed, and the temp is owner-only. It predates this branch and +affects every atomic config write, including secret-bearing ones, so fixing it is a change to a +security-adjacent path that belongs in its own lane rather than inside a telemetry branch. + +## The file-size ratchet caught this branch once + +`src/server/request-log.ts` carries the whole request-logging surface and was 1,962 lines against +the repository's 2,000-line seed threshold. The attribution wiring pushed it to 2,015, and +`file-size ratchet: repository` reported `NEW_OVERSIZED` on the first exact-head run. The remedy is +the one AGENTS.md gives — a move, never a number — so the two places a stage and cause are decided +and written moved to `src/server/request-log-failure-attribution.ts`, leaving the file at 1,979. + +Worth recording for the next lane that touches this file: 21 lines of headroom is not much, and +the cap only ever moves down. + +## Verification + +Static source review plus exact-head hosted CI, and three adversarial reviews at high effort +covering typecheck hazards, repository gates, and runtime correctness and privacy. Their findings +are in the branch: the transport-evidence precedence, the 402 mapping, `transport-unsent` no longer +being the fall-through, the parent-directory fsync, the buffered delivery accounting, the rosters +read instead of restated in two tests, and the invariant split into INV-RESEND-01 and +INV-ATTRIBUTION-01 so each binds exactly one test. + +NOT RUN on this branch, by instruction: `bun run test`, any individual `bun test` file, +`bun run typecheck`, `bun run build:gui`, `bun run lint:gui`, `bun install`, +`bun run structure:check`, `bun run privacy:scan`, and any live `ocx` execution. None of these may +be recorded as passing. + +Checked statically: + +- no file this branch touches is at or over its file-size ratchet cap; `src/server/index.ts` sits at + 884 against 893, and the ten catalogs are exempt; +- `scripts/test-layout/layout.json` and `tests/fixtures/test-layout-expected.json` agree key for key, + and each new test's regex seed resolves to the domain it is registered to — `failure-attribution` + is named to avoid the `request-` seed that would have placed it in `usage`; +- `src/usage/telemetry-contract.ts` still has no imports, `src/usage/request-outcome.ts` still reaches + nothing but it, and `gui/src/pages/Logs.tsx` still never names `src/usage/log`; +- no test or document restates a source constant: the rosters, the fingerprint version and the label + keys are read from the modules that declare them. + +## Issues + +#2366, #3748 and #3983 are addressed by these derived forms; the coordinator decides closure. #5063 +is partially addressed and must not be closed — its dashboard control is named above as remaining +scope. diff --git a/devlog/_plan/260920_round2_followups/050_r4_remainders.md b/devlog/_plan/260920_round2_followups/050_r4_remainders.md new file mode 100644 index 00000000000..4c826a9d172 --- /dev/null +++ b/devlog/_plan/260920_round2_followups/050_r4_remainders.md @@ -0,0 +1,59 @@ +# R4 — what the remainders reach, and what they do not + +The lane brief put #4191 and #5180 in R4 "as far as the rework reaches". This records where that +line actually fell, with the evidence, so the next lane starts from a finding rather than a +re-investigation. + +## #4191 — reached: nothing. Found: a wrong stage in the shared vocabulary + +The resend gate does not consult the WebSocket projection. The post-header path excludes a +`isCodexWsUpstreamResponse` body on purpose: the WS transport settles its own ambiguous +failures and marks them non-replayable, and a second reader of one exchange is a defect, not a +recovery. So the durable threading the round-2 plan names is untouched here. + +The investigation did surface a real defect in the projection itself. +`classifyCodexWsFailure` in `src/server/responses/codex-ws-wire.ts` returns +`after-response-started` — which `CODEX_WS_FAILURE_PROJECTION` maps to `semantic-output` — +as soon as `relayedEvents > 0`. But `src/server/responses/codex-ws-exchange.ts` increments +`relayedEvents` for every non-metadata Responses event, and `response.created` is one: +`controlFrame` is set only when the metadata channel consumes the frame, not for lifecycle +events. `src/lib/request-failure-model.ts` puts `response.created` in `protocol-prelude` +and requires an output-bearing event for `semantic-output`. A WS failure carrying only a +created event therefore projects as committed output today. + +It is left here rather than fixed because the fix needs a counter the classifier does not have, +and `CodexWsStageRecord` is derived from `CodexWsFailureStage` by `Omit`, so adding one +lands in a persisted record whose read-back whitelist in `src/usage/log.ts` would reject every +row written before it. Adding the counter and `Omit`-ing it from the durable twin avoids that, +but the output-bearing predicate lives in `combo-stream-preflight.ts` and restating it in the +exchange is the class of duplication this round already paid for three times. It belongs with +the lane that threads `failureStage` / `failureCause` into the record, where both halves can +be written once. + +The SSE fallback the issue asks for stays out regardless. After `ws.send()` returns, a +fallback is a second physical send on another transport, which is a transport decision with its +own duplicate-inference policy — not a retry-gate change. + +## #5180 — reached: nothing. The symptom is upstream of this gate + +The reported failure is a key-auth `openai-chat` provider answering a bare 429. Traced on +current `dev`: `rateLimitRetryPolicyFor` returns null for every provider except the +OpenCode Go destination, so the same-target wait never runs; key rotation needs a pool of at +least two; and `fetchWithResetRetry` returns the first received HTTP response without +consulting its status. One send, 429 returned, which is exactly what the reporter saw. +`Retry-After` is forwarded to the client — synthesized as `2` for a bare retryable 429 by +`src/lib/retry-after.ts` — but the proxy never waits on it itself. + +None of that is an ambiguous-resend question: a received 429 is `headers-only` with cause +`rate-limit`, which the stage table already answers `permitted` and funds from the +`transient` class. It needs no grant and no override. What it needs is a policy default and a +process-wide cooldown that a single-key provider can write, and `keyCooldowns` cannot be +reused unchanged because both its identity and its write path require a multi-key pool. + +One adjacent accounting gap is worth recording for whoever takes it. On the generic adapter +path, `prepareAdapterExchange` passes `attempts` and `onSendsConsumed` to its retry helper +only when `transientRetryOn5xx` is configured. An unconfigured provider's initial send is +therefore recorded in the attempt log but never charged to the request-wide send counter. It is +bounded today — without `replaySafe` the reset helper makes exactly one send — so it is an +under-count rather than an amplification, and widening it without a suite to run is not a change +worth making blind. diff --git a/devlog/_plan/260920_round2_followups/060_r6_usage_models_table.md b/devlog/_plan/260920_round2_followups/060_r6_usage_models_table.md new file mode 100644 index 00000000000..e3bd25cd139 --- /dev/null +++ b/devlog/_plan/260920_round2_followups/060_r6_usage_models_table.md @@ -0,0 +1,95 @@ +# R6 — the Usage models table + +Status: OPEN until the pull request lands on `dev`. + +Four defects a user hit on the dashboard Usage tab, all in the models table. Three are layout; the +first is a reading the table gets wrong. + +## The hit rate was withheld from every provider that reports partial cache detail + +On the reporter's dashboard `gpt-5.6-sol` shows 2.8B cache hits and a hit rate of `—`. +`gpt-6-astra`, `k3[1m]`, `gemini-3.8-flash`, `gpt-5.6-luna` and `grok-4.6` show the same thing. The +reporter read it as the zero in the cache-writes column suppressing the rate. + +It is not the writes column. The summary is right and the dashboard was throwing its answer away. + +`calculateCacheHitRate` in `src/usage/summary.ts` averages cache reads over +`cacheObservedInputTokens` — the input tokens whose cache detail was actually reported — and +returns `null` when nothing was observed. That denominator is the #4546 contract recorded in +`structure/gui-and-management-api.md`: a synthesized zero and an unreported detail must not be +averaged as cache misses, or a pool that discarded every warm prefix reports a plausible hit rate. +A provider that reports reads and never reports writes is observed, and it has a rate. + +The dashboard then required that denominator to cover the row's **entire** input before it would +show the number: + +```tsx +model.cacheObservedInputTokens >= model.inputTokens ? model.cacheHitRate : null +``` + +One request in the row with no cache detail — a locally answered turn, an unreported usage record, +a row written by an older proxy — puts the denominator below `inputTokens` and blanks the column. +For a busy model that is every row, which is why six models with billions of measured hits all read +`—`. The gate arrived with the cache columns in #5268 and was never the server's rule. + +The fix drops the gate. The cell renders whatever the summary supplied, because the summary already +refused to supply a number it could not justify, and the coverage becomes a tooltip instead of a +reason to hide the value: `usage.cacheHitRate.partial` names the measured and total input tokens on +a partially observed row, `usage.cacheHitRate.unmeasured` explains the em dash on a row where +nothing reported cache detail. That row — no basis at all — is now the only one that shows `—`. + +The coverage sentence is carried twice: a `title` for a pointer, and an `sr-only` span so it is not +mouse-only. A `td` is not focusable and a `title` never reaches a keyboard or a touch screen, and a +cell whose whole point is to explain a number should not explain it to one input device. + +No server change. The denominator, the provenance split and the `null` are all correct as they +stand, and the structure doc that owns the contract stays accurate. + +## Column order + +`Model, Provider, Share, Tokens, API list-price`, then the per-request detail: +`Requests, Measured, Input tokens, Output tokens, Cache hits, Cache writes, Hit rate`. Identity +first, then the three figures a reader compares models on, then the evidence behind them. The +previous order buried share and price behind five cache columns. + +## Sideways scroll and pinned identity columns + +`.tbl` is `width: 100%`, so twelve columns divided the shell between them until eight-digit token +totals folded onto a second line. The models table is now `width: max-content; min-width: 100%` and +the shell scrolls sideways — `.tbl-wrap` was already `overflow-x: auto`, so nothing else had to +move. Model and provider are `position: sticky` at fixed widths so a row stays identifiable while +its numbers scroll; both offsets are one `var(--space-3)` step negative, the same trick the sticky +header plays with `top`, so a stuck cell repaints the scrollport padding it slides over. Under +720px the pinning stands down, because at that width two pinned columns cost more reading room than +scrolling the whole table does. + +Every selector is doubled as `.tbl.usage-models-tbl`. This file is `@import`ed from the top of +`styles.css`, so the whole of `styles.css` cascades after it, and a single class ties +`.tbl { width: 100% }` on specificity and loses on source order — the sizing contract reads as +applied and does nothing. The rules that already lived in this file buy the same margin with a +`.usw-section` prefix. The source-oracle case asserts the doubled form, because the single-class +version is the failure that looks correct. + +## The exclusion caption + +`(56 requests excluded)` shared a line with the amount and folded mid-phrase. It is a block now, so +the amount is the first line and the caption is the second. + +## Verification + +GUI change, so the screenshot gate applies and this lane cannot satisfy it: builds are not +permitted here, so no dashboard was rendered to photograph. The evidence offered instead is the +column order and cell layout written out above, the regression assertions below, and hosted CI. + +- `gui/tests/usage-custom-range.test.tsx` — the partially observed row now asserts `90%` where it + asserted `—`, with both tooltips, and the header sequence asserts the new order. +- `gui/tests/usage-layout.test.ts` — new source-oracle case binding the scroll, the pinned columns + and the block caption, so removing the stylesheet rules fails rather than degrading silently. +- Adversarial static review by a second agent, since nothing here may be executed: it reproduced + the cascade defect above independently and hand-evaluated the rendered cell arrays for all three + fixture rows against the new JSX. +- NOT RUN: `bun run test`, `bun test` on any single file, `bun run typecheck`, `bun run lint:gui`, + `bun run build:gui`, `bun install`, and any `ocx` execution. Hosted CI at the exact head is the + only execution evidence for this lane; GUI lint, typecheck and `gui` tests all run in the + `gates` job of `Cross-platform CI`, which a branch push does not trigger and the pull request + does. diff --git a/devlog/_plan/260920_round2_followups/070_widget_signing.md b/devlog/_plan/260920_round2_followups/070_widget_signing.md new file mode 100644 index 00000000000..9fbf7ec0059 --- /dev/null +++ b/devlog/_plan/260920_round2_followups/070_widget_signing.md @@ -0,0 +1,70 @@ +# Widget extension signing + +Status: OPEN until the pull request lands on `dev`. + +The macOS app would have installed with no widget, and nothing in the build would have said so. + +## What was wrong + +`macOS.files` in `desktop/src-tauri/tauri.conf.json` puts `PlugIns/OpenCodexWidget.appex` into the +bundle. The Tauri bundler copies it and never signs it: `copy_custom_files_to_bundle` in +tauri-bundler 2.5.0 writes the file and does not add it to `sign_paths`, which only ever holds +`Contents/MacOS`, `Contents/Frameworks` and the `.app` itself. There is no `--deep` anywhere in +that path. Whatever signature `build-widget.sh` leaves is therefore the signature that ships. + +`build-widget.sh` left an ad-hoc one. Its signing branch keys on `MACOS_SIGN_IDENTITY`, and the +release workflow set that variable only on the `Build desktop bundles` step — the step *after* the +widget was built. `Build WidgetKit extension` carried no `env:` block at all, so the script always +took its `codesign --force --sign -` fallback, with `--timestamp=none` and no hardened runtime. + +macOS does not register an extension signed that way, and notarization rejects any Mach-O in a +bundle that lacks the hardened runtime. + +## What had not happened yet + +No release has shipped a macOS app. v2.58, v2.59 and v2.60 all carry zero desktop assets, and the +`APPLE_*` secrets were added to the repository hours after the last release ran. The signed branch +of this script has never executed. This is a defect found before its first victim, not one being +recovered from — the next release is where it would have landed. + +## The fix + +The script resolves its signing identity before the Swift build, so a release that holds Developer +ID material and somehow has no identity fails in a second instead of after a universal build, and +never leaves a half-built unsigned appex behind. `WIDGET_SIGN_REQUIRED=1` makes that refusal the +behaviour whenever the workflow holds a certificate; the ad-hoc branch stays for local builds. + +Signing walks every Mach-O the bundle actually contains, chosen by magic bytes rather than by name. +Today that set is one file. A suffix filter is the thing that fails silently when that stops being +true: a helper tool or an embedded dylib carries no extension to match, stays unsigned, and the +submission comes back "The binary is not signed with a valid Developer ID certificate" while the +containing bundle looks perfectly signed. Every signature now carries `--options runtime`, and the +script re-reads its own result and fails if the runtime flag is missing. + +The workflow imports the certificate into a temporary keychain before the widget is built, because +codesign resolves an identity through the keychain search list and Tauri does not build its own +keychain until the bundling step. Tauri re-adds itself to the same search list, so the two do not +collide, and a cleanup step deletes the keychain on any outcome. + +## Verification + +Run locally on macOS 27 with Xcode 27.0 and a real Developer ID in the keychain. + +- Signed path: `flags=0x10000(runtime)`, `Authority=Developer ID Application`, `TeamIdentifier` + set, secure timestamp present, `com.apple.security.app-sandbox` preserved, and + `codesign --verify --deep --strict` clean. `CFBundleShortVersionString` and `CFBundleVersion` + both resolve to the Tauri version. +- Ad-hoc path with no identity: `flags=0x10002(adhoc,runtime)` — the hardened runtime is now on + the local build too, so the two paths differ only in who signed. +- `WIDGET_SIGN_REQUIRED=1` with no identity: refuses in under a second, before the build. +- Bundle simulation: an `.app` holding the signed appex under `Contents/PlugIns`, signed the way + Tauri signs — inner executables, then the bundle, no `--deep` — keeps the nested Developer ID + signature, runtime flag, team identifier and entitlements intact, and + `codesign --verify --deep --strict` reports `--validated:...OpenCodexWidget.appex`. +- The same simulation over an appex left unsigned fails outer signing with + `In subcomponent: .../OpenCodexWidget.appex`. +- `tests/ci-workflows/release-desktop-scripts.test.ts` — 11 pass, binding the workflow wiring, the + magic-byte sweep, the hardened runtime and its self-check, and the refusal. + +End-to-end notarization of a full OpenCodex `.app` was not run; that needs a complete `tauri build` +and the release workflow is where it belongs. diff --git a/devlog/_plan/260920_round2_followups/090_closeout.md b/devlog/_plan/260920_round2_followups/090_closeout.md new file mode 100644 index 00000000000..7d491dff65e --- /dev/null +++ b/devlog/_plan/260920_round2_followups/090_closeout.md @@ -0,0 +1,66 @@ +# Round 2 closeout + +Status: CLOSED. Every R lane landed on `dev` and the branch is green again. This file records +what landed, the two incidents the round produced, and the rule the maintainer approved because +of them. + +## What landed + +| Lane | Pull request | Subject | +| --- | --- | --- | +| R1 | #5331 | Complete the provider-table transition on a paginated OpenAI home | +| R2 | #5338, #5351 | Keep the verification build out of updater signing, then assert the executable the bundle declares | +| R3 | #5332 | Make a failed browser launch and a failed account refresh visible (#5261) | +| R4 | #5342 | Rework #4942 and #4989 into one ambiguous-resend gate with one grant per request | +| R5 | #5347 | Derive the four telemetry pull requests from the landed recorder | +| R6 | #5333, #5345, #5353 | Usage table readability, WidgetKit Developer ID signing, keychain step location | + +#5342 is the one to notice. An earlier lane had ruled that #4942 and #4989 must not each buy an +independent replacement send for one logical request, and that they therefore belonged in a +single reworked change rather than two. That disposition closed as an implementation rather than +as a note. + +## Incident one: a default flip that no test could see + +#5271 removed a hostname test that decided the `developer` wire role. Deleting the inference was +right — a gateway proxying OpenAI accepts the role and the hostname cannot say so. The +replacement default was wrong in the other direction: forwarding to every destination assumed +each one accepts a standard role until an operator marks it. + +Three lane dispatches died on `400 role 'developer' is not allowed` within four seconds of +starting. Nothing in this repository saw it first, because every test in the tree was written +against the new default and passed. What broke was outside the tree. + +#5334 made the key tri-state with the unset state on the safe side, and then three more landings +were needed because three suites still asserted the forwarded role and the first sweep missed +them: the Lab conformance vector in `src/lab/` (#5341), a suite whose messages come from a +helper rather than a literal (#5344), and a suite about documents that reads the role only to +locate the turn (#5346). Searching for a string is not how you find what asserts a default; the +reliable question is which tests call the adapter at all. + +## Incident two: a verification step that had never run + +The `macos widget + bundle` job failed on `tauri build` because the updater public key is +committed and the private key is not in CI. #5338 scoped the opt-out to the verification build. +With that green, the Verify step ran for the first time and failed on its first line, silently, +because `test` prints nothing: it asserted `Contents/MacOS/OpenCodex` while Tauri keeps the Cargo +bin name unless `mainBinaryName` is set. #5351 reads `CFBundleExecutable` from the bundle instead. + +The same shape appeared once more at the end. #5345's test located a workflow step by name, #5339 +renamed that step while the branch was open, and the rename survived the merge while the assertion +did not. #5353 locates the steps by what they run. + +## The rule the maintainer approved + +A change that flips an existing default is a separate approval item before merge. Tests in the +tree are written against the new default and pass; what breaks is the set of real destinations +outside it, which exact-head CI cannot reach. Two instances landed on the same day — the 1 MiB +queue budget in #5182 and the role default in #5271 — and only the second was caught by a human +noticing that dispatch had stopped working. + +## Still open + +#5261 keeps two remainders: generic OAuth and key login still discard the launch result, and the +dashboard roster keeps last-good rows after a failed refresh. #4191 wants the SSE fallback and +#5180 the shared cooldown, both transport and routing changes. #5292 records the Logs page union +restatement. #2366, #3748, #3983 and #5063 remain deferred with reasons recorded on each. diff --git a/devlog/_plan/260920_round2_followups/r6-usage-table/r6-01-column-order.png b/devlog/_plan/260920_round2_followups/r6-usage-table/r6-01-column-order.png new file mode 100644 index 00000000000..eae536967f7 Binary files /dev/null and b/devlog/_plan/260920_round2_followups/r6-usage-table/r6-01-column-order.png differ diff --git a/devlog/_plan/260920_round2_followups/r6-usage-table/r6-02-pinned-scroll.png b/devlog/_plan/260920_round2_followups/r6-usage-table/r6-02-pinned-scroll.png new file mode 100644 index 00000000000..dc835c86e0f Binary files /dev/null and b/devlog/_plan/260920_round2_followups/r6-usage-table/r6-02-pinned-scroll.png differ diff --git a/devlog/_plan/260920_round2_followups/r6-usage-table/r6-03-narrow-fallback.png b/devlog/_plan/260920_round2_followups/r6-usage-table/r6-03-narrow-fallback.png new file mode 100644 index 00000000000..623f653e1a9 Binary files /dev/null and b/devlog/_plan/260920_round2_followups/r6-usage-table/r6-03-narrow-fallback.png differ diff --git a/docs-site/astro.config.mjs b/docs-site/astro.config.mjs index 29b5abb1e79..2b68b5d457b 100644 --- a/docs-site/astro.config.mjs +++ b/docs-site/astro.config.mjs @@ -96,6 +96,8 @@ export default defineConfig({ { label: "Codex App Model Picker", translations: { fr: "Sélecteur de modèles de Codex App", ko: "Codex App 모델 선택기", "zh-CN": "Codex App 模型选择器", "zh-TW": "Codex App 模型選擇器", ru: "Выбор модели в Codex App", ja: "Codex App モデルピッカー", tr: "Codex App Model Seçici" }, slug: "guides/codex-app-models" }, { label: "Codex Prompt Layers", translations: { fr: "Couches d'invite Codex", ko: "Codex 프롬프트 레이어", "zh-CN": "Codex 提示词层", "zh-TW": "Codex 提示詞層", ru: "Слои промпта Codex", ja: "Codex プロンプトレイヤー", tr: "Codex İstem Katmanları" }, slug: "guides/codex-prompt" }, { label: "Native Context Compatibility", translations: { ko: "네이티브 컨텍스트 호환성" }, slug: "guides/codex-native-context" }, + { label: "macOS Menu Bar App", translations: { fr: "Application barre de menus macOS", ko: "macOS 메뉴바 앱", "zh-CN": "macOS 菜单栏应用", "zh-TW": "macOS 選單列 App", ru: "Приложение в строке меню macOS", ja: "macOS メニューバーアプリ", tr: "macOS Menü Çubuğu Uygulaması" }, slug: "guides/macos-menu-bar" }, + { label: "Desktop App", translations: { fr: "Application de bureau", ko: "데스크톱 앱", "zh-CN": "桌面应用", "zh-TW": "桌面 App", ru: "Настольное приложение", ja: "デスクトップアプリ", tr: "Masaüstü Uygulaması" }, slug: "guides/desktop-app" }, { label: "Model Ordering", translations: { fr: "Ordre des modèles", ko: "모델 정렬에 관하여", "zh-CN": "模型排序", "zh-TW": "模型排序", ru: "Сортировка моделей", ja: "モデルの並び順", tr: "Model Sıralaması" }, slug: "guides/model-ordering" }, { label: "Combos", translations: { fr: "Combinaisons", ko: "콤보", "zh-CN": "组合", "zh-TW": "組合", ru: "Комбо", ja: "コンボ", tr: "Kombolar" }, slug: "guides/combos" }, { label: "Claude Code", translations: { fr: "Claude Code", ko: "Claude Code", "zh-CN": "Claude Code", "zh-TW": "Claude Code", ru: "Claude Code", ja: "Claude Code", tr: "Claude Code" }, slug: "guides/claude-code" }, @@ -164,6 +166,7 @@ export default defineConfig({ items: [ { label: "Windows Memory Growth", translations: { fr: "Augmentation de la mémoire sous Windows", ko: "Windows 메모리 증가", "zh-CN": "Windows 内存增长", "zh-TW": "Windows 記憶體增長", ru: "Рост памяти в Windows", ja: "Windows メモリ増加", tr: "Windows Bellek Artışı" }, slug: "troubleshooting/windows-memory" }, { label: "Disk Usage from Temp Files", translations: { fr: "Espace disque et fichiers temporaires", ko: "임시 파일 디스크 사용량", "zh-CN": "临时文件磁盘占用", "zh-TW": "暫存檔磁碟用量", ru: "Использование диска временными файлами", ja: "一時ファイルのディスク使用量", tr: "Geçici Dosya Disk Kullanımı" }, slug: "troubleshooting/disk-usage-temp-files" }, + { label: "Codex Cannot Sign In or Load", translations: { fr: "Codex ne peut pas se connecter", ko: "Codex 로그인 불가", "zh-CN": "Codex 无法登录", "zh-TW": "Codex 無法登入", ru: "Codex не может войти", ja: "Codex にサインインできない", tr: "Codex Oturum Açamıyor" }, slug: "troubleshooting/codex-cannot-sign-in" }, ], }, { label: "Contributing", translations: { fr: "Contribuer", ko: "기여하기", "zh-CN": "贡献", "zh-TW": "貢獻", ru: "Как внести вклад", ja: "コントリビュート", tr: "Katkıda Bulunma" }, slug: "contributing" }, diff --git a/docs-site/public/favicon.ico b/docs-site/public/favicon.ico index de1689b20be..8f120715e2d 100644 Binary files a/docs-site/public/favicon.ico and b/docs-site/public/favicon.ico differ diff --git a/docs-site/public/favicon.png b/docs-site/public/favicon.png index 7a741511cff..6c26bfafb00 100644 Binary files a/docs-site/public/favicon.png and b/docs-site/public/favicon.png differ diff --git a/docs-site/src/content/docs/fr/getting-started/quickstart.md b/docs-site/src/content/docs/fr/getting-started/quickstart.md index 7a7c3f8cec3..73512a4a6c8 100644 --- a/docs-site/src/content/docs/fr/getting-started/quickstart.md +++ b/docs-site/src/content/docs/fr/getting-started/quickstart.md @@ -13,7 +13,7 @@ ocx init `ocx init` vous accompagne dans les étapes suivantes : -1. **Choix d’un fournisseur** — sélectionnez l’un des 95 préréglages intégrés au registre, ou `custom` pour saisir une +1. **Choix d’un fournisseur** — sélectionnez l’un des 96 préréglages intégrés au registre, ou `custom` pour saisir une URL de base et un adaptateur. 2. **Clé API** — collez une clé ou référencez une variable d’environnement telle que `${ANTHROPIC_API_KEY}`. 3. **Modèle par défaut** — pour les fournisseurs clés, locaux et personnalisés, acceptez le préréglage ou saisissez un identifiant de modèle. diff --git a/docs-site/src/content/docs/fr/guides/claude-code.md b/docs-site/src/content/docs/fr/guides/claude-code.md index d207a3bc003..45851d1fc0d 100644 --- a/docs-site/src/content/docs/fr/guides/claude-code.md +++ b/docs-site/src/content/docs/fr/guides/claude-code.md @@ -121,7 +121,34 @@ Sur macOS, l'intégration automatique (`claudeCode.systemEnv`) suit la même ré `claude` lancée sans passer par `ocx` se comporte donc de la même manière. Le fichier d'environnement est un instantané actualisé au démarrage du proxy ou lors de l'enregistrement des paramètres, tandis que `ocx claude` effectue toujours une résolution immédiate. -## Profil Claude Desktop +## Modes Claude Desktop : first-party (par défaut) et passerelle + +Claude Desktop utilise OpenCodex dans l'un de deux modes mutuellement exclusifs. Choisissez-le dans +**Claude → Bureau → Mode de connexion** du tableau de bord ou avec +`ocx claude desktop apply --first-party|--gateway`. + +- **First-party (par défaut)** : Desktop lui-même n'est pas reconfiguré. La connexion claude.ai, + l'onglet Chat, les connecteurs et le contrôle à distance continuent de fonctionner. OpenCodex + n'écrit que deux valeurs dans le bloc `env` de `~/.claude/settings.json` : + `HTTPS_PROXY=http://127.0.0.1:` et + `NODE_EXTRA_CA_CERTS=~/.opencodex/claude-intercept/ca.pem`. Seuls Claude Code lancé par Desktop + pour l'onglet Code (sous-agents compris) et la CLI `claude` du terminal les lisent et passent par le + proxy d'interception local ; seuls `POST /v1/messages` et `count_tokens` sont traités par OpenCodex, + les autres chemins de `api.anthropic.com` sont relayés tels quels vers Anthropic. L'AC n'est jamais + installée dans le magasin de confiance du système. +- **Passerelle (tiers)** : l'ancien mode ; le profil ci-dessous fait basculer toute l'application sur + OpenCodex comme passerelle. Sélectionnez-le explicitement (`--gateway`, ou les anciens + `--static`/`--hybrid`/`--discovery-only`). + +Le mode est enregistré dans `claudeCode.desktopMode`. Les installations ayant déjà appliqué un profil +passerelle le conservent après mise à jour ; seules les nouvelles installations démarrent en +first-party. Changer de mode supprime la configuration de l'autre mode (uniquement les valeurs +écrites par OpenCodex) ; un `HTTPS_PROXY`/`NODE_EXTRA_CA_CERTS` étranger (proxy d'entreprise) n'est +jamais écrasé et l'application est refusée. Quittez complètement Desktop puis rouvrez-le après un +changement. Les détails et la compatibilité de la CLI Claude Code sont décrits dans la documentation +anglaise. + +## Profil Claude Desktop (mode passerelle) Claude Desktop utilise un profil distinct de Claude Code. Ouvrez **Claude → Bureau** dans le tableau de bord afin de placer chaque route disponible dans l'une des quatre familles : Opus, Fable, Sonnet ou Haiku. @@ -628,4 +655,4 @@ Utilisez `"haiku"` comme valeur de remplacement pour le modèle. Dans `config.json`, `claudeCode.stabilizePromptCache: true` déplace les notices Claude reconnues en fin des instructions système vers un dernier message utilisateur sur les routes traduites. La valeur par défaut est `false`. Activez cette option seulement si ce changement de rôle convient à vos clients. Les exemples dans des blocs de code et le texte non reconnu sont conservés ; le transfert Anthropic natif reste inchangé. Sans métadonnées, la clé de cache suit les instructions stabilisées. Cette option ne crée pas une identité de conversation et ne garantit aucun succès du cache amont. -Sur la route Chat d’OpenCode Go pour `deepseek-v4.1-flash`, les rappels système traduits dans l’historique conservent automatiquement leur position et leur rôle system, après les résultats d’outils encore attendus. Ainsi, l’ajout de rappels ne réécrit pas le prompt système initial. Ce comportement s’applique avec ou sans `stabilizePromptCache` ; la conversion des autres modèles et destinations, ainsi que le transfert Anthropic natif, restent inchangés. La réutilisation du cache exige toujours une identité de session stable et un cache disponible en amont. Les changements des instructions ou outils antérieurs et la compaction de la conversation peuvent aussi affecter les succès du cache ; préserver l’ordre des rappels ne suffit pas à garantir sa réutilisation. +Sur toutes les routes Chat traduites, les rappels de l’historique conservent leur position dans la conversation, après les résultats d’outils encore attendus. L’ajout d’un rappel ne réécrit donc pas le prompt système initial, et une instruction placée au milieu de la conversation n’arrive plus avant les tours qu’elle était censée suivre. Le rôle porté par cet emplacement se décide séparément : un rappel part en `system`, sauf si le fournisseur enregistre `foldDeveloperRoleToSystem: false`, ce qui indique que le service en amont accepte le rôle `developer` et le transmet à la même position. Un service qui ne l’accepte pas répond `400 role 'developer' is not allowed` et le tour ne démarre pas, d’où le repli d’une destination non enregistrée. Ce comportement s’applique avec ou sans `stabilizePromptCache` ; le transfert Anthropic natif reste inchangé. La réutilisation du cache exige toujours une identité de session stable et un cache disponible en amont. Les changements des instructions ou outils antérieurs et la compaction de la conversation peuvent aussi affecter les succès du cache ; préserver l’ordre des rappels ne suffit pas à garantir sa réutilisation. diff --git a/docs-site/src/content/docs/fr/guides/codex-integration.md b/docs-site/src/content/docs/fr/guides/codex-integration.md index dd1d5f97dd6..f5aada09618 100644 --- a/docs-site/src/content/docs/fr/guides/codex-integration.md +++ b/docs-site/src/content/docs/fr/guides/codex-integration.md @@ -21,7 +21,7 @@ l'identifiant du fournisseur `openai` intégré à Codex et fait pointer ce four ```toml # root keys, before the first table model_catalog_json = "/absolute/path/to/opencodex-catalog.json" -# Auto-injected by opencodex +# Auto-injected by opencodex (undo: ocx restore) openai_base_url = "http://127.0.0.1:10100/v1" # only when fastMode is set; unset adds no [features] table @@ -119,7 +119,7 @@ model_provider = "opencodex" model_catalog_json = "/absolute/path/to/opencodex-catalog.json" # appended at the end of the file -# Auto-injected by opencodex +# Auto-injected by opencodex (undo: ocx restore) [model_providers.opencodex] name = "OpenCodex Proxy" base_url = "http://your-host:10100/v1" @@ -419,7 +419,7 @@ Codex. Seule l'exécution explicite de `ocx stop` ou `ocx service stop` restaure ## Refus de sécurité pour l’historique paginé -Une transition de fournisseur peut renvoyer `history_paginated_requires_native_writer` si le stockage concerné prend en charge la pagination, même pour ses lignes legacy. Cette raison ne refuse plus la configuration Codex, le profil de référence ni le catalogue de modèles. `ocx sync` et `ocx start` écrivent toujours ces fichiers et définissent `model_catalog_json`, afin que le sélecteur de modèles Codex continue d’afficher tous les modèles routés par OpenCodex. Seule cette raison interrompt le réétiquetage de l’historique des conversations, car Codex attribue les numéros d’historique paginé dans son propre processus d’écriture et aucune nouvelle tentative n’y change rien. Toute autre raison de contrôle préalable de l’historique — une base d’état illisible, un historique dont l’identité a changé, ou un contrôle préalable qui n’a pas pu s’exécuter — refuse encore toute la transition et l’annule, car ces cas peuvent réussir plus tard. Dans cet état, OpenCodex ne modifie jamais les fichiers d’historique paginé ni les lignes de conversation. Les conversations existantes conservent le fournisseur déjà associé et ne sont pas migrées ; les nouvelles conversations passent par le proxy. Lorsque le réétiquetage est interrompu, une table `[model_providers.opencodex]` déjà présente dans le répertoire d’accueil est conservée plutôt que retirée, y compris sous la forme root-override (loopback), afin que les conversations dont les lignes sont étiquetées `opencodex` gardent un identifiant de fournisseur qui existe encore. Le CLI affiche `Codex resume history: left to Codex's native writer (history_paginated_requires_native_writer)`. `ocx restore` et la suppression de la configuration Codex refusent toujours sur `history_paginated_requires_native_writer`. Retirer la définition `[model_providers.opencodex]` alors que des lignes de conversation la référencent encore rendrait ces conversations irrésolubles, et le chemin de restauration n’a aucun moyen de conserver une table de fournisseur de compatibilité. Un répertoire d’accueil déjà paginé ne peut pas actuellement être désinstallé par le produit ; c’est un travail ouvert connu, et non le comportement voulu. +Une transition de fournisseur peut renvoyer `history_paginated_requires_native_writer` si le stockage concerné prend en charge la pagination, même pour ses lignes legacy. Cette raison ne refuse plus la configuration Codex, le profil de référence ni le catalogue de modèles. `ocx sync` et `ocx start` écrivent toujours ces fichiers et définissent `model_catalog_json`, afin que le sélecteur de modèles Codex continue d’afficher tous les modèles routés par OpenCodex. Seule cette raison interrompt le réétiquetage de l’historique des conversations, car Codex attribue les numéros d’historique paginé dans son propre processus d’écriture et aucune nouvelle tentative n’y change rien. Toute autre raison de contrôle préalable de l’historique — une base d’état illisible, un historique dont l’identité a changé, ou un contrôle préalable qui n’a pas pu s’exécuter — refuse encore toute la transition et l’annule, car ces cas peuvent réussir plus tard. Dans cet état, OpenCodex ne modifie jamais les fichiers d’historique paginé ni les lignes de conversation. Les conversations existantes conservent le fournisseur déjà associé et ne sont pas migrées ; les nouvelles conversations passent par le proxy. Lorsque le réétiquetage est interrompu, une table `[model_providers.opencodex]` déjà présente dans le répertoire d’accueil est conservée plutôt que retirée, y compris sous la forme root-override (loopback), afin que les conversations dont les lignes sont étiquetées `opencodex` gardent un identifiant de fournisseur qui existe encore. Le CLI affiche `Codex resume history: left to Codex's native writer (history_paginated_requires_native_writer)`. `ocx restore`, `ocx stop` et `ocx uninstall` ne refusent plus sur `history_paginated_requires_native_writer`. Ils retirent toutes les clés de routage racine d'OpenCodex et conservent la définition `[model_providers.opencodex]` sur le disque : les conversations dont les lignes nomment encore ce fournisseur restent résolubles, tandis que `codex` seul cesse de pointer vers le proxy. Le résultat est signalé comme une restauration partielle qui nomme les lignes conservées, et `ocx restore --remove-codex-provider-table` les supprime aussi, après quoi ces conversations ne s'ouvrent plus. Par ailleurs, activer l'intégration sous sa forme table de fournisseur sur un répertoire d'accueil dont les conversations marquées `openai` ont déjà été paginées par Codex était auparavant refusé d'emblée avec `history_paginated_openai_requires_native_writer` : rien n'était écrit et l'intégration restait désactivée. OpenCodex termine désormais cette transition en conservant la redéfinition racine gérée `openai_base_url` à côté de la table `[model_providers.opencodex]`. Codex fusionne cette redéfinition avec son fournisseur `openai` intégré, donc ces conversations continuent d'atteindre le proxy sans être réétiquetées, et aucun octet d'historique ni ligne de conversation n'est modifié. Seule une forme de routage exigeant l'en-tête d'admission `x-opencodex-api-key` refuse encore, car le fournisseur intégré de Codex ne peut pas porter cet en-tête ; son message nomme les deux réglages qui résolvent la situation — router Codex par l'écouteur loopback pour conserver la redéfinition, ou mettre `syncResumeHistory` à `false` en acceptant que ces conversations reprennent sur le point de terminaison OpenAI propre à Codex. Lors du retour au mode de remplacement de l’URL racine, OpenCodex conserve la définition `[model_providers.opencodex]` existante avant de valider la configuration, même si la vérification préalable de l’historique réussit. Les anciennes conversations `opencodex` peuvent ainsi toujours retrouver leur fournisseur si Codex migre l’historique après cette validation ou pendant le démarrage du traitement en arrière-plan. Les nouvelles conversations utilisent le fournisseur racine sélectionné ; la restauration explicite conserve ses contrôles de suppression distincts. diff --git a/docs-site/src/content/docs/fr/guides/integrations.md b/docs-site/src/content/docs/fr/guides/integrations.md index 38fb208516d..1f3e04ada55 100644 --- a/docs-site/src/content/docs/fr/guides/integrations.md +++ b/docs-site/src/content/docs/fr/guides/integrations.md @@ -254,6 +254,36 @@ Les détails des clients ont été vérifiés par rapport au format de configura consultez les notes de recherche dans `devlog/_fin/260802_client_toggle_api/002_client_toggle_matrix.md` pour savoir ce qui a été contrôlé et quand. +## ZCode 3.14 et versions ultérieures + +ZCode 3.14 a déplacé ses fournisseurs personnalisés vers `~/.zcode/v2/provider_config.json` et ne +lit plus `~/.zcode/v2/config.json` qu'au travers d'un import unique, exécuté seulement quand le +nouveau fichier est absent. ZCode crée ce nouveau fichier au premier lancement : sur toute +installation déjà démarrée une fois, l'import a donc déjà eu lieu et une écriture dans +`config.json` n'atteint plus rien. + +opencodex écrit désormais `provider_config.json` directement quand il le peut. Activer +l'intégration ajoute la règle de fournisseur `opencodex` dans ce fichier, une actualisation du +catalogue la met à jour, et la désactivation retire exactement ce qu'opencodex y a mis. Toutes les +autres règles du fichier restent intactes, y compris celle qu'un autre fournisseur conserve pour un +identifiant de modèle qui figure aussi chez nous. Une règle portant l'identifiant `opencodex` +qu'opencodex n'a pas écrite est un conflit et non quelque chose à reprendre : réglez-la dans ZCode, +ou utilisez l'écrasement explicite. + +Deux situations refusent encore au lieu d'écrire. Un bloc écrit par opencodex avant le déplacement +du stockage maintient l'intégration sur `config.json` : désactivez-la d'abord à cet endroit, puis +réactivez-la pour écrire le nouveau stockage. Et un `provider_config.json` dont le +`schemaVersion` n'est pas un de ceux qu'opencodex a observés est signalé plutôt que fusionné : +ce fichier contient tous les fournisseurs de ZCode, et y affirmer une forme échangerait une +absence d'effet silencieuse contre une perte silencieuse. L'état nomme le fichier que ZCode lit dès +que l'intégration ne l'écrit pas. + +Dans ce second cas, ajoutez le fournisseur dans les réglages de ZCode : URL de base +`http://127.0.0.1:10100/v1` (ajustez le port à votre écoute), une clé non vide quelconque, et les +identifiants de modèle donnés par `ocx export --client zcode`. Supprimer +`provider_config.json` pour relancer l'import de ZCode n'est pas pris en charge : cela détruit +tous les fournisseurs que ZCode y conserve. + ## Cline CLI Cline CLI utilise providers.json et models.json. Quittez Cline avant toute modification ou synchronisation, puis redémarrez-le. Annuler restaure les deux originaux. Le fournisseur par défaut reste inchangé. Cette intégration ne migre pas le stockage des anciennes extensions VS Code. diff --git a/docs-site/src/content/docs/fr/guides/providers.md b/docs-site/src/content/docs/fr/guides/providers.md index f34081cfcc5..7e9115e0033 100644 --- a/docs-site/src/content/docs/fr/guides/providers.md +++ b/docs-site/src/content/docs/fr/guides/providers.md @@ -283,7 +283,7 @@ existante n'est pas concernée. ## 3. Catalogue des clés API -opencodex fournit 95 préréglages intégrés : 79 à clé, 12 OAuth, trois locaux et un préréglage par défaut de +opencodex fournit 96 préréglages intégrés : 80 à clé, 12 OAuth, trois locaux et un préréglage par défaut de transfert ChatGPT. Dans le tableau de bord, le sélecteur **Ajouter un fournisseur** ouvre le tableau de bord du fournisseur à clé, valide la clé et l'enregistre ; la validation dépend du fournisseur. Parmi les entrées notables : diff --git a/docs-site/src/content/docs/fr/reference/cli/lifecycle.md b/docs-site/src/content/docs/fr/reference/cli/lifecycle.md index c134193f422..f59ebe9916b 100644 --- a/docs-site/src/content/docs/fr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/fr/reference/cli/lifecycle.md @@ -303,6 +303,7 @@ Utilisez `ocx service` pour maintenir un proxy d’arrière-plan toujours actif, ### `ocx tray [--json] [--no-start]` Installe et contrôle l’icône OpenCodex dans la zone de notification Windows. Elle démarre à l’ouverture de session et fournit des commandes du proxy accessibles en un clic. `start` et `stop` contrôlent uniquement l’icône ; utilisez son menu pour contrôler le proxy. `--no-start` s’applique à `install` et installe l’icône sans la lancer immédiatement. +Obsolète : l’application OpenCodex fournit la zone de notification sous Windows, macOS et Linux ; `ocx tray` reste disponible pour les installations sans l’application de bureau. ## Tableau de bord diff --git a/docs-site/src/content/docs/fr/reference/configuration/providers.md b/docs-site/src/content/docs/fr/reference/configuration/providers.md index a96d502b6c4..c4fd2dc57de 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/fr/reference/configuration/providers.md @@ -132,12 +132,14 @@ sauvegarde dont le contenu diffère, puis réécrit en identifiants sans préfix | `noPenaltyModels?` | `string[]` | Modèles qui rejettent les pénalités presence/frequency. | | `noStructuredOutputModels?` | `string[]` | ID de modèle exact dont le point final `openai-chat` rejette `response_format`. Seule une correspondance exacte du modèle demandé omet le champ ; la traduction à sortie structurée reste activée pour tous les autres modèles `openai-chat`. | | `noJsonSchemaModels?` | `string[]` | ID de modèle exact dont le point final `openai-chat` rejette un `response_format` `json_schema` mais accepte encore `json_object`. Une telle requête est rétrogradée vers `json_object` au lieu d’être supprimée, donc un appelant qui demande du JSON en reçoit toujours. `noStructuredOutputModels` l’emporte quand un modèle figure dans les deux listes. Les préréglages `opencode go`, `opencode zen` et `opencode free` l’embarquent pour leurs routes DeepSeek. | +| `foldDeveloperRoleToSystem?` | `boolean` | Indique si une destination `openai-chat` accepte le rôle `developer`. `foldDeveloperRoleToSystem` non défini envoie `system`, `true` envoie `system` et `false` envoie `developer`. Non défini signifie que rien n'a été enregistré pour cette destination ; `true` enregistre un service en amont qui refuse le rôle ; `false` en enregistre un qui l'accepte. Dans tous les cas le message conserve sa position dans la conversation ; seul le rôle change. Une destination qui refuse le rôle répond `400 role 'developer' is not allowed` et le tour ne démarre pas, d'où l'état non enregistré replié par défaut. | | `parallelToolCalls?` | `boolean` | Contrôler les appels d’outils parallèles. Pour `openai-chat`, ils sont activés par défaut ; `false` envoie explicitement `parallel_tool_calls: false`. Les autres adaptateurs ne les annoncent que lorsque la valeur vaut explicitement `true`. | | `terminalContinuationGuard?` | `boolean` | Active, pour un fournisseur `openai-chat`, une relance interne bornée lorsqu’un tour exploitable annonce une action puis s’arrête proprement sans appel d’outil. La valeur par défaut est `false`, et une valeur explicite `false` équivaut à l’absence du champ. Les tentatives de combinaison et les tours de compactage routés sont exclus ; les autres adaptateurs ignorent cette option. | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` | Réparation SSE en aval désactivée par défaut pour les identifiants d'espace réservé exacts, les identifiants de terminal manquants et (avec `repairInvalidIds`) les identifiants message/reasoning manquant du préfixe canonique `msg_`/`rs_`. Les identifiants d’appel de fonction ne sont jamais réécrits. Le DeepSeek intégré active les deux derniers par défaut. | | `responsesSnapshotRepair?` | `boolean` | Réparation côté client désactivée par défaut pour les instantanés du cycle de vie des réponses clairsemés dans SSE et JSON. Remplit les métadonnées d'état canonique, de sortie et d'outil manquantes tandis que l'inspection brute et la persistance restent inchangées. | | `retryOn429?` | `{ enabled?: boolean; attempts?: number; intervalMs?: number; maxIntervalMs?: number; respectRetryAfter?: boolean }` | Fournisseurs à clé API uniquement (`authMode: "key"`). Nouvelle tentative facultative sur la même cible après un 429 : lorsque `retryOn429` est absent, la fonctionnalité est désactivée ; la présence d'un objet l'active, sauf avec `enabled: false`. Après un 429, le proxy attend selon `Retry-After` reçu en amont ou selon l'intervalle fixe, puis relit la requête à l'identique avec la même clé avant tout basculement de clé. Ce comportement couvre la boucle principale de récupération d'un tour textuel, le protocole de transfert Responses, le pont d'images et de vidéos, le service auxiliaire de recherche Web et les continuations du terminal. Seules les réponses HTTP 429 reçues avant le début de la diffusion peuvent être relues ; les transports `runTurn` personnalisés ne font pas partie de la boucle de nouvelle tentative HTTP. `attempts` compte les relectures avec la même clé après le premier 429, soit `attempts` + 1 envois au total, et constitue un budget commun à toute la requête, partagé entre la boucle principale de récupération, la continuation de la garde du terminal et les nouvelles tentatives du pont. L'épuisement de `attempts` arrête uniquement les relectures supplémentaires avec la même clé : le basculement normal de clé ou la gestion de l'erreur finale s'applique ensuite selon les cibles disponibles. Sur le protocole de transfert authentifié par clé, aucun basculement n'est possible ; le 429 final est donc renvoyé sans modification. Codex ne retente jamais lui-même une requête après un 429 : cette option constitue ainsi la seule protection pour les fournisseurs à clé unique. Valeurs par défaut : `enabled: true`, `attempts: 3`, `intervalMs: 5000`, `maxIntervalMs: 60000` (chaque attente est plafonnée à `maxIntervalMs`, lui-même plafonné à 600000), `respectRetryAfter: true`. | | `transientRetryOn5xx?` | `{ enabled?: boolean; attempts?: number }` | Fournisseurs `openai-chat` et `openai-responses` authentifiés par clé uniquement. Les fournisseurs `authMode: "forward"` (le pool de comptes ChatGPT) ne lisent jamais cette option et conservent l'échelle par défaut. Nouvelle tentative facultative pour les états transitoires reçus en amont avant le début de la diffusion (500, 502, 503, 504, 520, 521, 522) : l'absence de l'option la désactive ; la présence d'un objet l'active, sauf avec `enabled: false`. Ce comportement couvre la requête Responses initiale, la continuation de la garde du terminal, le point de terminaison natif `/v1/chat/completions` et les réémissions liées à la récupération après un 429 ou à la récupération de compte. `attempts` représente le nombre TOTAL d'envois en amont autorisés pour une requête, premier envoi compris (de 1 à 10, valeur par défaut : 3). Il constitue un budget commun à la requête, partagé avec la récupération après une réinitialisation de connexion ; ainsi, `3` signifie qu'au plus trois requêtes réelles atteignent le fournisseur. Les attentes utilisent une temporisation exponentielle à base fixe de 400 ms, plafonnée à 5 s, et respectent `Retry-After`. Cette option est distincte de `retryOn429`, qui traite la limitation de débit ; les échecs en cours de diffusion ne sont jamais relus. | +| `retryOnReset?` | `{ enabled?: boolean; replacements?: number }` | Fournisseurs `openai-responses` natifs uniquement, `authMode: "forward"` compris. Remplacement facultatif d'un envoi qui a échoué alors que l'appelant n'avait rien observé : l'absence de l'option la désactive ; la présence d'un objet l'active, sauf avec `enabled: false`. Couvre les deux étapes ambiguës — une connexion rompue avant tout en-tête de réponse, et un corps SSE rompu après l'en-tête alors qu'il ne portait que des événements de contrôle. Seule une requête autonome est remplacée : `store: false`, `input` complet, ni `previous_response_id`, ni `conversation`, ni `stream_id`, et uniquement des outils exécutés par le client. `replacements` est le nombre d'envois de remplacement qu'UNE requête logique peut effectuer, toutes étapes et tous enfants de combo confondus (de 1 à 2, valeur par défaut : 1). Ce n'est ni un nombre de tentatives par étape ni un budget d'envoi : un remplacement doit toujours tenir dans l'allocation d'envois dont l'étape disposait déjà. Une requête qui a déjà émis une sortie ou un appel d'outil n'est jamais remplacée, quelle que soit cette valeur. L'inférence de remplacement peut tout de même être facturée si l'origine avait déjà démarré la première, d'où la désactivation par défaut. | | `autoToolChoiceOnlyModels?` | `string[]` | Modèles dont `tool_choice` accepte uniquement `auto` ou `none` ; les choix forcés sont dévalorisés. | | `preserveReasoningContentModels?` | `string[]` | Modèles nécessitant un assistant préalable `reasoning_content` dans l'historique des discussions. | | `reasoningDetailsModels?` | `string[]` | Modèles dont le point de terminaison renvoie la réflexion sous forme de tableau structuré `reasoning_details` (MiniMax série M avec `reasoning_split`) ; les deltas de flux sont des instantanés cumulatifs comparés par préfixe, et la réflexion conservée est rejouée sous forme de tableau `reasoning_details` plutôt que de chaîne `reasoning_content`. | diff --git a/docs-site/src/content/docs/getting-started/installation.md b/docs-site/src/content/docs/getting-started/installation.md index c5a7d36578e..9ff7e5a72c6 100644 --- a/docs-site/src/content/docs/getting-started/installation.md +++ b/docs-site/src/content/docs/getting-started/installation.md @@ -49,6 +49,19 @@ ocx --version opencodex --version ``` +## Standalone binary (no npm) + +Release downloads also include a standalone `ocx` binary for supported macOS, Linux, and Windows +targets. It includes the Bun runtime and dashboard, so npm, Node, and a separate Bun installation +are not required. Download the archive for your platform, extract it, and run: + +```bash +./ocx --version +./ocx start +``` + +The extracted `gui/dist` directory must stay beside the binary so `GET /` can serve the dashboard. + ### Release channels The stable `latest` channel already includes GPT-5.6 Sol/Terra/Luna catalog support for ChatGPT, diff --git a/docs-site/src/content/docs/getting-started/quickstart.md b/docs-site/src/content/docs/getting-started/quickstart.md index 042eb183045..a673daf5e82 100644 --- a/docs-site/src/content/docs/getting-started/quickstart.md +++ b/docs-site/src/content/docs/getting-started/quickstart.md @@ -5,6 +5,11 @@ description: Configure your first provider and route OpenAI Codex through openco This guide takes you from a fresh install to running Codex against a non-OpenAI model. +## Standalone binary (no npm) + +You can also use a release archive containing the `ocx` binary and Bun runtime without npm. +Extract it with its `gui/dist` directory beside the binary, then run `./ocx start`. + ## 1. Run the setup wizard ```bash @@ -13,7 +18,7 @@ ocx init `ocx init` walks you through: -1. **Pick a provider** — choose one of the 95 built-in registry presets or `custom` to type a base +1. **Pick a provider** — choose one of the 96 built-in registry presets or `custom` to type a base URL and adapter. 2. **API key** — paste a key, or reference an environment variable like `${ANTHROPIC_API_KEY}`. 3. **Default model** — for key, local, and custom providers, accept the preset or enter a model id. diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index 89078ca1f6d..83da2dad162 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -135,9 +135,73 @@ the proxy starts or you save settings, while `ocx claude` always resolves live. ## System environment integration (macOS) -## Claude Desktop profile +## Claude Desktop modes: first-party (default) and gateway -Claude Desktop uses a separate profile from Claude Code. Open **Claude → Desktop** in the +Claude Desktop can use OpenCodex in one of two mutually exclusive modes. Pick it in +**Claude → Desktop → Connection mode** in the dashboard or with `ocx claude desktop apply +--first-party|--gateway`. + +**First-party** is the default for new installs. Desktop itself is not reconfigured: it stays +signed in to claude.ai, and the Chat tab, connectors, cloud sessions and remote control keep +working. OpenCodex only writes two variables into the `env` block of `~/.claude/settings.json` +(honoured by `CLAUDE_CONFIG_DIR`): + +```json +{ + "env": { + "HTTPS_PROXY": "http://127.0.0.1:10200", + "NODE_EXTRA_CA_CERTS": "/.opencodex/claude-intercept/ca.pem" + } +} +``` + +Claude Code — the process Desktop spawns for its Code tab, every subagent it launches, and the +standalone `claude` CLI — reads that env and sends its `api.anthropic.com` traffic through the +local intercept proxy. The proxy listens on the public port + 100 (`claudeCode.intercept.port` +overrides it), terminates TLS with a per-install CA stored under `~/.opencodex/claude-intercept/` +(never installed into the OS trust store; only Node processes that read `NODE_EXTRA_CA_CERTS` +trust it), and hands `POST /v1/messages` and `POST /v1/messages/count_tokens` to the same +Messages handler `ocx claude` uses. Every other path on `api.anthropic.com` (OAuth, profile, +usage) is relayed byte-for-byte to Anthropic, and unrelated hosts are tunnelled untouched, so your +subscription login keeps working. Existing OpenCodex features — `modelMap`, aliases, native +passthrough, sidecars, auto-context — apply the same way they do for `ocx claude`. + +**Gateway** is the previous third-party mode: the profile described in the next section switches +the whole app to OpenCodex as its inference gateway. Chat runs locally through OpenCodex and the +claude.ai-only features are unavailable. Select it explicitly (`--gateway`, the dashboard +selector, or the legacy `--static` / `--hybrid` / `--discovery-only` flags, which imply it). + +Mode is persisted as `claudeCode.desktopMode`. Installs that already applied a gateway profile +keep gateway after updating; nothing is switched silently. Switching in either direction removes +the other mode's configuration (only values OpenCodex wrote — a foreign `HTTPS_PROXY` or +`NODE_EXTRA_CA_CERTS`, for example a corporate proxy, is never overwritten and the apply is +refused instead). Fully quit and reopen Desktop after switching. `ocx ensure` refreshes a stale +first-party env when the integration is ON and removes it when OFF. Set +`claudeCode.intercept.enabled: false` to disable the proxy entirely; first-party then cannot be +applied and an implicit apply falls back to gateway. On a connected client the proxy runs on the +hub, so `ocx claude desktop apply` there uses the gateway profile. + +### Claude Code CLI compatibility + +The same `settings.json` env drives the standalone `claude` CLI, so a first-party apply also +covers terminal sessions, `claude -p`, and subagents without `ocx claude`'s +`ANTHROPIC_BASE_URL` / `ANTHROPIC_AUTH_TOKEN` shell env. Differences from `ocx claude`: + +- Model discovery (`/model` → "From gateway") is not available; Claude Code only queries + `GET /v1/models` on a configured gateway. Use `modelMap` to route the built-in Anthropic model + ids, or type an alias directly. +- `ANTHROPIC_SMALL_FAST_MODEL` and `CLAUDE_CODE_SUBAGENT_MODEL` are chosen by the CLI before the + request is sent; set them in `settings.json` yourself if a sidecar or subagent should use a + mapped id. +- `ocx claude` and first-party coexist: a session started with `ocx claude` talks to + `ANTHROPIC_BASE_URL` (plain HTTP on loopback), which `HTTPS_PROXY` does not cover, so that + process reaches OpenCodex directly and the proxy simply sees no traffic from it. +- Claude Code honours `HTTPS_PROXY`/`NODE_EXTRA_CA_CERTS` as documented for corporate proxies; + a CLI release that stops doing so would stop routing, not break login. + +## Claude Desktop profile (gateway mode) + +The profile below is written only in gateway mode. Claude Desktop uses a separate profile from Claude Code. Open **Claude → Desktop** in the dashboard to place each available route in one of four families: Opus, Fable, Sonnet, or Haiku. All routes start in Opus on a new profile. The first Opus route becomes the initial overall default, and every non-empty family always has one family default. @@ -161,9 +225,10 @@ ocx claude desktop export ocx claude desktop import [--apply] ``` -`ocx claude desktop` and `apply` both write the current profile to Claude Desktop. `show` gives a -readable summary; `status` reports the applied profile, drift, request activity, and Windows -managed-policy health. Add `--json` for scripts. `export -` writes versioned JSON to standard output. +`ocx claude desktop` and `apply` apply the selected mode: first-party writes the Claude Code proxy +env, gateway writes the current profile to Claude Desktop. `show` gives a +readable summary; `status` reports the effective mode, the applied profile or proxy env, drift, +request activity, and (gateway mode only) Windows managed-policy health. Add `--json` for scripts. `export -` writes versioned JSON to standard output. Import validates the complete file before saving, so an invalid file leaves the current profile unchanged. Add `--apply` to write a valid imported profile to Desktop immediately. Use `none` only for an empty family; every non-empty family must keep one default. @@ -637,6 +702,9 @@ Claude debug immediately clears the ring. The dashboard sidebar has a dedicated **Claude** page (below API) and a **Claude ON** toggle (label intentionally identical in every language). The page shows: +- Desktop tab: **Connection mode** selector — first-party (default) or gateway — with the + running proxy port in first-party mode. Only **Save & apply** switches modes; **Save** alone + stores the gateway profile lanes for a later gateway apply and leaves the current mode as is - Inbound kill switch (enabled toggle) - Quickstart (`ocx claude`) and manual env block - Fast Mode selector (Auto / ON / OFF) @@ -708,12 +776,16 @@ route. Pass `"haiku"` as the model placeholder. Set `claudeCode.stabilizePromptCache` to `true` in `config.json` to relocate supported trailing Claude harness notices from system instructions to a trailing user message on translated routes. The default is `false`. Enable it only when this role change is appropriate for your clients. It preserves fenced examples and unmatched text; native Anthropic passthrough is unchanged. The metadata-less prompt-cache key then follows stabilized instructions. This does not create conversation identity or guarantee upstream cache hits. -On OpenCode Go's `deepseek-v4.1-flash` Chat route, translated timeline system -reminders automatically retain their position and system role, after any pending -tool results. This prevents newly appended reminders from rewriting the leading -system prompt. It applies with or without `stabilizePromptCache`; other models -and destinations keep their existing conversion; native Anthropic passthrough -is unchanged. Cache reuse still requires stable session identity and upstream -cache availability. Changes to earlier instructions or tools, and conversation -compaction, can still affect cache hits; preserving reminder order alone does -not guarantee reuse. +On every translated Chat route, timeline reminders keep their position in the +conversation, after any pending tool results. This prevents a newly appended +reminder from rewriting the leading system prompt, and stops a mid-conversation +instruction from arriving ahead of the turns it was written to follow. The role +that slot carries is decided separately: a reminder is sent as `system` unless +the provider records `foldDeveloperRoleToSystem: false`, which states that the +upstream accepts the `developer` role and forwards it in the same position. An +upstream that does not accept it answers `400 role 'developer' is not allowed` +and the turn never starts, so an unrecorded destination folds. This applies with or without +`stabilizePromptCache`, and native Anthropic passthrough is unchanged. Cache +reuse still requires stable session identity and upstream cache availability. +Changes to earlier instructions or tools, and conversation compaction, can still +affect cache hits; preserving reminder order alone does not guarantee reuse. diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index 66e28cb9ab2..6513400b4a6 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -35,9 +35,9 @@ Codex's built-in `openai` provider id and points that provider at opencodex: ```toml # root keys, before the first table model_catalog_json = "/absolute/path/to/opencodex-catalog.json" -# Auto-injected by opencodex +# Auto-injected by opencodex (undo: ocx restore) openai_base_url = "http://127.0.0.1:10100/v1" -# Auto-injected by opencodex +# Auto-injected by opencodex (undo: ocx restore) experimental_realtime_ws_base_url = "http://127.0.0.1:10100/v1" # only when fastMode is set; unset adds no [features] table @@ -299,7 +299,7 @@ model_provider = "opencodex" model_catalog_json = "/absolute/path/to/opencodex-catalog.json" # appended at the end of the file -# Auto-injected by opencodex +# Auto-injected by opencodex (undo: ocx restore) [model_providers.opencodex] name = "OpenCodex Proxy" base_url = "http://your-host:10100/v1" @@ -720,6 +720,22 @@ See [The parser and bridge](/reference/architecture/#the-parser) for the explici There is no provider-level setting that can add a missing `tool_search` declaration; ordinary code-mode discovery remains a separate path. +### Cache-read diagnostics + +Set `OPENCODEX_CACHE_DEBUG=1` before starting the proxy to write one diagnostic record per +finalized request to `/cache-debug.jsonl`. The switch is off by default; set it to `0` +or remove it to disable capture. The file is owner-only (`0600`) in the hardened config directory +and rolls after 200 lines, retaining the newest 100. + +Each JSONL record contains the protocol, routed provider/model, cache-counter presence and +provenance, process-local equality tags for the account, prompt-cache key, and allowlisted session +headers, plus ordered fingerprints for instructions, tools, and message/input blocks. Prefix +sections retain at most 128 tags and identify only the first divergent section/index. The +diagnostic never stores prompt or message text, tool names, raw headers, raw cache/session/account +identifiers, or a durable tag derived from them. Its random HMAC key is created at process start, +separate from other debug keys, and is never persisted; tags therefore compare values only within +one proxy process. + ### Catalog troubleshooting If a model is missing from Codex, or the catalog order/visibility looks wrong, check in order: @@ -903,7 +919,9 @@ When an affected history store supports paginated records, a provider transition When returning to the root-override form, OpenCodex retains an existing `[model_providers.opencodex]` definition before committing the configuration, even if history preflight currently passes. This keeps older `opencodex` conversations resolvable if Codex migrates history after that commit or while the background worker starts. New conversations still use the selected root provider; explicit restore keeps its separate removal guards. -`ocx restore` and Codex config removal still refuse on `history_paginated_requires_native_writer`. Stripping the `[model_providers.opencodex]` definition while thread rows still reference it would make those conversations unresolvable, and the restore path has no way to keep a compatibility provider table. A home that is already paginated cannot currently be uninstalled through the product; that is known open work rather than intended behaviour. +`ocx restore`, `ocx stop` and `ocx uninstall` no longer refuse on `history_paginated_requires_native_writer`. They take every OpenCodex root routing key out and keep the `[model_providers.opencodex]` definition on disk, so conversations whose rows still name that provider keep resolving while plain `codex` stops pointing at the proxy. The result is reported as a partial restore that names the retained lines, and `ocx restore --remove-codex-provider-table` removes them too, after which those conversations stop opening. + +Enabling the integration in its provider-table form on a home whose `openai`-tagged conversations Codex has already paginated used to be refused outright with `history_paginated_openai_requires_native_writer`: nothing was written and the integration stayed disabled. OpenCodex now completes that transition by keeping the managed root `openai_base_url` override beside the `[model_providers.opencodex]` table. Codex merges the override onto its built-in `openai` provider, so those conversations keep reaching the proxy without being relabeled and no rollout byte or thread row is touched. Only a routing form that requires the `x-opencodex-api-key` admission header still refuses, because Codex's built-in provider cannot carry that header; its message names the two settings that resolve it — route Codex through the loopback listener so the override can be retained, or set `syncResumeHistory` to `false` to accept that those conversations resume against Codex's own OpenAI endpoint. Do not rewrite an active paginated rollout or thread row to migrate those conversations yourself. Close the affected conversation before any recovery, and report the exact error and versions without uploading private history. A backup or a successful script alone does not prove the conversation is visible again. Check the restored conversation in Codex after reopening. diff --git a/docs-site/src/content/docs/guides/desktop-app.md b/docs-site/src/content/docs/guides/desktop-app.md new file mode 100644 index 00000000000..c1405fc0c29 --- /dev/null +++ b/docs-site/src/content/docs/guides/desktop-app.md @@ -0,0 +1,84 @@ +--- +title: Desktop App +description: Install and use the OpenCodex desktop app on macOS, Windows, and Linux. +--- + +The OpenCodex desktop app combines a native tray with the web dashboard. It discovers an +existing local proxy, or starts the bundled `ocx` sidecar when no proxy is running. + +The dashboard remains available at [http://127.0.0.1:10100](http://127.0.0.1:10100). +The desktop app does not replace the proxy; it is a local shell around the dashboard and +its bundled runtime. + +## Install + +### macOS + +Download `OpenCodex--macos.dmg` from the +[latest release](https://github.com/lidge-jun/opencodex/releases). Open the DMG and drag +`OpenCodex.app` to Applications. + +On first launch, macOS Gatekeeper may warn that the developer cannot be verified. Right-click +the app, choose **Open**, and confirm **Open**. This build is signed for integrity but is not +yet notarized. + +### Windows + +Download `OpenCodex--windows-x64.msi` and run the installer. Windows SmartScreen may +warn because the installer is not yet code-signed; choose **More info → Run anyway** after +confirming that you downloaded it from the release page. + +### Linux + +Download `OpenCodex--linux-x86_64.AppImage` or +`OpenCodex--linux-amd64.deb` from the release page. + +For the AppImage: + +```bash +chmod +x OpenCodex--linux-x86_64.AppImage +./OpenCodex--linux-x86_64.AppImage +``` + +For Debian-based distributions: + +```bash +sudo apt install ./OpenCodex--linux-amd64.deb +``` + +The tray icon requires an AppIndicator-capable desktop environment. + +## First launch + +The app first looks for an existing `ocx` proxy on loopback, using the runtime port +metadata when available and falling back to port `10100`. If no proxy answers, it starts +the bundled sidecar. The dashboard is then opened inside the app's webview. + +Use the tray's **Open dashboard** or **Open in browser** action to move between the +embedded dashboard and your normal browser. The tray also provides update checks. + +## Updates + +Choose **Check for Updates…** in the tray menu to check immediately. Release builds also +check automatically after startup and every six hours. Updates are verified with the +project's signed updater public key before installation. On macOS, in-app updates download +`OpenCodex--macos.app.tar.gz`; the DMG is for the first installation. +The release manifest is generated only when the updater key secret is configured and then +requires all four platforms to be signed. + +## Widget + +The macOS app includes the OpenCodex WidgetKit extension. See the +[macOS Menu Bar App guide](/opencodex/guides/macos-menu-bar/) for widget setup and the +privacy-safe snapshot details. + +## Uninstall + +On macOS, drag `OpenCodex.app` from Applications to the Trash. On Windows, remove +OpenCodex from **Installed apps**. On Debian-based Linux systems, run: + +```bash +sudo apt remove opencodex +``` + +For an AppImage, delete the downloaded file. diff --git a/docs-site/src/content/docs/guides/integrations.md b/docs-site/src/content/docs/guides/integrations.md index 32263f12a67..2b32b59122e 100644 --- a/docs-site/src/content/docs/guides/integrations.md +++ b/docs-site/src/content/docs/guides/integrations.md @@ -375,6 +375,32 @@ catalogs are refused; the existing explicit overwrite and drift-confirmation con available. Fully quit and reopen Aside to load changed model files. +## ZCode 3.14 and later + +ZCode 3.14 moved its custom providers to `~/.zcode/v2/provider_config.json` and left +`~/.zcode/v2/config.json` reachable only through a one-shot import that runs when the new file is +missing. ZCode creates the new file the first time it runs, so on any install that has ever been +launched the import is already spent and a write to `config.json` reaches nothing. + +opencodex writes `provider_config.json` directly where it can. Enabling the integration adds the +`opencodex` provider rule to that file, a catalog refresh updates it, and disabling removes exactly +what opencodex put there. Every other rule in the file is left alone, including a rule another +provider keeps for a model id that also appears under ours. A rule carrying the `opencodex` id that +opencodex did not write is a conflict rather than something to take over; resolve it in ZCode, or +use the explicit overwrite. + +Two situations still refuse rather than write. A block opencodex applied before ZCode moved its +store keeps the integration on `config.json`: disable it there first, then enable it again to write +the new store. And a `provider_config.json` whose `schemaVersion` is not one opencodex has observed +is reported rather than merged into, because that file holds every provider ZCode has and asserting +a shape into it would trade a silent no-op for a silent loss. Status names the file ZCode reads +whenever the integration is not writing it. + +In that second case, add the provider in ZCode's own settings: base URL +`http://127.0.0.1:10100/v1` (adjust the port to your bind), any non-empty key, and the model ids +from `ocx export --client zcode`. Deleting `provider_config.json` to re-trigger ZCode's import is +not supported — it discards every provider ZCode keeps there. + ## Cline CLI This integration targets Cline's current CLI/shared SDK provider store, whose native schema has diff --git a/docs-site/src/content/docs/guides/macos-menu-bar.md b/docs-site/src/content/docs/guides/macos-menu-bar.md new file mode 100644 index 00000000000..c95888a716e --- /dev/null +++ b/docs-site/src/content/docs/guides/macos-menu-bar.md @@ -0,0 +1,171 @@ +--- +title: macOS Menu Bar App +description: A native menu bar companion that shows OpenCodex proxy status, usage, and provider quotas at a glance. +--- + +The macOS companion puts OpenCodex in your menu bar: proxy health, recent usage, and +per-provider quota pressure, without opening the dashboard. + +It is a separate application from the proxy. `ocx` keeps running as it always has; the +companion is a read-mostly client that talks to the local management API. + +## Desktop app (Tauri) + +The same dashboard can run inside the OpenCodex desktop app. The Usage companion panel +uses the OS selector to show the matching macOS, Windows, or Linux installation steps. +While the dashboard is inside the desktop shell, choose **Open in browser** to open the +current dashboard view in your normal browser. + +## Install + +Install the desktop app from the +[latest release](https://github.com/lidge-jun/opencodex/releases). On macOS, download +`OpenCodex--macos.dmg`, open it, and drag `OpenCodex.app` to Applications. +Windows users can run `OpenCodex--windows-x64.msi`; Linux users can use the +AppImage or `OpenCodex--linux-amd64.deb`. + +```bash +chmod +x OpenCodex--linux-x86_64.AppImage +sudo apt install ./OpenCodex--linux-amd64.deb +``` + +## First launch: Gatekeeper + +**The first launch will be blocked.** macOS will say: + +> "OpenCodex.app" cannot be opened because the developer cannot be verified. + +This is expected, and it is worth explaining rather than talking you past it. Gatekeeper +wants a Developer ID signature and a notarization ticket from Apple, both of which +require a paid Apple Developer account. OpenCodex does not have one, so the app ships +ad-hoc signed: the bundle is intact and its signature is valid, but Apple has not +vouched for the publisher. + +To open it anyway: + +1. Right-click (or Control-click) `OpenCodex.app` in Finder. +2. Choose **Open**. +3. Click **Open** in the dialog that appears. + +If that dialog does not offer an Open button, go to **System Settings → Privacy & +Security**, find the blocked-app notice, and click **Open Anyway**. + +macOS remembers the decision, so this is a one-time step per version. + +Alternatively, remove the quarantine attribute from the terminal: + +```bash +xattr -d com.apple.quarantine /Applications/OpenCodex.app +``` + +If you would rather not do either, build from source — a local build carries no +quarantine attribute at all. See [Build from source](#build-from-source). + +## What it shows + +The menu bar icon reflects proxy state without using colour, since macOS menu bar items +are monochrome by convention: + +| Icon | Meaning | +| --- | --- | +| Solid mark | Running and protected | +| Solid mark with a notch | Running, but routing protection is at risk | +| Outlined mark | Starting up, or degraded | +| Faded outline | Not running, or needs an API key | + +Clicking it opens a panel with four sections: + +**Status** — whether the proxy is running, the loopback endpoint the app is using, and +the protection state. When the proxy recommends a remediation command (for example +`ocx service install`), it appears here as selectable text. The app never runs it for +you. + +**Usage** — requests, tokens, and estimated cost over the last 7 days, with a daily +trend. A `~` after the request count means part of it is estimated rather than reported +by the provider. + +By default, the menu bar headline shows total tokens; change the headline metric in the +dashboard Usage companion settings when you prefer requests, cost, quota, or an icon only. +On macOS 26, the popover and widgets adopt Liquid Glass; earlier macOS versions use the +standard popover material. + +**Quotas** — one row per provider, showing the window under the most pressure. A +provider at 99% of a five-hour limit and 10% of its monthly limit shows the five-hour +figure, because that is the one currently blocking you. The window name is printed under +the provider so `42% of API usage` and `42% of a month` are never confused. + +**Providers** — a collapsible list with a switch per provider. The default provider's +switch is inert while it is enabled, because the proxy refuses to disable it; choose a +different default in the dashboard first. + +## What it can do + +- **Dashboard** opens the web dashboard in your browser. +- **Stop proxy** stops the proxy, after confirming. This is deliberately not called + "Restart": stopping also stops the launchd service, so nothing brings the proxy back + automatically. The panel then shows the command to start it again. +- **Provider switches** enable or disable a provider. + +Everything else — accounts, model configuration, storage — stays in the dashboard. + +## Widget + +Add the widget from the desktop: right-click, choose **Edit Widgets**, then add +**OpenCodex**. It shows proxy status, today's usage, quota pressure, and the same +privacy-safe usage snapshot as the desktop app. The widget refreshes when the app polls. +It requires macOS 14 or later and reads only the privacy-safe snapshot written by the +OpenCodex app; it does not receive API keys or raw account data. + +## Connecting to the proxy + +The app finds the proxy automatically. It reads `~/.opencodex/runtime-port.json` (or +`$OPENCODEX_HOME/runtime-port.json`) and falls back to port `10100`. Only the port is +taken from that file; the host is always loopback. + +If your proxy is bound to a non-loopback address it will require an API key. The panel +says so and offers a link to the dashboard. + +**This case is not supported yet.** The app reads a key from the macOS Keychain and +retries once with it, but there is no UI for entering one and no supported way to +provision it by hand — the item is a data-protection Keychain entry, which Keychain +Access does not create. So on a non-loopback bind the panel stays on "Needs API key". + +A loopback proxy — the default — needs no key at all. Native key entry is planned. + +## Polling + +The app is deliberately quiet. It checks whether the proxy is alive every 5 seconds, and +fetches the expensive aggregate data — usage and quotas — only while the panel is open, +at most once a minute. After three consecutive failures it backs off to every 30 seconds +rather than hammering a proxy you stopped on purpose. + +## Build from source + +Requires macOS 13 or later, the Xcode Command Line Tools, and [Bun](https://bun.sh): + +```bash +git clone https://github.com/lidge-jun/opencodex.git +cd opencodex +bun run prepare-sidecar +bun run prepare-widget +bunx tauri build +``` + +The bundle appears in Tauri's release output, with the WidgetKit appex under +`OpenCodex.app/Contents/PlugIns/`. + +Building a universal binary (`UNIVERSAL=1`) needs the full Xcode toolchain — Command +Line Tools ships only current-architecture Swift compatibility libraries, and the build +will tell you so rather than failing with a linker error. + +If you have a Developer ID certificate in your keychain, set `MACOS_SIGN_IDENTITY` to +sign with the hardened runtime instead of ad-hoc: + +```bash +MACOS_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" bun run prepare-widget +``` + +## Uninstall + +Drag `OpenCodex.app` to the Trash. The app writes no preferences or state of its own, and +stores nothing in the Keychain today. diff --git a/docs-site/src/content/docs/guides/pi.md b/docs-site/src/content/docs/guides/pi.md index d7b9e3c3edf..99f44144f9b 100644 --- a/docs-site/src/content/docs/guides/pi.md +++ b/docs-site/src/content/docs/guides/pi.md @@ -146,14 +146,17 @@ An explicit reasoning effort of `none` survives Chat conversion. Output limits a controls are preserved for generic API-key Responses targets; the canonical ChatGPT target still applies its own restrictions. This does not make all providers' controls equivalent. -**Audio and files need a native input wire that supports them.** OpenCodex does not yet have -a lossless audio/file carrier for translated requests. When Chat requires projection, or a -Responses request targets a translated adapter, recognized audio/file attachments return an -explicit error rather than succeeding without the attachment. File-ID-only images have the -same restriction because translated adapters cannot resolve those IDs. Convert the attachment -to text first, or use a native wire and model that support it. Native Chat and raw Responses -(including Azure) retain their existing behavior; this is not a promise of every model's -upstream media support. Video conversion limits remain adapter-specific. +**Audio and most file attachments need a native input wire that supports them.** A document +that carries its own base64 bytes in a user message is the exception: it survives translation +and reaches the Anthropic, OpenAI Chat and Google wires as a native document, file part and +inline data part. Everything else still returns an explicit error rather than succeeding +without the attachment — audio, a file-ID or remote reference the proxy cannot dereference, an +attachment in a tool output or a system message, and a document routed to a wire with no byte +carrier. File-ID-only images have the same restriction because translated adapters cannot +resolve those IDs. Convert the attachment to text first, or use a native wire and model that +support it. Native Chat and raw Responses (including Azure) retain their existing behavior; +this is not a promise of every model's upstream media support. Video conversion limits remain +adapter-specific. ## Schema status diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index cd5b5c8b61c..e8cba6c928d 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -198,7 +198,7 @@ ocx logout | --- | --- | --- | --- | | `xai` | `openai-chat` | `https://cli-chat-proxy.grok.com/v1` | OAuth uses the separate Grok CLI subscription gateway. The API-key override uses `https://api.x.ai/v1` and may inject Priority Processing. Live-first Grok catalog; `grok-4.5` is the fallback default. | | `anthropic` | `anthropic` | `https://api.anthropic.com` | Claude models; live model list fetched from `/v1/models`. | -| `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Kimi K2.7/K2.6/K2.5 coding models. | +| `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Kimi Code Plan coding models. Defaults to the stable `kimi-for-coding` alias (currently K2.8 Preview): 1M-token context window, adjustable `low`/`high`/`max` thinking (default `max`), text + image input. Retired `kimi-k2.x` selections are migrated to the alias on upgrade. | | `nous` | `openai-chat` | `https://inference-api.nousresearch.com/v1` | Nous Research subscription gateway (same backend Hermes Agent uses). Device-grant login against `portal.nousresearch.com`; the access token is the per-request inference JWT. Mixed paid + `:free` model catalog (`tencent/hy3:free`, `stepfun/step-3.7-flash:free`, ...) discovered live from the signed-in account. Refresh tokens are single-use and rotated on every refresh. | | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | Initial login imports the installed, signed-in `kiro-cli` session (on Unix, install with `curl -fsSL https://cli.kiro.dev/install` | `bash`; on Windows PowerShell, use `irm 'https://cli.kiro.dev/install.ps1'` | `iex`; then run `kiro-cli login`). **Add account** logs `kiro-cli` out, starts a fresh browser login that switches the account used by `kiro-cli`, and stores account-scoped profile metadata. Existing OpenCodex accounts are preserved, and cancellation or failure restores the previous `kiro-cli` session. | | `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth over the Cloud Code Assist wire. Live discovery uses CCA's authenticated `v1internal:fetchAvailableModels` endpoint and publishes the agent models available to the signed-in account; the maintained catalog remains the fallback. | @@ -412,7 +412,7 @@ selectors, then retry. Signing in from a machine with no existing `kiro-cli` ses ## 3. API-key catalog -opencodex ships 95 built-in presets: 79 key-based, 12 OAuth, three local, and one default +opencodex ships 96 built-in presets: 80 key-based, 12 OAuth, three local, and one default ChatGPT-forward preset. The dashboard's **Add provider** picker opens a key provider's dashboard, validates the key, and stores it; validation is provider-specific. Notable entries: @@ -692,7 +692,7 @@ voice models on the same host. Two things worth knowing before you pick it. **A Muse Code subscription does not apply here:** Meta scopes that credential to the Muse Code CLI and bills any other key pay-as-you-go. And the Contributor tier is cheap because Meta trains on your prompts — -roughly 92% off input, 95% off output, and 99% off cached input — so keep confidential +roughly 92% off input, 96% off output, and 99% off cached input — so keep confidential material off it. Muse Spark is also reachable through resellers, with a narrower roster: `command-code` carries both tiers, while `opencode-go` serves only `muse-spark-1.3-contributor`. diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index ed8f4cb709c..72c1a821d96 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -93,7 +93,7 @@ badge or the version value to read the full value. | **Subagents** | Feature up to five bare native or namespaced routed models in the `spawn_agent` override list. | | **Models** | Toggle native GPT and routed models, set provider allowlists and context caps, choose v1/base/v2, and configure the v2 thread limit. The page distinguishes a catalog saved on the hub, a catalog fetched by this client, and activation in a running client. A fetch timestamp does not prove it includes the latest hub save, and runtime activation is shown as unverified. Configured providers stay visible as zero-model groups when discovery is off or returns no rows. | | **Logs** | Auto-refresh recent requests with tokens, requested effort and (when available) effective outbound effort, resolved model, provider, status, request id, duration, and error details. The detail view includes the exact reasoning wire field when the adapter emits one. Filter by opaque conversation/session id (when the client sends one) to total tokens and estimated list-price cost for the currently loaded Logs ring. | -| **Usage / Debug** | Inspect token-usage coverage and trends, or enable opt-in provider transport and usage-extraction diagnostics. | +| **Usage / Debug** | Inspect token-usage coverage and trends. The Usage page's Models table also breaks each model down into input tokens, output tokens, cache hits, cache writes, and cache hit rate; a dash means cache telemetry for that metric is unavailable. Or enable opt-in provider transport and usage-extraction diagnostics. | | **Storage** | Read-only CODEX_HOME disk breakdown (sessions, archives, DBs, attachments). Optional archived cleanup: preview the oldest N%, then quarantine to `CODEX_HOME/.trash` (default) or permanently delete behind an explicit checkbox. **Auto-cleanup policy** is opt-in and **default OFF** (`storageCleanupPolicy.enabled`); configure threshold/target/schedule/mode on the Storage page, or trigger **Run now**. Quarantined entries can be restored from the Storage page (JSONL + threads). Active sessions stay read-only. Cleanup and restore are refused while Codex holds the newest/active `state_*.sqlite` locked. | | **Stop** | Gracefully stop the proxy and installed background service, restore native Codex, and exit (`POST /api/stop`). On Windows with the Task Scheduler backend the dashboard refuses and asks you to run `ocx stop` instead: that wrapper can respawn the proxy after the task ends, and only a stop running outside this process can verify the restart window before restoring your client config. Nothing is changed when it refuses. | diff --git a/docs-site/src/content/docs/ja/getting-started/installation.md b/docs-site/src/content/docs/ja/getting-started/installation.md index a62daede0a6..49b50f7d37d 100644 --- a/docs-site/src/content/docs/ja/getting-started/installation.md +++ b/docs-site/src/content/docs/ja/getting-started/installation.md @@ -43,6 +43,19 @@ ocx --version opencodex --version ``` +## スタンドアロンバイナリ(npm 不要) + +リリースには、対応する macOS、Linux、Windows 向けのスタンドアロン `ocx` バイナリも含まれます。 +Bun ランタイムとダッシュボードが含まれるため、npm、Node、別途の Bun インストールは必要ありません。 +お使いの環境向けのアーカイブをダウンロードして展開し、次のように実行します。 + +```bash +./ocx --version +./ocx start +``` + +ダッシュボードを提供するため、展開した `gui/dist` ディレクトリはバイナリの隣に置いたままにしてください。 + ### 配布チャネル 安定チャネルの `latest` にも ChatGPT、OpenAI API キー、OpenRouter、実験段階の Cursor 経路のための diff --git a/docs-site/src/content/docs/ja/getting-started/quickstart.md b/docs-site/src/content/docs/ja/getting-started/quickstart.md index f9184dfc5f6..d9d83201963 100644 --- a/docs-site/src/content/docs/ja/getting-started/quickstart.md +++ b/docs-site/src/content/docs/ja/getting-started/quickstart.md @@ -5,6 +5,11 @@ description: 最初のプロバイダーを構成し、3 つのコマンドで O このガイドでは、新規インストールから非 OpenAI モデルに対して Codex を実行するまでを説明します。 +## スタンドアロンバイナリ(npm 不要) + +npm を使わず、Bun ランタイムを含むリリースアーカイブの `ocx` バイナリも利用できます。 +`gui/dist` ディレクトリをバイナリの隣に置いて展開し、`./ocx start` を実行してください。 + ## 1. セットアップウィザードを実行します ```bash @@ -13,7 +18,7 @@ ocx init `ocx init` では次の手順を説明します。 -1. **プロバイダーを選択してください** — 95 個の組み込みレジストリプリセットのいずれか、または `custom` を選択してベース URL とアダプターを入力します。 +1. **プロバイダーを選択してください** — 96 個の組み込みレジストリプリセットのいずれか、または `custom` を選択してベース URL とアダプターを入力します。 2. **API キー** — キーを貼り付けるか、`${ANTHROPIC_API_KEY}` のような環境変数を参照します。 3. **デフォルト モデル** — キー、ローカル、カスタム プロバイダーの場合は、プリセットを受け入れるか、モデル ID を入力します。 4. **プロキシ ポート** — デフォルトは `10100` です。 diff --git a/docs-site/src/content/docs/ja/guides/claude-code.md b/docs-site/src/content/docs/ja/guides/claude-code.md index 4dfa380673f..390fbccd054 100644 --- a/docs-site/src/content/docs/ja/guides/claude-code.md +++ b/docs-site/src/content/docs/ja/guides/claude-code.md @@ -95,6 +95,29 @@ hook を削除します。Claude Desktop は独立した profile を使用し、 `claudeCode.nativePassthrough: false` でオフにでき、`claudeCode.anthropicBaseUrl` で別のアドレスを 指定できます。 +## Claude Desktop のモード: 1P(デフォルト)とゲートウェイ + +Claude Desktop は排他的な 2 つのモードのどちらかで OpenCodex を使います。ダッシュボードの +**Claude → Desktop → 接続モード**、または `ocx claude desktop apply --first-party|--gateway` で選びます。 + +- **1P(ファーストパーティ、デフォルト)**: Desktop 本体は変更しません。claude.ai のログイン、 + チャットタブ、コネクタ、リモート操作はそのまま動きます。OpenCodex は `~/.claude/settings.json` の + `env` に `HTTPS_PROXY=http://127.0.0.1:<公開ポート+100>` と + `NODE_EXTRA_CA_CERTS=~/.opencodex/claude-intercept/ca.pem` の 2 つだけを書きます。Desktop が + Code タブ用に起動する Claude Code(サブエージェント含む)とターミナルの `claude` CLI だけがこれを読み、 + ローカルのインターセプトプロキシを通ります。`POST /v1/messages` と `count_tokens` のみ OpenCodex が + 処理し、他の `api.anthropic.com` パスはそのまま Anthropic に中継されます。CA は OS の信頼ストアには + インストールされません。 +- **ゲートウェイ(3P)**: 従来の方式で、下記のプロファイルによりアプリ全体が OpenCodex を + ゲートウェイとして使います。`--gateway`(または従来の `--static`/`--hybrid`/`--discovery-only`)で + 明示的に選びます。 + +モードは `claudeCode.desktopMode` に保存されます。すでにゲートウェイプロファイルを適用済みの環境は +更新後もゲートウェイのままで、新規インストールだけが 1P になります。切り替えると他方のモードの設定 +(OpenCodex が書いた値のみ)が削除され、社内プロキシなど外部の `HTTPS_PROXY`/`NODE_EXTRA_CA_CERTS` +は上書きせず適用を拒否します。切り替え後は Desktop を完全に終了して再起動してください。詳細と +Claude Code CLI 互換性は英語版ドキュメントを参照してください。 + ## リモートハブに接続した Claude Desktop 接続中のマシンで `ocx claude desktop apply` または `ocx claude desktop` を実行すると、 @@ -498,4 +521,4 @@ Anthropic バックエンドを明示すると意図的に失敗後停止しま `config.json` の `claudeCode.stabilizePromptCache` を `true` にすると、変換ルートのシステム指示末尾にある対応済み Claude 通知を最後のユーザーメッセージへ移します。既定値は `false` です。このロール変更が適切なクライアントでのみ有効にしてください。コードフェンス内の例と一致しない本文は保持され、Anthropic のネイティブ転送は変わりません。メタデータがない場合のキャッシュキーは安定化した指示から計算されます。会話 ID の生成やキャッシュヒットの保証は行いません。 -OpenCode Go の `deepseek-v4.1-flash` Chat ルートでは、変換されたタイムライン上のシステムリマインダーは、保留中のツール結果の後で位置と system ロールを自動的に維持します。これにより、新しいリマインダーを追加しても先頭のシステムプロンプトが書き換わりません。`stabilizePromptCache` の設定にかかわらず適用され、他のモデルや接続先の変換、および Anthropic のネイティブ転送は変わりません。キャッシュの再利用には、安定したセッション ID と上流キャッシュの利用可能性が引き続き必要です。過去の指示やツールの変更、会話の圧縮もキャッシュヒットに影響します。リマインダーの順序を保つだけで再利用が保証されるわけではありません。 +変換されたすべての Chat ルートで、タイムライン上のリマインダーは保留中のツール結果の後、会話内の元の位置を保ちます。これにより、新しいリマインダーを追加しても先頭のシステムプロンプトが書き換わらず、会話の途中に置かれた指示がそれより前のターンの前に移動することもありません。そのスロットが運ぶロールは別に決まります。プロバイダーが `foldDeveloperRoleToSystem: false` を記録していないかぎり、リマインダーは `system` として送られます。この記録は上流が `developer` ロールを受け付けることを表し、その場合は同じ位置のまま転送します。受け付けない上流は `400 role 'developer' is not allowed` を返してターンが始まらないため、記録のない宛先は畳む側になります。`stabilizePromptCache` の設定にかかわらず適用され、Anthropic のネイティブ転送は変わりません。キャッシュの再利用には、安定したセッション ID と上流キャッシュの利用可能性が引き続き必要です。過去の指示やツールの変更、会話の圧縮もキャッシュヒットに影響します。リマインダーの順序を保つだけで再利用が保証されるわけではありません。 diff --git a/docs-site/src/content/docs/ja/guides/codex-integration.md b/docs-site/src/content/docs/ja/guides/codex-integration.md index 56c69f388e7..4bd4530decc 100644 --- a/docs-site/src/content/docs/ja/guides/codex-integration.md +++ b/docs-site/src/content/docs/ja/guides/codex-integration.md @@ -14,7 +14,7 @@ opencodex は、Codex が読み取る 2 つの内容 (構成 (`$CODEX_HOME/confi ```toml # root keys, before the first table model_catalog_json = "/absolute/path/to/opencodex-catalog.json" -# Auto-injected by opencodex +# Auto-injected by opencodex (undo: ocx restore) openai_base_url = "http://127.0.0.1:10100/v1" # fastMode を設定した場合のみ。未設定なら [features] は作られません @@ -82,7 +82,7 @@ model_provider = "opencodex" model_catalog_json = "/absolute/path/to/opencodex-catalog.json" # appended at the end of the file -# Auto-injected by opencodex +# Auto-injected by opencodex (undo: ocx restore) [model_providers.opencodex] name = "OpenCodex Proxy" base_url = "http://your-host:10100/v1" @@ -281,7 +281,7 @@ opencodex が管理対象 [バックグラウンドサービス](/reference/cli/ ## ページ分割履歴の保護による拒否 -対象の履歴ストアがページ分割をサポートする場合、プロバイダー変更は `history_paginated_requires_native_writer` を返すことがあります。この理由では、Codex の設定、参照プロファイル、モデルカタログは拒否されません。`ocx sync` と `ocx start` はこれらのファイルを書き込み、`model_catalog_json` を設定するため、Codex のモデル選択には OpenCodex 経由のモデルがすべて表示され続けます。会話履歴の再ラベル付けを控えるのはこの理由だけの場合です。ページ分割された履歴の番号は Codex 自身の書き込み処理が割り当て、再試行しても変わりません。読み取れない状態データベース、識別子が変わった履歴、実行できなかった事前検査など、それ以外の履歴事前検査の理由では、後から成功する可能性があるため、遷移全体を拒否してロールバックします。この状態では OpenCodex はページ分割された履歴ファイルやスレッド行を変更しません。既存の会話はすでに付いているプロバイダーのまま移行されず、新しい会話は通常どおりプロキシ経由でルーティングされます。再ラベル付けを控えるとき、ホームに既にある `[model_providers.opencodex]` テーブルは廃止せず残します。ルート上書き(loopback)形式でも同じで、行が `opencodex` と付いている会話は、まだ存在するプロバイダー id を保てます。移行可能なストアの legacy 行も対象です。CLI は `Codex resume history: left to Codex's native writer (history_paginated_requires_native_writer)` と表示します。`ocx restore` と Codex 設定の削除は、いまも `history_paginated_requires_native_writer` で拒否されます。スレッド行がまだ参照しているのに `[model_providers.opencodex]` 定義を外すと、それらの会話は解決できなくなり、復元経路には互換プロバイダー表を残す手段がありません。すでにページ分割されているホームは、現状では製品からアンインストールできません。意図した動作ではなく、既知の未解決作業です。 +対象の履歴ストアがページ分割をサポートする場合、プロバイダー変更は `history_paginated_requires_native_writer` を返すことがあります。この理由では、Codex の設定、参照プロファイル、モデルカタログは拒否されません。`ocx sync` と `ocx start` はこれらのファイルを書き込み、`model_catalog_json` を設定するため、Codex のモデル選択には OpenCodex 経由のモデルがすべて表示され続けます。会話履歴の再ラベル付けを控えるのはこの理由だけの場合です。ページ分割された履歴の番号は Codex 自身の書き込み処理が割り当て、再試行しても変わりません。読み取れない状態データベース、識別子が変わった履歴、実行できなかった事前検査など、それ以外の履歴事前検査の理由では、後から成功する可能性があるため、遷移全体を拒否してロールバックします。この状態では OpenCodex はページ分割された履歴ファイルやスレッド行を変更しません。既存の会話はすでに付いているプロバイダーのまま移行されず、新しい会話は通常どおりプロキシ経由でルーティングされます。再ラベル付けを控えるとき、ホームに既にある `[model_providers.opencodex]` テーブルは廃止せず残します。ルート上書き(loopback)形式でも同じで、行が `opencodex` と付いている会話は、まだ存在するプロバイダー id を保てます。移行可能なストアの legacy 行も対象です。CLI は `Codex resume history: left to Codex's native writer (history_paginated_requires_native_writer)` と表示します。`ocx restore`、`ocx stop`、`ocx uninstall` は `history_paginated_requires_native_writer` で拒否しなくなりました。OpenCodex が書いたルートのルーティングキーをすべて取り除き、`[model_providers.opencodex]` の定義はディスクに残します。そのプロバイダーを指している会話は解決でき、素の `codex` はプロキシを向かなくなります。結果は残した行を示す部分復元として報告され、`ocx restore --remove-codex-provider-table` を使えばその行も削除できます。そのときは該当の会話が開かなくなります。また、`openai` と付いた会話を Codex がすでにページ分割したホームでプロバイダーテーブル形式の統合を有効にすると、以前は `history_paginated_openai_requires_native_writer` で全体が拒否され、何も書かれず統合も無効のままでした。現在は、管理対象のルート `openai_base_url` 上書きを `[model_providers.opencodex]` テーブルと一緒に残す形で移行を完了します。Codex はこの上書きを組み込みの `openai` プロバイダーに統合するため、それらの会話は再ラベル付けなしでプロキシに届き、履歴ファイルやスレッド行は変更されません。`x-opencodex-api-key` の受け入れヘッダーを必要とするルーティング形式だけは今も拒否されます。組み込みプロバイダーがそのヘッダーを運べないためで、そのメッセージは解決策を二つ名指しします。ループバックリスナー経由で Codex を接続して上書きを維持するか、`syncResumeHistory` を `false` にして、それらの会話が Codex 自身の OpenAI エンドポイントに向かうことを受け入れるかです。 ルート URL 上書き方式に戻すとき、履歴の事前確認が成功していても、OpenCodex は設定を確定する前に既存の `[model_providers.opencodex]` 定義を保持します。確定後やバックグラウンドの履歴処理開始中に Codex が履歴形式を移行しても、以前の `opencodex` 会話はプロバイダーを引き続き解決できます。新しい会話は選択されたルートプロバイダーを使い、明示的な復元には従来の個別の削除チェックが適用されます。 diff --git a/docs-site/src/content/docs/ja/guides/macos-menu-bar.md b/docs-site/src/content/docs/ja/guides/macos-menu-bar.md new file mode 100644 index 00000000000..bf429ac0dc1 --- /dev/null +++ b/docs-site/src/content/docs/ja/guides/macos-menu-bar.md @@ -0,0 +1,169 @@ +--- +title: macOS メニューバーアプリ +description: OpenCodex プロキシの状態、使用量、プロバイダーのクォータをメニューバーから確認できるネイティブアプリ。 +--- + +メニューバーアプリは、ダッシュボードを開かずにプロキシの状態、直近の使用量、プロバイダーごとの +クォータ状況を表示します。 + +プロキシとは別のアプリケーションです。`ocx` はこれまで通り動作し、メニューバーアプリは +ローカルの管理 API に接続するクライアントとして動きます。 + +## デスクトップアプリ (Tauri) + +同じダッシュボードを OpenCodex デスクトップアプリ内で実行できます。Usage コンパニオン +パネルは OS に合ったインストール手順を表示し、デスクトップシェル内では **ブラウザーで開く** +を選ぶと現在の画面を通常のブラウザーで開けます。 + +## インストール + +基本のインストール方法は OpenCodex デスクトップアプリです。[リリースページ](https://github.com/lidge-jun/opencodex/releases)から、macOS では +`OpenCodex--macos.dmg` をダウンロードし、DMG を開いて `OpenCodex.app` を +アプリケーションフォルダへドラッグします。Windows では +`OpenCodex--windows-x64.msi` を実行し、Linux では AppImage または +`OpenCodex--linux-amd64.deb` を使います。 + +```bash +chmod +x OpenCodex--linux-x86_64.AppImage +sudo apt install ./OpenCodex--linux-amd64.deb +``` + +Windows SmartScreen や macOS Gatekeeper の警告が表示されることがあります。アプリは +既存の `ocx` に接続し、見つからなければ同梱のサイドカーを起動します。 + +## 初回起動: Gatekeeper + +**初回起動はブロックされます。** 次のメッセージが表示されます。 + +> "OpenCodex.app"は、開発元を検証できないため開けません。 + +これは想定された動作なので、読み飛ばさずに理由を説明します。Gatekeeper は Apple の +Developer ID 署名と公証(notarization)チケットを要求しますが、どちらも有料の Apple +Developer アカウントが必要です。OpenCodex はそのアカウントを持たないため、アプリは ad-hoc +署名で配布されます。バンドル自体は壊れておらず署名も有効ですが、Apple が配布元を保証しては +いない、という状態です。 + +それでも開くには: + +1. Finder で `OpenCodex.app` を右クリック(または Control クリック)します。 +2. **開く** を選択します。 +3. 表示されたダイアログで再度 **開く** をクリックします。 + +ダイアログに「開く」が無い場合は、**システム設定 → プライバシーとセキュリティ** でブロック +通知を探し、**このまま開く** をクリックしてください。 + +一度許可すれば macOS が記憶するため、バージョンごとに一度だけの操作です。 + +ターミナルから隔離属性を削除する方法もあります。 + +```bash +xattr -d com.apple.quarantine /Applications/OpenCodex.app +``` + +どちらも避けたい場合はソースからビルドしてください。ローカルビルドには隔離属性が付きません。 +[ソースからビルド](#ソースからビルド)を参照してください。 + +## 表示される内容 + +メニューバーのアイコンは色ではなく形で状態を示します。macOS のメニューバーアイコンは単色が +慣例だからです。 + +| アイコン | 意味 | +| --- | --- | +| 塗りつぶし | 実行中、ルーティング保護あり | +| 切り欠き付き | 実行中だがルーティング保護が不安定 | +| 輪郭のみ | 確認中、または応答が異常 | +| 薄い輪郭 | 停止中、または API キーが必要 | + +アイコンをクリックすると 4 つのセクションを持つパネルが開きます。 + +**ステータス** — プロキシの稼働状況、アプリが使用しているループバックアドレス、保護状態。プロキシが対処コマンド +(例: `ocx service install`)を推奨している場合は選択可能なテキストとして表示します。アプリが +代わりに実行することはありません。 + +**使用量** — 直近 7 日間のリクエスト数、トークン、推定コストと日別の推移。リクエスト数の後ろの +`~` は、一部がプロバイダー報告値ではなく推定値であることを示します。 + +デフォルトでは、メニューバーのヘッドラインに合計トークン数が表示されます。リクエスト数、コスト、クォータ、 +またはアイコンだけを表示したい場合は、ダッシュボードの Usage コンパニオン設定でヘッドライン指標を変更できます。 +macOS 26 ではポップオーバーとウィジェットに Liquid Glass が採用され、それ以前の macOS バージョンでは標準の +ポップオーバーマテリアルが使われます。 + +**クォータ** — プロバイダーごとに 1 行、最も逼迫しているウィンドウを表示します。5 時間枠を +99%、月間枠を 10% 使っているプロバイダーなら 5 時間枠の数値を出します。いま実際に制限に +かかっているのはそちらだからです。ウィンドウ名を併記するため、`API usage の 42%` と +`1 か月の 42%` を取り違えることはありません。 + +**プロバイダー** — 展開できる一覧で、プロバイダーごとにスイッチがあります。デフォルト +プロバイダーは有効な間スイッチが無効化されます。プロキシがデフォルトの無効化を拒否するため、 +先にダッシュボードでデフォルトを変更してください。 + +## できること + +- **Dashboard** — ブラウザで Web ダッシュボードを開きます。 +- **Stop proxy** — 確認のうえプロキシを停止します。あえて「再起動」とは呼びません。停止すると + launchd サービスも止まり、自動的には復帰しないためです。停止後は再起動用のコマンドを + パネルに表示します。 +- **プロバイダースイッチ** — プロバイダーの有効・無効を切り替えます。 + +アカウント、モデル設定、ストレージなどはダッシュボードで操作します。 + +## ウィジェット + +デスクトップを右クリックして **ウィジェットを編集** を選び、**OpenCodex** を追加します。 +プロキシの状態、今日の使用量、クォータを表示し、メニューバーアプリと同じプライバシー保護済み +スナップショットを使います。アプリのポーリング時に更新されます。macOS 14 以降が必要で、 +API キーや生のアカウント情報は受け取りません。 + +## プロキシへの接続 + +アプリが自動で見つけます。`~/.opencodex/runtime-port.json`(または +`$OPENCODEX_HOME/runtime-port.json`)を読み、無ければポート `10100` を使います。この +ファイルから取得するのはポートのみで、ホストは常にループバックです。 + +プロキシがループバック以外のアドレスにバインドされている場合は API キーが必要です。パネルが +その旨を表示し、ダッシュボードへのボタンを出します。 + +**この経路はまだサポートされていません。** アプリは macOS キーチェーンからキーを読み取って +一度だけ再試行しますが、キーを入力する画面はなく、手動で用意する方法もありません。データ保護 +キーチェーンの項目であり、キーチェーンアクセスでは作成できないためです。したがってループバック +以外のバインドではパネルは「Needs API key」のままになります。 + +既定であるループバックのプロキシではキーは不要です。ネイティブのキー入力は今後追加予定です。 + +## ポーリング + +アプリは意図的に控えめに動作します。プロキシの生存確認は 5 秒ごと、負荷の大きい集計データ +(使用量とクォータ)はパネルが開いている間のみ、最大でも 1 分に 1 回取得します。3 回連続で +失敗した場合は 30 秒間隔に広げます。ユーザーが意図的に停止したプロキシを叩き続けないためです。 + +## ソースからビルド + +macOS 13 以降、Xcode Command Line Tools、および [Bun](https://bun.sh) が必要です。 + +```bash +git clone https://github.com/lidge-jun/opencodex.git +cd opencodex +bun run prepare-sidecar +bun run prepare-widget +bunx tauri build +``` + +バンドルは Tauri のリリース出力に生成され、WidgetKit 拡張は +`OpenCodex.app/Contents/PlugIns/` に含まれます。 + +ユニバーサルバイナリ(`UNIVERSAL=1`)には完全な Xcode が必要です。Command Line Tools には +現在のアーキテクチャ用の Swift 互換ライブラリしか含まれないため、その場合はリンカーエラーでは +なく理由を説明するメッセージが表示されます。 + +キーチェーンに Developer ID 証明書がある場合は、`MACOS_SIGN_IDENTITY` を指定すると ad-hoc +ではなく hardened runtime で署名できます。 + +```bash +MACOS_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" bun run prepare-widget +``` + +## アンインストール + +`OpenCodex.app` をゴミ箱に移動してください。アプリは設定ファイルなどを残さず、現時点では +キーチェーンにも何も保存しません。 diff --git a/docs-site/src/content/docs/ja/guides/providers.md b/docs-site/src/content/docs/ja/guides/providers.md index f60ba96b0e6..adad28ad00c 100644 --- a/docs-site/src/content/docs/ja/guides/providers.md +++ b/docs-site/src/content/docs/ja/guides/providers.md @@ -185,7 +185,7 @@ Kiro のログインには Kiro CLI が必要です。Unix では `curl -fsSL ht ## 3. API キーカタログ -opencodex には組み込みプリセットが 95 個含まれています。キー方式 79、OAuth 12、ローカル 3、 +opencodex には組み込みプリセットが 96 個含まれています。キー方式 80、OAuth 12、ローカル 3、 デフォルト ChatGPT 転送プリセット 1 です。ダッシュボードの **Add provider** ピッカーはキー発行ページを開き、 入力したキーを検証した後保存します(検証はプロバイダー固有です)。主な項目は以下のとおりです: diff --git a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md index bf37bc911b6..877954e9755 100644 --- a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md @@ -272,6 +272,7 @@ OpenCodex の更新後、既存の Windows シムにこの動作を適用する ### `ocx tray [--json] [--no-start]` Windows ステータス トレイ アイコンをインストールして制御します。 Windows ログイン時に開始され、ワンクリックでプロキシ コントロールを提供します。 `start` および `stop` はアイコンのみを制御します。そのメニューを使用してプロキシを制御します。 `--no-start` は `install` に適用され、トレイをすぐに起動せずにインストールします。 +非推奨: OpenCodex デスクトップアプリは Windows、macOS、Linux のトレイを提供します。`ocx tray` はデスクトップアプリを使わないインストール向けに残っています。 ## ダッシュボード diff --git a/docs-site/src/content/docs/ja/reference/configuration/providers.md b/docs-site/src/content/docs/ja/reference/configuration/providers.md index a9fdaf8c813..7dacef8a955 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -125,11 +125,13 @@ account を削除しても mapping は保持され、同じ id を再追加す | `noPenaltyModels?` | `string[]` |存在/周波数ペナルティを拒否するモデル。 | | `noStructuredOutputModels?` | `string[]` | `openai-chat` エンドポイントが `response_format` を拒否する正確なモデル ID。要求モデルが項目と完全一致する場合だけフィールドを省略し、その他の `openai-chat` モデルでは structured-output 変換を維持します。 | | `noJsonSchemaModels?` | `string[]` | `openai-chat` エンドポイントが `json_schema` 形式は拒否しつつ `json_object` は受け入れる正確なモデル ID。この要求はフィールドを削除せず `json_object` に降格して送るため、JSON を求めた呼び出し側は散文ではなく JSON を受け取れます。両方の一覧に載るモデルでは `noStructuredOutputModels` が優先します。`opencode go` / `opencode zen` / `opencode free` プリセットが DeepSeek 経路に既定で載せます。 | +| `foldDeveloperRoleToSystem?` | `boolean` | `openai-chat` の宛先が `developer` ロールを受け付けるかを記録します。`foldDeveloperRoleToSystem` が未設定なら `system`、`true` なら `system`、`false` なら `developer` として送ります。未設定はこの宛先について何も記録されていないことを意味し、`true` は上流がロールを拒否する記録、`false` は受け付ける記録です。いずれの場合もメッセージは会話内の位置を保ち、変わるのはロールだけです。ロールを拒否する宛先は `400 role 'developer' is not allowed` を返してターンが始まらないため、未記録の既定は畳む側にしてあります。 | | `parallelToolCalls?` | `boolean` |並列ツール呼び出しを切り替えます。 OpenAI Chat はデフォルトでオンになっています。非チャット アダプターは明示的な `true` でのみアドバタイズします。 | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` |正確なプレースホルダー ID、欠落している端末 ID、および(`repairInvalidIds` で)正規の `msg_`/`rs_` 接頭辞を欠く message/reasoning ID に対するダウンストリーム SSE 修復はデフォルトで無効になっています。関数呼び出し ID は決して書き換えられません。組み込み DeepSeek は最後の 2 つをデフォルトで有効にします。 | | `responsesSnapshotRepair?` | `boolean` | デフォルトで無効のクライアント向け修復です。SSE と JSON の Responses ライフサイクルで欠落した status、output、ツールメタデータを補完し、raw 検査と永続化は変更しません。 | | `retryOn429?` | `{ enabled?: boolean; attempts?: number; intervalMs?: number; maxIntervalMs?: number; respectRetryAfter?: boolean }` | API-key プロバイダーのみ(`authMode: "key"`)。オプトインの同一ターゲット 429 リトライ: `retryOn429` が無ければ無効で、オブジェクトがあれば `enabled: false` でない限り有効になります。429 時に待機(上流の `Retry-After` または固定間隔)してから、キー フェイルオーバーの前に同一キーで同一リクエストを再送します — メインのテキストターン回復ループ、Responses passthrough、画像/動画ブリッジ、web-search サイドカー、ターミナル継続要求をすべてカバーします。再送の対象はプリストリームの HTTP 429 応答のみで、カスタム `runTurn` トランスポートは HTTP リトライループの対象外です。`attempts` は最初の 429 以降の同一キー再送回数(合計送信数 = `attempts` + 1)で、メインの回復ループ・ターミナルガード継続・ブリッジ再試行で共有されるリクエスト単位の予算です。`attempts` を使い切っても同一キーでの再送が止まるだけで、通常のキー フェイルオーバーまたは最終エラー処理が利用可能なターゲットに応じて続きます — キー認証の passthrough ワイヤにはフェイルオーバーがないため、使い切った 429 はそのまま返ります。Codex 自体は 429 をリトライしないため、単一キーのプロバイダーでは唯一の防御です。デフォルト: `enabled: true`、`attempts: 3`、`intervalMs: 5000`、`maxIntervalMs: 60000`(1回の待機は `maxIntervalMs` で上限、その上限は 600000)、`respectRetryAfter: true`。 | | `transientRetryOn5xx?` | `{ enabled?: boolean; attempts?: number }` | キー認証の `openai-chat` および `openai-responses` プロバイダーのみ。`authMode: "forward"` のプロバイダー(ChatGPT アカウントプール)はこのオプションを読まず、既定の再試行段数を維持します。ストリーム開始前に上流から返される一時的なステータス(500、502、503、504、520、521、522)に対するオプトインの再試行です。設定がなければ無効で、オブジェクトを指定すると `enabled: false` でない限り有効になります。最初の Responses リクエスト、ターミナルガード継続、ネイティブの `/v1/chat/completions`、および 429/アカウント回復時の再取得が対象です。`attempts` は最初の送信を含め、1 回のリクエストで許可される上流への送信総数です(1~10、デフォルトは 3)。接続リセット回復と共有するリクエスト単位の単一予算であるため、`3` を指定した場合、プロバイダーに到達する実リクエストは最大 3 回です。待機には 400 ms を基準とする固定式の指数バックオフを使用し、上限は 5 秒で、`Retry-After` に従います。レート制限を扱う `retryOn429` とは別の機能であり、ストリーム開始後の失敗は再送されません。 | +| `retryOnReset?` | `{ enabled?: boolean; replacements?: number }` | ネイティブ `openai-responses` プロバイダー専用で、`authMode: "forward"` も含みます。呼び出し側が何も観測しないまま失敗した送信を、オプトインで置き換えます。設定がなければ無効で、オブジェクトを指定すると `enabled: false` でない限り有効になります。レスポンスヘッダーが届く前に接続が切れた場合と、ヘッダー後に SSE 本文が制御イベントだけを運んだまま切れた場合の両方が対象です。置き換えるのは自己完結したリクエストだけで、`store: false`、完全な `input`、`previous_response_id` / `conversation` / `stream_id` がないこと、クライアントが実行するツールのみ、が条件です。`replacements` は、すべてのレッグとすべてのコンボ子リクエストを合わせて 1 つの論理リクエストが行える置き換え送信の回数です(1..2、デフォルトは 1)。レッグ単位の再試行回数でも送信予算でもないため、置き換え送信もそのレッグがすでに持つ送信許容量に収まる必要があります。すでに出力やツール呼び出しを送ったリクエストは、この値に関わらず置き換えません。元の送信がすでに開始されていた場合は置き換えた推論も課金される可能性があるため、既定では無効です。 | | `autoToolChoiceOnlyModels?` | `string[]` | `tool_choice` が `auto` または `none` のみを受け入れるモデル。強制的な選択は格下げされます。 | | `preserveReasoningContentModels?` | `string[]` |チャット履歴に以前のアシスタント `reasoning_content` が必要なモデル。 | | `reasoningDetailsModels?` | `string[]` | thinking を構造化された `reasoning_details` 配列で返すモデル(`reasoning_split` 使用の MiniMax M シリーズ)。ストリーム差分は累積スナップショットとして prefix-diff され、保持された reasoning は `reasoning_content` 文字列ではなく `reasoning_details` 配列としてリプレイされます。 | diff --git a/docs-site/src/content/docs/ko/getting-started/installation.md b/docs-site/src/content/docs/ko/getting-started/installation.md index 70a62f86099..146f25bb01c 100644 --- a/docs-site/src/content/docs/ko/getting-started/installation.md +++ b/docs-site/src/content/docs/ko/getting-started/installation.md @@ -43,6 +43,19 @@ ocx --version opencodex --version ``` +## 독립 실행형 바이너리(npm 없음) + +릴리스에는 지원되는 macOS, Linux, Windows용 독립 실행형 `ocx` 바이너리도 포함됩니다. +Bun 런타임과 대시보드가 포함되어 있으므로 npm, Node 또는 별도의 Bun 설치가 필요하지 않습니다. +플랫폼에 맞는 아카이브를 다운로드해 압축을 풀고 다음과 같이 실행하세요. + +```bash +./ocx --version +./ocx start +``` + +대시보드를 제공하려면 압축을 푼 `gui/dist` 디렉터리를 바이너리 옆에 그대로 두어야 합니다. + ### 배포 채널 안정화 채널인 `latest`에도 ChatGPT, OpenAI API 키, OpenRouter, 실험 단계의 Cursor 경로를 위한 diff --git a/docs-site/src/content/docs/ko/getting-started/quickstart.md b/docs-site/src/content/docs/ko/getting-started/quickstart.md index f1a179649b7..91d11f1eff3 100644 --- a/docs-site/src/content/docs/ko/getting-started/quickstart.md +++ b/docs-site/src/content/docs/ko/getting-started/quickstart.md @@ -5,6 +5,11 @@ description: 첫 프로바이더를 설정하고 명령어 세 개로 OpenAI Cod 이 가이드는 새로 설치한 상태에서 OpenAI가 아닌 모델로 Codex를 실행하기까지의 과정을 안내합니다. +## 독립 실행형 바이너리(npm 없음) + +npm 없이 Bun 런타임이 포함된 릴리스 아카이브의 `ocx` 바이너리를 사용할 수도 있습니다. +`gui/dist` 디렉터리를 바이너리 옆에 둔 채 압축을 풀고 `./ocx start`를 실행하세요. + ## 1. 설정 마법사 실행 ```bash @@ -13,7 +18,7 @@ ocx init `ocx init`은 다음 과정을 안내합니다: -1. **프로바이더 선택** — 내장 레지스트리 프리셋 95개 중 하나를 고르거나 `custom`을 선택해 base URL과 adapter를 직접 입력합니다. +1. **프로바이더 선택** — 내장 레지스트리 프리셋 96개 중 하나를 고르거나 `custom`을 선택해 base URL과 adapter를 직접 입력합니다. 2. **API 키** — 키를 붙여넣거나 `${ANTHROPIC_API_KEY}` 같은 환경 변수를 참조합니다. 3. **기본 모델** — 키, 로컬, custom 프로바이더에서는 프리셋을 그대로 쓰거나 모델 ID를 직접 입력합니다. 4. **프록시 포트** — 기본값은 `10100`입니다. diff --git a/docs-site/src/content/docs/ko/guides/claude-code.md b/docs-site/src/content/docs/ko/guides/claude-code.md index 97ccbf35666..27759bbc709 100644 --- a/docs-site/src/content/docs/ko/guides/claude-code.md +++ b/docs-site/src/content/docs/ko/guides/claude-code.md @@ -118,6 +118,27 @@ hook을 제거해요. Claude Desktop은 별도 profile을 사용하며 shell hoo `claudeCode.nativePassthrough: false`로 끌 수 있고, `claudeCode.anthropicBaseUrl`로 다른 주소를 지정할 수 있어요. +## Claude Desktop 모드: 1P(기본값)와 게이트웨이 + +Claude Desktop은 서로 배타적인 두 모드 중 하나로 OpenCodex를 사용해요. 대시보드의 +**Claude → Desktop → 연결 모드** 또는 `ocx claude desktop apply --first-party|--gateway`로 선택합니다. + +- **1P(퍼스트파티, 기본값)**: Desktop 자체는 건드리지 않아요. claude.ai 로그인, 채팅 탭, 커넥터, + 원격 제어가 그대로 유지됩니다. OpenCodex는 `~/.claude/settings.json`의 `env`에 + `HTTPS_PROXY=http://127.0.0.1:<공개 포트+100>`과 `NODE_EXTRA_CA_CERTS=~/.opencodex/claude-intercept/ca.pem` + 두 값만 씁니다. Desktop이 Code 탭용으로 실행하는 Claude Code(서브에이전트 포함)와 터미널의 + `claude` CLI만 이 값을 읽어 로컬 인터셉트 프록시를 거치고, `POST /v1/messages`·`count_tokens`만 + OpenCodex가 처리하며 나머지 `api.anthropic.com` 경로는 그대로 Anthropic으로 전달돼요. CA는 OS + 신뢰 저장소에 설치되지 않습니다. +- **게이트웨이(3P)**: 기존 방식으로, 아래 프로필을 써서 앱 전체가 OpenCodex를 게이트웨이로 + 사용해요. `--gateway`(또는 기존 `--static`/`--hybrid`/`--discovery-only`)로 명시적으로 선택합니다. + +모드는 `claudeCode.desktopMode`에 저장돼요. 이미 게이트웨이 프로필을 적용한 설치는 업데이트 후에도 +게이트웨이를 유지하고, 새 설치만 1P가 기본이에요. 모드를 바꾸면 다른 모드의 설정(OpenCodex가 쓴 +값만)이 제거되며, 회사 프록시 같은 외부 `HTTPS_PROXY`/`NODE_EXTRA_CA_CERTS` 값은 덮어쓰지 않고 적용을 +거부해요. 전환 후에는 Desktop을 완전히 종료하고 다시 열어 주세요. 자세한 내용과 Claude Code CLI +호환성은 영어 문서를 참고하세요. + ## 원격 허브에 연결된 Claude Desktop 허브에 연결된 컴퓨터에서 `ocx claude desktop apply` 또는 `ocx claude desktop`을 실행하면 @@ -563,4 +584,4 @@ Anthropic 백엔드를 명시하면 의도적으로 실패 후 중단해요. `config.json`에서 `claudeCode.stabilizePromptCache`를 `true`로 설정하면 번역 경로의 시스템 지시 끝에 붙은 지원 대상 Claude 알림을 마지막 사용자 메시지로 옮깁니다. 기본값은 `false`입니다. 사용하는 클라이언트에서 이 역할 변경을 허용할 때만 켜세요. 코드 펜스 안의 예제와 일치하지 않는 원문은 보존하며, Anthropic 원본 전달 경로는 바꾸지 않습니다. 메타데이터가 없는 요청의 캐시 키는 정리된 지시문을 기준으로 계산합니다. 대화 식별자를 만들거나 상위 서비스의 캐시 적중을 보장하는 기능은 아닙니다. -OpenCode Go의 `deepseek-v4.1-flash` Chat 경로에서는 변환된 타임라인 시스템 알림이 대기 중인 도구 결과 뒤에서 원래 위치와 system 역할을 자동으로 유지합니다. 따라서 새 알림을 추가해도 맨 앞의 시스템 프롬프트를 다시 쓰지 않습니다. `stabilizePromptCache` 설정과 관계없이 적용되며, 다른 모델과 대상의 변환 및 Anthropic 네이티브 전달은 기존 동작을 유지합니다. 캐시 재사용에는 안정적인 세션 식별자와 사용 가능한 상위 서비스 캐시가 여전히 필요합니다. 이전 지시나 도구의 변경, 대화 압축도 캐시 적중에 영향을 줄 수 있으며, 알림 순서를 유지하는 것만으로 재사용을 보장하지는 않습니다. +변환된 모든 Chat 경로에서 타임라인 알림은 대기 중인 도구 결과 뒤, 대화 안의 원래 위치를 그대로 유지합니다. 덕분에 새 알림을 추가해도 맨 앞의 시스템 프롬프트를 다시 쓰지 않고, 대화 중간의 지시가 그 지시보다 앞선 턴으로 끌려가지도 않습니다. 그 자리가 어떤 역할을 싣는지는 따로 정합니다. 공급자가 `foldDeveloperRoleToSystem: false`를 기록하지 않는 한 알림은 `system`으로 보내며, 이 기록은 상위 서비스가 `developer` 역할을 받아들인다는 뜻이라 같은 위치에서 그대로 전달합니다. 받아들이지 않는 상위 서비스는 `400 role 'developer' is not allowed`로 응답해 턴이 시작조차 못 하므로, 기록이 없는 목적지는 접는 쪽을 씁니다. `stabilizePromptCache` 설정과 관계없이 적용되며 Anthropic 네이티브 전달은 기존 동작을 유지합니다. 캐시 재사용에는 안정적인 세션 식별자와 사용 가능한 상위 서비스 캐시가 여전히 필요합니다. 이전 지시나 도구의 변경, 대화 압축도 캐시 적중에 영향을 줄 수 있으며, 알림 순서를 유지하는 것만으로 재사용을 보장하지는 않습니다. diff --git a/docs-site/src/content/docs/ko/guides/codex-integration.md b/docs-site/src/content/docs/ko/guides/codex-integration.md index fa172286f18..1aefaeb8b75 100644 --- a/docs-site/src/content/docs/ko/guides/codex-integration.md +++ b/docs-site/src/content/docs/ko/guides/codex-integration.md @@ -20,9 +20,9 @@ Pool 모드에서는 선택된 저장 계정이 쿨다운 중이고 사용 가 ```toml # root keys, before the first table model_catalog_json = "/absolute/path/to/opencodex-catalog.json" -# Auto-injected by opencodex +# Auto-injected by opencodex (undo: ocx restore) openai_base_url = "http://127.0.0.1:10100/v1" -# Auto-injected by opencodex +# Auto-injected by opencodex (undo: ocx restore) experimental_realtime_ws_base_url = "http://127.0.0.1:10100/v1" # fastMode를 설정했을 때만 들어갑니다. 설정하지 않으면 [features] 자체가 생기지 않습니다 @@ -169,7 +169,7 @@ model_provider = "opencodex" model_catalog_json = "/absolute/path/to/opencodex-catalog.json" # appended at the end of the file -# Auto-injected by opencodex +# Auto-injected by opencodex (undo: ocx restore) [model_providers.opencodex] name = "OpenCodex Proxy" base_url = "http://your-host:10100/v1" @@ -391,7 +391,7 @@ opencodex가 managed [background service](/reference/cli/#ocx-service)로 실행 ## 페이지 분할 기록 보호에 따른 거부 -영향받는 기록 저장소가 페이지 분할을 지원하면 프로바이더 전환이 `history_paginated_requires_native_writer`를 반환할 수 있습니다. 이 이유로는 Codex 설정, 참조 프로필, 모델 카탈로그를 더 이상 거부하지 않습니다. `ocx sync`와 `ocx start`는 해당 파일과 `model_catalog_json`을 계속 쓰므로 Codex 모델 선택기에는 OpenCodex가 라우팅하는 모델이 모두 그대로 보입니다. 대화 기록의 프로바이더 재지정을 건너뛰는 것은 이 이유뿐이며, 페이지 분할 순번은 Codex 자체의 네이티브 기록 작성자가 할당하고 재시도해도 달라지지 않기 때문입니다. 읽을 수 없는 상태 데이터베이스, 식별자가 바뀐 대화 원본, 실행하지 못한 사전 검사처럼 다른 기록 사전 검사 이유는 나중에 성공할 수 있으므로 전환 전체를 거부하고 되돌립니다. 이 상태에서 OpenCodex는 페이지 분할 대화 원본이나 스레드 행을 수정하지 않습니다. 기존 대화는 이미 붙어 있는 프로바이더를 유지하고 이전되지 않으며, 새 대화는 평소처럼 프록시를 통해 라우팅됩니다. 재지정을 건너뛸 때 홈에 이미 있던 `[model_providers.opencodex]` 테이블은 폐기하지 않고 유지합니다. root-override(loopback) 형식에서도 같아서, 행이 `opencodex`로 표시된 대화는 아직 존재하는 프로바이더 id를 유지합니다. 변환 가능한 저장소의 `legacy` 행도 포함됩니다. CLI는 `Codex resume history: left to Codex's native writer (history_paginated_requires_native_writer)`를 출력합니다. `ocx restore`와 Codex 설정 제거는 여전히 `history_paginated_requires_native_writer`로 거부됩니다. 스레드 행이 아직 참조하는데 `[model_providers.opencodex]` 정의를 걷어내면 그 대화를 해석할 수 없고, 복원 경로에는 호환 프로바이더 테이블을 남겨 둘 방법이 없습니다. 이미 페이지 분할된 홈은 지금은 제품으로 제거할 수 없습니다. 의도한 동작이 아니라 알려진 미해결 작업입니다. +영향받는 기록 저장소가 페이지 분할을 지원하면 프로바이더 전환이 `history_paginated_requires_native_writer`를 반환할 수 있습니다. 이 이유로는 Codex 설정, 참조 프로필, 모델 카탈로그를 더 이상 거부하지 않습니다. `ocx sync`와 `ocx start`는 해당 파일과 `model_catalog_json`을 계속 쓰므로 Codex 모델 선택기에는 OpenCodex가 라우팅하는 모델이 모두 그대로 보입니다. 대화 기록의 프로바이더 재지정을 건너뛰는 것은 이 이유뿐이며, 페이지 분할 순번은 Codex 자체의 네이티브 기록 작성자가 할당하고 재시도해도 달라지지 않기 때문입니다. 읽을 수 없는 상태 데이터베이스, 식별자가 바뀐 대화 원본, 실행하지 못한 사전 검사처럼 다른 기록 사전 검사 이유는 나중에 성공할 수 있으므로 전환 전체를 거부하고 되돌립니다. 이 상태에서 OpenCodex는 페이지 분할 대화 원본이나 스레드 행을 수정하지 않습니다. 기존 대화는 이미 붙어 있는 프로바이더를 유지하고 이전되지 않으며, 새 대화는 평소처럼 프록시를 통해 라우팅됩니다. 재지정을 건너뛸 때 홈에 이미 있던 `[model_providers.opencodex]` 테이블은 폐기하지 않고 유지합니다. root-override(loopback) 형식에서도 같아서, 행이 `opencodex`로 표시된 대화는 아직 존재하는 프로바이더 id를 유지합니다. 변환 가능한 저장소의 `legacy` 행도 포함됩니다. CLI는 `Codex resume history: left to Codex's native writer (history_paginated_requires_native_writer)`를 출력합니다. `ocx restore`, `ocx stop`, `ocx uninstall`은 이제 `history_paginated_requires_native_writer`로 거부하지 않습니다. OpenCodex가 넣은 루트 라우팅 키를 모두 걷어내고 `[model_providers.opencodex]` 정의는 디스크에 남기므로, 그 프로바이더를 가리키는 대화는 계속 열리고 plain `codex`는 더 이상 프록시를 향하지 않습니다. 결과는 남겨 둔 줄을 함께 알려 주는 부분 복원으로 보고되며, `ocx restore --remove-codex-provider-table`을 쓰면 그 줄까지 지웁니다. 대신 해당 대화는 열리지 않게 됩니다. 한편 `openai`로 표시된 대화를 Codex가 이미 페이지 분할한 홈에서 프로바이더 테이블 형식으로 통합을 켜면, 예전에는 `history_paginated_openai_requires_native_writer`로 전체가 거부되어 아무것도 쓰이지 않고 통합도 꺼진 채로 남았습니다. 지금은 관리 대상 루트 `openai_base_url` 재정의를 `[model_providers.opencodex]` 테이블과 함께 남겨 두는 방식으로 전환을 끝냅니다. Codex가 이 재정의를 내장 `openai` 프로바이더에 합치므로 해당 대화는 재지정 없이 계속 프록시에 닿고, 대화 원본이나 스레드 행은 건드리지 않습니다. `x-opencodex-api-key` 승인 헤더가 필요한 라우팅 형식만 여전히 거부합니다. 내장 프로바이더가 그 헤더를 실을 수 없기 때문이며, 이때 메시지는 해결 방법 두 가지를 이름으로 알려 줍니다. 루프백 리스너로 Codex를 연결해 재정의를 유지하거나, `syncResumeHistory`를 `false`로 두어 해당 대화가 Codex 자체 OpenAI 엔드포인트로 이어지는 것을 받아들이는 것입니다. 루트 URL 재정의 방식으로 돌아갈 때 OpenCodex는 기록 사전 점검이 통과하더라도 기존 `[model_providers.opencodex]` 정의를 설정 적용 전에 유지합니다. 설정 적용 후나 백그라운드 기록 작업 시작 중에 Codex가 기록 형식을 전환해도 이전 `opencodex` 대화가 제공자를 계속 찾을 수 있습니다. 새 대화는 선택된 루트 제공자를 사용하며, 명시적 복원에는 기존의 별도 제거 검사가 적용됩니다. diff --git a/docs-site/src/content/docs/ko/guides/macos-menu-bar.md b/docs-site/src/content/docs/ko/guides/macos-menu-bar.md new file mode 100644 index 00000000000..6aeb3e77425 --- /dev/null +++ b/docs-site/src/content/docs/ko/guides/macos-menu-bar.md @@ -0,0 +1,164 @@ +--- +title: macOS 메뉴바 앱 +description: OpenCodex 프록시 상태와 사용량, 프로바이더 쿼터를 메뉴바에서 바로 확인하는 네이티브 앱입니다. +--- + +메뉴바 앱은 대시보드를 열지 않아도 프록시 상태와 최근 사용량, 프로바이더별 쿼터를 한눈에 +보여줍니다. + +프록시와는 별개의 앱입니다. `ocx`는 지금까지처럼 그대로 돌아가고, 메뉴바 앱은 로컬 관리 +API에 붙는 클라이언트입니다. + +## 데스크톱 앱 (Tauri) + +같은 대시보드를 OpenCodex 데스크톱 앱에서 실행할 수 있습니다. 사용량 패널은 운영체제에 +맞는 설치 단계를 보여주며, 데스크톱 셸 안에서는 **브라우저에서 열기**를 선택해 현재 +대시보드 화면을 일반 브라우저로 열 수 있습니다. + +## 설치 + +기본 설치 경로는 OpenCodex 데스크톱 앱입니다. [릴리스 페이지](https://github.com/lidge-jun/opencodex/releases)에서 macOS용 +`OpenCodex-<버전>-macos.dmg`를 내려받아 DMG를 열고 `OpenCodex.app`을 응용 프로그램 +폴더로 드래그하세요. Windows에서는 `OpenCodex-<버전>-windows-x64.msi`를 실행하고, +Linux에서는 AppImage 또는 `OpenCodex-<버전>-linux-amd64.deb`를 사용하세요. + +```bash +chmod +x OpenCodex-<버전>-linux-x86_64.AppImage +sudo apt install ./OpenCodex-<버전>-linux-amd64.deb +``` + +Windows SmartScreen 또는 macOS Gatekeeper 경고가 표시될 수 있습니다. 앱은 기존 `ocx` +프록시에 연결하고, 찾지 못하면 포함된 사이드카를 시작합니다. + +## 첫 실행: Gatekeeper 차단 + +**처음 실행하면 macOS가 막습니다.** 이런 메시지가 뜹니다. + +> "OpenCodex.app"은(는) 개발자를 확인할 수 없기 때문에 열 수 없습니다. + +예상된 동작이라 그냥 넘어가지 않고 이유를 적어둡니다. Gatekeeper는 Apple의 Developer ID +서명과 공증(notarization) 티켓을 요구하는데, 둘 다 유료 Apple Developer 계정이 있어야 +합니다. OpenCodex에는 그 계정이 없어서 앱은 ad-hoc 서명 상태로 배포됩니다. 번들 자체는 +온전하고 서명도 유효하지만, Apple이 배포자를 보증해 주지는 않았다는 뜻입니다. + +그래도 열려면: + +1. Finder에서 `OpenCodex.app`을 우클릭(또는 Control-클릭)합니다. +2. **열기**를 선택합니다. +3. 뜨는 대화상자에서 다시 **열기**를 누릅니다. + +대화상자에 열기 버튼이 없다면 **시스템 설정 → 개인정보 보호 및 보안**에서 차단 알림을 찾아 +**그래도 열기**를 누르세요. + +한 번 허용하면 macOS가 기억하므로 버전마다 한 번씩만 하면 됩니다. + +터미널에서 격리 속성을 지워도 됩니다. + +```bash +xattr -d com.apple.quarantine /Applications/OpenCodex.app +``` + +둘 다 내키지 않으면 직접 빌드하세요. 로컬 빌드에는 격리 속성이 아예 붙지 않습니다. +[소스에서 빌드하기](#소스에서-빌드하기)를 참고하세요. + +## 무엇을 보여주나 + +메뉴바 아이콘은 색이 아니라 형태로 상태를 나타냅니다. macOS 메뉴바 아이콘은 단색이 +관례이기 때문입니다. + +| 아이콘 | 의미 | +| --- | --- | +| 꽉 찬 마크 | 실행 중이고 라우팅이 보호됨 | +| 홈이 파인 마크 | 실행 중이지만 라우팅 보호가 불안정함 | +| 외곽선 마크 | 확인 중이거나 응답이 이상함 | +| 흐린 외곽선 | 실행 중이 아니거나 API 키가 필요함 | + +아이콘을 누르면 네 영역이 있는 패널이 열립니다. + +**상태** — 프록시 실행 여부, 앱이 사용 중인 루프백 주소, 보호 상태를 보여줍니다. 프록시가 조치 명령을 +권할 때(예: `ocx service install`) 선택 가능한 텍스트로 표시합니다. 앱이 대신 실행하지는 +않습니다. + +**사용량** — 최근 7일간 요청 수, 토큰, 예상 비용과 일자별 추이입니다. 요청 수 뒤의 `~`는 +일부가 프로바이더 보고값이 아니라 추정치라는 표시입니다. + +기본적으로 메뉴 막대 헤드라인은 총 토큰 수를 표시합니다. 요청 수, 비용, 할당량 또는 아이콘만 보고 싶다면 대시보드 Usage의 컴패니언 설정에서 헤드라인 지표를 바꿀 수 있습니다. +macOS 26에서는 팝오버와 위젯이 Liquid Glass를 사용하며, 이전 macOS 버전은 기본 팝오버 머티리얼을 사용합니다. + +**쿼터** — 프로바이더마다 한 줄씩, 가장 압박이 큰 창을 보여줍니다. 5시간 한도를 99% 쓰고 +월 한도는 10%만 쓴 프로바이더라면 5시간 수치를 표시합니다. 지금 막고 있는 쪽이 그것이기 +때문입니다. 창 이름을 아래에 적어두어 `API usage의 42%`와 `한 달의 42%`를 헷갈릴 일이 +없습니다. + +**프로바이더** — 펼칠 수 있는 목록이고 프로바이더마다 스위치가 있습니다. 기본 프로바이더는 +켜져 있는 동안 스위치가 잠깁니다. 프록시가 기본 프로바이더 비활성화를 거부하기 때문이며, +대시보드에서 기본값을 먼저 바꿔야 합니다. + +## 무엇을 할 수 있나 + +- **Dashboard** — 브라우저에서 웹 대시보드를 엽니다. +- **Stop proxy** — 확인을 거쳐 프록시를 중지합니다. 일부러 "재시작"이라고 부르지 않습니다. + 중지하면 launchd 서비스도 함께 멈춰서 자동으로 다시 뜨지 않기 때문입니다. 중지 후에는 + 다시 시작하는 명령을 패널에 보여줍니다. +- **프로바이더 스위치** — 프로바이더를 켜고 끕니다. + +계정, 모델 설정, 저장소 관리 같은 나머지는 대시보드에서 합니다. + +## 위젯 + +바탕화면을 우클릭하고 **위젯 편집**을 선택한 다음 **OpenCodex**를 추가하세요. 프록시 상태, +오늘의 사용량과 쿼터를 표시하며 메뉴바 앱과 동일한 개인정보 보호 스냅샷을 사용합니다. 앱이 +폴링할 때 새로 고침됩니다. macOS 14 이상이 필요하고 API 키나 원시 계정 정보는 전달하지 않습니다. + +## 프록시 연결 + +앱이 알아서 찾습니다. `~/.opencodex/runtime-port.json`(또는 +`$OPENCODEX_HOME/runtime-port.json`)을 읽고, 없으면 `10100` 포트를 씁니다. 이 파일에서 +가져오는 건 포트뿐이고 호스트는 항상 루프백입니다. + +프록시가 루프백이 아닌 주소에 바인딩돼 있으면 API 키가 필요합니다. 패널이 그 사실을 알려주고 +대시보드로 가는 버튼을 보여줍니다. + +**아직 지원되지 않는 경로입니다.** 앱은 macOS 키체인에서 키를 읽어 한 번 재시도하지만, +키를 입력하는 화면이 없고 손으로 넣을 방법도 없습니다. 데이터 보호 키체인 항목이라 키체인 +접근으로는 만들 수 없기 때문입니다. 따라서 루프백이 아닌 바인딩에서는 패널이 "Needs API key" +상태로 남습니다. + +기본값인 루프백 프록시는 키가 필요 없습니다. 네이티브 키 입력은 예정돼 있습니다. + +## 폴링 주기 + +앱은 일부러 조용하게 동작합니다. 프록시 생존 확인은 5초마다 하고, 비용이 큰 집계 데이터인 +사용량과 쿼터는 패널이 열려 있을 때만, 그것도 최대 1분에 한 번 가져옵니다. 연속 세 번 +실패하면 30초 간격으로 늘립니다. 사용자가 일부러 끈 프록시를 계속 두드리지 않기 위해서입니다. + +## 소스에서 빌드하기 + +macOS 13 이상, Xcode Command Line Tools, 그리고 [Bun](https://bun.sh)이 필요합니다. + +```bash +git clone https://github.com/lidge-jun/opencodex.git +cd opencodex +bun run prepare-sidecar +bun run prepare-widget +bunx tauri build +``` + +번들은 Tauri 릴리스 출력에 생성되며, WidgetKit 확장은 +`OpenCodex.app/Contents/PlugIns/` 아래에 포함됩니다. + +유니버설 바이너리(`UNIVERSAL=1`)를 만들려면 전체 Xcode가 필요합니다. Command Line Tools +에는 현재 아키텍처용 Swift 호환 라이브러리만 들어 있어서, 이 경우 링커 오류 대신 그 이유를 +설명하는 메시지가 나옵니다. + +키체인에 Developer ID 인증서가 있다면 `MACOS_SIGN_IDENTITY`를 지정해 ad-hoc 대신 하드닝된 +런타임으로 서명할 수 있습니다. + +```bash +MACOS_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" bun run prepare-widget +``` + +## 삭제 + +`OpenCodex.app`을 휴지통으로 옮기면 됩니다. 앱은 환경설정이나 별도 상태 파일을 남기지 +않고, 현재는 키체인에도 아무것도 저장하지 않습니다. diff --git a/docs-site/src/content/docs/ko/guides/providers.md b/docs-site/src/content/docs/ko/guides/providers.md index 9e8b372e967..0eea4be4b70 100644 --- a/docs-site/src/content/docs/ko/guides/providers.md +++ b/docs-site/src/content/docs/ko/guides/providers.md @@ -182,7 +182,7 @@ Kiro 로그인에는 Kiro CLI가 필요합니다. Unix에서는 `curl -fsSL http ## 3. API 키 카탈로그 -opencodex에는 빌트인 프리셋이 95개 들어 있습니다. 키 방식 79개, OAuth 12개, 로컬 3개, +opencodex에는 빌트인 프리셋이 96개 들어 있습니다. 키 방식 80개, OAuth 12개, 로컬 3개, 기본 ChatGPT 포워드 프리셋 1개입니다. 대시보드의 **Add provider** 선택기는 키 발급 페이지를 열고, 입력한 키를 검증한 뒤 저장합니다(검증은 프로바이더별로 다릅니다). 주요 항목은 다음과 같습니다: @@ -345,6 +345,15 @@ discovery를 256 KiB와 raw 행 256개로 제한합니다. agent 전용 및 dedi Project ID가 포함된 URL과 dedicated deployment는 custom provider로 설정하세요. API 키는 [Scaleway console](https://console.scaleway.com/generative-api)에서 생성합니다. +**Featherless 검색:** 이 프리셋은 고정된 OpenAI 호환 host에 인증하고, 상위에서 chat과 현재 plan으로 +필터링된 인기 모델 100개만 요청합니다. 이후 registry 규칙은 각 행이 plan 사용 가능 여부, Hugging Face +gate 없음, `features.tool_use: true`를 스스로 보고하지 않으면 fail closed로 제외하고, discovery를 +128 KiB와 raw 행 100개로 제한합니다. 덕분에 수만 개 규모의 catalog를 통째로 내려받거나 캐시하지 +않습니다. `/v1/models`는 인증 없이도 호출할 수 있다고 문서화되어 있어 전달한 키가 유효한지 증명하지 +못합니다. chat 요청에는 설정된 Bearer key를 그대로 사용합니다. Featherless 약관은 개인 plan을 대화형 +및 프로토타이핑 용도로 제한하며, 임의의 애플리케이션에는 Scale plan이 필요합니다. 키는 +[Featherless dashboard](https://featherless.ai/account/api-keys)에서 생성합니다. + **Novita 검색:** 키 기반 프리셋은 `openai-chat` adapter를 사용하며 Bearer key를 Novita의 고정 OpenAI 호환 host에만 보냅니다. 공개 model list에서 `model_type: chat`과 `chat/completions` endpoint를 모두 보고하는 행만 유지하고 discovery를 512 KiB와 raw 256행으로 제한합니다. catalog가 공개되어 있으므로 diff --git a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md index e4f2913c6f6..97048c3768e 100644 --- a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md @@ -390,6 +390,8 @@ OpenCodex를 업데이트한 뒤 기존 Windows shim에 이 동작을 적용하 Windows 상태 트레이 아이콘을 설치하고 제어합니다. Windows 로그인 시 시작되며, 프록시를 원클릭으로 제어할 수 있습니다. `start`와 `stop`은 아이콘만 제어합니다. 프록시 제어는 메뉴를 사용하세요. `--no-start`는 `install`에 적용되며, 트레이를 바로 실행하지 않고 설치합니다. +지원 중단 예정: OpenCodex 데스크톱 앱이 Windows, macOS, Linux에서 트레이를 제공합니다. +`ocx tray`는 데스크톱 앱이 없는 설치를 위해 계속 사용할 수 있습니다. ## 대시보드 diff --git a/docs-site/src/content/docs/ko/reference/configuration/providers.md b/docs-site/src/content/docs/ko/reference/configuration/providers.md index 55e1f4457d4..fdbf9c697a8 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -125,11 +125,13 @@ managed map을 활성화하면 privacy-safe selector를 만들고, 이후 계정 | `noPenaltyModels?` | `string[]` | presence/frequency penalty를 허용하지 않는 모델입니다. | | `noStructuredOutputModels?` | `string[]` | `openai-chat` 엔드포인트가 `response_format`을 거부하는 정확한 모델 ID입니다. 요청 모델이 항목과 정확히 일치할 때만 필드를 생략하며, 그 외 `openai-chat` 모델에서는 structured-output 변환을 유지합니다. | | `noJsonSchemaModels?` | `string[]` | `openai-chat` 엔드포인트가 `json_schema` 형식은 거부하지만 `json_object`는 받는 정확한 모델 ID입니다. 이런 요청은 필드를 지우는 대신 `json_object`로 낮춰 보내므로, JSON을 요청한 클라이언트가 산문 대신 JSON을 받습니다. 한 모델이 두 목록에 모두 있으면 `noStructuredOutputModels`가 우선합니다. `opencode go`, `opencode zen`, `opencode free` 프리셋이 DeepSeek 경로에 기본으로 싣습니다. | +| `foldDeveloperRoleToSystem?` | `boolean` | `openai-chat` 목적지가 `developer` 역할을 받는지 기록합니다. `foldDeveloperRoleToSystem`이 없으면 `system`, `true`이면 `system`, `false`이면 `developer`로 보냅니다. 값이 없다는 것은 이 목적지에 대해 기록된 것이 없다는 뜻이고, `true`는 상위 서비스가 역할을 거부한다는 기록, `false`는 받아들인다는 기록입니다. 어느 경우에도 메시지는 대화 안의 원래 위치를 유지하며 역할만 바뀝니다. 역할을 거부하는 목적지는 `400 role 'developer' is not allowed`로 응답해 턴이 시작조차 못 하므로, 기록이 없는 상태의 기본값을 접는 쪽으로 둡니다. | | `parallelToolCalls?` | `boolean` | 병렬 도구 호출을 켜거나 끕니다. OpenAI Chat은 기본으로 켜져 있고, 비-chat 어댑터는 명시적으로 `true`일 때만 이를 노출합니다. | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` | 기본값이 꺼진 downstream SSE 복구입니다. 정확한 자리표시자 id, 누락된 종료 id, 그리고(`repairInvalidIds`) 정규 `msg_`/`rs_` 접두사가 없는 message/reasoning id를 복구합니다. function-call id는 다시 쓰지 않습니다. 내장 DeepSeek은 마지막 두 가지를 기본으로 켭니다. | | `responsesSnapshotRepair?` | `boolean` | 기본값이 꺼진 클라이언트용 복구입니다. SSE와 JSON의 Responses 수명 주기에서 누락된 status, output, 도구 메타데이터를 채우며 raw 검사와 영속화는 변경하지 않습니다. | | `retryOn429?` | `{ enabled?: boolean; attempts?: number; intervalMs?: number; maxIntervalMs?: number; respectRetryAfter?: boolean }` | API-key 프로바이더 전용(`authMode: "key"`). 동일 대상 429 재시도: `retryOn429`가 없으면 기능이 꺼져 있고, 객체가 있으면 `enabled: false`가 아닌 한 활성화됩니다. 429 시 대기(업스트림 `Retry-After` 또는 고정 간격) 후 키 장애 조치 전에 동일 키로 동일 요청을 재전송합니다 — 일반 텍스트 턴 복구 루프, Responses passthrough, 이미지/비디오 브리지, web-search 사이드카, 터미널 연속 요청을 모두 포함합니다. 재전송 대상은 프리스트림 HTTP 429 응답뿐이며, 커스텀 `runTurn` 전송은 HTTP 재시도 루프에서 제외됩니다. `attempts`는 첫 429 이후의 동일 키 재전송 횟수(총 전송 = `attempts` + 1)이며, 메인 복구 루프·터미널 가드 연속 요청·브리지 재시도가 공유하는 요청 단위 예산입니다. `attempts`를 모두 소진해도 동일 키 재전송만 중단되며, 이후에는 일반 키 장애 조치 또는 최종 오류 처리가 사용 가능한 대상에 따라 진행됩니다 — 키 인증 passthrough 와이어에는 장애 조치가 없으므로 소진된 429가 그대로 반환됩니다. Codex 자체는 429를 재시도하지 않으므로 단일 키 프로바이더의 유일한 방어선입니다. 기본값: `enabled: true`, `attempts: 3`, `intervalMs: 5000`, `maxIntervalMs: 60000`(단일 대기는 `maxIntervalMs`로 상한, 그 자체는 600000으로 상한), `respectRetryAfter: true`. | | `transientRetryOn5xx?` | `{ enabled?: boolean; attempts?: number }` | 키 인증 `openai-chat` 및 `openai-responses` 프로바이더 전용입니다. `authMode: "forward"` 프로바이더(ChatGPT 계정 풀)는 이 옵션을 읽지 않고 기본 재시도 단계를 유지합니다. 스트림 시작 전의 일시적인 업스트림 상태(500, 502, 503, 504, 520, 521, 522)를 선택적으로 재시도합니다. 이 옵션이 없으면 꺼져 있고, 객체가 있으면 `enabled: false`가 아닌 한 활성화됩니다. 최초 Responses 요청, 터미널 가드 연속 요청, 네이티브 `/v1/chat/completions`, 429/계정 복구 재조회를 포함합니다. `attempts`는 최초 전송을 포함하여 요청 하나에 허용되는 업스트림 전송의 총횟수(1..10, 기본값 3)입니다. 연결 재설정 복구와 요청 단위 예산 하나를 공유하므로 `3`이면 실제로 프로바이더에 도달하는 요청은 최대 세 번입니다. 대기에는 400ms로 고정된 지수 백오프를 사용하고 상한은 5초이며 `Retry-After`를 따릅니다. 속도 제한을 처리하는 `retryOn429`와는 별개이며, 스트림 도중의 실패는 절대 재전송하지 않습니다. | +| `retryOnReset?` | `{ enabled?: boolean; replacements?: number }` | 네이티브 `openai-responses` 프로바이더 전용이며 `authMode: "forward"`도 포함합니다. 호출자가 아무것도 관측하지 못한 채 실패한 전송을 선택적으로 대체합니다. 이 옵션이 없으면 꺼져 있고, 객체가 있으면 `enabled: false`가 아닌 한 활성화됩니다. 응답 헤더가 오기 전에 연결이 끊어진 경우와, 헤더 이후 SSE 본문이 제어 이벤트만 실은 채 끊어진 경우를 모두 다룹니다. 자체 완결된 요청만 대체합니다. `store: false`, 완전한 `input`, `previous_response_id`·`conversation`·`stream_id` 없음, 클라이언트가 실행하는 도구만 해당합니다. `replacements`는 모든 구간과 모든 콤보 자식을 합쳐 논리 요청 하나가 만들 수 있는 대체 전송 횟수입니다(1..2, 기본값 1). 구간별 재시도 횟수도 전송 예산도 아니므로, 대체 전송도 해당 구간이 이미 가진 전송 허용량 안에 들어가야 합니다. 이미 출력이나 도구 호출을 내보낸 요청은 이 값과 무관하게 대체하지 않습니다. 원본 전송이 이미 시작됐다면 대체한 추론도 과금될 수 있어서 기본값은 꺼짐입니다. | | `autoToolChoiceOnlyModels?` | `string[]` | `tool_choice`가 `auto` 또는 `none`만 받는 모델입니다. 강제 선택은 낮은 수준으로 바뀝니다. | | `preserveReasoningContentModels?` | `string[]` | chat 기록에서 이전 assistant `reasoning_content`가 필요한 모델입니다. | | `reasoningDetailsModels?` | `string[]` | thinking을 구조화된 `reasoning_details` 배열로 반환하는 모델(`reasoning_split` 사용 MiniMax M 시리즈). 스트림 델타는 누적 스냅샷이라 prefix-diff로 처리하고, 보존된 reasoning은 `reasoning_content` 문자열 대신 `reasoning_details` 배열로 리플레이합니다. | diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index e8380c73ca6..3473d3b19cc 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -42,7 +42,7 @@ ocx start --port 10100 --socks5 ocx start --socks5-off ``` -### `ocx stop` +### `ocx stop [--json]` Stop the running proxy (by PID), remove the PID file, and restore native Codex. If a managed background service is installed, `ocx stop` also stops it first so it cannot respawn the proxy. @@ -65,6 +65,14 @@ It does not enter the forced-stop fallback for a process already observed to hav receipt-backed deferral still leaves final restoration and receipt cleanup with the parent; failure to restore shared client configuration keeps the stop failed and its receipt outstanding. +`ocx stop --json` runs exactly the same stop path and prints one versioned summary document +(`schema: "ocx-stop/1"`) on stdout, while the human progress lines move to stderr. The summary +carries the outcome class (`stopped`, `not-running`, `history-incomplete`, `history-deferred`, +or `failed`), the service and proxy classifications, whether the runtime is down, and a stable +one-line message. Exit codes are identical with and without `--json`: 0 on success, 1 on failure, +79 when only Codex history cleanup did not complete, and 80 when the shared teardown was deferred +and is still owed. + ### `ocx restart` When a proxy is running, ask that exact attested PID and port to restart in place, wait for its @@ -239,6 +247,23 @@ The CLI's own `--json` output is deliberately narrower than the HTTP body: it em `unreachable`. Exit codes are 0 for ready; 1 for not-ready, pending, failed, timeout, or unreachable; and 64 for invalid arguments. +### `ocx resolve [--json]` + +Resolve the runtime facts a shell needs without re-implementing them: the config home, the +effective port, and the identity-checked liveness verdict. `--json` emits one versioned +document (`schema: "ocx-resolve/1"`) with `cliVersion`, `configHome`, `port` +(`effective`, `configured`, and `source`), and `liveness` (`status`, `pid`, `port`, +`source`, plus `version`, `role`, and `hostname` when the live proxy reports them). +Liveness has three answers: `live`, `absent-proven` (every recorded and configured endpoint +definitively refused or answered non-opencodex), and unknown — a timed-out probe or a listener +that withholds `/healthz` exits 1 rather than reading as absent, so only `absent-proven` may +authorise starting a new runtime. The port is the live listener's port when a proxy answers, +otherwise the configured port (default 10100). Exit 0 carries a trustworthy verdict; exit 1 means +the CLI could not resolve — including an invalid `config.json`, which is never repaired to +defaults here — and the caller must refuse to guess; any unknown argument exits 64. Discovery uses +the same ownership-safe probe budget as `ocx start`, because a false "nothing listening" answer +is how duplicate proxies happen. The verb is read-only and skips the shim auto-restore preflight. + ### `ocx doctor` The default report includes the native-write coordinator state and exact path using immutable @@ -423,6 +448,50 @@ On Windows, a bare `ocx service` runs the install path only after both Task Sche proven absent. If either status query is inconclusive, it refuses to register anything and asks you to run `ocx service status`; use explicit `ocx service install` only after confirming absence. +### Runtime ownership + +The OpenCodex desktop app can take the background proxy over from a CLI installation. When it does, +it records the handover in the shared service install state, and that record is what makes the +takeover survive a restart. Your service registration is **kept, never deleted** — the record +supersedes it rather than replacing it. + +A state file with no ownership record means the CLI installation owns the runtime, which is what +every installation made before this feature is in. Nothing changes for you until an app takes over. + +While something other than this CLI owns the runtime, the subcommands that would **activate** your +registration refuse instead: + +| Subcommand | Behaviour under a foreign owner | +| --- | --- | +| `repair`, `restart` | Refuse before changing anything. The registration is not re-enabled, rewritten or restarted. | +| `start` | Refuses for the same reason, so an automatic tray start cannot put a second proxy beside the app's. | +| `stop`, `uninstall` | Unchanged. They deactivate, so they are never gated. | +| `install` | Takes the runtime back. It clears the ownership record after the registration succeeds, and reports whose it was. | + +`ocx update` behaves the same way: it neither stops the running proxy nor refreshes the service +while the app owns the runtime, because the running server is the app's own bundled binary and the +refresh would re-enable the launcher the takeover superseded. The app updates its own runtime. + +The refusal names the owning installation and the consent generation, for example: + +```text +Background service repair stopped: the desktop app owns the runtime (install , consent generation 2). +The service registration was left exactly as it is — not re-enabled, not rewritten and not restarted. +Quit the desktop app and run 'ocx service install' to hand the runtime back to this CLI. +``` + +A record that cannot be read or does not parse produces the same refusal with a different first +line, because an unreadable claim is not the same as no claim — treating it as "nobody owns this" +is how a permissions error would silently reactivate your service. + +**Recovery in every case is `ocx service install`.** It is deliberately the one verb that is never +gated, so removing the app without handing the runtime back, or a corrupted state file, still leaves +you a way to take the service back: + +```bash +ocx service install +``` + ```bash ocx service ocx service install @@ -611,6 +680,8 @@ file is not part of the injected `env_key` contract; the launching process must Install and control the Windows status tray icon. It starts at Windows login and provides one-click proxy controls. `start` and `stop` control the icon only; use its menu to control the proxy. `--no-start` applies to `install` and installs the tray without launching it immediately. +Deprecated: the OpenCodex desktop app provides the tray on Windows, macOS, and Linux; `ocx tray` +remains for installs without the desktop app. ## Dashboard diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 29f66c70ffa..c7535203297 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -141,6 +141,8 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | --- | --- | --- | | `adapter` | `string` | One of `openai-chat`, `openai-responses`, `anthropic`, `google`, `kiro`, `cursor`, `ollama-native`, `azure-openai` (or alias `azure`), `codebuddy`, `qoder`. | | `baseUrl` | `string` | Upstream API base URL. Most built-in fixed endpoints ignore a mismatch; collision-safe key presets preserve an older same-named custom destination. | +| `proxy?` | `string \| null` | Per-provider egress route. Omit it to inherit the global proxy decision; use `"direct"` or `null` to force direct egress; or provide an absolute `http://`, `https://`, `socks5://`, or `socks5h://` proxy URL. An empty string is rejected. | +| `noProxy?` | `string \| string[]` | Destinations this provider reaches directly, using `NO_PROXY` host-pattern syntax. A match bypasses both this provider's own proxy and an inherited global proxy. | | `requestPacing?` | `{ enabled, requestsPerMinute?, minIntervalMs?, models? }` | Optional client-side outbound request-start pacing, separate from upstream usage, billing, and rate-limit indicators. RPM is converted to an even interval; `minIntervalMs` may impose a longer interval. Provider limits apply across all models, while `models` entries use exact upstream model IDs (for example `nvidia/llama-3.1-nemotron-ultra-253b-v1`) and can only add delay. Queue waits do not consume the upstream response-header timeout. HTTP, Responses WebSocket, and explicit adapter `fetchResponse`/`runTurn` dispatches are covered. | | `upstreamHttpVersion?` | `"auto" \| "http1.1" \| "h1" \| "http2" \| "h2"` | Pin the HTTP version used for upstream requests to this provider. Defaults to `auto`, which lets Bun negotiate. An explicit pin requires an HTTPS target and fails locally when it cannot be honored. Set `http1.1` when a provider's HTTP/2 SSE stream stalls instead of delivering events — the symptom is a long-running streaming request that produces nothing and eventually times out. For Cursor, `http1.1`/`h1` selects its `RunSSE` + `BidiAppend` compatibility transport for inference and also pins live model discovery. Management `POST`/`PATCH` accept `null` to clear it back to `auto`. | | `responsesPath?` | `string` | Relative resource path for key-auth `openai-responses` requests. It must start with `/` and contain no scheme, query, or fragment. | @@ -201,6 +203,7 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | `noPenaltyModels?` | `string[]` | Models that reject presence/frequency penalties. | | `noStructuredOutputModels?` | `string[]` | Exact model IDs whose `openai-chat` endpoint rejects `response_format`. Only an exact requested-model match omits the field; structured-output translation stays enabled for every other `openai-chat` model. | | `noJsonSchemaModels?` | `string[]` | Exact model IDs whose `openai-chat` endpoint rejects a `json_schema` `response_format` but still accepts `json_object`. Such a request is downgraded to `json_object` instead of being dropped, so a caller asking for JSON still gets JSON. `noStructuredOutputModels` wins when a model is on both lists. The `opencode go`, `opencode zen`, and `opencode free` presets ship this for their DeepSeek routes. | +| `foldDeveloperRoleToSystem?` | `boolean` | Whether an `openai-chat` destination accepts the `developer` role. `foldDeveloperRoleToSystem` unset sends `system`, `true` sends `system`, and `false` sends `developer`. Unset means nothing has been recorded about this destination; `true` records an upstream that rejects the role; `false` records one that accepts it. The message keeps its position in the conversation in every case — only the role changes. A destination that rejects the role answers `400 role 'developer' is not allowed` and the turn never starts, which is why the unrecorded state is the folded one. | | `omitReasoningEffortWithToolsModels?` | `string[]` | Exact `openai-chat` model IDs that accept a reasoning-effort field on an ordinary turn but reject it once function tools are present. The model keeps its advertised effort ladder; OpenCodex omits the wire field for tool-bearing requests only and the upstream default applies. Narrower than `noReasoningModels`, which strips reasoning from every request and costs the model its picker entirely. | | `parallelToolCalls?` | `boolean` | Toggle parallel tool calls. OpenAI Chat defaults on; non-chat adapters advertise only on explicit `true`. | | `terminalContinuationGuard?` | `boolean` | Opt in an `openai-chat` provider to one bounded internal re-ask when an actionable turn announces work, then cleanly stops without a tool call. Defaults to `false`; explicit `false` behaves like omission. Combo attempts and routed compaction turns are excluded, and non-`openai-chat` adapters ignore this option. | @@ -209,6 +212,7 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | `webSearchBridge?` | `{ enabled?: boolean; backend?: "ollama" \| "openai" \| "anthropic" \| "xai" \| "gemini" \| "exa"; maxSearches?: number; timeoutMs?: number; endpoint?: string }` | Key-auth `openai-responses` passthrough providers only. Off by default. Codex always declares the hosted `web_search` tool, and the passthrough relays it on the assumption the destination executes it. A gateway that does not run hosted search answers with a `function_call` named `web_search` that nothing runs, and the undeclared-tool guard ends the turn. With `enabled: true` and an explicit `backend` OpenCodex intercepts that call, runs the search itself, feeds the result back to the same upstream, and shows Codex a hosted `web_search_call` cell. Never armed for `authMode: "forward"` (ChatGPT already searches) or for a provider that executes hosted search upstream. `backend` is required; there is no implicit default and a missing credential for the named backend leaves the bridge disarmed rather than falling through to another paid search. `ollama` reuses this provider's own API key on `POST /api/web_search`, so the origin must be `https://ollama.com` unless the operator names `endpoint` explicitly. `openai` / `anthropic` / `xai` / `gemini` / `exa` reuse the matching sidecar executor and that executor's own credential (`webSearchSidecar.exaApiKey` for Exa). The search model comes from `webSearchSidecar.model` only when `webSearchSidecar.backend` resolves to the same backend this bridge names; otherwise the bridge runs that backend's own default, because a model chosen for one vendor is rejected by another. An unset `webSearchSidecar.backend` resolves to `openai`, so an unset-backend model reaches an `openai` bridge and no other. There is no per-provider bridge model override. Streaming turns only. A turn that mixes `web_search` with another client tool call still fails closed rather than dropping the client's call. Assistant text such as XML-like `` prose is not executed. Defaults: `maxSearches: 3` (1..10), `timeoutMs: 60000` (1000..600000). | | `retryOn429?` | `{ enabled?: boolean; attempts?: number; intervalMs?: number; maxIntervalMs?: number; respectRetryAfter?: boolean }` | API-key providers only (`authMode: "key"`). Opt-in same-target 429 retry: when `retryOn429` is absent the feature is off; object presence enables it unless `enabled: false`. On 429 the proxy waits (upstream `Retry-After` or the fixed interval) and replays the identical request on the same key before any key failover — across the main text-turn recovery loop, the Responses passthrough wire, the image/video bridge, the web-search sidecar, and terminal continuations. Only pre-stream HTTP 429 responses are eligible for replay; custom `runTurn` transports are outside the HTTP retry loop. `attempts` counts same-key replays after the first 429 (total sends = `attempts` + 1) and is one request-wide budget shared by the main recovery loop, the terminal-guard continuation, and bridge retries. Exhausting `attempts` only stops further same-key replays: normal key failover or final-error handling then applies per the available targets — on the key-auth passthrough wire there is no failover, so the exhausted 429 surfaces as-is. Codex itself never retries 429, so this is the only defense for single-key providers. Defaults: `enabled: true`, `attempts: 3`, `intervalMs: 5000`, `maxIntervalMs: 60000` (any single wait is capped at `maxIntervalMs`, itself capped at 600000), `respectRetryAfter: true`. | | `transientRetryOn5xx?` | `{ enabled?: boolean; attempts?: number }` | Key-auth `openai-chat` and `openai-responses` providers only. `authMode: "forward"` providers (the ChatGPT account pool) never read this option and keep the default ladder. Opt-in retry for pre-stream transient upstream statuses (500, 502, 503, 504, 520, 521, 522): absent means off, object presence enables it unless `enabled: false`. Covers the initial Responses request, the Responses passthrough lane and each of its recovery legs (OAuth-401 replay, same-target 429 replay, validated rebuild), the terminal-guard continuation, and native `/v1/chat/completions`. `attempts` is the TOTAL number of upstream sends allowed for one request including the first (1..10, default 3) — it is one budget shared with connection-reset recovery, so `3` means at most three real requests reach the provider. On the Responses passthrough lane the configured value is additionally intersected with the request-wide send allowance, so a value below that allowance narrows the ladder exactly while a value above it does not raise the bound. Waits use a fixed 400 ms exponential backoff capped at 5 s and honor `Retry-After`. Separate from `retryOn429`, which handles rate limiting; mid-stream failures are never replayed. | +| `retryOnReset?` | `{ enabled?: boolean; replacements?: number }` | Native `openai-responses` providers, including `authMode: "forward"`. Opt-in replacement of a send that failed while the caller had observed nothing: absent means off, object presence enables it unless `enabled: false`. Covers both ambiguous stages — a connection that died before any response header, and an SSE body that died after the header while carrying only control events. Only a self-contained request is ever replaced: `store: false`, complete `input`, no `previous_response_id`, `conversation` or `stream_id`, and only client-executed tools. `replacements` is the number of replacement sends ONE logical request may make across every leg and every combo child (1..2, default 1) — not a per-leg retry count and not a send budget, so a replacement still has to fit inside the send allowance the leg already had. A request that already emitted output or a tool call is never replaced, whatever this is set to. The replacement inference may still be billed if the origin had already started the first one, which is why this is off by default. | | `autoToolChoiceOnlyModels?` | `string[]` | Models whose `tool_choice` accepts only `auto` or `none`; forced choices are downgraded. | | `preserveReasoningContentModels?` | `string[]` | Models requiring prior assistant `reasoning_content` in chat history. | | `reasoningDetailsModels?` | `string[]` | Models whose endpoint returns thinking as a structured `reasoning_details` array (MiniMax M-series with `reasoning_split`); stream deltas are cumulative snapshots that are prefix-diffed, and preserved reasoning replays as a `reasoning_details` array instead of a `reasoning_content` string. | @@ -247,6 +251,66 @@ nonempty incompatible list falls back to the native default as a single choice. belong to the final list. This changes the catalog projection, not stored configuration. See [custom native catalog examples](/guides/codex-app-models/). +### Per-provider egress + +Set `proxy` on a provider when that upstream needs a different exit from the process-wide proxy: + +- Omit `proxy` to inherit the global proxy and `NO_PROXY` decision. +- Set `proxy` to `"direct"` or `null` to force this provider to connect directly, even when a global proxy is set. +- Set `proxy` to an absolute `http://` or `https://` URL to use that HTTP proxy for this provider. +- Set `proxy` to an absolute `socks5://` or `socks5h://` URL to use that SOCKS5 proxy for this provider. + +An empty or whitespace-only string is rejected on purpose. A cleared field must not silently change +from “inherit the global proxy” to “force direct”; remove the field to inherit, or write `"direct"` +to choose direct egress explicitly. + +`noProxy` accepts a comma-separated string or an array of strings in `NO_PROXY` syntax. It is +evaluated for each request. A matching destination goes direct whether the provider would otherwise +use its own `proxy` or inherit a global proxy. + +#### What the route covers + +The route is applied to routed inference, provider discovery and connection tests, and API-key +quota probes. Some transports cannot carry it, and OpenCodex says so rather than pretending +otherwise: + +- **OAuth token exchange and refresh** keep using the process-wide proxy. These reach fixed vendor + endpoints from code that holds no provider configuration, so a provider pinned to its own proxy + or to `"direct"` still refreshes its credentials by the global route. OAuth-backed quota probes + and API-key validation probes behave the same way. +- **The Responses WebSocket fast lane** selects its proxy when it dials and cannot carry a + per-provider route, so a provider that declares one serves those turns over HTTP/SSE instead and + logs a one-time notice. +- **Cursor's default HTTP/2 transport**, the **CodeBuddy and Qoder subprocess providers** (their + child environment omits proxy variables), and the **Compatibility Lab** pinned sender do not + apply it. +- Endpoints that do not route a model — image generation and edits, audio transcription, live and + realtime calls, and unqualified `/v1/alpha/search` — have no provider route to apply. + +A provider configured with a custom `fetch` executor is refused rather than silently sent by the +executor's own route. + +This example keeps a global proxy for ordinary traffic, sends one provider through a regional HTTP +proxy, and pins another provider to a direct connection: + +```json +{ + "proxy": "http://global-proxy.example:8080", + "providers": { + "regional-gateway": { + "adapter": "openai-chat", + "baseUrl": "https://regional-api.example/v1", + "proxy": "http://regional-proxy.example:3128" + }, + "direct-gateway": { + "adapter": "openai-chat", + "baseUrl": "https://direct-api.example/v1", + "proxy": "direct" + } + } +} +``` + ### Operator-pinned reasoning effort Set `pinnedReasoningEffort` on an existing provider to override incoming effort choices, or diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index 6deae1998c5..cf317ae8e0c 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -26,6 +26,7 @@ runs helper features around provider requests. | `corsAllowOrigins?` | `string[]` | `[]` | Additional exact origins allowed by CORS. Loopback origins are always allowed. Authority-based browser extension origins such as `chrome-extension://` are supported; `*` is not a wildcard. Firefox and Safari regenerate the extension UUID (per install / per browser launch), so update the entry when the origin changes. | | `apiKeys?` | `OcxApiKey[]` | `[]` | Generated `ocx_…` data-plane admission credentials on non-loopback binds. They do not authorize management APIs; management access uses the separate credential documented in the [management reference](/reference/management-api/). Dashboard-managed. | | `storageCleanupPolicy?` | `StorageCleanupPolicy` | disabled | Opt-in archived-session cleanup policy. Never enabled implicitly. | +| `usageLedgerMaxBytes?` | `number` | unset | Opt-in ceiling in bytes for `usage.jsonl`. Absent means the request history grows without limit, which stays the default. See [usage history size](#usage-history-size). | | `appOwnedMemoryBudgetMb?` | `number` | `256` | Cap in MiB for evictable app-owned logs, caches, blobs, and continuation payloads. Range 64–4096; not an RSS cap. | | `metricsExport.enabled?` | `boolean` | `false` | Enable process-local aggregate request metrics at authenticated `GET /api/metrics`. Restart required; disabled mode returns 404 and starts no exporter activity. | | `spend?` | `{ root?: { maxTokens?: number }; identity?: { maxTokens?: number }; pool?: { maxTokens?: number }; retentionDays?: number }` | unset | Durable token ceilings, off unless you write one. Each scope bounds settled spend plus in-flight reservations plus unresolved spend: `root` is one task including its whole fan-out, `identity` is one account across every task it serves, and `pool` is one provider pool. They intersect, so a request is admitted only when all three have room — which is what holds a ceiling against a client that mints a new task id per request. A reservation is the request's whole input plus its enforceable output ceiling, counted as if every cached prefix misses. Observe-only mode still journals, so every server owns the state directory's single-writer lease; an explicit sibling must use a separate `OPENCODEX_HOME`. Spend survives an ordinary process restart when its writes reached the filesystem, but the journal does not promise survival across host power loss because each append is not fsynced. Raising or removing the value is what grants more. `maxTokens` must be a positive integer (0 would refuse everything), `retentionDays` is 1–365 and defaults to 7, and an unknown key in this section is rejected rather than ignored. A refusal is a local HTTP 429 carrying `x-opencodex-local-refusal: workflow_spend_exhausted`, and its message names the scope and the ceiling; no provider is contacted. | @@ -63,10 +64,27 @@ arrives, the proxy cannot tell whether the model already processed the request, to send it again and answers HTTP 429 with `upstream_reset_replay_refused`. The status is deliberate: a 5xx here is an instruction to most clients, including Codex, to send the whole turn again, which is the duplicate the refusal exists to prevent. No `Retry-After` is -attached, and the proxy performs no key rotation, account failover or same-target replay on -it, nor does it record the refusal as rate-limit or quota evidence against the credential it -was holding. Tool-call side requests such as vision and web search are replayed normally, because -repeating them cannot duplicate a turn. +attached, and the response carries `x-should-retry: false`, which the official OpenAI and +Anthropic SDKs read before their own status rules — without it those clients retry a 429 on +their own schedule and resend the turn anyway. The same replay-refusal behavior applies on +`/v1/responses` and `/v1/chat/completions`, whether the request is forwarded natively or translated. +Routed `/v1/messages` requests translate through Responses and project the same status, code, and +retry headers into an Anthropic-shaped error envelope. The proxy +performs no key rotation, account failover or same-target replay on it, nor does it record the +refusal as rate-limit or quota evidence against the credential it was holding. Tool-call side +requests such as vision and web search are replayed normally, because repeating them cannot +duplicate a turn. + +A native Responses provider can opt into replacing that send with +[`retryOnReset`](providers.md#provider-entries-ocxproviderconfig). The same grant covers the +case where the connection survives the header and the SSE body then dies carrying only control +events, because the caller has observed nothing in either one. A replacement happens only when +the request is self-contained (`store: false`, complete input, client-executed tools only, no +server-side continuation state), and one logical request gets the configured number of +replacements in total — across every recovery leg and every combo child, not one each. The +refusal returns as soon as that grant is spent, the leg has no send left, or a replacement fails +for any other reason. A request that already emitted output or a tool call keeps the refusal +regardless. A caller that cancels mid-replacement gets the cancellation, not the refusal. `noProxy` accepts either a comma-separated string or an array. Both forms add entries without replacing an inherited `NO_PROXY`: @@ -370,6 +388,26 @@ either `target.reduceToBytes` or `target.removeOldestPercent`. `mode` defaults t Configure it on the Storage page or with `GET`/`PUT /api/storage/cleanup-policy`; trigger a manual run with `POST /api/storage/cleanup-policy/run`. +## Usage history size + +`usageLedgerMaxBytes` is unset by default, and unset means the request history in `usage.jsonl` +grows without limit. Nothing deletes history you did not ask to have deleted. + +Set it to a byte ceiling and the proxy trims the file after an append crosses it, keeping the +newest whole rows and dropping the oldest. It trims a little below the ceiling rather than exactly +to it, so the next append does not immediately re-cross the line. The minimum accepted value is +1 MiB; a smaller number, or one that is not a safe integer, leaves the limit off rather than +failing the configuration. + +Rows are copied byte for byte and never rewritten, so every field survives a trim — including +fields a newer build wrote that an older one does not understand. The replacement is refused +outright if anything appended to the ledger while it ran, so a request logged during a trim is +never lost; the next append tries again. Trimming also refreshes what the dashboard shows, so +`/api/logs` stops serving rows the ledger no longer has. + +There is no dashboard control for this yet; set it in `config.json` or with +`ocx config set usageLedgerMaxBytes `. + ## Quota-reset notifications (`quotaResetNotify`) Off by default. When the section is absent, no detection runs, no timer starts, and no state diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index 4f47ad8bbc1..9a91de21711 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -285,11 +285,19 @@ boundary. Histogram buckets are cumulative and end with `le="+Inf"`, equal to th | `opencodex_logical_requests_total` | `protocol`, `result` | One observation per finalized logical request. | | `opencodex_physical_sends_total` | `protocol` | Actual upstream sends summed from finalized attempts. | | `opencodex_recoveries_total` | `protocol`, `recovery` | Distinct recovery kinds observed per attempt, projected to a closed class. | +| `opencodex_request_failures_total` | `protocol`, `cause` | Finalized requests that did not deliver an answer, by the cause the recorder derived. Counter only; no histogram carries a cause. | | `opencodex_request_duration_seconds` | `protocol`, `result` | Fixed-bucket duration histogram for finalized requests. | | `opencodex_ttft_seconds` | `protocol`, `result` | Fixed-bucket TTFT histogram for requests with observed first output. | | `opencodex_ttft_missing_total` | `protocol`, `result` | Complementary count for requests without observed TTFT. | | `opencodex_metrics_process_start_time_seconds` | none | Process-local reset boundary. | +The `recovery` label takes one of a fixed set of classes: `transient`, `connection`, `credential`, +`rate_limit`, `quota`, `policy`, `ciphertext`, `payload`, `empty_completion`, `effort_downgrade` and +`other`. The set is closed, so no model, account, user or request identifier can ever appear in a +series. `rate_limit`, `quota`, `policy` and `ciphertext` are separate because the operator response +differs: wait out the limit, move to another account, change the prompt, or drop stale encrypted +state. A rejected opaque reasoning blob counts as `ciphertext` rather than `payload`. + If a scanned row exceeds the existing parser size limit, `GET /api/usage` and `GET /api/keys` keep the readable-row aggregates and add `usageIncomplete: true` with `usageIncompleteReason: "oversized_rows"` at response level. This diagnostic survives cached diff --git a/docs-site/src/content/docs/ru/getting-started/installation.md b/docs-site/src/content/docs/ru/getting-started/installation.md index a1f3724a4ba..ca0fe20b3e4 100644 --- a/docs-site/src/content/docs/ru/getting-started/installation.md +++ b/docs-site/src/content/docs/ru/getting-started/installation.md @@ -45,6 +45,19 @@ ocx --version opencodex --version ``` +## Автономный бинарный файл (без npm) + +В релиз входят автономные бинарные файлы `ocx` для поддерживаемых macOS, Linux и Windows. +Они содержат рантайм Bun и дашборд, поэтому npm, Node и отдельная установка Bun не нужны. +Скачайте архив для своей платформы, распакуйте его и выполните: + +```bash +./ocx --version +./ocx start +``` + +Чтобы дашборд был доступен, оставьте распакованный каталог `gui/dist` рядом с бинарным файлом. + ### Каналы релизов Стабильный канал `latest` уже включает поддержку каталога GPT-5.6 Sol/Terra/Luna для маршрутов diff --git a/docs-site/src/content/docs/ru/getting-started/quickstart.md b/docs-site/src/content/docs/ru/getting-started/quickstart.md index 1b086c7db2c..750d2fa7476 100644 --- a/docs-site/src/content/docs/ru/getting-started/quickstart.md +++ b/docs-site/src/content/docs/ru/getting-started/quickstart.md @@ -5,6 +5,11 @@ description: Настройте первого провайдера и напр Это руководство проводит от чистой установки до запуска Codex с моделью не от OpenAI. +## Автономный бинарный файл (без npm) + +Можно также использовать архив с бинарным файлом `ocx` и рантаймом Bun без npm. +Распакуйте его, оставив каталог `gui/dist` рядом с бинарным файлом, и выполните `./ocx start`. + ## 1. Запустите мастер настройки ```bash @@ -13,7 +18,7 @@ ocx init `ocx init` проведёт вас по следующим шагам: -1. **Выбор провайдера** — выберите один из 95 встроенных пресетов реестра или `custom`, чтобы +1. **Выбор провайдера** — выберите один из 96 встроенных пресетов реестра или `custom`, чтобы ввести базовый URL и адаптер вручную. 2. **API-ключ** — вставьте ключ или сошлитесь на переменную окружения вида `${ANTHROPIC_API_KEY}`. 3. **Модель по умолчанию** — для провайдеров с ключом, локальных и `custom` примите значение из diff --git a/docs-site/src/content/docs/ru/guides/claude-code.md b/docs-site/src/content/docs/ru/guides/claude-code.md index 1ceea7da0e8..f82e2e007e4 100644 --- a/docs-site/src/content/docs/ru/guides/claude-code.md +++ b/docs-site/src/content/docs/ru/guides/claude-code.md @@ -529,4 +529,4 @@ Responses `web_search_call` в парные блоки Anthropic `server_tool_us Параметр `claudeCode.stabilizePromptCache: true` в `config.json` переносит поддерживаемые уведомления Claude в конце системных инструкций в последнее пользовательское сообщение на маршрутах с преобразованием. По умолчанию он выключен (`false`). Включайте его только когда такое изменение роли допустимо для ваших клиентов. Примеры в блоках кода и нераспознанный текст сохраняются; нативная передача Anthropic не меняется. Без метаданных ключ кэша рассчитывается по стабилизированным инструкциям. Идентификатор разговора не создаётся, попадания в кэш не гарантируются. -На Chat-маршруте OpenCode Go для `deepseek-v4.1-flash` преобразованные системные напоминания в истории автоматически сохраняют свою позицию и роль system после ожидаемых результатов инструментов. Поэтому добавление новых напоминаний не переписывает начальный системный промпт. Это работает независимо от `stabilizePromptCache`; преобразование для других моделей и адресатов, а также нативная передача Anthropic остаются прежними. Для повторного использования кэша по-прежнему нужны стабильный идентификатор сессии и доступный кэш провайдера. Изменения прежних инструкций или инструментов и сжатие разговора также могут влиять на попадания в кэш; само сохранение порядка напоминаний не гарантирует повторного использования. +На всех преобразованных Chat-маршрутах напоминания в истории сохраняют свою позицию в разговоре — после ожидаемых результатов инструментов. Поэтому добавление нового напоминания не переписывает начальный системный промпт, а инструкция из середины разговора не оказывается раньше тех ходов, после которых она была написана. Роль этой позиции определяется отдельно: напоминание отправляется как `system`, если провайдер не записал `foldDeveloperRoleToSystem: false` — эта запись означает, что вышестоящий сервис принимает роль `developer`, и тогда она передаётся в той же позиции. Сервис, который её не принимает, отвечает `400 role 'developer' is not allowed`, и ход не начинается, поэтому незаписанное назначение сворачивается. Это работает независимо от `stabilizePromptCache`; нативная передача Anthropic остаётся прежней. Для повторного использования кэша по-прежнему нужны стабильный идентификатор сессии и доступный кэш провайдера. Изменения прежних инструкций или инструментов и сжатие разговора также могут влиять на попадания в кэш; само сохранение порядка напоминаний не гарантирует повторного использования. diff --git a/docs-site/src/content/docs/ru/guides/codex-integration.md b/docs-site/src/content/docs/ru/guides/codex-integration.md index 4f318434807..056f4b42917 100644 --- a/docs-site/src/content/docs/ru/guides/codex-integration.md +++ b/docs-site/src/content/docs/ru/guides/codex-integration.md @@ -21,7 +21,7 @@ opencodex заставляет Codex маршрутизировать запро ```toml # root keys, before the first table model_catalog_json = "/absolute/path/to/opencodex-catalog.json" -# Auto-injected by opencodex +# Auto-injected by opencodex (undo: ocx restore) openai_base_url = "http://127.0.0.1:10100/v1" # только если fastMode задан; без него таблица [features] не создаётся @@ -122,7 +122,7 @@ model_provider = "opencodex" model_catalog_json = "/absolute/path/to/opencodex-catalog.json" # appended at the end of the file -# Auto-injected by opencodex +# Auto-injected by opencodex (undo: ocx restore) [model_providers.opencodex] name = "OpenCodex Proxy" base_url = "http://your-host:10100/v1" @@ -413,7 +413,7 @@ ocx restore back # point plain Codex at the running proxy again ## Защитный отказ для постраничной истории -Если затронутое хранилище поддерживает постраничную историю, смена провайдера может вернуть `history_paginated_requires_native_writer`, в том числе для строк legacy. По этой причине больше не отклоняются конфигурация Codex, опорный профиль и каталог моделей. `ocx sync` и `ocx start` по-прежнему записывают эти файлы и задают `model_catalog_json`, поэтому выбор модели Codex продолжает показывать все модели, маршрутизируемые через OpenCodex. Переразметку истории разговоров останавливает только эта причина: порядковые номера постраничной истории выделяет собственный процесс записи Codex, и повторная попытка этого не меняет. Любая другая причина предварительной проверки истории — нечитаемая база состояния, история со сменившейся идентификацией или проверка, которую не удалось запустить, — по-прежнему отклоняет весь переход и откатывает его, потому что такие случаи могут пройти позже. В этом состоянии OpenCodex не изменяет постраничные файлы истории и строки тредов. Существующие разговоры сохраняют уже назначенного провайдера и не мигрируют; новые разговоры идут через прокси как обычно. Когда переразметка останавливается, таблица `[model_providers.opencodex]`, уже бывшая в домашнем каталоге, сохраняется, а не снимается, в том числе в форме root-override (loopback), чтобы разговоры со строками, помеченными `opencodex`, сохраняли существующий идентификатор провайдера. CLI выводит `Codex resume history: left to Codex's native writer (history_paginated_requires_native_writer)`. `ocx restore` и удаление конфигурации Codex по-прежнему отказывают по `history_paginated_requires_native_writer`. Удаление определения `[model_providers.opencodex]`, пока строки тредов на него ссылаются, сделало бы эти разговоры неразрешимыми, а путь восстановления не умеет оставлять таблицу совместимости провайдера. Домашний каталог, уже переведённый на постраничную историю, сейчас нельзя удалить средствами продукта; это известная открытая задача, а не задуманное поведение. +Если затронутое хранилище поддерживает постраничную историю, смена провайдера может вернуть `history_paginated_requires_native_writer`, в том числе для строк legacy. По этой причине больше не отклоняются конфигурация Codex, опорный профиль и каталог моделей. `ocx sync` и `ocx start` по-прежнему записывают эти файлы и задают `model_catalog_json`, поэтому выбор модели Codex продолжает показывать все модели, маршрутизируемые через OpenCodex. Переразметку истории разговоров останавливает только эта причина: порядковые номера постраничной истории выделяет собственный процесс записи Codex, и повторная попытка этого не меняет. Любая другая причина предварительной проверки истории — нечитаемая база состояния, история со сменившейся идентификацией или проверка, которую не удалось запустить, — по-прежнему отклоняет весь переход и откатывает его, потому что такие случаи могут пройти позже. В этом состоянии OpenCodex не изменяет постраничные файлы истории и строки тредов. Существующие разговоры сохраняют уже назначенного провайдера и не мигрируют; новые разговоры идут через прокси как обычно. Когда переразметка останавливается, таблица `[model_providers.opencodex]`, уже бывшая в домашнем каталоге, сохраняется, а не снимается, в том числе в форме root-override (loopback), чтобы разговоры со строками, помеченными `opencodex`, сохраняли существующий идентификатор провайдера. CLI выводит `Codex resume history: left to Codex's native writer (history_paginated_requires_native_writer)`. `ocx restore`, `ocx stop` и `ocx uninstall` больше не отказывают по причине `history_paginated_requires_native_writer`. Они убирают все корневые ключи маршрутизации OpenCodex и оставляют определение `[model_providers.opencodex]` на диске, поэтому разговоры, строки которых всё ещё называют этого провайдера, продолжают разрешаться, а обычный `codex` перестаёт указывать на прокси. Результат сообщается как частичное восстановление с перечислением оставленных строк; `ocx restore --remove-codex-provider-table` удаляет и их, после чего такие разговоры перестают открываться. Кроме того, включение интеграции в форме таблицы провайдера в домашнем каталоге, где разговоры с меткой `openai` Codex уже перевёл на постраничную историю, раньше отклонялось целиком с `history_paginated_openai_requires_native_writer`: ничего не записывалось, а интеграция оставалась выключенной. Теперь OpenCodex завершает этот переход, сохраняя управляемое корневое переопределение `openai_base_url` рядом с таблицей `[model_providers.opencodex]`. Codex объединяет это переопределение со своим встроенным провайдером `openai`, поэтому такие разговоры продолжают попадать в прокси без переразметки, а файлы истории и строки тредов не изменяются. Отказ сохраняется только для формы маршрутизации, требующей заголовок допуска `x-opencodex-api-key`, потому что встроенный провайдер Codex не может его нести; в этом случае сообщение называет две настройки, которые решают задачу: направить Codex через loopback-слушатель, чтобы переопределение можно было сохранить, или задать `syncResumeHistory` значение `false`, приняв, что такие разговоры пойдут к собственной конечной точке OpenAI в Codex. При возврате к режиму переопределения корневого URL OpenCodex сохраняет существующее определение `[model_providers.opencodex]` до фиксации конфигурации, даже если предварительная проверка истории успешна. Поэтому старые разговоры `opencodex` сохраняют доступ к своему провайдеру, если Codex преобразует историю после фиксации или во время запуска фоновой обработки. Новые разговоры используют выбранный корневой провайдер; явное восстановление по-прежнему выполняет отдельные проверки удаления. diff --git a/docs-site/src/content/docs/ru/guides/macos-menu-bar.md b/docs-site/src/content/docs/ru/guides/macos-menu-bar.md new file mode 100644 index 00000000000..1d264a93c73 --- /dev/null +++ b/docs-site/src/content/docs/ru/guides/macos-menu-bar.md @@ -0,0 +1,171 @@ +--- +title: Приложение в строке меню macOS +description: Нативное приложение, показывающее состояние прокси OpenCodex, расход и квоты провайдеров прямо в строке меню. +--- + +Приложение показывает состояние прокси, недавний расход и загрузку квот по провайдерам, +не требуя открывать панель управления. + +Это отдельная программа. `ocx` работает как раньше, а приложение в строке меню — +клиент, который обращается к локальному management API. + +## Настольное приложение (Tauri) + +Ту же панель можно открыть в настольном приложении OpenCodex. Панель компаньона показывает +шаги установки для выбранной ОС, а пункт **Открыть в браузере** открывает текущий экран +в обычном браузере, когда панель работает внутри desktop shell. + +## Установка + +Основной способ установки — настольное приложение OpenCodex. На +[странице релизов](https://github.com/lidge-jun/opencodex/releases) скачайте для macOS +`OpenCodex-<версия>-macos.dmg`, откройте DMG и перетащите `OpenCodex.app` в «Программы». +В Windows запустите `OpenCodex-<версия>-windows-x64.msi`, а в Linux используйте AppImage +или `OpenCodex-<версия>-linux-amd64.deb`. + +```bash +chmod +x OpenCodex-<версия>-linux-x86_64.AppImage +sudo apt install ./OpenCodex-<версия>-linux-amd64.deb +``` + +Windows SmartScreen и macOS Gatekeeper могут показать предупреждение. Приложение подключается +к существующему `ocx`, а если его нет — запускает встроенный sidecar. + +## Первый запуск: Gatekeeper + +**Первый запуск будет заблокирован.** macOS покажет: + +> Не удаётся открыть «OpenCodex.app», так как не удалось проверить разработчика. + +Это ожидаемо, поэтому объясняем причину, а не предлагаем просто нажать дальше. Gatekeeper +требует подпись Developer ID и билет нотаризации от Apple — и то и другое доступно только +с платным аккаунтом Apple Developer. У OpenCodex его нет, поэтому приложение выпускается +с ad-hoc подписью: сам бандл цел и подпись корректна, но Apple не подтверждает издателя. + +Чтобы всё-таки открыть: + +1. Нажмите правой кнопкой (или Control-клик) на `OpenCodex.app` в Finder. +2. Выберите **Открыть**. +3. В появившемся диалоге снова нажмите **Открыть**. + +Если в диалоге нет кнопки «Открыть», откройте **Системные настройки → Конфиденциальность и +безопасность**, найдите уведомление о заблокированной программе и нажмите **Всё равно +открыть**. + +macOS запомнит решение, так что это разовое действие для каждой версии. + +Можно также снять атрибут карантина из терминала: + +```bash +xattr -d com.apple.quarantine /Applications/OpenCodex.app +``` + +Если ни один вариант не подходит, соберите приложение сами — у локальной сборки атрибута +карантина нет вовсе. См. [Сборка из исходников](#сборка-из-исходников). + +## Что показывает + +Иконка в строке меню передаёт состояние формой, а не цветом: в macOS иконки строки меню +по традиции монохромны. + +| Иконка | Значение | +| --- | --- | +| Сплошная метка | Работает, маршрутизация защищена | +| Метка с выемкой | Работает, но защита маршрутизации под угрозой | +| Контурная метка | Проверка или нештатный ответ | +| Блёклый контур | Не запущен или нужен API-ключ | + +По клику открывается панель с четырьмя разделами. + +**Состояние** — работает ли прокси, локальный адрес, который использует приложение, и +состояние защиты. Если прокси +рекомендует команду (например, `ocx service install`), она показывается выделяемым +текстом. Приложение её не выполняет. + +**Расход** — запросы, токены и оценочная стоимость за последние 7 дней с дневной +динамикой. Знак `~` после числа запросов означает, что часть значения оценочная, а не +сообщённая провайдером. + +По умолчанию в заголовке строки меню отображается общее число токенов; если нужны запросы, стоимость, квота или только значок, измените метрику заголовка в настройках Companion раздела Usage на дашборде. +В macOS 26 всплывающее окно и виджеты используют Liquid Glass; в более ранних версиях macOS используется стандартный материал всплывающего окна. + +**Квоты** — по строке на провайдера, показывается окно под наибольшим давлением. Если +провайдер израсходовал 99% пятичасового лимита и 10% месячного, показывается пятичасовое +значение — именно оно сейчас блокирует работу. Название окна печатается под провайдером, +поэтому `42% от API usage` и `42% от месяца` невозможно перепутать. + +**Провайдеры** — раскрывающийся список с переключателем для каждого провайдера. +Переключатель провайдера по умолчанию заблокирован, пока тот включён: прокси отказывается +отключать провайдера по умолчанию, поэтому сначала смените его в панели управления. + +## Что умеет + +- **Dashboard** — открывает веб-панель в браузере. +- **Stop proxy** — останавливает прокси после подтверждения. Намеренно не называется + «перезапуск»: остановка также останавливает службу launchd, поэтому прокси не поднимется + сам. После остановки панель показывает команду для повторного запуска. +- **Переключатели провайдеров** — включают и выключают провайдера. + +Всё остальное — аккаунты, настройка моделей, хранилище — остаётся в панели управления. + +## Виджет + +Щёлкните правой кнопкой по рабочему столу, выберите **Изменить виджеты** и добавьте +**OpenCodex**. Он показывает состояние прокси, расход за сегодня и квоты, используя тот же +конфиденциальный снимок, что и приложение в строке меню. Виджет обновляется при опросе приложения. +Требуется macOS 14 или новее; API-ключи и необработанные данные аккаунтов не передаются. + +## Подключение к прокси + +Приложение находит прокси само. Оно читает `~/.opencodex/runtime-port.json` (или +`$OPENCODEX_HOME/runtime-port.json`), а при отсутствии использует порт `10100`. Из файла +берётся только порт; хост всегда локальный. + +Если прокси привязан не к локальному адресу, потребуется API-ключ. Панель сообщит об этом +и предложит перейти в панель управления. + +**Этот сценарий пока не поддержан.** Приложение читает ключ из связки ключей macOS и делает +одну повторную попытку, но интерфейса для ввода ключа нет и нет поддерживаемого способа +создать его вручную: это элемент data-protection keychain, который «Связка ключей» не +создаёт. Поэтому при нелокальной привязке панель остаётся в состоянии «Needs API key». + +Локальному прокси, который используется по умолчанию, ключ не нужен. Нативный ввод ключа +запланирован. + +## Опрос + +Приложение намеренно ведёт себя тихо. Проверка доступности — раз в 5 секунд, а тяжёлые +агрегаты (расход и квоты) запрашиваются только при открытой панели и не чаще раза в +минуту. После трёх неудач подряд интервал увеличивается до 30 секунд, чтобы не долбить +прокси, который вы остановили намеренно. + +## Сборка из исходников + +Требуются macOS 13 или новее, Xcode Command Line Tools и [Bun](https://bun.sh): + +```bash +git clone https://github.com/lidge-jun/opencodex.git +cd opencodex +bun run prepare-sidecar +bun run prepare-widget +bunx tauri build +``` + +Бандл появится в выходных файлах Tauri, а расширение WidgetKit будет включено в +`OpenCodex.app/Contents/PlugIns/`. + +Для универсального бинарника (`UNIVERSAL=1`) нужен полный Xcode: в Command Line Tools есть +только библиотеки совместимости Swift для текущей архитектуры, и сборка сообщит об этом +вместо ошибки компоновщика. + +Если в связке ключей есть сертификат Developer ID, задайте `MACOS_SIGN_IDENTITY`, чтобы +подписать с hardened runtime вместо ad-hoc: + +```bash +MACOS_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" bun run prepare-widget +``` + +## Удаление + +Перетащите `OpenCodex.app` в корзину. Приложение не оставляет ни настроек, ни собственных +файлов состояния и пока ничего не хранит в связке ключей. diff --git a/docs-site/src/content/docs/ru/guides/providers.md b/docs-site/src/content/docs/ru/guides/providers.md index a79efd4d6d4..53e86522e2b 100644 --- a/docs-site/src/content/docs/ru/guides/providers.md +++ b/docs-site/src/content/docs/ru/guides/providers.md @@ -198,7 +198,7 @@ Inline JSON и лишние позиционные аргументы откло ## 3. Каталог API-ключей -opencodex поставляется с 95 встроенными пресетами: 79 на основе ключей, 12 OAuth, три локальных и +opencodex поставляется с 96 встроенными пресетами: 80 на основе ключей, 12 OAuth, три локальных и один пресет ChatGPT-форварда по умолчанию. Селектор **Add provider** в дашборде открывает страницу выдачи ключей провайдера, проверяет ключ и сохраняет его; проверка зависит от провайдера. Наиболее заметные записи: diff --git a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md index f338d75388f..6719e9cf2cb 100644 --- a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md @@ -395,6 +395,8 @@ ocx codex-shim uninstall one-click управление прокси. `start` и `stop` управляют только иконкой; самим прокси нужно управлять из её меню. `--no-start` применяется к `install` и устанавливает tray, не запуская её немедленно. +Устарело: приложение OpenCodex для рабочего стола предоставляет трей в Windows, macOS и Linux; +`ocx tray` остаётся для установок без приложения для рабочего стола. ## Дашборд diff --git a/docs-site/src/content/docs/ru/reference/configuration/providers.md b/docs-site/src/content/docs/ru/reference/configuration/providers.md index 21227ef1abd..35de1cf8541 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -138,11 +138,13 @@ cross-route credential fallback не существует. Строки API GPT- | `noPenaltyModels?` | `string[]` | Модели, отвергающие penalty presence/frequency. | | `noStructuredOutputModels?` | `string[]` | Точные идентификаторы моделей, чей endpoint `openai-chat` отклоняет `response_format`. Поле опускается только при точном совпадении запрошенной модели; для остальных моделей `openai-chat` преобразование structured output остаётся включённым. | | `noJsonSchemaModels?` | `string[]` | Точные идентификаторы моделей, чей endpoint `openai-chat` отклоняет `response_format` типа `json_schema`, но принимает `json_object`. Такой запрос понижается до `json_object`, а не отбрасывается, поэтому вызывающая сторона всё равно получает JSON. Если модель есть в обоих списках, побеждает `noStructuredOutputModels`. Пресеты `opencode go`, `opencode zen` и `opencode free` включают это для своих маршрутов DeepSeek. | +| `foldDeveloperRoleToSystem?` | `boolean` | Принимает ли назначение `openai-chat` роль `developer`. Если `foldDeveloperRoleToSystem` не задан, сообщение уходит как `system`; при `true` — как `system`; при `false` — как `developer`. Не задано означает, что об этом назначении ничего не записано; `true` фиксирует вышестоящий сервис, который роль отклоняет; `false` — тот, который её принимает. В любом случае сообщение сохраняет свою позицию в разговоре, меняется только роль. Назначение, отклоняющее роль, отвечает `400 role 'developer' is not allowed`, и ход не начинается — поэтому незаписанное состояние по умолчанию свёрнуто. | | `parallelToolCalls?` | `boolean` | Переключатель parallel tool call'ов. Для OpenAI Chat по умолчанию включено; не-chat adapter'ы рекламируют это только при явном `true`. | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` | По умолчанию выключенная downstream SSE-repair для exact placeholder-id, отсутствующих terminal-id и (с `repairInvalidIds`) message/reasoning id без канонического префикса `msg_`/`rs_`. Function-call id никогда не переписываются. Встроенный DeepSeek включает последние два по умолчанию. | | `responsesSnapshotRepair?` | `boolean` | По умолчанию выключенная клиентская repair для неполных lifecycle snapshot'ов Responses в SSE и JSON. Добавляет отсутствующие status, output и tool metadata, не меняя raw inspection и persistence. | | `retryOn429?` | `{ enabled?: boolean; attempts?: number; intervalMs?: number; maxIntervalMs?: number; respectRetryAfter?: boolean }` | Только для провайдеров с API-ключом (`authMode: "key"`). Опциональный повтор при 429 на том же таргете: если `retryOn429` отсутствует, функция выключена; наличие объекта включает её, если только `enabled: false`. При 429: ожидание (`Retry-After` апстрима или фиксированный интервал) и повтор идентичного запроса на том же ключе до любого фейловера ключей — покрывает основной цикл восстановления текстовых ходов, passthrough-канал Responses, мост изображений/видео, sidecar web-search и терминальные продолжения. Повтор допустим только для HTTP 429, полученных до начала потока; пользовательские транспорты `runTurn` не входят в цикл HTTP-повторов. `attempts` — это число повторов на том же ключе после первого 429 (всего отправок = `attempts` + 1) и единый бюджет на запрос, общий для основного цикла восстановления, терминального продолжения и повторов моста. Исчерпание `attempts` лишь останавливает дальнейшие повторы на том же ключе; далее применяется обычный фейловер ключей или финальная обработка ошибки в зависимости от доступных таргетов — на passthrough-канале с ключевой аутентификацией фейловера нет, поэтому исчерпанный 429 возвращается как есть. Codex сам никогда не повторяет 429, поэтому это единственная защита для провайдеров с одним ключом. По умолчанию: `enabled: true`, `attempts: 3`, `intervalMs: 5000`, `maxIntervalMs: 60000` (любое ожидание ограничено `maxIntervalMs`, который сам ограничен 600000), `respectRetryAfter: true`. | | `transientRetryOn5xx?` | `{ enabled?: boolean; attempts?: number }` | Только для провайдеров `openai-chat` и `openai-responses` с аутентификацией по ключу. Провайдеры с `authMode: "forward"` (пул аккаунтов ChatGPT) никогда не читают эту настройку и сохраняют число повторов по умолчанию. Опциональный повтор при временных статусах апстрима до начала потока (500, 502, 503, 504, 520, 521, 522): если параметр отсутствует, функция выключена; наличие объекта включает её, если только `enabled: false`. Покрывает исходный запрос `Responses`, продолжение терминального предохранителя, нативный `/v1/chat/completions`, а также повторные запросы при восстановлении после 429 или ошибки учётной записи. `attempts` — ОБЩЕЕ число разрешённых отправок в апстрим для одного запроса, включая первую (1..10, по умолчанию 3). Это единый бюджет на запрос, общий с восстановлением после сброса соединения, поэтому `3` означает, что до провайдера дойдут не более трёх реальных запросов. Ожидание использует экспоненциальную задержку с фиксированной начальной величиной 400 мс, ограниченную 5 с, и учитывает `Retry-After`. Параметр не связан с `retryOn429`, который обрабатывает ограничение частоты запросов; сбои после начала потока никогда не воспроизводятся. | +| `retryOnReset?` | `{ enabled?: boolean; replacements?: number }` | Только для нативных провайдеров `openai-responses`, включая `authMode: "forward"`. Необязательная замена отправки, которая завершилась неудачей, когда вызывающая сторона ещё ничего не наблюдала: если параметр отсутствует, функция выключена; наличие объекта включает её, если только `enabled: false`. Покрывает обе неоднозначные стадии — обрыв соединения до любого заголовка ответа и обрыв тела SSE после заголовка, когда оно несло только управляющие события. Заменяется только самодостаточный запрос: `store: false`, полный `input`, отсутствие `previous_response_id`, `conversation` и `stream_id`, и только инструменты, исполняемые клиентом. `replacements` — число замещающих отправок, которые ОДИН логический запрос может сделать по всем участкам и всем дочерним запросам комбо (1..2, по умолчанию 1). Это не число повторов на участок и не бюджет отправок, поэтому замена всё равно должна поместиться в уже имеющийся у участка лимит отправок. Запрос, который уже выдал вывод или вызов инструмента, не заменяется никогда, каким бы ни было это значение. Замещающий вывод модели всё равно может быть оплачен, если источник уже начал первый, поэтому параметр выключен по умолчанию. | | `autoToolChoiceOnlyModels?` | `string[]` | Модели, у которых `tool_choice` принимает только `auto` или `none`; forced choice понижается. | | `preserveReasoningContentModels?` | `string[]` | Модели, которым нужен предыдущий assistant `reasoning_content` в chat history. | | `reasoningDetailsModels?` | `string[]` | Модели, чей endpoint возвращает thinking как структурированный массив `reasoning_details` (MiniMax M-series с `reasoning_split`); потоковые дельты — кумулятивные снимки, сравниваемые по префиксу, а сохранённый reasoning воспроизводится массивом `reasoning_details` вместо строки `reasoning_content`. | diff --git a/docs-site/src/content/docs/tr/getting-started/quickstart.md b/docs-site/src/content/docs/tr/getting-started/quickstart.md index 6a595544d76..d73cb2bff12 100644 --- a/docs-site/src/content/docs/tr/getting-started/quickstart.md +++ b/docs-site/src/content/docs/tr/getting-started/quickstart.md @@ -14,7 +14,7 @@ ocx init `ocx init` adım adım size rehberlik eder: -1. **Bir sağlayıcı seçin** — yerleşik kayıt defterindeki 95 önayardan birini +1. **Bir sağlayıcı seçin** — yerleşik kayıt defterindeki 96 önayardan birini veya bir temel URL ile adaptör yazmak için `custom` seçeneğini belirleyin. 2. **API anahtarı** — bir anahtar yapıştırın veya `${ANTHROPIC_API_KEY}` gibi bir ortam değişkenine başvurun. diff --git a/docs-site/src/content/docs/tr/guides/claude-code.md b/docs-site/src/content/docs/tr/guides/claude-code.md index 4920210639a..d6f5496db14 100644 --- a/docs-site/src/content/docs/tr/guides/claude-code.md +++ b/docs-site/src/content/docs/tr/guides/claude-code.md @@ -738,4 +738,4 @@ tutucusu olarak `"haiku"` iletin. `config.json` içindeki `claudeCode.stabilizePromptCache: true`, dönüştürülen rotalarda sistem talimatlarının sonundaki desteklenen Claude bildirimlerini son kullanıcı mesajına taşır. Varsayılan değer `false` olur. Yalnızca bu rol değişikliği istemcileriniz için uygunsa etkinleştirin. Kod bloklarındaki örnekler ve eşleşmeyen metin korunur; yerel Anthropic aktarımı değişmez. Meta veri yoksa önbellek anahtarı kararlı talimatlardan hesaplanır. Bu seçenek konuşma kimliği oluşturmaz veya üst hizmette önbellek isabeti garanti etmez. -OpenCode Go’nun `deepseek-v4.1-flash` Chat rotasında, dönüştürülen zaman çizelgesi sistem hatırlatmaları bekleyen araç sonuçlarından sonra konumlarını ve system rolünü otomatik olarak korur. Böylece yeni hatırlatmalar eklenmesi, baştaki sistem istemini yeniden yazmaz. Bu davranış `stabilizePromptCache` açık veya kapalıyken geçerlidir; diğer modellerin ve hedeflerin dönüşümü ile yerel Anthropic aktarımı değişmez. Önbelleğin yeniden kullanımı için kararlı bir oturum kimliği ve kullanılabilir üst hizmet önbelleği hâlâ gereklidir. Önceki talimatların veya araçların değişmesi ve konuşmanın sıkıştırılması da önbellek isabetini etkileyebilir; hatırlatma sırasını korumak tek başına yeniden kullanımı garanti etmez. +Dönüştürülen tüm Chat rotalarında zaman çizelgesi hatırlatmaları, bekleyen araç sonuçlarından sonra konuşmadaki konumlarını korur. Böylece yeni bir hatırlatma eklenmesi baştaki sistem istemini yeniden yazmaz ve konuşmanın ortasındaki bir yönerge, izlemesi gereken turların önüne geçmez. O konumun hangi rolü taşıdığı ayrı bir karardır: sağlayıcı `foldDeveloperRoleToSystem: false` kaydetmedikçe hatırlatma `system` olarak gönderilir; bu kayıt, üst hizmetin `developer` rolünü kabul ettiğini belirtir ve rol aynı konumda iletilir. Kabul etmeyen bir üst hizmet `400 role 'developer' is not allowed` yanıtı verir ve tur hiç başlamaz; kaydı olmayan hedefin katlanmasının nedeni budur. Bu davranış `stabilizePromptCache` açık veya kapalıyken geçerlidir; yerel Anthropic aktarımı değişmez. Önbelleğin yeniden kullanımı için kararlı bir oturum kimliği ve kullanılabilir üst hizmet önbelleği hâlâ gereklidir. Önceki talimatların veya araçların değişmesi ve konuşmanın sıkıştırılması da önbellek isabetini etkileyebilir; hatırlatma sırasını korumak tek başına yeniden kullanımı garanti etmez. diff --git a/docs-site/src/content/docs/tr/guides/codex-integration.md b/docs-site/src/content/docs/tr/guides/codex-integration.md index e1ce40af776..a7ccadae455 100644 --- a/docs-site/src/content/docs/tr/guides/codex-integration.md +++ b/docs-site/src/content/docs/tr/guides/codex-integration.md @@ -25,7 +25,7 @@ ve bu sağlayıcıyı opencodex'e yönlendirir: ```toml # kök anahtarlar, ilk tablodan önce model_catalog_json = "/absolute/path/to/opencodex-catalog.json" -# Auto-injected by opencodex +# Auto-injected by opencodex (undo: ocx restore) openai_base_url = "http://127.0.0.1:10100/v1" # yalnızca fastMode ayarlandığında; ayarlanmadığında [features] tablosu eklenmez @@ -134,7 +134,7 @@ model_provider = "opencodex" model_catalog_json = "/absolute/path/to/opencodex-catalog.json" # dosyanın sonuna eklenir -# Auto-injected by opencodex +# Auto-injected by opencodex (undo: ocx restore) [model_providers.opencodex] name = "OpenCodex Proxy" base_url = "http://your-host:10100/v1" @@ -470,7 +470,7 @@ service stop` yerel Codex'i geri yükler. ## Sayfalanmış geçmiş için güvenlik reddi -Etkilenen geçmiş deposu sayfalamayı destekliyorsa sağlayıcı değişimi `history_paginated_requires_native_writer` döndürebilir; legacy satırlar da buna dahildir. Bu neden artık Codex yapılandırmasını, başvuru profilini veya model kataloğunu reddetmez. `ocx sync` ve `ocx start` bu dosyaları yazmaya ve `model_catalog_json` yolunu ayarlamaya devam eder; böylece Codex model seçicisi OpenCodex üzerinden yönlendirilen her modeli göstermeyi sürdürür. Konuşma geçmişinin yeniden etiketlenmesini durduran yalnızca bu nedendir, çünkü sayfalanmış geçmiş sıra numaralarını Codex’in kendi yerel yazıcısı atar ve yeniden denemek bunu değiştirmez. Okunamayan bir durum veritabanı, kimliği değişmiş bir geçmiş veya çalıştırılamayan bir ön kontrol gibi diğer geçmiş ön kontrol nedenleri, daha sonra başarılı olabilecekleri için hâlâ tüm değişimi reddeder ve geri alır. Bu durumda OpenCodex sayfalanmış geçmiş dosyalarını veya iş parçacığı satırlarını değiştirmez. Mevcut konuşmalar zaten etiketlendikleri sağlayıcıda kalır ve taşınmaz; yeni konuşmalar proxy üzerinden normal şekilde yönlendirilir. Yeniden etiketleme durduğunda, ev dizininde zaten bulunan bir `[model_providers.opencodex]` tablosu kaldırılmaz, kök-override (loopback) biçimde bile tutulur; böylece satırları `opencodex` olarak etiketlenmiş konuşmalar hâlâ var olan bir sağlayıcı kimliğini korur. CLI şunu yazdırır: `Codex resume history: left to Codex's native writer (history_paginated_requires_native_writer)`. `ocx restore` ve Codex yapılandırmasının kaldırılması `history_paginated_requires_native_writer` nedeniyle hâlâ reddedilir. İş parçacığı satırları hâlâ ona başvuruyken `[model_providers.opencodex]` tanımını kaldırmak o konuşmaları çözülemez yapar ve geri yükleme yolu uyumluluk sağlayıcı tablosunu tutamaz. Zaten sayfalanmış bir ev dizini şu anda ürün üzerinden kaldırılamaz; bu amaçlanan davranış değil, bilinen açık iştir. +Etkilenen geçmiş deposu sayfalamayı destekliyorsa sağlayıcı değişimi `history_paginated_requires_native_writer` döndürebilir; legacy satırlar da buna dahildir. Bu neden artık Codex yapılandırmasını, başvuru profilini veya model kataloğunu reddetmez. `ocx sync` ve `ocx start` bu dosyaları yazmaya ve `model_catalog_json` yolunu ayarlamaya devam eder; böylece Codex model seçicisi OpenCodex üzerinden yönlendirilen her modeli göstermeyi sürdürür. Konuşma geçmişinin yeniden etiketlenmesini durduran yalnızca bu nedendir, çünkü sayfalanmış geçmiş sıra numaralarını Codex’in kendi yerel yazıcısı atar ve yeniden denemek bunu değiştirmez. Okunamayan bir durum veritabanı, kimliği değişmiş bir geçmiş veya çalıştırılamayan bir ön kontrol gibi diğer geçmiş ön kontrol nedenleri, daha sonra başarılı olabilecekleri için hâlâ tüm değişimi reddeder ve geri alır. Bu durumda OpenCodex sayfalanmış geçmiş dosyalarını veya iş parçacığı satırlarını değiştirmez. Mevcut konuşmalar zaten etiketlendikleri sağlayıcıda kalır ve taşınmaz; yeni konuşmalar proxy üzerinden normal şekilde yönlendirilir. Yeniden etiketleme durduğunda, ev dizininde zaten bulunan bir `[model_providers.opencodex]` tablosu kaldırılmaz, kök-override (loopback) biçimde bile tutulur; böylece satırları `opencodex` olarak etiketlenmiş konuşmalar hâlâ var olan bir sağlayıcı kimliğini korur. CLI şunu yazdırır: `Codex resume history: left to Codex's native writer (history_paginated_requires_native_writer)`. `ocx restore`, `ocx stop` ve `ocx uninstall` artık `history_paginated_requires_native_writer` nedeniyle reddetmez. OpenCodex'in yazdığı tüm kök yönlendirme anahtarlarını kaldırır ve `[model_providers.opencodex]` tanımını diskte bırakır; böylece satırları hâlâ o sağlayıcıyı adlandıran konuşmalar çözülmeye devam ederken düz `codex` proxy'yi göstermeyi bırakır. Sonuç, bırakılan satırları adlandıran kısmi bir geri yükleme olarak raporlanır; `ocx restore --remove-codex-provider-table` onları da kaldırır ve ardından o konuşmalar açılmaz. Ayrıca, `openai` etiketli konuşmaları Codex'in zaten sayfaladığı bir ev dizininde entegrasyonu sağlayıcı tablosu biçiminde açmak eskiden `history_paginated_openai_requires_native_writer` ile tümüyle reddediliyordu: hiçbir şey yazılmıyor ve entegrasyon devre dışı kalıyordu. OpenCodex bu geçişi artık yönetilen kök `openai_base_url` geçersiz kılmasını `[model_providers.opencodex]` tablosunun yanında tutarak tamamlar. Codex bu geçersiz kılmayı yerleşik `openai` sağlayıcısıyla birleştirdiği için o konuşmalar yeniden etiketlenmeden proxy'ye ulaşmayı sürdürür ve hiçbir geçmiş baytı veya iş parçacığı satırı değişmez. Yalnızca `x-opencodex-api-key` kabul başlığını gerektiren yönlendirme biçimi hâlâ reddeder, çünkü Codex'in yerleşik sağlayıcısı bu başlığı taşıyamaz; mesajı bunu çözen iki ayarı adlandırır: geçersiz kılmanın korunabilmesi için Codex'i loopback dinleyicisi üzerinden yönlendirin ya da `syncResumeHistory` değerini `false` yaparak o konuşmaların Codex'in kendi OpenAI uç noktasına gitmesini kabul edin. Kök URL geçersiz kılma biçimine dönülürken OpenCodex, geçmiş ön kontrolü başarılı olsa bile yapılandırmayı kaydetmeden önce mevcut `[model_providers.opencodex]` tanımını korur. Böylece Codex, kayıttan sonra veya arka plan geçmiş işlemi başlarken geçmiş biçimini değiştirirse eski `opencodex` konuşmaları sağlayıcılarını bulmaya devam eder. Yeni konuşmalar seçili kök sağlayıcıyı kullanır; açıkça istenen geri yükleme, mevcut ayrı kaldırma kontrollerini korur. diff --git a/docs-site/src/content/docs/tr/guides/integrations.md b/docs-site/src/content/docs/tr/guides/integrations.md index c1cc94e2a33..298930cd408 100644 --- a/docs-site/src/content/docs/tr/guides/integrations.md +++ b/docs-site/src/content/docs/tr/guides/integrations.md @@ -281,6 +281,34 @@ doğrulanmıştır; neyin ne zaman denetlendiğine ilişkin `devlog/_fin/260802_client_toggle_api/002_client_toggle_matrix.md` içindeki araştırma notlarına bakın. +## ZCode 3.14 ve sonrası + +ZCode 3.14 özel sağlayıcılarını `~/.zcode/v2/provider_config.json` dosyasına taşıdı; bu +entegrasyonun yazdığı `~/.zcode/v2/config.json` dosyasına artık yalnızca, yeni dosya yokken bir kez +çalışan bir içe aktarma üzerinden ulaşıyor. ZCode yeni dosyayı ilk çalıştırmada oluşturduğu için, +bir kez bile başlatılmış her kurulumda bu içe aktarma çoktan tükenmiştir ve `config.json` dosyasına +yazmak hiçbir şeye ulaşmaz. + +opencodex artık mümkün olduğunda `provider_config.json` dosyasını doğrudan yazıyor. Entegrasyonu +etkinleştirmek bu dosyaya `opencodex` sağlayıcı kuralını ekler, katalog yenilemesi onu günceller ve +devre dışı bırakmak opencodex'in oraya koyduğu şeyi tam olarak kaldırır. Dosyadaki diğer her kural +olduğu gibi kalır; buna başka bir sağlayıcının, bizde de bulunan bir model kimliği için tuttuğu +kural da dahildir. opencodex'in yazmadığı, `opencodex` kimliğini taşıyan bir kural devralınacak bir +şey değil, bir çakışmadır: ZCode içinde çözün ya da açık üzerine yazmayı kullanın. + +İki durum hâlâ yazmak yerine reddeder. ZCode deposunu taşımadan önce opencodex'in yazdığı bir blok, +entegrasyonu `config.json` üzerinde tutar: önce orada devre dışı bırakın, sonra yeni depoyu yazmak +için yeniden etkinleştirin. `schemaVersion` değeri opencodex'in gözlemlediklerinden biri olmayan +bir `provider_config.json` ise birleştirilmez, bildirilir: o dosya ZCode'un tüm sağlayıcılarını +tutar ve oraya bir şekil dayatmak sessiz bir etkisizliği sessiz bir kayıpla değiştirirdi. Durum +ekranı, entegrasyon o dosyayı yazmadığı her durumda ZCode'un okuduğu dosyayı adlandırır. + +Bu ikinci durumda sağlayıcıyı ZCode'un kendi ayarlarından ekleyin: temel URL +`http://127.0.0.1:10100/v1` (bağlantı noktasını kendi bağınıza göre ayarlayın), boş olmayan +herhangi bir anahtar ve `ocx export --client zcode` çıktısındaki model kimlikleri. ZCode'un içe +aktarmasını yeniden tetiklemek için `provider_config.json` dosyasını silmek desteklenmez: bu, +ZCode'un orada sakladığı tüm sağlayıcıları yok eder. + ## Cline CLI Cline CLI providers.json ve models.json kullanır. Değişiklik veya eşitleme öncesinde Cline’ı kapatın, sonra yeniden başlatın. Geri al iki özgün dosyayı geri yükler. Varsayılan sağlayıcı değişmez. Eski VS Code uzantısının depolaması taşınmaz. diff --git a/docs-site/src/content/docs/tr/guides/providers.md b/docs-site/src/content/docs/tr/guides/providers.md index abca3a24279..c49040b0960 100644 --- a/docs-site/src/content/docs/tr/guides/providers.md +++ b/docs-site/src/content/docs/tr/guides/providers.md @@ -316,7 +316,7 @@ olmayan bir makineden oturum açmak bundan etkilenmez. ## 3. API anahtarı kataloğu -opencodex 95 yerleşik önayar ile birlikte gelir: 79 anahtar tabanlı, 12 +opencodex 96 yerleşik önayar ile birlikte gelir: 80 anahtar tabanlı, 12 OAuth, üç yerel ve bir varsayılan ChatGPT iletme önayarı. Kontrol panelinin **Sağlayıcı ekle** seçicisi bir anahtar sağlayıcısının kontrol panelini açar, anahtarı doğrular ve saklar; doğrulama sağlayıcıya özgüdür. Dikkate değer diff --git a/docs-site/src/content/docs/tr/reference/cli/lifecycle.md b/docs-site/src/content/docs/tr/reference/cli/lifecycle.md index ffa5b19df50..f1c30392609 100644 --- a/docs-site/src/content/docs/tr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/tr/reference/cli/lifecycle.md @@ -470,6 +470,8 @@ Windows durum tepsisi simgesini kurun ve kontrol edin. Windows oturum açılış başlar ve tek tıklamayla proxy kontrolleri sağlar. `start` ve `stop` yalnızca simgeyi kontrol eder; proxy'yi kontrol etmek için menüsünü kullanın. `--no-start`, `install` için geçerlidir ve tepsiyi hemen başlatmadan kurar. +Kullanımdan kaldırıldı: OpenCodex masaüstü uygulaması Windows, macOS ve Linux'ta tepsi sağlar; +`ocx tray`, masaüstü uygulaması olmayan kurulumlar için kullanılmaya devam eder. ## Kontrol Paneli diff --git a/docs-site/src/content/docs/tr/reference/configuration/providers.md b/docs-site/src/content/docs/tr/reference/configuration/providers.md index 57f5d867189..1fbe43c91d8 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/tr/reference/configuration/providers.md @@ -139,11 +139,13 @@ alanlı seçilmiş kimlikleri yalın kimliklere yeniden yazar. | `noPenaltyModels?` | `string[]` | Varlık/frekans cezalarını reddeden modeller. | | `noStructuredOutputModels?` | `string[]` | `openai-chat` uç noktası `response_format`'ı reddeden tam model kimlikleri. Yalnızca tam bir istenen model eşleşmesi alanı atlar; yapılandırılmış çıktı çevirisi diğer her `openai-chat` modeli için etkin kalır. | | `noJsonSchemaModels?` | `string[]` | `openai-chat` uç noktası `json_schema` biçimini reddeden ama `json_object` kabul eden tam model kimlikleri. Böyle bir istek atılmak yerine `json_object` seviyesine düşürülür, böylece JSON isteyen çağıran yine JSON alır. Bir model her iki listede de varsa `noStructuredOutputModels` kazanır. `opencode go`, `opencode zen` ve `opencode free` hazır ayarları bunu DeepSeek rotaları için getirir. | +| `foldDeveloperRoleToSystem?` | `boolean` | Bir `openai-chat` hedefinin `developer` rolünü kabul edip etmediğini kaydeder. `foldDeveloperRoleToSystem` ayarlanmamışsa `system`, `true` ise `system`, `false` ise `developer` olarak gönderilir. Ayarlanmamış olması bu hedef için hiçbir şey kaydedilmediği anlamına gelir; `true` rolü reddeden bir üst hizmeti, `false` ise kabul edeni kaydeder. Her durumda mesaj konuşmadaki konumunu korur; yalnızca rol değişir. Rolü reddeden bir hedef `400 role 'developer' is not allowed` yanıtı verir ve tur hiç başlamaz; kaydedilmemiş durumun katlanmış olmasının nedeni budur. | | `parallelToolCalls?` | `boolean` | Paralel araç çağrılarını açıp kapatın. OpenAI Chat varsayılan olarak açıktır; sohbet harici adaptörler yalnızca açık `true` durumunda bildirir. | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` | Tam yer tutucu kimlikleri, eksik terminal kimlikleri ve (`repairInvalidIds` ile) kurallı `msg_`/`rs_` öneki eksik olan mesaj/akıl yürütme kimlikleri için varsayılan olarak devre dışı bırakılmış aşağı akış SSE onarımı. Fonksiyon çağrısı kimlikleri asla yeniden yazılmaz. Yerleşik DeepSeek son ikisini varsayılan olarak etkinleştirir. | | `responsesSnapshotRepair?` | `boolean` | SSE ve JSON'daki seyrek Responses yaşam döngüsü anlık görüntüleri için varsayılan olarak devre dışı bırakılmış istemciye yönelik onarım. Ham inceleme ve kalıcılık değişmeden kalırken eksik kurallı durumu, çıktıyı ve araç meta verilerini doldurur. | | `retryOn429?` | `{ enabled?: boolean; attempts?: number; intervalMs?: number; maxIntervalMs?: number; respectRetryAfter?: boolean }` | Yalnızca API anahtarı sağlayıcıları (`authMode: "key"`). İsteğe bağlı aynı hedef 429 yeniden denemesi: `retryOn429` olmadığında özellik kapalıdır; nesnenin varlığı `enabled: false` olmadığı sürece özelliği etkinleştirir. 429'da proxy bekler (yukarı akış `Retry-After` veya sabit aralık) ve herhangi bir anahtar yük devretmesinden önce aynı istek üzerinde aynı anahtarla aynı isteği yeniden oynatır — ana metin turu kurtarma döngüsü, Responses doğrudan geçiş hattı, görsel/video köprüsü, web araması sidecar'ı ve terminal devamları genelinde. Yalnızca akış öncesi HTTP 429 yanıtları yeniden oynatma için uygundur; özel `runTurn` aktarımları HTTP yeniden deneme döngüsünün dışındadır. `attempts`, ilk 429'dan sonraki aynı anahtar yeniden oynatmalarını sayar (toplam gönderim = `attempts` + 1) ve ana kurtarma döngüsü, terminal koruma devamı ve köprü yeniden denemeleri tarafından paylaşılan tek bir istek genelinde bütçedir. `attempts`'ı tüketmek yalnızca daha fazla aynı anahtar yeniden oynatmasını durdurur: normal anahtar yük devretmesi veya nihai hata işleme daha sonra kullanılabilir hedeflere göre geçerli olur — anahtar kimlik doğrulamalı doğrudan geçiş hattında yük devretme yoktur, bu nedenle tükenen 429 olduğu gibi görünür. Codex'in kendisi 429'u asla yeniden denemez, bu nedenle tek anahtarlı sağlayıcılar için tek savunma budur. Varsayılanlar: `enabled: true`, `attempts: 3`, `intervalMs: 5000`, `maxIntervalMs: 60000` (tek bir bekleme `maxIntervalMs` ile sınırlandırılır, kendisi de 600000 ile sınırlandırılır), `respectRetryAfter: true`. | | `transientRetryOn5xx?` | `{ enabled?: boolean; attempts?: number }` | Yalnızca anahtarla kimlik doğrulanan `openai-chat` ve `openai-responses` sağlayıcıları. `authMode: "forward"` sağlayıcıları (ChatGPT hesap havuzu) bu seçeneği hiç okumaz ve varsayılan merdiveni korur. Akış öncesi geçici yukarı akış durumları (500, 502, 503, 504, 520, 521, 522) için isteğe bağlı yeniden deneme: seçenek belirtilmezse kapalıdır; nesnenin varlığı, `enabled: false` olmadığı sürece özelliği etkinleştirir. İlk Responses isteğini, terminal koruma devamını, yerel `/v1/chat/completions` isteklerini ve 429/hesap kurtarma yeniden getirmelerini kapsar. `attempts`, bir istek için ilk gönderim dahil izin verilen yukarı akış gönderimlerinin TOPLAM sayısıdır (1..10, varsayılan 3) — bağlantı sıfırlama kurtarmasıyla paylaşılan, istek kapsamlı tek bütçedir; dolayısıyla `3`, sağlayıcıya en fazla üç gerçek isteğin ulaşması anlamına gelir. Beklemelerde 400 ms'lik sabit üstel geri çekilme uygulanır, süre 5 sn ile sınırlandırılır ve `Retry-After` dikkate alınır. Hız sınırlamasını işleyen `retryOn429` seçeneğinden ayrıdır; akış ortası hataları hiçbir zaman yeniden oynatılmaz. | +| `retryOnReset?` | `{ enabled?: boolean; replacements?: number }` | Yalnızca yerel `openai-responses` sağlayıcıları, `authMode: "forward"` dahil. Çağıranın hiçbir şey gözlemlemediği bir anda başarısız olan gönderimin isteğe bağlı olarak değiştirilmesi: seçenek belirtilmezse kapalıdır; nesnenin varlığı, `enabled: false` olmadığı sürece özelliği etkinleştirir. İki belirsiz aşamayı da kapsar: yanıt başlığı gelmeden kopan bağlantı ve başlıktan sonra yalnızca denetim olayları taşırken kopan SSE gövdesi. Yalnızca kendi kendine yeten bir istek değiştirilir: `store: false`, eksiksiz `input`, `previous_response_id`, `conversation` veya `stream_id` bulunmaması ve yalnızca istemcinin yürüttüğü araçlar. `replacements`, BİR mantıksal isteğin tüm bacaklar ve tüm combo alt istekleri boyunca yapabileceği değiştirme gönderimi sayısıdır (1..2, varsayılan 1). Bacak başına yeniden deneme sayısı da gönderim bütçesi de değildir; bu yüzden bir değiştirme gönderimi, ilgili bacağın hâlihazırda sahip olduğu gönderim payına sığmak zorundadır. Halihazırda çıktı veya araç çağrısı üretmiş bir istek, bu değer ne olursa olsun asla değiştirilmez. Kaynak ilk çıkarımı zaten başlatmışsa değiştirilen çıkarım yine ücretlendirilebilir; bu nedenle varsayılan olarak kapalıdır. | | `autoToolChoiceOnlyModels?` | `string[]` | `tool_choice`'u yalnızca `auto` veya `none` kabul eden modeller; zorunlu seçimlerin derecesi düşürülür. | | `preserveReasoningContentModels?` | `string[]` | Sohbet geçmişinde önceki asistan `reasoning_content`'ini gerektiren modeller. | | `reasoningDetailsModels?` | `string[]` | Thinking'i yapılandırılmış bir `reasoning_details` dizisi olarak döndüren modeller (`reasoning_split` ile MiniMax M-serisi); akış deltaları önek farkıyla işlenen kümülatif anlık görüntülerdir ve korunan reasoning, `reasoning_content` dizesi yerine `reasoning_details` dizisi olarak yeniden oynatılır. | diff --git a/docs-site/src/content/docs/troubleshooting/codex-cannot-sign-in.md b/docs-site/src/content/docs/troubleshooting/codex-cannot-sign-in.md new file mode 100644 index 00000000000..a5971443b34 --- /dev/null +++ b/docs-site/src/content/docs/troubleshooting/codex-cannot-sign-in.md @@ -0,0 +1,99 @@ +--- +title: Codex Cannot Sign In or Load +description: What to do when Codex fails at sign-in or every request errors after opencodex was applied, and how to hand Codex back to its own account without starting the proxy. +--- + +If Codex stops at a sign-in screen, reports that it cannot load sign-in +requirements, or fails every model request after you set up opencodex, the most +likely cause is that Codex is still pointed at the opencodex proxy while the +proxy is not running. This was reported as +[#5261](https://github.com/lidge-jun/opencodex/issues/5261). + +## Why this happens + +On the default loopback setup, opencodex does not give Codex a separate +provider. It points Codex's own built-in `openai` provider at the proxy, by +writing a root override into `$CODEX_HOME/config.toml` (`%USERPROFILE%\.codex` +on Windows): + +```toml +model_catalog_json = "/absolute/path/to/opencodex-catalog.json" +# Auto-injected by opencodex (undo: ocx restore) +openai_base_url = "http://127.0.0.1:10100/v1" +# Auto-injected by opencodex (undo: ocx restore) +experimental_realtime_ws_base_url = "http://127.0.0.1:10100/v1" +``` + +Those lines are on disk, so they survive a reboot. If the proxy is not running +when Codex starts, that address answers nothing, and Codex has no second +endpoint to fall back to. The screen you get says nothing about opencodex, +which is why the state is easy to misread as a Codex problem. + +The proxy can be absent for ordinary reasons. Applying the Codex integration +does not install a background service — that is a separate `ocx service install` +step — so after a restart there may be nothing to bring the proxy back. A +registered Windows scheduled task starts at logon rather than at boot, and it +can also be disabled, fail to launch, or lose the port to another process. + +## Get Codex working again + +Pick whichever outcome you want. Both are safe to run while the proxy is down. + +**Hand Codex back to its own account and endpoints:** + +```bash +ocx restore +``` + +This removes the injected routing, the realtime override and the opencodex +catalog pointer, and needs no running proxy, no dashboard session and no +network. Codex signs in and runs normally afterwards. When you want opencodex +back, `ocx restore back` re-points Codex at the proxy. + +**Or bring the proxy back instead:** + +```bash +ocx start +ocx service install # keep it running across restarts +``` + +`ocx status` reports whether the proxy is answering and whether Codex is +currently routed through it. `ocx doctor` explains the same state in more +detail and names the repair it recommends. + +## If ocx is not available + +You can undo the routing by hand. Open `$CODEX_HOME/config.toml` and delete +three things: the `openai_base_url` line, the +`experimental_realtime_ws_base_url` line, and any `model_catalog_json` line +ending in `opencodex-catalog.json`. Remove the +`# Auto-injected by opencodex` comment sitting directly above each of the first +two along with them. + +Go by the key name, not by the comment. opencodex uses the same ownership +comment above other keys it manages, such as an injected +`developer_instructions`, and deleting those will not help you sign in while +costing you configuration you may want back. + +Delete the `model_catalog_json` line **with** the routing, not on its own. A +`model_catalog_json` that names a file which no longer exists makes Codex fail +to load its configuration at all, which looks like the same lockout for a +different reason. + +## Accounts that would not add or display + +Failures adding accounts to the pool, or added accounts not appearing, are a +separate matter from the lockout above, even when they happen in the same +session. The account pool is served by the proxy's management API, so both the +`ocx account login openai` flow and the dashboard list need a running proxy +before anything else can work. The browser sign-in also returns to +`http://localhost:1455/auth/callback`, a fixed address that cannot move to +another port. If something else holds port 1455, or a browser cannot be +launched, use the device flow instead: + +```bash +ocx account login openai --device +``` + +See [Codex Integration](/guides/codex-integration/) for what the injection +writes and how routing is chosen. diff --git a/docs-site/src/content/docs/zh-cn/getting-started/installation.md b/docs-site/src/content/docs/zh-cn/getting-started/installation.md index eb5b02ec948..335debd845d 100644 --- a/docs-site/src/content/docs/zh-cn/getting-started/installation.md +++ b/docs-site/src/content/docs/zh-cn/getting-started/installation.md @@ -42,6 +42,18 @@ ocx --version opencodex --version ``` +## 独立二进制文件(无需 npm) + +发布包还包含适用于 macOS、Linux 和 Windows 的独立 `ocx` 二进制文件。 +它内置 Bun 运行时和仪表盘,因此无需安装 npm、Node 或单独的 Bun。下载适合你平台的压缩包,解压后运行: + +```bash +./ocx --version +./ocx start +``` + +为了让仪表盘可用,请将解压后的 `gui/dist` 目录保留在二进制文件旁边。 + ### 发布渠道 稳定的 `latest` 渠道已经包含 ChatGPT、OpenAI API key、OpenRouter 以及实验性 Cursor 路由所需的 diff --git a/docs-site/src/content/docs/zh-cn/getting-started/quickstart.md b/docs-site/src/content/docs/zh-cn/getting-started/quickstart.md index e965b159a6b..14f76c58cb9 100644 --- a/docs-site/src/content/docs/zh-cn/getting-started/quickstart.md +++ b/docs-site/src/content/docs/zh-cn/getting-started/quickstart.md @@ -5,6 +5,11 @@ description: 配置你的第一个 provider,并在三条命令内让 OpenAI Co 本指南将带你从全新安装,一路走到用一个非 OpenAI 模型运行 Codex。 +## 独立二进制文件(无需 npm) + +你也可以使用包含 Bun 运行时的发布压缩包中的 `ocx`,无需 npm。 +解压时将 `gui/dist` 目录保留在二进制文件旁边,然后运行 `./ocx start`。 + ## 1. 运行设置向导 ```bash @@ -13,7 +18,7 @@ ocx init `ocx init` 会引导你完成: -1. **选择 provider** — 从内置 registry 的 95 个预设中选择一个,或选择 `custom` 手动输入 base URL 和 adapter。 +1. **选择 provider** — 从内置 registry 的 96 个预设中选择一个,或选择 `custom` 手动输入 base URL 和 adapter。 2. **API key** — 粘贴一个 key,或引用一个环境变量,例如 `${ANTHROPIC_API_KEY}`。 3. **默认模型** — 对于 key、本地和 custom provider,接受预设值或输入模型 id。 4. **代理端口** — 默认为 `10100`。 diff --git a/docs-site/src/content/docs/zh-cn/guides/claude-code.md b/docs-site/src/content/docs/zh-cn/guides/claude-code.md index db814d3aef7..746eeba39f4 100644 --- a/docs-site/src/content/docs/zh-cn/guides/claude-code.md +++ b/docs-site/src/content/docs/zh-cn/guides/claude-code.md @@ -467,4 +467,4 @@ Claude 模型时自动加载。对于原生透传,这是正常现象;对于 在 `config.json` 中设置 `claudeCode.stabilizePromptCache: true`,可在转换路由上将系统指令末尾受支持的 Claude 提示移到最后一条用户消息。默认值为 `false`。仅在客户端允许这种角色变化时启用。代码围栏内的示例和不匹配的文本会保留,Anthropic 原生透传不变。没有元数据时,缓存键按稳定后的指令计算。该选项不会生成会话标识,也不保证上游缓存命中。 -在 OpenCode Go 的 `deepseek-v4.1-flash` Chat 路由上,转换后的时间线系统提醒会自动保留原有位置和 system 角色,并排在尚待返回的工具结果之后。因此,追加提醒不会重写开头的系统提示。无论 `stabilizePromptCache` 是否启用,该行为都会生效;其他模型、目标地址的转换方式以及 Anthropic 原生透传保持不变。缓存复用仍需要稳定的会话标识和可用的上游缓存。修改较早的指令或工具、压缩对话也可能影响缓存命中;仅保留提醒顺序并不保证缓存复用。 +在所有转换后的 Chat 路由上,时间线提醒都会保留在对话中的原有位置(排在尚待返回的工具结果之后)。因此,追加提醒不会重写开头的系统提示,对话中途的指令也不会被挪到它本应跟随的轮次之前。该位置携带哪个角色是单独决定的:除非提供方记录了 `foldDeveloperRoleToSystem: false`,否则提醒以 `system` 发送;该记录表示上游接受 `developer` 角色,此时提醒在同一位置按原样转发。不接受该角色的上游会返回 `400 role 'developer' is not allowed`,这一轮根本无法开始,所以未记录的目的地采用折叠。无论 `stabilizePromptCache` 是否启用,该行为都会生效;Anthropic 原生透传保持不变。缓存复用仍需要稳定的会话标识和可用的上游缓存。修改较早的指令或工具、压缩对话也可能影响缓存命中;仅保留提醒顺序并不保证缓存复用。 diff --git a/docs-site/src/content/docs/zh-cn/guides/codex-integration.md b/docs-site/src/content/docs/zh-cn/guides/codex-integration.md index 4643248dfe3..94a00079d97 100644 --- a/docs-site/src/content/docs/zh-cn/guides/codex-integration.md +++ b/docs-site/src/content/docs/zh-cn/guides/codex-integration.md @@ -20,7 +20,7 @@ Codex 内置的 `openai` provider id,并将该 provider 指向 opencodex: ```toml # root keys, before the first table model_catalog_json = "/absolute/path/to/opencodex-catalog.json" -# Auto-injected by opencodex +# Auto-injected by opencodex (undo: ocx restore) openai_base_url = "http://127.0.0.1:10100/v1" # 仅在设置了 fastMode 时写入;未设置则不会创建 [features] 表 @@ -110,7 +110,7 @@ model_provider = "opencodex" model_catalog_json = "/absolute/path/to/opencodex-catalog.json" # appended at the end of the file -# Auto-injected by opencodex +# Auto-injected by opencodex (undo: ocx restore) [model_providers.opencodex] name = "OpenCodex Proxy" base_url = "http://your-host:10100/v1" @@ -356,7 +356,7 @@ ocx restore back # point plain Codex at the running proxy again ## 分页历史记录安全拒绝 -如果受影响的历史存储支持分页,提供商切换可能返回 `history_paginated_requires_native_writer`。该原因不再拒绝写入 Codex 配置、参考配置档和模型目录。`ocx sync` 与 `ocx start` 仍会写入这些文件并设置 `model_catalog_json`,因此 Codex 模型选择器会继续显示所有经 OpenCodex 路由的模型。只有这一条原因会让会话历史的重新标记停手,因为分页历史序号由 Codex 自己的写入器分配,重试也不会改变。无法读取的状态数据库、身份已变的历史文件、未能运行的预检等其他历史预检原因仍会拒绝整个切换并回滚,因为那些情况以后可能成功。在此状态下,OpenCodex 不会修改分页历史文件或线程行。现有会话保留已标记的提供商,不会被迁移;新会话仍正常经代理路由。重新标记停手时,主目录里已有的 `[model_providers.opencodex]` 表会保留而不是撤下,即便是 root-override(loopback)形式也一样,这样行上标记为 `opencodex` 的会话仍能对应到还存在的提供商 id。可迁移存储中的 legacy 记录也适用。CLI 会打印 `Codex resume history: left to Codex's native writer (history_paginated_requires_native_writer)`。`ocx restore` 和移除 Codex 配置仍会因 `history_paginated_requires_native_writer` 被拒绝。线程行仍在引用时撤掉 `[model_providers.opencodex]` 定义会使这些会话无法解析,而恢复路径没有办法留下兼容提供商表。已经分页的主目录目前无法通过产品卸载;这是已知的未完成工作,而非预期行为。 +如果受影响的历史存储支持分页,提供商切换可能返回 `history_paginated_requires_native_writer`。该原因不再拒绝写入 Codex 配置、参考配置档和模型目录。`ocx sync` 与 `ocx start` 仍会写入这些文件并设置 `model_catalog_json`,因此 Codex 模型选择器会继续显示所有经 OpenCodex 路由的模型。只有这一条原因会让会话历史的重新标记停手,因为分页历史序号由 Codex 自己的写入器分配,重试也不会改变。无法读取的状态数据库、身份已变的历史文件、未能运行的预检等其他历史预检原因仍会拒绝整个切换并回滚,因为那些情况以后可能成功。在此状态下,OpenCodex 不会修改分页历史文件或线程行。现有会话保留已标记的提供商,不会被迁移;新会话仍正常经代理路由。重新标记停手时,主目录里已有的 `[model_providers.opencodex]` 表会保留而不是撤下,即便是 root-override(loopback)形式也一样,这样行上标记为 `opencodex` 的会话仍能对应到还存在的提供商 id。可迁移存储中的 legacy 记录也适用。CLI 会打印 `Codex resume history: left to Codex's native writer (history_paginated_requires_native_writer)`。`ocx restore`、`ocx stop` 和 `ocx uninstall` 不再因 `history_paginated_requires_native_writer` 被拒绝。它们会移除 OpenCodex 写入的全部根路由键,并把 `[model_providers.opencodex]` 定义保留在磁盘上,因此行上仍指向该提供商的会话依旧可以解析,而裸 `codex` 不再指向代理。结果会报告为部分恢复并列出保留的行;`ocx restore --remove-codex-provider-table` 会连这些行一并删除,之后那些会话将无法打开。另外,在 Codex 已把 `openai` 标记会话迁移为分页历史的主目录上启用提供商表形式的集成,过去会以 `history_paginated_openai_requires_native_writer` 整体拒绝:什么都不写,集成保持关闭。现在 OpenCodex 会保留受管的根 `openai_base_url` 覆盖,与 `[model_providers.opencodex]` 表并存,从而完成这次切换。Codex 会把该覆盖合并到内置 `openai` 提供商上,所以那些会话无需重新标记即可继续到达代理,历史文件与线程行都不会被改动。只有需要 `x-opencodex-api-key` 准入标头的路由形式仍会拒绝,因为 Codex 内置提供商无法携带该标头;此时消息会点名两个可行设置——让 Codex 走回环监听器以便保留该覆盖,或把 `syncResumeHistory` 设为 `false`,接受那些会话转向 Codex 自己的 OpenAI 端点。 返回根 URL 覆盖模式时,即使历史预检通过,OpenCodex 也会在提交配置前保留已有的 `[model_providers.opencodex]` 定义。这样,即使 Codex 在提交后或后台历史任务启动时迁移历史格式,旧的 `opencodex` 对话仍能找到其提供商。新对话继续使用所选的根提供商;显式恢复仍执行原有的独立删除检查。 diff --git a/docs-site/src/content/docs/zh-cn/guides/macos-menu-bar.md b/docs-site/src/content/docs/zh-cn/guides/macos-menu-bar.md new file mode 100644 index 00000000000..2f3dc02616e --- /dev/null +++ b/docs-site/src/content/docs/zh-cn/guides/macos-menu-bar.md @@ -0,0 +1,150 @@ +--- +title: macOS 菜单栏应用 +description: 在菜单栏中查看 OpenCodex 代理状态、用量和各提供商配额的原生应用。 +--- + +菜单栏应用让你无需打开仪表板,就能看到代理状态、近期用量和各提供商的配额压力。 + +它与代理是两个独立的程序。`ocx` 照常运行,菜单栏应用只是连接本地管理 API 的客户端。 + +## 桌面应用(Tauri) + +同一个仪表板也可以在 OpenCodex 桌面应用中运行。用量面板会显示匹配操作系统的安装步骤; +在桌面壳中选择**在浏览器中打开**,即可在普通浏览器中打开当前页面。 + +## 安装 + +推荐使用 OpenCodex 桌面应用安装。从[发布页面](https://github.com/lidge-jun/opencodex/releases)下载 macOS 的 +`OpenCodex--macos.dmg`,打开 DMG 后将 `OpenCodex.app` 拖到「应用程序」文件夹。 +Windows 运行 `OpenCodex--windows-x64.msi`,Linux 使用 AppImage 或 +`OpenCodex--linux-amd64.deb`。 + +```bash +chmod +x OpenCodex--linux-x86_64.AppImage +sudo apt install ./OpenCodex--linux-amd64.deb +``` + +Windows SmartScreen 或 macOS Gatekeeper 可能显示警告。应用会连接现有的 `ocx` 代理; +找不到代理时则启动内置 sidecar。 + +## 首次启动:Gatekeeper + +**首次启动会被阻止。** macOS 会提示: + +> 无法打开“OpenCodex.app”,因为无法验证开发者。 + +这是预期行为,所以这里说明原因而不是直接略过。Gatekeeper 需要 Apple 的 Developer ID 签名和 +公证(notarization)票据,两者都需要付费的 Apple Developer 账号。OpenCodex 没有该账号,因此 +应用以 ad-hoc 签名发布:程序包本身完整、签名有效,但 Apple 并未为发布者背书。 + +仍要打开: + +1. 在 Finder 中右键点击(或按住 Control 点击)`OpenCodex.app`。 +2. 选择**打开**。 +3. 在弹出的对话框中再次点击**打开**。 + +如果对话框没有「打开」按钮,请前往**系统设置 → 隐私与安全性**,找到被拦截的提示并点击 +**仍要打开**。 + +macOS 会记住这个选择,因此每个版本只需操作一次。 + +也可以在终端移除隔离属性: + +```bash +xattr -d com.apple.quarantine /Applications/OpenCodex.app +``` + +如果两种方式都不想用,可以自行构建——本地构建不会带有隔离属性。参见[从源码构建](#从源码构建)。 + +## 显示的内容 + +菜单栏图标用形状而非颜色表示状态,因为 macOS 菜单栏图标按惯例是单色的: + +| 图标 | 含义 | +| --- | --- | +| 实心标记 | 运行中,路由受保护 | +| 带缺口的实心标记 | 运行中,但路由保护存在风险 | +| 轮廓标记 | 正在检查,或响应异常 | +| 淡色轮廓 | 未运行,或需要 API 密钥 | + +点击图标会打开包含四个部分的面板。 + +**状态** — 代理是否运行、应用正在使用的回环地址以及保护状态。当代理给出修复命令(例如 +`ocx service install`)时,会以可选中的文本显示。应用不会替你执行。 + +**用量** — 最近 7 天的请求数、令牌数和预估成本,以及每日趋势。请求数后的 `~` 表示其中一部分 +是估算值,而非提供商上报的数据。 + +默认情况下,菜单栏标题显示令牌总数;如果您更想查看请求数、成本、配额,或只显示图标,可在控制台 Usage 的 Companion 设置中更改标题指标。 +在 macOS 26 中,弹出面板和小组件采用 Liquid Glass;更早版本的 macOS 使用标准弹出面板材质。 + +**配额** — 每个提供商一行,显示压力最大的那个窗口。如果某个提供商 5 小时额度用了 99%、月度 +额度只用了 10%,会显示 5 小时的数值,因为真正卡住你的是它。窗口名称标注在提供商下方,因此 +`API usage 的 42%` 和`一个月的 42%` 不会混淆。 + +**提供商** — 可展开的列表,每个提供商带一个开关。默认提供商在启用状态下开关是锁定的,因为 +代理会拒绝停用默认提供商;请先在仪表板中更换默认值。 + +## 可以做什么 + +- **Dashboard** — 在浏览器中打开 Web 仪表板。 +- **Stop proxy** — 确认后停止代理。这里刻意不叫「重启」:停止会同时停掉 launchd 服务,代理不会 + 自动恢复。停止后面板会显示重新启动的命令。 +- **提供商开关** — 启用或停用某个提供商。 + +账号、模型配置、存储等其余操作仍在仪表板中完成。 + +## 小组件 + +在桌面上右键点击,选择**编辑小组件**,然后添加 **OpenCodex**。它显示代理状态、今日用量和 +配额,并使用与菜单栏应用相同的隐私安全快照。应用轮询时小组件会刷新。需要 macOS 14 或更高 +版本;它不会接收 API 密钥或原始账户信息。 + +## 连接到代理 + +应用会自动查找。它读取 `~/.opencodex/runtime-port.json`(或 +`$OPENCODEX_HOME/runtime-port.json`),找不到则使用端口 `10100`。该文件只提供端口,主机始终 +为回环地址。 + +如果代理绑定在非回环地址上,就需要 API 密钥。面板会说明这一点并提供前往仪表板的按钮。 + +**该路径尚未支持。** 应用会从 macOS 钥匙串读取密钥并重试一次,但没有输入密钥的界面,也没有 +手动写入的办法——它是数据保护钥匙串条目,「钥匙串访问」无法创建。因此在非回环绑定下,面板会 +一直停在「Needs API key」。 + +默认的回环代理不需要密钥。原生密钥输入已在计划中。 + +## 轮询 + +应用刻意保持安静。存活检查每 5 秒一次;开销较大的聚合数据(用量和配额)只在面板打开时获取, +且最多每分钟一次。连续三次失败后会退避到 30 秒一次,以免不断敲打你主动停掉的代理。 + +## 从源码构建 + +需要 macOS 13 或更高版本、Xcode Command Line Tools 以及 [Bun](https://bun.sh): + +```bash +git clone https://github.com/lidge-jun/opencodex.git +cd opencodex +bun run prepare-sidecar +bun run prepare-widget +bunx tauri build +``` + +程序包会生成在 Tauri 的发布输出中,WidgetKit 扩展位于 +`OpenCodex.app/Contents/PlugIns/`。 + +构建通用二进制(`UNIVERSAL=1`)需要完整的 Xcode。Command Line Tools 只包含当前架构的 Swift +兼容库,此时构建会给出说明信息,而不是抛出链接器错误。 + +如果钥匙串中有 Developer ID 证书,可以设置 `MACOS_SIGN_IDENTITY`,以 hardened runtime 签名 +替代 ad-hoc 签名: + +```bash +MACOS_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" bun run prepare-widget +``` + +## 卸载 + +把 `OpenCodex.app` 拖到废纸篓即可。应用不会留下偏好设置或其他状态文件,目前也不会在钥匙串中 +保存任何内容。 diff --git a/docs-site/src/content/docs/zh-cn/guides/providers.md b/docs-site/src/content/docs/zh-cn/guides/providers.md index d2f4d1b5f5e..d799f306705 100644 --- a/docs-site/src/content/docs/zh-cn/guides/providers.md +++ b/docs-site/src/content/docs/zh-cn/guides/providers.md @@ -101,7 +101,7 @@ ocx logout | --- | --- | --- | --- | | `xai` | `openai-chat` | `https://cli-chat-proxy.grok.com/v1` | OAuth 使用独立的 Grok CLI 订阅网关。API 密钥覆盖模式使用 `https://api.x.ai/v1`,并可能注入 Priority Processing。优先使用实时 Grok 目录;回退默认模型为 `grok-4.5`。 | | `anthropic` | `anthropic` | `https://api.anthropic.com` | Claude 模型;实时模型列表从 `/v1/models` 获取。 | -| `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Kimi K2.7/K2.6/K2.5 编程模型。 | +| `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Kimi Code Plan 编程模型。默认使用稳定的 `kimi-for-coding` 别名(当前指向 K2.8 Preview):100 万 token 上下文、可调 `low`/`high`/`max` 思考档(默认 `max`)、支持文本 + 图片输入。已下架的 `kimi-k2.x` 选择会在升级时自动迁移到该别名。 | | `nous` | `openai-chat` | `https://inference-api.nousresearch.com/v1` | Nous Research 订阅网关(与 Hermes Agent 使用同一后端)。通过设备授权登录 `portal.nousresearch.com`;access 令牌是每个请求的 inference JWT。付费 + `:free` 模型混合目录(`tencent/hy3:free`、`stepfun/step-3.7-flash:free` 等)会从已登录账户实时发现。Refresh 令牌是单次使用,每次刷新都会轮换。 | | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | 首次登录会导入已安装并已登录的 Kiro CLI 会话(Unix 使用 `curl -fsSL https://cli.kiro.dev/install` | `bash`;Windows PowerShell 使用 `irm 'https://cli.kiro.dev/install.ps1'` | `iex`;然后运行 `kiro-cli login`)。**添加账户**会先退出 `kiro-cli`,再启动新的浏览器登录,从而切换 `kiro-cli` 自身使用的账户,并保存账户范围的配置文件元数据。现有 OpenCodex 账户会保留;如果取消或失败,则恢复之前的 `kiro-cli` 会话。 | | `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | 通过 Cloud Code Assist 协议使用 Google OAuth。实时发现调用已认证的 CCA `v1internal:fetchAvailableModels` 端点,并仅发布当前登录账户可用的 agent 模型;维护中的目录仍作为回退。 | @@ -173,7 +173,7 @@ Kiro 登录需要 Kiro CLI:Unix 使用 `curl -fsSL https://cli.kiro.dev/instal ## 3. API 密钥目录 -opencodex 内置 95 个预设:79 个密钥预设、12 个 OAuth 预设、3 个本地预设,以及 1 个默认的 +opencodex 内置 96 个预设:80 个密钥预设、12 个 OAuth 预设、3 个本地预设,以及 1 个默认的 ChatGPT 转发预设。仪表盘的 **Add provider** 选择器会打开密钥提供商的控制台,验证并保存密钥。 验证因提供商而异。主要条目包括: diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md b/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md index 30687e2b81e..b1fdef059a3 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md @@ -259,6 +259,7 @@ ocx codex-shim uninstall ### `ocx tray [--json] [--no-start]` 安装并控制 Windows 状态托盘图标。它会在 Windows 登录时启动,并提供一键代理控制。`start` 和 `stop` 只控制图标本身;要控制代理,请使用其菜单。`--no-start` 适用于 `install`,会安装托盘但不会立即启动。 +已弃用:OpenCodex 桌面应用在 Windows、macOS 和 Linux 上提供托盘;没有桌面应用的安装仍可使用 `ocx tray`。 ## 仪表盘 diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md index e0e61490a1d..85a12fa08ba 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md @@ -125,11 +125,13 @@ selector,而不是分配一个新名称。 | `noPenaltyModels?` | `string[]` | 会拒绝 presence/frequency penalty 的模型。 | | `noStructuredOutputModels?` | `string[]` | `openai-chat` 端点拒绝 `response_format` 的精确模型 ID。仅当请求模型与条目完全匹配时才省略该字段;其他 `openai-chat` 模型仍启用 structured-output 转换。 | | `noJsonSchemaModels?` | `string[]` | `openai-chat` 端点拒绝 `json_schema` 形式但仍接受 `json_object` 的精确模型 ID。这类请求会降级为 `json_object` 而不是被丢弃,因此请求 JSON 的调用方仍能拿到 JSON。同一模型同时出现在两个列表时,以 `noStructuredOutputModels` 为准。`opencode go`、`opencode zen`、`opencode free` 预设已为其 DeepSeek 路由内置该项。 | +| `foldDeveloperRoleToSystem?` | `boolean` | 记录某个 `openai-chat` 目的地是否接受 `developer` 角色。`foldDeveloperRoleToSystem` 未设置时按 `system` 发送,`true` 时按 `system` 发送,`false` 时按 `developer` 发送。未设置表示尚未记录该目的地的情况;`true` 记录上游拒绝该角色;`false` 记录其接受该角色。无论哪种情况,消息都保留在对话中的原有位置,只有角色改变。拒绝该角色的目的地会返回 `400 role 'developer' is not allowed`,这一轮根本无法开始,这就是未记录状态默认折叠的原因。 | | `parallelToolCalls?` | `boolean` | 切换并行工具调用。OpenAI Chat 默认开启;非 chat 适配器只有显式 `true` 时才会声明支持。 | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` | 默认关闭的下游 SSE 修复,用于精确占位 id、缺失的终止 id,以及(`repairInvalidIds`)缺少规范 `msg_`/`rs_` 前缀的 message/reasoning id。function-call id 永远不会被重写。内置 DeepSeek 默认启用后两项。 | | `responsesSnapshotRepair?` | `boolean` | 默认关闭的客户端修复,用于补全 SSE 与 JSON 中稀疏 Responses 生命周期快照缺失的 status、output 和工具元数据;原始检查与持久化保持不变。 | | `retryOn429?` | `{ enabled?: boolean; attempts?: number; intervalMs?: number; maxIntervalMs?: number; respectRetryAfter?: boolean }` | 仅限 API-key 提供商(`authMode: "key"`)。可选的同目标 429 重试:未配置 `retryOn429` 时功能关闭;对象存在即启用,除非 `enabled: false`。收到 429 时等待(上游 `Retry-After` 或固定间隔)后在相同 key 上重放完全相同请求,再进入任何 key 故障转移——覆盖主文本恢复循环、Responses passthrough、图像/视频桥、web-search 侧车与终结续接。重放仅适用于流开始前的 HTTP 429 响应;自定义 `runTurn` 传输不在 HTTP 重试循环范围内。`attempts` 是首个 429 之后的同 key 重放次数(总发送次数 = `attempts` + 1),是主恢复循环、终结守卫续接与桥接重试共享的按请求统一预算;`attempts` 耗尽只会停止进一步的同 key 重放:随后按可用目标进行正常的 key 故障转移或最终错误处理——key 认证的 passthrough 线路上没有故障转移,因此耗尽的 429 会原样透出。Codex 自身从不重试 429,因此这是单 key 提供商唯一的防线。默认值:`enabled: true`、`attempts: 3`、`intervalMs: 5000`、`maxIntervalMs: 60000`(单次等待以 `maxIntervalMs` 为上限,其本身上限 600000)、`respectRetryAfter: true`。 | | `transientRetryOn5xx?` | `{ enabled?: boolean; attempts?: number }` | 仅限使用 key 认证的 `openai-chat` 与 `openai-responses` 提供商。`authMode: "forward"` 的提供商(ChatGPT 账号池)从不读取此选项,保持默认重试次数。可选的流开始前上游瞬态状态码(500、502、503、504、520、521、522)重试:未配置时关闭;对象存在即启用,除非 `enabled: false`。覆盖初始 Responses 请求、终结守卫续接、原生 `/v1/chat/completions`,以及 429/账户恢复重新获取。`attempts` 是单个请求允许向上游发送的总次数,包含首次发送(1..10,默认 3);它是与连接重置恢复共享的按请求预算,因此 `3` 表示最多只有三个实际请求到达提供商。等待采用固定 400 毫秒的指数退避,上限为 5 秒,并遵循 `Retry-After`。此选项独立于处理速率限制的 `retryOn429`;流开始后的故障绝不会重放。 | +| `retryOnReset?` | `{ enabled?: boolean; replacements?: number }` | 仅限原生 `openai-responses` 提供商,包含 `authMode: "forward"`。可选地替换一次在调用方尚未观察到任何内容时就失败的发送:未配置时关闭;对象存在即启用,除非 `enabled: false`。涵盖两个不确定阶段——响应头到达前连接断开,以及响应头之后 SSE 正文只承载控制事件时断开。只有自包含的请求才会被替换:`store: false`、完整的 `input`、没有 `previous_response_id`/`conversation`/`stream_id`,且只使用由客户端执行的工具。`replacements` 是单个逻辑请求在所有环节和所有组合子请求中可以进行的替换发送次数(1..2,默认 1);它既不是按环节的重试次数,也不是发送预算,因此替换发送仍必须落在该环节已有的发送额度之内。已经产生输出或工具调用的请求,无论此值为何都不会被替换。如果上游已经开始了第一次推理,被替换的推理仍可能计费,因此该选项默认关闭。 | | `autoToolChoiceOnlyModels?` | `string[]` | `tool_choice` 只接受 `auto` 或 `none` 的模型;强制选择会被降级。 | | `preserveReasoningContentModels?` | `string[]` | 需要在聊天历史中保留先前 assistant `reasoning_content` 的模型。 | | `reasoningDetailsModels?` | `string[]` | 以结构化 `reasoning_details` 数组返回思考内容的模型(启用 `reasoning_split` 的 MiniMax M 系列);流式增量为累积快照,按前缀差分处理,保留的推理以 `reasoning_details` 数组而非 `reasoning_content` 字符串回放。 | diff --git a/docs-site/src/content/docs/zh-tw/getting-started/quickstart.md b/docs-site/src/content/docs/zh-tw/getting-started/quickstart.md index 3af31658c5a..a0e17af51a2 100644 --- a/docs-site/src/content/docs/zh-tw/getting-started/quickstart.md +++ b/docs-site/src/content/docs/zh-tw/getting-started/quickstart.md @@ -13,7 +13,7 @@ ocx init `ocx init` 會引導你完成: -1. **選擇 provider** —— 從內建 registry 的 95 個預設中選擇一個,或選擇 `custom` 手動輸入 +1. **選擇 provider** —— 從內建 registry 的 96 個預設中選擇一個,或選擇 `custom` 手動輸入 base URL 和 adapter。 2. **API key** —— 貼上一個 key,或引用一個環境變數,例如 `${ANTHROPIC_API_KEY}`。 3. **預設模型** —— 對於 API key、本機和 custom provider,可接受預設值或輸入模型 id。 diff --git a/docs-site/src/content/docs/zh-tw/guides/claude-code.md b/docs-site/src/content/docs/zh-tw/guides/claude-code.md index 493aeea9a5b..fc35c52b399 100644 --- a/docs-site/src/content/docs/zh-tw/guides/claude-code.md +++ b/docs-site/src/content/docs/zh-tw/guides/claude-code.md @@ -538,4 +538,4 @@ Claude 模型時自動載入。對於原生透傳,這是正常現象;對於 在 `config.json` 中設定 `claudeCode.stabilizePromptCache: true`,可在轉換路由上將系統指令末尾支援的 Claude 提示移到最後一則使用者訊息。預設值為 `false`。僅在用戶端允許這種角色變更時啟用。程式碼圍欄中的範例和不符合的文字會保留,Anthropic 原生轉送不變。沒有中繼資料時,快取鍵依穩定後的指令計算。此選項不會產生對話識別碼,也不保證上游快取命中。 -在 OpenCode Go 的 `deepseek-v4.1-flash` Chat 路由上,轉換後的時間線系統提醒會自動保留原有位置和 system 角色,並排在尚待傳回的工具結果之後。因此,新增提醒不會重寫開頭的系統提示。無論 `stabilizePromptCache` 是否啟用,此行為都會生效;其他模型、目標位址的轉換方式以及 Anthropic 原生轉送維持不變。快取重用仍需要穩定的工作階段識別碼和可用的上游快取。修改較早的指令或工具、壓縮對話也可能影響快取命中;僅保留提醒順序並不保證快取重用。 +在所有轉換後的 Chat 路由上,時間線提醒都會保留在對話中的原有位置(排在尚待傳回的工具結果之後)。因此,新增提醒不會重寫開頭的系統提示,對話中途的指令也不會被移到它原本應跟隨的輪次之前。該位置攜帶哪個角色是另外決定的:除非提供者記錄了 `foldDeveloperRoleToSystem: false`,否則提醒以 `system` 傳送;該記錄表示上游接受 `developer` 角色,此時提醒在相同位置照原樣轉送。不接受該角色的上游會回應 `400 role 'developer' is not allowed`,該回合根本無法開始,所以未記錄的目的地採用摺疊。無論 `stabilizePromptCache` 是否啟用,此行為都會生效;Anthropic 原生轉送維持不變。快取重用仍需要穩定的工作階段識別碼和可用的上游快取。修改較早的指令或工具、壓縮對話也可能影響快取命中;僅保留提醒順序並不保證快取重用。 diff --git a/docs-site/src/content/docs/zh-tw/guides/codex-integration.md b/docs-site/src/content/docs/zh-tw/guides/codex-integration.md index 274ae984599..15b740e5743 100644 --- a/docs-site/src/content/docs/zh-tw/guides/codex-integration.md +++ b/docs-site/src/content/docs/zh-tw/guides/codex-integration.md @@ -19,7 +19,7 @@ bearer。這些路徑不會彼此 fallback。shipped v1 設定會遷移到 marke ```toml # 根級鍵,必須位於第一個 table 之前 model_catalog_json = "/absolute/path/to/opencodex-catalog.json" -# Auto-injected by opencodex +# Auto-injected by opencodex (undo: ocx restore) openai_base_url = "http://127.0.0.1:10100/v1" # 僅在設定 fastMode 時寫入;未設定時不新增 [features] table @@ -107,7 +107,7 @@ model_provider = "opencodex" model_catalog_json = "/absolute/path/to/opencodex-catalog.json" # 追加到檔案末尾 -# Auto-injected by opencodex +# Auto-injected by opencodex (undo: ocx restore) [model_providers.opencodex] name = "OpenCodex Proxy" base_url = "http://your-host:10100/v1" @@ -363,7 +363,7 @@ ocx restore back # 讓普通 Codex 再次指向仍在執行的 proxy ## 分頁歷史記錄安全拒絕 -如果受影響的歷史儲存區支援分頁,提供者切換可能傳回 `history_paginated_requires_native_writer`。此原因不再拒絕寫入 Codex 設定、參考設定檔與模型目錄。`ocx sync` 與 `ocx start` 仍會寫入這些檔案並設定 `model_catalog_json`,因此 Codex 模型選擇器會繼續顯示所有經 OpenCodex 路由的模型。只有這一條原因會讓對話歷史的重新標記停手,因為分頁歷史序號由 Codex 自己的寫入器分配,重試也不會改變。無法讀取的狀態資料庫、身分已變的歷史檔案、未能執行的預檢等其他歷史預檢原因仍會拒絕整個切換並回復,因為那些情況以後可能成功。在此狀態下,OpenCodex 不會修改分頁歷史檔案或執行緒列。既有對話保留已標記的提供者,不會被遷移;新對話仍正常經代理路由。重新標記停手時,家目錄裡既有的 `[model_providers.opencodex]` 表會保留而不是撤下,即便是 root-override(loopback)形式也一樣,這樣列上標記為 `opencodex` 的對話仍能對應到還存在的提供者 id。可遷移儲存區中的 legacy 記錄也適用。CLI 會印出 `Codex resume history: left to Codex's native writer (history_paginated_requires_native_writer)`。`ocx restore` 與移除 Codex 設定仍會因 `history_paginated_requires_native_writer` 被拒絕。執行緒列仍在參照時撤掉 `[model_providers.opencodex]` 定義會使這些對話無法解析,而復原路徑沒有辦法留下相容提供者表。已經分頁的家目錄目前無法透過產品解除安裝;這是已知的未完成工作,而非預期行為。 +如果受影響的歷史儲存區支援分頁,提供者切換可能傳回 `history_paginated_requires_native_writer`。此原因不再拒絕寫入 Codex 設定、參考設定檔與模型目錄。`ocx sync` 與 `ocx start` 仍會寫入這些檔案並設定 `model_catalog_json`,因此 Codex 模型選擇器會繼續顯示所有經 OpenCodex 路由的模型。只有這一條原因會讓對話歷史的重新標記停手,因為分頁歷史序號由 Codex 自己的寫入器分配,重試也不會改變。無法讀取的狀態資料庫、身分已變的歷史檔案、未能執行的預檢等其他歷史預檢原因仍會拒絕整個切換並回復,因為那些情況以後可能成功。在此狀態下,OpenCodex 不會修改分頁歷史檔案或執行緒列。既有對話保留已標記的提供者,不會被遷移;新對話仍正常經代理路由。重新標記停手時,家目錄裡既有的 `[model_providers.opencodex]` 表會保留而不是撤下,即便是 root-override(loopback)形式也一樣,這樣列上標記為 `opencodex` 的對話仍能對應到還存在的提供者 id。可遷移儲存區中的 legacy 記錄也適用。CLI 會印出 `Codex resume history: left to Codex's native writer (history_paginated_requires_native_writer)`。`ocx restore`、`ocx stop` 與 `ocx uninstall` 不再因 `history_paginated_requires_native_writer` 被拒絕。它們會移除 OpenCodex 寫入的所有根路由鍵,並把 `[model_providers.opencodex]` 定義留在磁碟上,因此列上仍指向該提供者的對話依舊可以解析,而純 `codex` 不再指向代理。結果會回報為部分復原並列出保留的列;`ocx restore --remove-codex-provider-table` 會連這些列一併刪除,之後那些對話將無法開啟。另外,在 Codex 已把 `openai` 標記對話遷移為分頁歷史的家目錄上啟用提供者表形式的整合,過去會以 `history_paginated_openai_requires_native_writer` 整體拒絕:什麼都不寫,整合維持關閉。現在 OpenCodex 會保留受管的根 `openai_base_url` 覆寫,與 `[model_providers.opencodex]` 表並存,藉此完成這次切換。Codex 會把該覆寫合併到內建 `openai` 提供者上,所以那些對話無需重新標記即可繼續抵達代理,歷史檔案與執行緒列都不會被更動。只有需要 `x-opencodex-api-key` 准入標頭的路由形式仍會拒絕,因為 Codex 內建提供者無法攜帶該標頭;此時訊息會點名兩個可行設定——讓 Codex 走回送監聽器以便保留該覆寫,或把 `syncResumeHistory` 設為 `false`,接受那些對話轉向 Codex 自己的 OpenAI 端點。 返回根 URL 覆寫模式時,即使歷史預檢通過,OpenCodex 也會在提交設定前保留既有的 `[model_providers.opencodex]` 定義。如此一來,即使 Codex 在提交後或背景歷史工作啟動時遷移歷史格式,舊的 `opencodex` 對話仍能找到其提供者。新對話繼續使用所選的根提供者;明確要求的還原仍執行原有的獨立刪除檢查。 diff --git a/docs-site/src/content/docs/zh-tw/guides/integrations.md b/docs-site/src/content/docs/zh-tw/guides/integrations.md index c0f9ef2bf82..83f5cc7cf6a 100644 --- a/docs-site/src/content/docs/zh-tw/guides/integrations.md +++ b/docs-site/src/content/docs/zh-tw/guides/integrations.md @@ -165,6 +165,27 @@ OAuth 或 API key,並拒絕 `--api-key`、`--base-url` 與 `--region` 覆寫 客戶端細節是針對各專案自己的設定格式驗證過的;檢查了什麼、何時檢查,請見 `devlog/_fin/260802_client_toggle_api/002_client_toggle_matrix.md` 中的研究筆記。 +## ZCode 3.14 以後 + +ZCode 3.14 把自訂供應商移到 `~/.zcode/v2/provider_config.json`,而本整合原本寫入的 +`~/.zcode/v2/config.json` 只剩下一次性匯入會讀取,而那次匯入只在新檔案不存在時執行。ZCode 首次啟動 +就會建立新檔案,因此只要曾經啟動過的安裝,匯入早已用掉,之後寫入 `config.json` 不會被任何東西讀到。 + +在可行的情況下,opencodex 現在直接寫入 `provider_config.json`。啟用整合會把 `opencodex` 供應商規則 +加進該檔案,目錄重新整理會更新它,停用則精確移除 opencodex 放進去的內容。檔案中其他規則一律保持原樣, +包含其他供應商為某個同樣出現在我們這裡的模型 ID 所保留的規則。帶有 `opencodex` ID 但不是 opencodex +寫入的規則屬於衝突,而不是可以接管的東西:請在 ZCode 中處理,或使用明確的覆寫。 + +仍有兩種情況會拒絕而不寫入。ZCode 搬移儲存位置之前由 opencodex 寫入的區塊,會讓整合留在 +`config.json`:請先在那裡停用,再重新啟用以寫入新的儲存檔。至於 `schemaVersion` 不是 opencodex +曾觀察過的 `provider_config.json`,則只會被回報而不會合併:該檔案存放 ZCode 的所有供應商,對它斷言 +一種結構等於把靜默的無效果換成靜默的資料遺失。只要整合不是在寫那個檔案,狀態頁就會指出 ZCode 實際 +讀取的檔案。 + +在第二種情況下,請在 ZCode 自己的設定中新增供應商:base URL 為 `http://127.0.0.1:10100/v1` +(請依實際繫結調整連接埠)、任意非空白金鑰,以及 `ocx export --client zcode` 列出的模型 ID。不支援 +刪除 `provider_config.json` 來重新觸發 ZCode 的匯入:那會丟掉 ZCode 存放在其中的所有供應商。 + ## Cline CLI Cline CLI 使用 providers.json 與 models.json。修改或同步前請結束 Cline,完成後重新啟動。復原會還原兩個原始檔案,預設供應商保持不變。此整合不會遷移舊版 VS Code 擴充功能的儲存資料。 diff --git a/docs-site/src/content/docs/zh-tw/guides/providers.md b/docs-site/src/content/docs/zh-tw/guides/providers.md index 47594ee2578..44263dc002f 100644 --- a/docs-site/src/content/docs/zh-tw/guides/providers.md +++ b/docs-site/src/content/docs/zh-tw/guides/providers.md @@ -239,7 +239,7 @@ database 並移除目前的 WAL、SHM 與 journal sidecar,再發布先前的 s ## 3. API 金鑰目錄 -opencodex 內建 95 個 preset:79 個 key-based、12 個 OAuth、3 個 local,以及 1 個預設 ChatGPT-forward +opencodex 內建 96 個 preset:80 個 key-based、12 個 OAuth、3 個 local,以及 1 個預設 ChatGPT-forward preset。儀表板的 **Add provider** picker 會開啟 key provider 的 dashboard、驗證金鑰並儲存;驗證方式 依 provider 而異。主要條目如下。 diff --git a/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md b/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md index d9c104d3c91..4317ef37b30 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md @@ -244,6 +244,7 @@ ocx codex-shim uninstall ### `ocx tray [--json] [--no-start]` 安裝並控制 Windows 狀態列圖示。它在 Windows 登入時啟動並提供一鍵代理控制。`start` 與 `stop` 僅控制圖示;請用其選單控制代理。`--no-start` 適用於 `install`,並在不立即啟動它的情況下安裝 tray。 +已淘汰:OpenCodex 桌面應用程式在 Windows、macOS 與 Linux 提供系統匣;沒有桌面應用程式的安裝仍可使用 `ocx tray`。 ## 儀表板 diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md index 395ec06ec77..cdbd005d00f 100644 --- a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md @@ -99,9 +99,11 @@ ocx models provider openrouter on | `noPenaltyModels?` | `string[]` | 拒絕 presence/frequency penalty 的模型。 | | `noStructuredOutputModels?` | `string[]` | 其 `openai-chat` 端點拒絕 `response_format` 的精確模型 ID。僅精確符合的請求模型會省略該欄位;structured-output 轉譯對其他每個 `openai-chat` 模型保持啟用。 | | `noJsonSchemaModels?` | `string[]` | 其 `openai-chat` 端點拒絕 `json_schema` 形式但仍接受 `json_object` 的精確模型 ID。這類請求會降級為 `json_object` 而非被丟棄,因此要求 JSON 的呼叫端仍會拿到 JSON。同一模型同時列在兩份清單時,以 `noStructuredOutputModels` 為準。`opencode go`、`opencode zen`、`opencode free` 預設已為其 DeepSeek 路由內建。 | +| `foldDeveloperRoleToSystem?` | `boolean` | 記錄某個 `openai-chat` 目的地是否接受 `developer` 角色。`foldDeveloperRoleToSystem` 未設定時以 `system` 傳送,`true` 時以 `system` 傳送,`false` 時以 `developer` 傳送。未設定表示尚未記錄該目的地的情況;`true` 記錄上游拒絕該角色;`false` 記錄其接受該角色。無論何者,訊息都保留在對話中的原有位置,只有角色改變。拒絕該角色的目的地會回應 `400 role 'developer' is not allowed`,該回合根本無法開始,這就是未記錄狀態預設摺疊的原因。 | | `parallelToolCalls?` | `boolean` | 切換平行工具呼叫。OpenAI Chat 預設開啟;非 chat adapter 僅在明確 `true` 時廣告。 | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean }` | 預設停用的下游 SSE 修復,用於精確佔位 id 與缺失的終端 id。Function-call id 永不被重寫。 | | `transientRetryOn5xx?` | `{ enabled?: boolean; attempts?: number }` | 僅限使用金鑰認證的 `openai-chat` 與 `openai-responses` 供應商。`authMode: "forward"` 的供應商(ChatGPT 帳號池)從不讀取此選項,維持預設重試次數。選擇性重試串流開始前的暫時性上游狀態(500、502、503、504、520、521、522):未設定時停用;只要有此物件即啟用,除非 `enabled: false`。涵蓋初始 `Responses` 請求、終止防護續接、原生 `/v1/chat/completions`,以及 429/帳號復原的重新擷取。`attempts` 是單一請求允許傳送至上游的總次數,包含第一次(1..10,預設 3);這是與連線重設復原共用的單一請求範圍預算,因此 `3` 表示最多只有三個實際請求會送達供應商。等待採固定 400 毫秒、上限 5 秒的指數退避,並遵循 `Retry-After`。此機制獨立於處理速率限制的 `retryOn429`;串流中的失敗絕不重播。 | +| `retryOnReset?` | `{ enabled?: boolean; replacements?: number }` | 僅限原生 `openai-responses` 供應商,包含 `authMode: "forward"`。可選擇性地替換一次在呼叫端尚未觀察到任何內容時就失敗的傳送:未設定時停用;只要有此物件即啟用,除非 `enabled: false`。涵蓋兩個不確定階段——回應標頭抵達前連線中斷,以及標頭之後 SSE 內文只載有控制事件時中斷。只有自我完備的請求才會被替換:`store: false`、完整的 `input`、沒有 `previous_response_id`/`conversation`/`stream_id`,且僅使用由用戶端執行的工具。`replacements` 是單一邏輯請求在所有環節與所有組合子請求中可進行的替換傳送次數(1..2,預設 1);它既不是各環節的重試次數,也不是傳送預算,因此替換傳送仍必須落在該環節既有的傳送額度之內。已經產生輸出或工具呼叫的請求,無論此值為何都不會被替換。若上游已經開始第一次推論,被替換的推論仍可能計費,因此此選項預設停用。 | | `autoToolChoiceOnlyModels?` | `string[]` | 其 `tool_choice` 僅接受 `auto` 或 `none` 的模型;強制選擇被降級。 | | `preserveReasoningContentModels?` | `string[]` | 需要在 chat 歷史中保留先前 assistant `reasoning_content` 的模型。 | | `reasoningDetailsModels?` | `string[]` | 以結構化 `reasoning_details` 陣列回傳思考內容的模型(啟用 `reasoning_split` 的 MiniMax M 系列);串流增量為累積快照,以前綴差分處理,保留的推理以 `reasoning_details` 陣列而非 `reasoning_content` 字串重播。 | diff --git a/docs/design-system/foundations.md b/docs/design-system/foundations.md index a3b38f9eae5..c3f3dbb39f7 100644 --- a/docs/design-system/foundations.md +++ b/docs/design-system/foundations.md @@ -24,7 +24,7 @@ ### Font families -- `--font-ui`: 일반 UI, 제목, 본문, 버튼, 입력. Pretendard/Noto Sans KR/Apple SD Gothic Neo/Malgun Gothic을 포함해 한글 fallback을 보장한다. +- `--font-ui`: 일반 UI, 제목, 본문, 버튼, 입력. 제품 서체를 우선하고, 그 뒤에는 시스템 UI 폰트를 영문도 지원하는 한글 fallback보다 먼저 선언한다. 그래야 한글 fallback이 시스템 UI 폰트의 영문·숫자 글리프까지 대신 표시하지 않는다. 한글은 앞선 서체의 지원 여부에 따라 시스템의 언어별 fallback 또는 뒤에 선언된 한글 글꼴로 표시한다. - `--font-code`: 모델 ID, URL, 버전, 토큰 수, 로그, 코드. 숫자는 tabular 형태로 정렬한다. 외부 CDN 폰트를 사용하지 않는다. 프록시 관리 화면은 오프라인에서도 열려야 하고, 폰트 diff --git a/gui/public/favicon.png b/gui/public/favicon.png index 3a50bfa241d..8827b1fdc27 100644 Binary files a/gui/public/favicon.png and b/gui/public/favicon.png differ diff --git a/gui/public/provider-icons/stepfun-color.svg b/gui/public/provider-icons/stepfun-color.svg new file mode 100644 index 00000000000..7d04a37bc80 --- /dev/null +++ b/gui/public/provider-icons/stepfun-color.svg @@ -0,0 +1 @@ +Stepfun diff --git a/gui/src/App.tsx b/gui/src/App.tsx index b7ec8f9db04..9a3c448074c 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useKeyedClientResource } from "./client-resource"; import Dashboard from "./pages/Dashboard"; import Providers from "./pages/Providers"; @@ -15,7 +15,7 @@ import ErrorBoundary from "./components/ErrorBoundary"; import { SidebarGithubRow } from "./components/sidebar-github-row"; import { IconGrid, IconServer, IconBoxes, IconBot, IconList, IconActivity, IconHardDrive, IconCodex, IconMenu, IconSun, IconMoon, IconMonitor, IconGlobe, IconPower, IconX, IconRefresh} from "./icons"; import { useI18n, useT, LOCALES, localeDisplayName, type Locale, type TKey } from "./i18n/shared"; -import { Select } from "./ui"; +import { Select, ToastNotice, type NoticeTone } from "./ui"; import { configureApiTargets, hasApiSession, installApiAuthFetch, installApiSessionFromHtml, logoutApiSession, SESSION_UNAVAILABLE_EVENT } from "./api"; import { apiBaseForPlane, discoverApiTargets, isConnectedRuntime, standaloneApiTargets, type ApiTargets } from "./api-targets"; import { ConnectPairingForm } from "./connect-pairing"; @@ -24,6 +24,8 @@ import { readModelsTab, type ModelsTab } from "./pages/models-tab"; import { useAppRouteState } from "./use-app-route-state"; import { requestProxyStop } from "./stop-proxy"; import { useCodexRestart } from "./use-codex-restart"; +import { confirmAction } from "./action-dialogs"; +import { isDesktopShell, isExternalLink } from "./lib/desktop-shell"; type Theme = "light" | "dark" | "system"; @@ -116,6 +118,18 @@ export default function App() { const [sharedSessionReady, setSharedSessionReady] = useState(() => hasApiSession("shared")); const [sharedSessionEpoch, setSharedSessionEpoch] = useState(0); const [sessionLoggingOut, setSessionLoggingOut] = useState(false); + /* + * Results from the two sidebar orbs used to be `alert()`, which the app's webview draws + * nowhere, so a refused stop and a completed one looked identical: nothing happened. + * The toast is portaled, so reporting from the shell costs the page no layout. + */ + const [actionFeedback, setActionFeedback] = useState<{ tone: NoticeTone; text: string } | null>(null); + /** Bumped on every report so a repeated identical message restarts the dismiss timer. */ + const [feedbackRevision, setFeedbackRevision] = useState(0); + const report = useCallback((text: string, tone: NoticeTone) => { + setActionFeedback({ tone, text }); + setFeedbackRevision(revision => revision + 1); + }, []); useEffect(() => { const unavailable = (event: Event) => { @@ -172,12 +186,37 @@ export default function App() { }; }, []); + useEffect(() => { + if (!isDesktopShell()) return; + const interceptExternalLinks = (event: MouseEvent) => { + const target = event.target; + if (!(target instanceof Element)) return; + const anchor = target.closest("a[href]"); + if (!(anchor instanceof HTMLAnchorElement)) return; + const href = anchor.href; + if (!isExternalLink(href)) return; + event.preventDefault(); + // Rust denies external HTTP(S) navigation and opens it in the system browser. + window.location.assign(href); + }; + document.addEventListener("click", interceptExternalLinks, true); + return () => document.removeEventListener("click", interceptExternalLinks, true); + }, []); + useEffect(() => { const el = document.documentElement; if (theme === "system") { el.removeAttribute("data-theme"); localStorage.removeItem(THEME_KEY); } else { el.setAttribute("data-theme", theme); localStorage.setItem(THEME_KEY, theme); } }, [theme]); + // Success expires on its own; a failure and a degraded result stay until the user + // dismisses them, because those are the two the user has to act on. + useEffect(() => { + if (actionFeedback?.tone !== "ok") return; + const timer = window.setTimeout(() => setActionFeedback(null), 4500); + return () => window.clearTimeout(timer); + }, [actionFeedback, feedbackRevision]); + const healthPoll = useKeyedClientResource( `app-healthz:${machineBase}`, [machineBase, targetsSettled], @@ -230,21 +269,35 @@ export default function App() { const [codexRestartEpoch, setCodexRestartEpoch] = useState(0); const { restarting: codexRestarting, restart: handleCodexRestart } = useCodexRestart(sharedBase, { onSettled: () => setCodexRestartEpoch(epoch => epoch + 1), + report, }); const handleStop = async () => { - if (!confirm(t(targets.connected ? "connection.disconnectConfirm" : "dash.stopConfirm"))) return; + const consented = await confirmAction({ + message: t(targets.connected ? "connection.disconnectConfirm" : "dash.stopConfirm"), + confirmLabel: t(targets.connected ? "connection.disconnect" : "dash.stop"), + tone: "danger", + }); + if (!consented) return; setStopping(true); const outcome = await requestProxyStop(machineBase, { formatFailure: status => t("dash.stopFailed", { status: String(status) }), + formatStillRunning: () => t("dash.stopStillRunning"), + formatUnknown: () => t("dash.stopUnknown"), mode: targets.connected ? "client" : "standalone", }); - // Refusals and restore failures return normally instead of dropping the connection. - // In both cases the proxy did not reach a clean-stop result, so re-enable the control - // and surface the server's remediation instead of leaving "stopping…" stuck forever. - if (!outcome.accepted) { + /* + * Only an accepted stop leaves the control pending, because the page is about to go + * away with the server. A refusal and an unknown both mean the user is still here and + * still looking at a running dashboard, so the control comes back either way. + * + * They are not reported the same, though. A refusal is the server's own answer and + * reads as a failure; an unknown is the absence of an answer, and claiming either + * success or failure there is the thing this lane exists to stop. + */ + if (outcome.status !== "accepted") { setStopping(false); - alert(outcome.message); + report(outcome.message, outcome.status === "rejected" ? "err" : "warn"); } }; @@ -254,7 +307,7 @@ export default function App() { const loggedOut = await logoutApiSession("shared"); setSessionLoggingOut(false); if (loggedOut) setSharedSessionReady(false); - else alert(t("connection.sessionLogoutFailed")); + else report(t("connection.sessionLogoutFailed"), "err"); }; /* @@ -285,6 +338,11 @@ export default function App() { return (
    + {actionFeedback && ( + setActionFeedback(null)} dismissLabel={t("common.close")}> + {actionFeedback.text} + + )} {/* inert while the drawer is open: keeps focus and assistive tech inside the drawer */}
    - {provider &&
    {combo + {model &&
    {isCombo ? t("compactionRouting.comboWarning", { combo: model, providers }) : t("compactionRouting.providerWarning", { provider })}
    } - {provider && routesAutomatic &&
    {t("compactionRouting.autoNotice")}
    } + {model && routesAutomatic &&
    {t("compactionRouting.autoNotice")}
    } {loadError &&
    {t("compactionRouting.loadFailed")}
    } {feedback === "failed" &&
    {t("compactionRouting.saveFailed")}
    } {feedback === "saved" &&
    {t("compactionRouting.saved")}
    } diff --git a/gui/src/components/MemoryObservabilityCard.tsx b/gui/src/components/MemoryObservabilityCard.tsx index ebf12a4ad70..8bea5857ca1 100644 --- a/gui/src/components/MemoryObservabilityCard.tsx +++ b/gui/src/components/MemoryObservabilityCard.tsx @@ -3,6 +3,7 @@ import { formatUptime } from "../formatUptime"; import { IconActivity } from "../icons"; import { useI18n, type Locale, type TFn } from "../i18n/shared"; import { createBoundedFetch, type BoundedFetch } from "../bounded-fetch"; +import { confirmAction } from "../action-dialogs"; import { startVisibilityPoll } from "../visibility-poll"; /** @@ -355,27 +356,26 @@ export default function MemoryObservabilityCard({ apiBase }: { apiBase: string } }; }, [apiBase, restartPhase, restartFromPid, t]); - const confirmRestart = () => { + const confirmRestart = async () => { const count = data?.activeTurnCount ?? 0; const lines = [ t("dash.mem.restartConfirm", { count, seconds: DRAIN_TIMEOUT_S }), ]; if (noSupervisor) lines.push(t("dash.mem.restartNoSupervisor")); - if (!window.confirm(lines.join("\n\n"))) return; - void (async () => { - setRestartError(null); - setRestartFromPid(typeof data?.pid === "number" ? data.pid : null); - setRestartPhase("draining"); - try { - const res = await fetch(`${apiBase}/api/system/restart`, { method: "POST" }); - if (!res.ok) throw new Error("restart_failed"); - // Proxy will drain then exit; memory poll will trip reconnecting or pid change. - } catch { - setRestartPhase("error"); - setRestartFromPid(null); - setRestartError(t("dash.mem.restartFailed")); - } - })(); + // Blank lines still separate the paragraphs; the dialog renders each as its own

    . + if (!(await confirmAction({ message: lines.join("\n\n"), tone: "danger" }))) return; + setRestartError(null); + setRestartFromPid(typeof data?.pid === "number" ? data.pid : null); + setRestartPhase("draining"); + try { + const res = await fetch(`${apiBase}/api/system/restart`, { method: "POST" }); + if (!res.ok) throw new Error("restart_failed"); + // Proxy will drain then exit; memory poll will trip reconnecting or pid change. + } catch { + setRestartPhase("error"); + setRestartFromPid(null); + setRestartError(t("dash.mem.restartFailed")); + } }; if (unavailable && !data && restartPhase === "idle") { @@ -432,7 +432,7 @@ export default function MemoryObservabilityCard({ apiBase }: { apiBase: string } type="button" className="btn btn-ghost btn-sm" disabled={busy} - onClick={confirmRestart} + onClick={() => { void confirmRestart(); }} > {t("dash.mem.restart")} diff --git a/gui/src/components/codex-account-pool-main-card.tsx b/gui/src/components/codex-account-pool-main-card.tsx index 25af1eb49f9..051424184f5 100644 --- a/gui/src/components/codex-account-pool-main-card.tsx +++ b/gui/src/components/codex-account-pool-main-card.tsx @@ -3,7 +3,7 @@ import { IconLock, IconPause, IconPlay, IconPlus, IconRefresh, IconTicket } from import AccountPriorityControl, { AccountPriorityBadge } from "./AccountPriorityControl"; import QuotaBars from "./QuotaBars"; import { CodexPauseToggleLabel, CodexTicketBadge } from "./codex-account-pool-helpers"; -import type { CodexAccountEntry } from "./codex-account-pool-types"; +import type { CodexAccountEntry, CodexAccountLoadState } from "./codex-account-pool-types"; import type { CodexAccountModeState } from "../codex-multi-state"; import type { TFn } from "../i18n/shared"; import type { MainDeviceReauthState } from "./use-main-device-reauth"; @@ -359,11 +359,13 @@ export function CodexAccountPoolActions(props: { export function CodexAccountPoolLoadStates({ t, loadState, + refreshFailed, accountsCount, onRetry, }: { t: TFn; - loadState: "loading" | "ready" | "error"; + loadState: CodexAccountLoadState; + refreshFailed: boolean; accountsCount: number; onRetry: () => void; }): ReactNode { @@ -419,5 +421,16 @@ export function CodexAccountPoolLoadStates({

    ); } + // Rows survived a failed refresh, so they are still worth showing — but they are the ones from + // before it, and an account added since is simply not among them. A status rather than an alert: + // nothing on screen is wrong, it is just older than it looks. + if (refreshFailed && accountsCount > 0) { + return ( +
    + {t("codexAuth.accountsRefreshFailed")} + +
    + ); + } return null; } diff --git a/gui/src/components/codex-account-pool-types.ts b/gui/src/components/codex-account-pool-types.ts index 679350fa23f..0e16acca880 100644 --- a/gui/src/components/codex-account-pool-types.ts +++ b/gui/src/components/codex-account-pool-types.ts @@ -1 +1 @@ -export type { CodexAccountEntry } from "../hooks/useCodexAccountPool"; +export type { CodexAccountEntry, CodexAccountLoadState } from "../hooks/useCodexAccountPool"; diff --git a/gui/src/components/provider-workspace/ProviderModels.tsx b/gui/src/components/provider-workspace/ProviderModels.tsx index 944e6a0cb5c..e8a10189520 100644 --- a/gui/src/components/provider-workspace/ProviderModels.tsx +++ b/gui/src/components/provider-workspace/ProviderModels.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react"; import { useT } from "../../i18n/shared"; import { Switch } from "../../ui"; +import { confirmAction } from "../../action-dialogs"; import type { WorkspaceItem } from "../../provider-workspace/catalog"; import { filterFreeModelRows, freeOnlyInForce, modelPricingKnown, type ModelRow } from "../../pages/models-shared"; import { putModelVisibility } from "../../model-visibility"; @@ -190,8 +191,21 @@ function ProviderModelInventory({ item, apiBase, availableModels, selectedModels const removeModel = async (row: ModelRow, button: HTMLButtonElement) => { const action = actionFor(row); if (actionsBlocked || flight.current || !action) return; - if (!window.confirm(t(action === "delete" ? "models.customDeleteConfirm" : "models.hideConfirm", { name: row.namespaced }))) return; + /* + * The single flight is claimed BEFORE consent is awaited. The gate used to be the + * synchronous `window.confirm()`, which nothing could interleave with; an in-page dialog + * yields, so without this a second row's button could open its own dialog while this one + * is still open and two removals would run against one revision. A browser's modal + * dialog makes the page inert, but that is the platform's courtesy, not this + * component's invariant. + */ flight.current = true; + const consented = await confirmAction({ + message: t(action === "delete" ? "models.customDeleteConfirm" : "models.hideConfirm", { name: row.namespaced }), + confirmLabel: t(action === "delete" ? "common.delete" : "common.ok"), + tone: "danger", + }); + if (!consented) { flight.current = false; return; } setRequestPending(true); setMutation(null); focusIntent.current = { button, retained: document.activeElement === button }; diff --git a/gui/src/components/provider-workspace/ProviderSettings.tsx b/gui/src/components/provider-workspace/ProviderSettings.tsx index 847fe7e7122..ebfa5a20e02 100644 --- a/gui/src/components/provider-workspace/ProviderSettings.tsx +++ b/gui/src/components/provider-workspace/ProviderSettings.tsx @@ -10,6 +10,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { baseUrlForChoice, matchChoiceId, resolvedBaseUrlForChoice } from "../../base-url-choice"; import { readJsonIfOk } from "../../fetch-json"; +import { confirmAction } from "../../action-dialogs"; import { createBoundedFetch } from "../../bounded-fetch"; import { startVisibilityPoll } from "../../visibility-poll"; import { useT } from "../../i18n/shared"; @@ -295,6 +296,23 @@ export default function ProviderSettings({ } }; + /** + * Asks before switching, because flipping modes rebinds running threads and changes quota + * accounting. Written as a named async function rather than a promise chain inside the + * handler: a floating `.then` in a JSX handler has no rejection path and is what + * `no-floating-then-in-jsx-handler` exists to catch. + */ + const requestAccountMode = async (next: "pool" | "direct", select: HTMLSelectElement) => { + if (await confirmAction({ message: t("pws.accountModeConfirm") })) { + await applyAccountMode(next); + return; + } + // Keep the visible choice aligned with the applied mode. React re-renders this + // controlled setChosenMode(mode)} + /> + + {mode === "first-party" ? t("claudeDesktop.mode.firstParty") : t("claudeDesktop.mode.gateway")} + {mode === "first-party" && {t("claudeDesktop.mode.defaultBadge")}} + {modeKnown && effectiveMode === mode && {t("claudeDesktop.mode.current")}} + + + {mode === "first-party" ? t("claudeDesktop.mode.firstPartyHint") : t("claudeDesktop.mode.gatewayHint")} + + + ))} + {modeDirty && {t("claudeDesktop.mode.switchNote")}} + + {/* Always mount the bar (pending strut when status is still cold) so a late /status response cannot insert a full row under the title and shove the lanes down. */}
    {/* Desktop serving another profile outranks content drift: stale config that is - read still works, a config that is never read does not. */} + read still works, a config that is never read does not. First-party never + writes a Desktop profile, so that check only applies in gateway mode. */} {statusFailed && !status ? t("claudeDesktop.loadFail") @@ -475,14 +540,23 @@ export default function ClaudeDesktop({ ? t("claudeDesktop.loading") : !status.desiredEnabled ? t("claudeDesktop.status.disabled") - : status.activeProfile === false + : effectiveMode === "gateway" && status.activeProfile === false ? t("claudeDesktop.status.notActiveProfile") : status.stale ? t("claudeDesktop.status.stale") : status.applied - ? t("claudeDesktop.status.applied") + ? effectiveMode === "first-party" ? t("claudeDesktop.status.appliedFirstParty") : t("claudeDesktop.status.applied") : t("claudeDesktop.status.notApplied")} + {status?.firstParty && effectiveMode === "first-party" && status.desiredEnabled && ( + + {status.firstParty.interceptRunning + ? t("claudeDesktop.firstParty.proxyRunning", { port: status.firstParty.proxyPort }) + : status.firstParty.interceptEnabled + ? t("claudeDesktop.firstParty.proxyStopped", { port: status.firstParty.proxyPort }) + : t("claudeDesktop.firstParty.interceptDisabled")} + + )} {status?.health.lastRequestAt && ( {t("claudeDesktop.health.lastRequest")}:{" "} @@ -508,7 +582,7 @@ export default function ClaudeDesktop({ {pending === "save" ? t("claudeDesktop.saving") : t("common.save")}
    diff --git a/gui/src/pages/Logs.tsx b/gui/src/pages/Logs.tsx index d7fc3ab5c49..9f55f37e45d 100644 --- a/gui/src/pages/Logs.tsx +++ b/gui/src/pages/Logs.tsx @@ -28,6 +28,19 @@ import { validCachedRouteDecision, } from "./log-route-decision"; import { mergeLogDelta, parseLogPollResponse } from "./log-poll"; +import type { + AttemptRecoveryKind, + RequestFailureCause, + RequestFailureStage, + RequestSpendTotals, + ResendPermission, +} from "../../../src/usage/telemetry-contract"; +import { + classifyRequestOutcome, + requestPhysicalSends, + requestUnresolvedSends, + type RequestOutcomeClass, +} from "../../../src/usage/request-outcome"; function logsCacheKey(apiBase: string): string { return `ocx.logs.list.v1:${apiBase}`; @@ -102,21 +115,20 @@ interface LogDisplayMetrics { } /** - * Recovery kinds recorded on a log attempt; rendered as localized labels in the logs - * detail dialog instead of raw wire values. + * The durable attribution and the verdict the API derives from it. + * + * `resendPermission` arrives computed rather than stored: the tables that decide it live in the + * proxy and a row must not be able to assert a permission the current tables would refuse. The + * page renders the answer and derives nothing of its own, which is the same rule that keeps the + * outcome class agreeing with the exporter. */ -type AttemptRecoveryKind = - | "transient-5xx" - | "connection-reset" - | "oauth-401" - | "key-429" - | "rate-limit-429" - | "anthropic-oauth-429" - | "image-413" - | "empty-completion" - | "console-go-upload-retry"; - -interface LogAttempt { +interface LogFailureAttribution { + failureStage?: RequestFailureStage; + failureCause?: RequestFailureCause; + resendPermission?: ResendPermission; +} + +interface LogAttempt extends LogFailureAttribution { ordinal: number; provider: string; model: string; @@ -138,7 +150,7 @@ interface LogAttempt { displayMetrics?: LogDisplayMetrics; } -export interface LogEntry { +export interface LogEntry extends LogFailureAttribution { requestId?: string; timestamp: number; model: string; @@ -172,6 +184,15 @@ export interface LogEntry { durationMs: number; errorCode?: string; upstreamError?: string; + /** + * Semantic terminal facts. `/api/logs` has always carried these -- `requestLogDto` spreads the + * whole durable entry -- but this page declared neither, so it classified every request by its + * numeric HTTP status alone and reported an incomplete 200 as a plain success. + */ + terminalStatus?: string; + closeReason?: "terminal" | "client_cancel" | "non_stream" | "body_stall" | "body_overflow"; + /** Upstream spend for the whole logical request, aggregated across attempts and combo children. */ + spend?: RequestSpendTotals; usageStatus?: LogUsageStatus; usage?: UsageBreakdown; totalTokens?: number; @@ -298,17 +319,27 @@ const ESTIMATE_REASON_KEYS = { /** * i18n keys for every {@link AttemptRecoveryKind}, so the logs detail dialog renders a * localized label instead of the raw wire value (e.g. `rate-limit-429`). + * + * The union is now the durable roster rather than a copy of it. The copy had drifted to nine of + * thirteen members, so `key-401`, `oauth-account-429`, `opaque-blob-rejection` and + * `reasoning-effort-downgrade` all reached the operator as "Unknown recovery reason" -- four real + * causes rendered as an absence of information. `satisfies Record` is + * what now makes the next added kind a typecheck failure here instead of a silent blank. */ const RECOVERY_KIND_KEYS = { "transient-5xx": "logs.detail.attempt.recovery.transient5xx", "connection-reset": "logs.detail.attempt.recovery.connectionReset", "oauth-401": "logs.detail.attempt.recovery.oauth401", + "key-401": "logs.detail.attempt.recovery.key401", "key-429": "logs.detail.attempt.recovery.key429", "rate-limit-429": "logs.detail.attempt.recovery.rateLimit429", "anthropic-oauth-429": "logs.detail.attempt.recovery.anthropicOauth429", + "oauth-account-429": "logs.detail.attempt.recovery.oauthAccount429", "image-413": "logs.detail.attempt.recovery.image413", "empty-completion": "logs.detail.attempt.recovery.emptyCompletion", "console-go-upload-retry": "logs.detail.attempt.recovery.consoleGoUpload", + "opaque-blob-rejection": "logs.detail.attempt.recovery.opaqueBlobRejection", + "reasoning-effort-downgrade": "logs.detail.attempt.recovery.reasoningEffortDowngrade", } as const satisfies Record; /** Map a metric-unavailable reason to its i18n key. */ @@ -334,6 +365,91 @@ function verificationKey(status: MatchedPriceInfo["status"]): "logs.detail.verif return status === "verified" ? "logs.detail.verification.verified" : "logs.detail.verification.derived"; } +/** i18n key for each shared outcome class, total by construction. */ +const OUTCOME_KEYS = { + completed: "logs.detail.outcome.completed", + failed: "logs.detail.outcome.failed", + incomplete: "logs.detail.outcome.incomplete", + aborted: "logs.detail.outcome.aborted", +} as const satisfies Record; + +/** + * i18n key for each shared failure cause, total by construction. + * + * The `satisfies` clause is the point. The recovery-kind catalog on this page drifted to nine of + * the durable thirteen and four real causes reached the operator as "Unknown recovery reason" -- + * an absence of a label rendered as an absence of a cause. A missing member here is a typecheck + * failure instead. + */ +const FAILURE_CAUSE_KEYS = { + "transport-unsent": "logs.detail.cause.transportUnsent", + "transport-ambiguous": "logs.detail.cause.transportAmbiguous", + "upstream-declined": "logs.detail.cause.upstreamDeclined", + "rate-limit": "logs.detail.cause.rateLimit", + "quota-exhausted": "logs.detail.cause.quotaExhausted", + "credential-rejected": "logs.detail.cause.credentialRejected", + "policy-refusal": "logs.detail.cause.policyRefusal", + "parameter-rejected": "logs.detail.cause.parameterRejected", + "ciphertext-refusal": "logs.detail.cause.ciphertextRefusal", + "payload-too-large": "logs.detail.cause.payloadTooLarge", + "payload-rejected": "logs.detail.cause.payloadRejected", + "upstream-fault": "logs.detail.cause.upstreamFault", + "empty-output": "logs.detail.cause.emptyOutput", + "client-cancelled": "logs.detail.cause.clientCancelled", + "local-refusal": "logs.detail.cause.localRefusal", +} as const satisfies Record; + +/** i18n key for each stage the caller's view of the exchange reached. */ +const FAILURE_STAGE_KEYS = { + "pre-header": "logs.detail.stage.preHeader", + "headers-only": "logs.detail.stage.headersOnly", + "protocol-prelude": "logs.detail.stage.protocolPrelude", + "semantic-output": "logs.detail.stage.semanticOutput", + "side-effect": "logs.detail.stage.sideEffect", + "terminal": "logs.detail.stage.terminal", +} as const satisfies Record; + +/** i18n key for each resend verdict; every refusal names which refusal it is. */ +const RESEND_PERMISSION_KEYS = { + "permitted": "logs.detail.resend.permitted", + "permitted-after-repair": "logs.detail.resend.permittedAfterRepair", + "refused-ambiguous": "logs.detail.resend.refusedAmbiguous", + "refused-committed": "logs.detail.resend.refusedCommitted", + "refused-futile": "logs.detail.resend.refusedFutile", +} as const satisfies Record; + +/** + * How this request ended, using the same classifier the Prometheus exporter uses. + * + * Calling the shared function rather than reimplementing the precedence is the point: the numeric + * status beside it can be 200 while the answer was never delivered, and reading the status first + * is exactly the disagreement this removes. + */ +function outcomeKey(entry: Pick) { + return OUTCOME_KEYS[classifyRequestOutcome(entry)]; +} + +/** + * Localized cause, stage and resend verdict for a row that carries them. + * + * A stale or hand-edited row can carry a value outside the roster, so each lookup falls back to + * the wire value rather than handing `t()` an undefined key. Showing the raw member is more + * useful than showing nothing, which is the mistake the recovery catalog made. + */ +function failureAttributionLabels( + row: LogFailureAttribution, + t: TFn, +): { cause?: string; stage?: string; resend?: string } { + const causeKey = row.failureCause === undefined ? undefined : FAILURE_CAUSE_KEYS[row.failureCause]; + const stageKey = row.failureStage === undefined ? undefined : FAILURE_STAGE_KEYS[row.failureStage]; + const resendKey = row.resendPermission === undefined ? undefined : RESEND_PERMISSION_KEYS[row.resendPermission]; + return { + ...(row.failureCause ? { cause: causeKey ? t(causeKey) : row.failureCause } : {}), + ...(row.failureStage ? { stage: stageKey ? t(stageKey) : row.failureStage } : {}), + ...(row.resendPermission ? { resend: resendKey ? t(resendKey) : row.resendPermission } : {}), + }; +} + function statusColor(status: number): string { if (status >= 200 && status < 300) return "var(--green)"; if (status >= 400) return "var(--red)"; @@ -937,6 +1053,7 @@ function LogDetailDialog({ const tokenSplit = cacheSplit(detail); const cost = detail.displayMetrics?.cost; const reasoningWire = reasoningWireLabel(detail); + const detailFailure = failureAttributionLabels(detail, t); const copyRequestId = async () => { if (!detail.requestId) return; @@ -971,6 +1088,33 @@ function LogDetailDialog({

    {t("logs.detail.section.basic")}

    {t("logs.col.time")}{formatLogDateTime(detail.timestamp, localeTag, serverTimeZone)} + {t("logs.detail.outcome.label")} + {t(outcomeKey(detail))} + {detailFailure.cause && ( + <> + {t("logs.detail.cause.label")} + + {detailFailure.cause} + {detailFailure.stage && ` (${t("logs.detail.stage.label")}: ${detailFailure.stage})`} + + + )} + {detailFailure.resend && ( + <> + {t("logs.detail.resend.label")} + {detailFailure.resend} + + )} + {detail.spend && ( + <> + {t("logs.detail.sends.label")} + + {requestPhysicalSends(detail.spend)} + {requestUnresolvedSends(detail.spend) > 0 + && ` (${t("logs.detail.sends.unresolved")}: ${requestUnresolvedSends(detail.spend)})`} + + + )} {t("logs.col.request")} {detail.requestId ?? "\u2014"} @@ -1121,7 +1265,13 @@ function LogDetailDialog({ const attemptCost = attempt.displayMetrics?.cost; const attemptReasoningWire = reasoningWireLabel(attempt); const matched = attemptCost?.kind === "value" ? attemptCost.estimate.price : undefined; - const reason = attempt.errorCode + const attemptFailure = failureAttributionLabels(attempt, t); + // The derived cause leads, because it is the one value in this row that says + // WHY in a vocabulary an operator can act on. `errorCode` stays behind it + // rather than being dropped: it is the exact wire code, which is what a bug + // report needs. + const reason = attemptFailure.cause + ?? attempt.errorCode ?? (attempt.recoveryKinds.length ? attempt.recoveryKinds.map(kind => t(recoveryKindKey(kind))).join(", ") : undefined) diff --git a/gui/src/pages/Models.tsx b/gui/src/pages/Models.tsx index c9c8ddb5736..7d099bd877f 100644 --- a/gui/src/pages/Models.tsx +++ b/gui/src/pages/Models.tsx @@ -5,8 +5,10 @@ import ModelPriceDialog from "../components/ModelPriceDialog"; import { fetchCodexAppServerState } from "../codex-app-server-state"; import type { AppServerStateOutcome } from "../codex-app-server-state"; import { useCodexRestart } from "../use-codex-restart"; +import { confirmAction } from "../action-dialogs"; +import { editModelAlias, editProviderAlias } from "./models-alias-editing"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; -import { Switch, Notice, EmptyState, Select, Tooltip } from "../ui"; +import { Switch, Notice, EmptyState, Select, Tooltip, type NoticeTone } from "../ui"; import { IconChevron, IconBoxes, IconInfo, IconCheck, IconAlert, IconRefresh, IconPencil } from "../icons"; import { useT } from "../i18n/shared"; import type { TFn, TKey } from "../i18n/shared"; @@ -140,7 +142,7 @@ interface AliasView { defaults: { global: boolean; providers: Record }; } -export default function Models({ apiBase, restartEpoch = 0, catalogSyncedAt }: { apiBase: string; restartEpoch?: number; catalogSyncedAt?: string }) { +export default function Models({ apiBase, restartEpoch = 0, catalogSyncedAt, reportRestart }: { apiBase: string; restartEpoch?: number; catalogSyncedAt?: string; reportRestart: (message: string, tone: NoticeTone) => void }) { // Codex app-server staleness (devlog/_fin/260815_gui_codex_restart). Named // appServerState, not catalogState: this file already binds that name to the // model-catalog resource state, which is an unrelated concept. (Spelling the @@ -188,6 +190,10 @@ export default function Models({ apiBase, restartEpoch = 0, catalogSyncedAt }: { // this page, and a restart succeeding there must still clear the banner here. const { restarting: codexRestarting, restart: handleCodexRestart } = useCodexRestart(apiBase, { onSettled: () => { void reloadAppServerState(); }, + // Reported through the shell, not this page's toast: a restart takes up to 30s and + // outlives a navigation away, and an outcome that says app-servers are still running + // must not be discarded because the user moved on while waiting for it. + report: reportRestart, }); useEffect(() => { @@ -380,29 +386,11 @@ export default function Models({ apiBase, restartEpoch = 0, catalogSyncedAt }: { return () => controller.abort(); }, [reloadAliases]); - const saveProviderAlias = async (provider: string) => { - const entered = window.prompt(t("models.aliasPrompt"), aliases.providers[provider] ?? ""); - if (entered === null) return; - const response = await fetch(`${apiBase}/api/providers/${encodeURIComponent(provider)}/alias`, { - method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ alias: entered.trim() || null }), - }); - if (!response.ok) { publishFeedback(false, t("models.aliasConflict")); return; } - await reloadAliases(); - publishFeedback(true, t("models.aliasSaved")); - }; - - const saveModelAlias = async (provider: string, model: string) => { - const current = aliases.models[provider]?.[model]?.alias ?? ""; - const entered = window.prompt(t("models.modelAliasPrompt"), current); - if (entered === null) return; - const body = entered.trim() ? { set: { [model]: entered.trim() } } : { remove: [model] }; - const response = await fetch(`${apiBase}/api/providers/${encodeURIComponent(provider)}/model-aliases`, { - method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify(body), - }); - if (!response.ok) { publishFeedback(false, t("models.aliasConflict")); return; } - await reloadAliases(); - publishFeedback(true, t("models.aliasSaved")); - }; + const aliasEditingDeps = { apiBase, t, reloadAliases, publishFeedback }; + const saveProviderAlias = (provider: string) => + editProviderAlias(provider, aliases.providers[provider] ?? "", aliasEditingDeps); + const saveModelAlias = (provider: string, model: string) => + editModelAlias(provider, model, aliases.models[provider]?.[model]?.alias ?? "", aliasEditingDeps); const setDefaultAliases = async (enabled: boolean, provider?: string) => { const response = await fetch(`${apiBase}/api/default-aliases`, { @@ -1196,7 +1184,12 @@ export default function Models({ apiBase, restartEpoch = 0, catalogSyncedAt }: { await Promise.all([loadModelDiscovery(), load()]); }; - const applyPreset = async (provider: string, mode: "preset" | "all") => { + const applyPreset = async (provider: string, mode: "preset" | "all", replacing?: { presetCount: number }) => { + // Consent lives with the write, not with the button, so every caller is gated. + if (replacing && !(await confirmAction({ + message: t("models.presetConfirmReplace", { count: String(replacing.presetCount) }), + tone: "danger", + }))) return; if (catalogMutationRef.current) return; catalogMutationRef.current = true; setPresetBusy(provider); @@ -1367,7 +1360,8 @@ export default function Models({ apiBase, restartEpoch = 0, catalogSyncedAt }: { } }; - const deleteCustomModel = async (id: string) => { + const deleteCustomModel = async (id: string, name: string) => { + if (!(await confirmAction({ message: t("models.customDeleteConfirm", { name }), confirmLabel: t("common.delete"), tone: "danger" }))) return; try { const r = await fetch(`${apiBase}/api/custom-models/${encodeURIComponent(id)}`, { method: "DELETE" }); if (r.ok) { @@ -1553,13 +1547,11 @@ export default function Models({ apiBase, restartEpoch = 0, catalogSyncedAt }: { color: preset.mode === mode ? undefined : "var(--muted)", }} disabled={busy || busyHere || selectionPending} - onClick={(e) => { - e.stopPropagation(); - // Switching from a custom selection destroys it, so confirm first. - if (mode === "preset" && preset.mode === "custom" - && !confirm(t("models.presetConfirmReplace", { count: String(preset.presetCount) }))) return; - void applyPreset(provider, mode); - }} + onClick={(e) => { + e.stopPropagation(); + // Switching from a custom selection destroys it, so consent first. + void applyPreset(provider, mode, mode === "preset" && preset.mode === "custom" ? preset : undefined); + }} > {t(`models.presetMode_${mode}` as TKey)} @@ -1867,12 +1859,13 @@ export default function Models({ apiBase, restartEpoch = 0, catalogSyncedAt }: { type="button" className="btn btn-ghost btn-sm text-caption" style={{ color: "var(--red)" }} - onClick={() => { - if (window.confirm(t("models.customDeleteConfirm", { name: m.displayName ?? m.id }))) { - void deleteCustomModel(m.customId!); - } - setHoveredModel(null); - }} + onClick={() => { + // Hover is cleared AFTER the dialog closes, not before it + // opens: dropping it first unmounts this button, and the + // dialog then has nothing to return focus to. + void deleteCustomModel(m.customId!, m.displayName ?? m.id) + .finally(() => setHoveredModel(null)); + }} >{t("models.customDelete")}
    )} diff --git a/gui/src/pages/RemoteWorkspace.tsx b/gui/src/pages/RemoteWorkspace.tsx index d6eebb3258e..928a7f7ec25 100644 --- a/gui/src/pages/RemoteWorkspace.tsx +++ b/gui/src/pages/RemoteWorkspace.tsx @@ -4,6 +4,7 @@ import { readJsonOrThrow } from "../fetch-json"; import { IconLink, IconMonitor, IconPlus, IconRefresh, IconTerminal, IconTrash } from "../icons"; import { type TKey, useT } from "../i18n/shared"; import { Notice, Select } from "../ui"; +import { confirmAction } from "../action-dialogs"; import { remoteWorkspacePairingCommands } from "../remote-workspace-command"; type RuntimeProfile = "codex" | "claude" | "pi"; @@ -249,7 +250,7 @@ export default function RemoteWorkspace({ apiBase, hubOrigin }: { apiBase: strin }; const revokeDevice = async (device: RemoteDevice) => { - if (!confirm(t("remote.revokeConfirm", { name: device.name }))) return; + if (!(await confirmAction({ message: t("remote.revokeConfirm", { name: device.name }), confirmLabel: t("common.remove"), tone: "danger" }))) return; setBusy("revoke"); try { await mutate(`/api/remote-workspace/devices/${device.id}`, { method: "DELETE" }, t("remote.requestFailed")); diff --git a/gui/src/pages/RoutingProfiles.tsx b/gui/src/pages/RoutingProfiles.tsx index de737f803af..065df7a8153 100644 --- a/gui/src/pages/RoutingProfiles.tsx +++ b/gui/src/pages/RoutingProfiles.tsx @@ -18,6 +18,7 @@ import { } from "../routing-profile-editor-data"; import { readJsonIfOk } from "../fetch-json"; import { Notice } from "../ui"; +import { confirmAction } from "../action-dialogs"; import { useI18n, useT } from "../i18n/shared"; import { ROUTING_COMPATIBILITY_FIELD_LABELS } from "../i18n/routing-compatibility-labels"; @@ -486,7 +487,7 @@ export default function RoutingProfiles({ const removeProfile = async () => { if (!selected || saving) return; - if (!window.confirm(t("routing.removeConfirm", { id: selected.id }))) return; + if (!(await confirmAction({ message: t("routing.removeConfirm", { id: selected.id }), confirmLabel: t("common.remove"), tone: "danger" }))) return; setSaving(true); setStatus(null); try { diff --git a/gui/src/pages/Usage.tsx b/gui/src/pages/Usage.tsx index de96b16e115..b645b362b40 100644 --- a/gui/src/pages/Usage.tsx +++ b/gui/src/pages/Usage.tsx @@ -15,6 +15,7 @@ import { DataSurfaceSkeleton } from "../components/data-surface"; import { SectionTabs } from "../components/section-tabs"; import { sectionAnchorId } from "../section-anchors"; import { parseUsageTimeRange, type UsageRangeError, type UsageTimeWindow } from "../usage-time-range"; +import UsageCompanionPanel from "./usage-companion-panel"; type Range = "all" | "30d" | "7d"; type UsageSurface = "all" | "codex" | "claude" | "grok"; @@ -67,6 +68,12 @@ interface UsageModel { totalTokens: number; inputTokens: number; outputTokens: number; + cachedInputTokens?: number; + cacheReadInputTokens?: number; + cacheCreationInputTokens?: number; + cacheHitRate?: number | null; + /** Input tokens whose cache detail was observed; hit rate is not model-wide below inputTokens. */ + cacheObservedInputTokens?: number; /** API list-price estimate for the priced portion of this row. */ estimatedCostUsd?: number; /** Requests included in the API list-price estimate. */ @@ -128,6 +135,12 @@ type UsageCostRow = Pick — {excludedRequests > 0 && ( - {excludedCaption} + {excludedCaption} )} ); @@ -154,12 +167,43 @@ function UsageListPrice({ row, locale, t }: { row: UsageCostRow; locale: Locale; <> {formatUsdEstimate(row.estimatedCostUsd ?? 0, locale)} {excludedRequests > 0 && ( - {excludedCaption} + {excludedCaption} )} ); } +function formatOptionalTokens(value: number | undefined, locale: Locale, unavailable: string): string { + return typeof value === "number" && Number.isFinite(value) && value >= 0 + ? formatTokens(value, locale) + : unavailable; +} + +function formatOptionalPct(value: number | null | undefined, unavailable: string): string { + return typeof value === "number" && Number.isFinite(value) ? formatPct(value) : unavailable; +} + +/** + * Why a row's hit rate covers less than its input, or why it has none at all. + * + * The rate is an average over the input tokens whose cache detail was actually reported, so a + * provider that reports reads and never reports writes still has one. Only a row where nothing + * reported cache detail has nothing to average, and that is the row that shows an em dash. + */ +function cacheHitRateTitle(model: UsageModel, locale: Locale, t: TFn): string | undefined { + if (typeof model.cacheHitRate !== "number" || !Number.isFinite(model.cacheHitRate)) { + return t("usage.cacheHitRate.unmeasured"); + } + const observed = model.cacheObservedInputTokens; + if (typeof observed !== "number" || !Number.isFinite(observed) || observed >= model.inputTokens) { + return undefined; + } + return t("usage.cacheHitRate.partial", { + measured: formatTokens(observed, locale), + total: formatTokens(model.inputTokens, locale), + }); +} + // Stable per-model bar color: hash the provider/model id to a hue so the same model keeps its color // across days and renders. Saturation/lightness are fixed for a cohesive palette on the dark chart. function modelColor(model: string, provider: string): string { @@ -695,6 +739,7 @@ function UsageModelsTable({ const sectionLabel = t("usage.section.models"); const titleId = "usage-models-title"; const listPriceDisclaimerId = "usage-models-list-price-disclaimer"; + const unavailable = t("usage.unavailable"); const searchInput = ( - + {/* + Identity, then the three figures a reader compares models on, then the detail behind + them. The pair in front is also the pair the stylesheet pins while the rest scrolls + sideways, so their position here is load-bearing rather than cosmetic. + */} +
    - - + - + + + + + + + - {models.map(model => ( - - - - - - - - - - ))} + {models.map(model => { + const providerName = formatProviderDisplayName(model.provider, t); + const cacheCoverage = cacheHitRateTitle(model, locale, t); + return ( + + {/* Both pinned columns are width-capped, so carry the full value in a tooltip. */} + + + + + + + + + + + + {/* + The summary already averages only the input tokens whose cache detail was + reported, so whatever number it returns has a basis. Suppressing it unless that + basis covered the row's whole input is what hid a measured hit rate behind an em + dash for every provider that leaves some requests unreported; the coverage is a + note on the cell now, not a gate. + */} + + + ); + })}
    {t("logs.col.model")} {t("logs.col.provider")}{t("usage.col.requests")}{t("usage.col.measured")}{t("usage.col.share")} {t("usage.col.tokens")} {t("usage.col.apiListPrice")}{t("usage.col.share")}{t("usage.col.requests")}{t("usage.col.measured")}{t("usage.col.inputTokens")}{t("usage.col.outputTokens")}{t("usage.col.cacheHits")}{t("usage.col.cacheWrites")}{t("usage.col.cacheHitRate")}
    {modelLabel(model.model)}{formatProviderDisplayName(model.provider, t)}{model.requests}{model.measuredRequests}{formatTokens(model.totalTokens, locale)}
    {modelLabel(model.model)}{providerName}
    {formatTokens(model.totalTokens, locale)}{model.requests}{model.measuredRequests}{formatTokens(model.inputTokens, locale)}{formatTokens(model.outputTokens, locale)}{formatOptionalTokens(model.cacheReadInputTokens ?? model.cachedInputTokens, locale, unavailable)}{formatOptionalTokens(model.cacheCreationInputTokens, locale, unavailable)} + {formatOptionalPct(model.cacheHitRate, unavailable)} + {/* A `title` reaches a pointer and nothing else, so the sentence is also read. */} + {cacheCoverage !== undefined && {cacheCoverage}} +

    {t("usage.cost.disclaimer")}

    @@ -872,6 +948,7 @@ function UsageWorkspaceBody({ range, locale, t, + apiBase, }: { data: UsageResponse | null; heatmap: ReturnType; @@ -884,8 +961,10 @@ function UsageWorkspaceBody({ range: Range | null; locale: Locale; t: TFn; + apiBase: string; }) { const empty = !!data && data.summary.requests === 0; + const [companionMetric, setCompanionMetric] = useState(null); const sections = [ { id: "overview", @@ -920,6 +999,20 @@ function UsageWorkspaceBody({ meta: data ? formatPct(data.summary.coverageRatio) : "—", body: data ? : null, }, + { + id: "companion", + label: t("usage.section.companion"), + meta: companionMetric + ? t(`usage.companion.menu${companionMetric[0]!.toUpperCase()}${companionMetric.slice(1)}` as never) + : "—", + body: ( + + ), + }, ]; return (
    @@ -1190,6 +1283,7 @@ export default function Usage({ apiBase, connected = false, apiKeyId }: { apiBas range={customWindow ? null : range} locale={locale} t={t} + apiBase={apiBase} /> )} diff --git a/gui/src/pages/integrations/ConsequenceDialog.tsx b/gui/src/pages/integrations/ConsequenceDialog.tsx index 9ddaf95d871..2a2e85ebcea 100644 --- a/gui/src/pages/integrations/ConsequenceDialog.tsx +++ b/gui/src/pages/integrations/ConsequenceDialog.tsx @@ -88,7 +88,10 @@ export default function ConsequenceDialog({ dismiss(); }, [dismiss]); - const confirm = useCallback(async () => { + // Named for what it does rather than shadowing the banned global: a local `confirm` + // reads exactly like the platform dialog this dashboard no longer uses, and the source + // guard in tests/gui/platform-dialog-guard.test.ts cannot tell the two call forms apart. + const applyConsequence = useCallback(async () => { if (pending) return; setPending(true); setFailure(null); @@ -146,7 +149,7 @@ export default function ConsequenceDialog({ {failure && {failure}}
    -
    diff --git a/gui/src/pages/integrations/FileIntegrationPage.tsx b/gui/src/pages/integrations/FileIntegrationPage.tsx index 3ee7db1b967..b8806d563df 100644 --- a/gui/src/pages/integrations/FileIntegrationPage.tsx +++ b/gui/src/pages/integrations/FileIntegrationPage.tsx @@ -330,6 +330,14 @@ export default function FileIntegrationPage({

    {status.configPath}

    {/* Only the raycast envelope carries this; the guard is the field, not the id. */} {status.raycast && } + {/* + A file the client no longer opens. The badge above stays truthful about + the file -- our block really is where we put it -- so this is the only + place that can say the client has stopped reading it. + */} + {status.supersededBy && ( + {t("integrations.status.supersededStore", { path: status.supersededBy })} + )} {status.appliedAt && (

    diff --git a/gui/src/pages/integrations/IntegrationPlanDetails.tsx b/gui/src/pages/integrations/IntegrationPlanDetails.tsx index 742794de93c..d412282c99a 100644 --- a/gui/src/pages/integrations/IntegrationPlanDetails.tsx +++ b/gui/src/pages/integrations/IntegrationPlanDetails.tsx @@ -35,6 +35,7 @@ const REFUSAL_KEYS: Partial> = { conflict: "integrations.plan.refusal.conflict", unsafe: "integrations.plan.refusal.unsafe", non_loopback: "integrations.plan.refusal.nonLoopback", + superseded_store: "integrations.plan.refusal.supersededStore", drift_requires_confirm: "integrations.plan.refusal.driftRequiresConfirm", snapshot_expired: "integrations.plan.refusal.snapshotExpired", write_failed: "integrations.plan.refusal.writeFailed", diff --git a/gui/src/pages/integrations/integration-api.ts b/gui/src/pages/integrations/integration-api.ts index 856a9b105d0..02733e095ce 100644 --- a/gui/src/pages/integrations/integration-api.ts +++ b/gui/src/pages/integrations/integration-api.ts @@ -36,6 +36,7 @@ export type IntegrationRefusalReason = | "conflict" | "unsafe" | "non_loopback" + | "superseded_store" | "drift_requires_confirm" | "snapshot_expired" | "write_failed"; @@ -61,6 +62,14 @@ export interface IntegrationStatus { appliedAt?: string; lastOpId?: string; reason?: IntegrationReason; + /** + * The store this client reads instead of `configPath`, when one exists. + * + * Independent of `state`: the block can be current in a file the client + * stopped opening, which is the one case where a green badge alone misleads. + * Same role as `raycast`, whose plan can make a written file inert. + */ + supersededBy?: string; snapshotCount: number; retentionDegraded: boolean; /** Aside's explicit account-backed profile scope and desired sync state. */ @@ -194,6 +203,7 @@ const REFUSAL_REASONS: ReadonlySet = new Set([ "conflict", "unsafe", "non_loopback", + "superseded_store", "drift_requires_confirm", "snapshot_expired", "write_failed", @@ -228,6 +238,10 @@ const PLAN_SCHEMA_PATHS = new Set([ "providers.[id=opencodex]", "settings.providers.opencodex", "catalog.providers.opencodex", + // ZCode reads its providers from a second file; a plan for it publishes that + // file's templates, and a path missing here is rejected as an invalid preview. + "config.providerConfigRules.providerRules.[providerId=opencodex]", + "config.modelConfigRules.providerModelRules.*", ]); const PLAN_CHANGE_LIMIT = 256; @@ -254,7 +268,13 @@ export function parseIntegrationMutationPlan(value: unknown): IntegrationMutatio || !PLAN_OPERATIONS.includes(value.operation as IntegrationPlanOperation) || !INTEGRATION_STATES.has(String(value.state)) || !PLAN_FOREIGN_EDITS.includes(value.foreignEdit as IntegrationPlanForeignEdit) - || typeof value.fingerprint !== "string" || !/^p1:(?:[0-9a-f]{32}|unbound)$/.test(value.fingerprint) + /* + * The version is matched as a version, not as `p1`. The server calls this + * token opaque and bumps its prefix whenever the inputs it binds change; a + * literal here made that bump a silent client-side rejection of every + * preview, which is a worse failure than the drift it was meant to catch. + */ + || typeof value.fingerprint !== "string" || !/^p[0-9]+:(?:[0-9a-f]{32}|unbound)$/.test(value.fingerprint) || typeof value.canApply !== "boolean" || typeof value.willChange !== "boolean" || !Array.isArray(value.changes) || value.changes.length > PLAN_CHANGE_LIMIT || (value.profileId !== undefined && (typeof value.profileId !== "number" || !Number.isSafeInteger(value.profileId) || value.profileId < 0)) @@ -283,7 +303,7 @@ export function parseIntegrationMutationPlan(value: unknown): IntegrationMutatio } if ((value.willChange && (!value.canApply || changes.length === 0)) || (!value.willChange && changes.length !== 0) - || (value.fingerprint === "p1:unbound" && value.canApply) + || ((value.fingerprint as string).endsWith(":unbound") && value.canApply) || (value.canApply === (value.refusalReason !== undefined))) throw invalidPreviewResponse(); return { version: 1, diff --git a/gui/src/pages/models-alias-editing.ts b/gui/src/pages/models-alias-editing.ts new file mode 100644 index 00000000000..e405d0c812c --- /dev/null +++ b/gui/src/pages/models-alias-editing.ts @@ -0,0 +1,54 @@ +import type { TFn } from "../i18n/shared"; +import { requestTextValue } from "../action-dialogs"; + +/** + * Alias editing for the models page, extracted so Models.tsx can host the in-page dialog + * without growing: that file sits on its recorded line cap, and the cap only ever moves + * down (tests/fixtures/file-size-baseline.json). + * + * Both editors used to open `window.prompt()`, which the app's webview cannot draw at all — + * wry implements no text input panel — so inside the app these two pencil buttons could + * not be used. See devlog/_plan/260921_app_runtime_ownership/050_webview_dialogs.md. + */ +export interface AliasEditingDeps { + apiBase: string; + t: TFn; + /** Re-reads /api/aliases so the row shows what the server now holds. */ + reloadAliases: () => Promise; + publishFeedback: (ok: boolean, message: string) => void; +} + +/** Renames a provider. An empty value clears the alias, as the field label says. */ +export async function editProviderAlias( + provider: string, + current: string, + { apiBase, t, reloadAliases, publishFeedback }: AliasEditingDeps, +): Promise { + const entered = await requestTextValue({ message: t("models.aliasPrompt"), initialValue: current }); + // Dismissal is not an empty alias: a cancelled edit must write nothing. + if (entered === null) return; + const response = await fetch(`${apiBase}/api/providers/${encodeURIComponent(provider)}/alias`, { + method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ alias: entered.trim() || null }), + }); + if (!response.ok) { publishFeedback(false, t("models.aliasConflict")); return; } + await reloadAliases(); + publishFeedback(true, t("models.aliasSaved")); +} + +/** Renames one model within a provider. An empty value removes the alias. */ +export async function editModelAlias( + provider: string, + model: string, + current: string, + { apiBase, t, reloadAliases, publishFeedback }: AliasEditingDeps, +): Promise { + const entered = await requestTextValue({ message: t("models.modelAliasPrompt"), initialValue: current }); + if (entered === null) return; + const body = entered.trim() ? { set: { [model]: entered.trim() } } : { remove: [model] }; + const response = await fetch(`${apiBase}/api/providers/${encodeURIComponent(provider)}/model-aliases`, { + method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify(body), + }); + if (!response.ok) { publishFeedback(false, t("models.aliasConflict")); return; } + await reloadAliases(); + publishFeedback(true, t("models.aliasSaved")); +} diff --git a/gui/src/pages/startup-sections.tsx b/gui/src/pages/startup-sections.tsx index 301f568e01e..b808680f8bb 100644 --- a/gui/src/pages/startup-sections.tsx +++ b/gui/src/pages/startup-sections.tsx @@ -1,4 +1,5 @@ import { useI18n, type TKey } from "../i18n/shared"; +import { confirmAction } from "../action-dialogs"; import { startupRiskDetailKey } from "../startup-health-ui"; import { IconAlert, IconCheck, IconPower, IconTerminal } from "../icons"; import type { @@ -165,6 +166,17 @@ export function StartupTraySection({ }) { const { t } = useI18n(); + /** + * Uninstalling removes the tray helper, so it asks first. Written as a named async + * function rather than a promise chain inside the handler: a floating `.then` in a JSX + * handler has no rejection path, which is what `no-floating-then-in-jsx-handler` catches. + */ + const requestTrayUninstall = async () => { + if (await confirmAction({ message: t("startup.tray.uninstall"), tone: "danger" })) { + onTrayAction("uninstall"); + } + }; + return (

    @@ -196,9 +208,7 @@ export function StartupTraySection({ )} {!trayLoading && !trayError && tray && (tray.installed || tray.stale) && ( - + )}
    {(trayError || tray?.stale) && ( diff --git a/gui/src/pages/usage-companion-chart.tsx b/gui/src/pages/usage-companion-chart.tsx new file mode 100644 index 00000000000..0cf5505efb2 --- /dev/null +++ b/gui/src/pages/usage-companion-chart.tsx @@ -0,0 +1,119 @@ +import type { Locale, TFn } from "../i18n/shared"; +import { + chartPolylinePoints, + chartStackedBarRects, + formatCompanionTokens, + type UsageTimeline, +} from "./usage-companion-utils"; + +const CHART_COLORS = ["#0A84FF", "#FF9F0A", "#30D158", "#BF5AF2", "#FF453A", "#64D2FF"]; +const WIDTH = 640; +const HEIGHT = 160; +const PADDING = 28; + +function maxValue(timeline: UsageTimeline, chartStyle: "line" | "stackedBar"): number { + if (chartStyle === "stackedBar") { + return Math.max(...Array.from({ length: timeline.buckets }, (_, index) => + timeline.series.reduce((sum, series) => sum + (series.points[index] ?? 0), 0), + ), 0); + } + return Math.max(...timeline.series.flatMap(series => series.points), 0); +} + +function dateLabels(timeline: UsageTimeline, locale: Locale): string[] { + const formatter = new Intl.DateTimeFormat(locale, { month: "short", day: "numeric" }); + const interval = Math.max(1, Math.floor((timeline.buckets - 1) / 3)); + return [0, 1, 2, 3].map(index => { + const bucket = Math.min(timeline.buckets - 1, index * interval); + return formatter.format(new Date((timeline.start + bucket * timeline.bucketSeconds) * 1000)); + }); +} + +export function UsageCompanionChart({ + timeline, + chartStyle, + hours, + loading, + error, + onRetry, + locale, + t, +}: { + timeline: UsageTimeline | null; + chartStyle: "line" | "stackedBar"; + hours: number; + loading: boolean; + error: string | null; + onRetry: () => void; + locale: Locale; + t: TFn; +}) { + if (loading) { + return
    ; + } + if (error) { + return ( +
    + {t("usage.companion.timelineUnavailable")} + +
    + ); + } + if (!timeline || timeline.series.length === 0) { + return
    {t("usage.companion.empty", { hours: timeline?.buckets ? Math.round(timeline.buckets * timeline.bucketSeconds / 3600) : hours })}
    ; + } + const max = maxValue(timeline, chartStyle); + const labels = dateLabels(timeline, locale); + const plotWidth = WIDTH - PADDING * 2; + const plotHeight = HEIGHT - PADDING * 2; + const y = PADDING; + const baseline = PADDING + plotHeight; + const translate = "trans" + "late"; + const xLabels = labels.map((label, index) => ( + {label} + )); + const marks = chartStyle === "line" + ? timeline.series.map((series, index) => ( + + )) + : chartStackedBarRects(timeline.series, plotWidth, plotHeight, max, 0).map(rect => ( + + )); + return ( +
    + + + + {formatCompanionTokens(max)} + {marks} + {xLabels} + +
    + {timeline.series.map((series, index) => ( + + + ))} +
    + {timeline.truncated &&

    {t("usage.companion.olderRecordsSkipped")}

    } +
    + ); +} diff --git a/gui/src/pages/usage-companion-panel.tsx b/gui/src/pages/usage-companion-panel.tsx new file mode 100644 index 00000000000..44dd4f2dff2 --- /dev/null +++ b/gui/src/pages/usage-companion-panel.tsx @@ -0,0 +1,521 @@ +import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; +import { useI18n, type TFn, type TKey } from "../i18n/shared"; +import { relativeTimeLabelsFromT, formatRelativeTime } from "../provider-workspace/usage"; +import { Switch } from "../ui"; +import { UsageCompanionChart } from "./usage-companion-chart"; +import { desktopShellVersion, hostOs, isDesktopShell, type HostOs } from "../lib/desktop-shell"; +import { + bucketMinutesForWindow, + buildCompanionSettingsPatch, + formatCompanionTokens, + groupCompanionModels, + toggleCompanionModels, + type CompanionSettings, + type CompanionSettingsResponse, + type UsageTimeline, +} from "./usage-companion-utils"; + +interface CompanionProvider { + provider: string; +} + +const MENU_METRICS = ["requests", "tokens", "cost", "quota", "none"] as const; +const WINDOWS = [6, 24, 72, 168] as const; +const CHART_STYLES = ["line", "stackedBar"] as const; +const TOKEN_METRICS = ["total", "input", "output", "cached"] as const; +const AGGREGATIONS = ["sum", "average", "max"] as const; +const GROUPINGS = ["model", "modelAccount"] as const; +const MENU_METRIC_KEYS: Record<(typeof MENU_METRICS)[number], TKey> = { + requests: "usage.companion.menuRequests", + tokens: "usage.companion.menuTokens", + cost: "usage.companion.menuCost", + quota: "usage.companion.menuQuota", + none: "usage.companion.menuNone", +}; +const WINDOW_KEYS: Record<(typeof WINDOWS)[number], TKey> = { + 6: "usage.companion.window6", + 24: "usage.companion.window24", + 72: "usage.companion.window72", + 168: "usage.companion.window168", +}; +const TOKEN_METRIC_KEYS: Record<(typeof TOKEN_METRICS)[number], TKey> = { + total: "usage.companion.metricTotal", + input: "usage.companion.metricInput", + output: "usage.companion.metricOutput", + cached: "usage.companion.metricCached", +}; +const SECTION_OPTIONS = [ + ["showToday", "usage.companion.sectionToday"], + ["showChart", "usage.companion.sectionChart"], + ["showModels", "usage.companion.sectionModels"], + ["showCost", "usage.companion.sectionCost"], + ["showAccounts", "usage.companion.sectionAccounts"], +] as const; +const AGGREGATION_KEYS: Record<(typeof AGGREGATIONS)[number], TKey> = { + sum: "usage.companion.aggregationSum", + average: "usage.companion.aggregationAverage", + max: "usage.companion.aggregationMax", +}; +type InstallOs = Exclude; + +const INSTALL_STEP_KEYS: Record = { + macos: ["usage.companion.installMacStep1", "usage.companion.installMacStep2", "usage.companion.installMacStep3"], + windows: ["usage.companion.installWinStep1", "usage.companion.installWinStep2", "usage.companion.installWinStep3"], + linux: ["usage.companion.installLinuxStep1", "usage.companion.installLinuxStep2", "usage.companion.installLinuxStep3"], +}; + +const OS_LABEL_KEYS: Record = { + macos: "usage.companion.osMac", + windows: "usage.companion.osWindows", + linux: "usage.companion.osLinux", +}; + +function formatSaveTime(value: number, locale: string): string { + return new Intl.DateTimeFormat(locale, { hour: "2-digit", minute: "2-digit" }).format(value); +} + +function errorMessage(value: unknown): string { + if (value instanceof Error && value.message) return value.message; + return String(value); +} + +function OsSelector({ + value, + onChange, + t, +}: { + value: InstallOs; + onChange: (value: InstallOs) => void; + t: TFn; +}) { + return ( +
    + {(Object.keys(OS_LABEL_KEYS) as InstallOs[]).map(os => ( + + ))} +
    + ); +} + +function Segment({ + label, + value, + options, + optionLabel, + onChange, +}: { + label: string; + value: T; + options: readonly T[]; + optionLabel: (value: T) => string; + onChange: (value: T) => void; +}) { + return ( +
    + {label} +
    + {options.map(option => ( + + ))} +
    +
    + ); +} + +function SelectControl({ + label, + value, + options, + optionLabel, + onChange, +}: { + label: string; + value: T; + options: readonly T[]; + optionLabel: (value: T) => string; + onChange: (value: T) => void; +}) { + return ( + + ); +} + +function useVisible(ref: RefObject): boolean { + const [visible, setVisible] = useState(false); + useEffect(() => { + if (visible || !ref.current || typeof IntersectionObserver === "undefined") return; + const observer = new IntersectionObserver(entries => { + if (entries.some(entry => entry.isIntersecting)) { + setVisible(true); + observer.disconnect(); + } + }, { rootMargin: "240px" }); + observer.observe(ref.current); + return () => observer.disconnect(); + }, [ref, visible]); + return visible; +} + +export default function UsageCompanionPanel({ + apiBase, + providers, + onSettingsLoaded, +}: { + apiBase: string; + providers: CompanionProvider[]; + onSettingsLoaded?: (metric: CompanionSettings["menuBarMetric"]) => void; +}) { + const { t, locale } = useI18n(); + const rootRef = useRef(null); + const visible = useVisible(rootRef); + const [response, setResponse] = useState(null); + const [settings, setSettings] = useState(null); + const [timeline, setTimeline] = useState(null); + const [availableModels, setAvailableModels] = useState([]); + const [settingsError, setSettingsError] = useState(null); + const [timelineError, setTimelineError] = useState(null); + const [timelineLoading, setTimelineLoading] = useState(false); + const [saveState, setSaveState] = useState<"idle" | "saving" | "saved" | "error">("idle"); + const [fetchedAt, setFetchedAt] = useState(null); + const [saveError, setSaveError] = useState(null); + const saveTimer = useRef | null>(null); + const saveBaseline = useRef(null); + const timelineRequest = useRef(null); + const saveStateRef = useRef(saveState); + const settingsRef = useRef(settings); + const knownTotalsRef = useRef(new Map()); + const [knownTotals, setKnownTotals] = useState>(new Map()); + const [installOs, setInstallOs] = useState(() => { + const detected = hostOs(); + return detected === "unknown" ? "macos" : detected; + }); + + useEffect(() => { + saveStateRef.current = saveState; + }, [saveState]); + + useEffect(() => { + settingsRef.current = settings; + }, [settings]); + + const loadSettings = useCallback(async () => { + setSettingsError(null); + try { + const result = await fetch(`${apiBase}/api/companion/settings`); + if (!result.ok) throw new Error(`${result.status} ${result.statusText}`.trim()); + const next = await result.json() as CompanionSettingsResponse; + setResponse(next); + setFetchedAt(Date.now()); + setSettings(next.settings); + saveBaseline.current = next.settings; + onSettingsLoaded?.(next.settings.menuBarMetric); + } catch (error) { + setSettingsError(errorMessage(error)); + } + }, [apiBase, onSettingsLoaded]); + + useEffect(() => { + if (!visible || response) return; + const timer = setTimeout(() => void loadSettings(), 0); + return () => clearTimeout(timer); + }, [loadSettings, response, visible]); + + useEffect(() => { + if (!visible) return; + const interval = setInterval(() => { + if (saveStateRef.current === "saving") return; + if (settingsRef.current && saveBaseline.current !== settingsRef.current) return; + void loadSettings(); + }, 60_000); + return () => clearInterval(interval); + }, [loadSettings, visible]); + + const chartQuery = useMemo(() => { + if (!settings) return null; + const query = new URLSearchParams({ + hours: String(settings.chartHours), + bucketMinutes: String(settings.bucketMinutes), + metric: settings.tokenMetric, + aggregation: settings.aggregation, + grouping: settings.chartGrouping, + }); + if (settings.models?.length) query.set("models", settings.models.join(",")); + return query; + }, [settings]); + + const loadTimeline = useCallback(async () => { + if (!chartQuery) return; + timelineRequest.current?.abort(); + const controller = new AbortController(); + timelineRequest.current = controller; + setTimelineLoading(true); + setTimelineError(null); + try { + const result = await fetch(`${apiBase}/api/usage/timeline?${chartQuery}`, { signal: controller.signal }); + if (!result.ok) throw new Error(`${result.status} ${result.statusText}`.trim()); + const next = await result.json() as UsageTimeline; + setTimeline(next); + setAvailableModels(next.availableModels); + const currentTotals = new Map(); + for (const series of next.series) { + currentTotals.set(series.id, (currentTotals.get(series.id) ?? 0) + series.total); + } + for (const [id, total] of currentTotals) { + knownTotalsRef.current.set(id, total); + } + setKnownTotals(new Map(knownTotalsRef.current)); + } catch (error) { + if (!controller.signal.aborted) setTimelineError(errorMessage(error)); + } finally { + if (!controller.signal.aborted) setTimelineLoading(false); + } + }, [apiBase, chartQuery]); + + useEffect(() => { + if (!visible || !chartQuery) return; + const timer = setTimeout(() => void loadTimeline(), 250); + const interval = setInterval(() => void loadTimeline(), 60_000); + return () => { + clearTimeout(timer); + clearInterval(interval); + timelineRequest.current?.abort(); + }; + }, [chartQuery, loadTimeline, visible]); + + const updateSettings = useCallback((patch: Partial) => { + if (response?.corrupt) return; + setSettings(current => current ? { ...current, ...patch } : current); + setSaveState("saving"); + setSaveError(null); + }, [response?.corrupt]); + + useEffect(() => { + if (response?.corrupt || !settings || !saveBaseline.current || saveBaseline.current === settings || saveState !== "saving") return; + if (saveTimer.current) clearTimeout(saveTimer.current); + saveTimer.current = setTimeout(async () => { + try { + const patch = buildCompanionSettingsPatch(settings, availableModels); + const result = await fetch(`${apiBase}/api/companion/settings`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ settings: patch }), + }); + const body = await result.json() as CompanionSettingsResponse | { error?: string }; + if (!result.ok) throw new Error(body && "error" in body && body.error ? body.error : `${result.status} ${result.statusText}`.trim()); + setResponse(body as CompanionSettingsResponse); + setFetchedAt(Date.now()); + setSettings((body as CompanionSettingsResponse).settings); + saveBaseline.current = (body as CompanionSettingsResponse).settings; + setSaveState("saved"); + } catch (error) { + setSaveError(errorMessage(error)); + setSaveState("error"); + } + }, 300); + return () => { + if (saveTimer.current) clearTimeout(saveTimer.current); + }; + }, [apiBase, availableModels, response?.corrupt, saveState, settings]); + + const reset = useCallback(async () => { + setSaveState("saving"); + setSaveError(null); + try { + const result = await fetch(`${apiBase}/api/companion/settings`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ reset: true }), + }); + if (!result.ok) throw new Error(`${result.status} ${result.statusText}`.trim()); + await loadSettings(); + setSaveState("saved"); + } catch (error) { + setSaveError(errorMessage(error)); + setSaveState("error"); + } + }, [apiBase, loadSettings]); + + const openInBrowser = useCallback(async () => { + try { + const path = location.hash ? `/${location.hash}` : "/#/usage"; + const result = await fetch(`${apiBase}/api/companion/open-in-browser`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ path }), + }); + if (!result.ok) throw new Error(`${result.status} ${result.statusText}`.trim()); + } catch (error) { + setSaveError(errorMessage(error)); + } + }, [apiBase]); + + if (settingsError) { + return

    {t("usage.companion.settingsUnavailable")}

    ; + } + const current = settings; + if (!current) { + return
    {t("common.loading")}
    ; + } + const providerNames = providers.map(provider => provider.provider).filter((provider, index, all) => all.indexOf(provider) === index).toSorted(); + const selectedModels = current.models ?? availableModels; + const selectedModelSet = new Set(selectedModels); + const modelGroups = groupCompanionModels(availableModels, knownTotals); + const hiddenProviderSet = new Set(current.hiddenProviders); + const saveMessage = saveState === "saved" && response?.updatedAt + ? t("usage.companion.saved", { time: formatSaveTime(response.updatedAt, locale) }) + : saveState === "error" ? t("usage.companion.saveFailed", { error: saveError ?? "" }) : ""; + return ( +
    + {response?.corrupt &&
    + {t("usage.companion.corrupt")} + +
    } +
    +
    +

    {t("usage.companion.title")}

    +

    {t("usage.companion.description")}

    +
    + {t("usage.companion.installGuide")} +
    + {(() => { + const lastSeenAt = response?.companion?.lastSeenAt ?? null; + const connected = lastSeenAt !== null && fetchedAt !== null && fetchedAt - lastSeenAt <= 10 * 60 * 1000; + const age = lastSeenAt === null || fetchedAt === null ? "" : formatRelativeTime(lastSeenAt, relativeTimeLabelsFromT(t), fetchedAt); + const shell = isDesktopShell(); + const shellVersion = desktopShellVersion() ?? __APP_VERSION__; + const stepKeys = INSTALL_STEP_KEYS[installOs]; + const steps = ( +
      +
    1. {t(stepKeys[0])} {t("common.github")}
    2. +
    3. {t(stepKeys[1])}
    4. +
    5. {t(stepKeys[2])}
    6. +
    + ); + const installCommand = installOs === "macos" + ? xattr -d com.apple.quarantine /Applications/OpenCodex.app + : installOs === "linux" + ? chmod +x OpenCodex-*.AppImage + : null; + const installGuidance = ( +
    + {t("usage.companion.installAnother")} + + {steps} + {installCommand} +
    + ); + if (shell) { + return ( +
    +

    {t("usage.companion.runningInDesktop", { version: shellVersion })}

    + + {installGuidance} +
    + ); + } + return connected ? ( +
    +
    + {installGuidance} +
    + ) : ( +
    + {t("usage.companion.installTitle")} + {lastSeenAt !== null &&

    {t("usage.companion.lastSeen", { age })}

    } + {lastSeenAt === null &&

    {t("usage.companion.notConnected")}

    } + + {steps} + {installCommand} +
    + ); + })()} + void loadTimeline()} locale={locale} t={t} /> + {modelGroups.length > 0 &&
    +
    +
    + {t("usage.companion.modelsOnChart")} + {t("usage.companion.modelsCount", { selected: selectedModels.length, total: availableModels.length })} +
    + {current.models !== null && } +
    +
    + {modelGroups.map(group => { + const selectedCount = group.models.filter(model => selectedModelSet.has(model.id)).length; + const groupOn = selectedCount === group.models.length; + return
    +
    + {group.provider} + {group.models.length} + 0 && !groupOn} + onClick={() => updateSettings({ models: toggleCompanionModels(current.models, availableModels, group.models.map(model => model.id), !groupOn) })} + disabled={response?.corrupt} + label={group.provider} + title={group.provider} + /> +
    + {group.models.map(model => { + const on = selectedModelSet.has(model.id); + return
    + updateSettings({ models: toggleCompanionModels(current.models, availableModels, [model.id], !on) })} + disabled={response?.corrupt} + label={model.id} + title={model.id} + /> + {model.id} + {knownTotals.has(model.id) ? formatCompanionTokens(model.total) : "—"} +
    ; + })} +
    ; + })} +
    +
    } +
    + t(MENU_METRIC_KEYS[value])} onChange={value => updateSettings({ menuBarMetric: value })} /> + t(WINDOW_KEYS[value])} onChange={value => updateSettings({ chartHours: value, bucketMinutes: bucketMinutesForWindow(value) })} /> + value === "line" ? t("usage.companion.styleLine") : t("usage.companion.styleStacked")} onChange={value => updateSettings({ chartStyle: value })} /> + t(TOKEN_METRIC_KEYS[value])} onChange={value => updateSettings({ tokenMetric: value })} /> + value === "model" ? t("usage.companion.groupModel") : t("usage.companion.groupAccount")} onChange={value => updateSettings({ chartGrouping: value })} /> +
    + {t("usage.companion.popoverSections")} + {SECTION_OPTIONS.map(([key, label]) => ( +
    + {t(label)} + +
    + ))} +
    +
    + {t("usage.companion.advanced")} +
    + t(AGGREGATION_KEYS[value])} onChange={value => updateSettings({ aggregation: value })} /> + + {providerNames.length > 0 &&
    {t("usage.companion.hideProviders")}{providerNames.map(provider => )}
    } +
    +
    +
    +
    + {saveMessage || "\u00a0"} + {saveState === "error" && } +
    + +

    {t("usage.companion.footer")}

    +
    + ); +} diff --git a/gui/src/pages/usage-companion-utils.ts b/gui/src/pages/usage-companion-utils.ts new file mode 100644 index 00000000000..9c14c85c117 --- /dev/null +++ b/gui/src/pages/usage-companion-utils.ts @@ -0,0 +1,215 @@ +export type TimelineMetric = "total" | "input" | "output" | "cached"; +export type TimelineAggregation = "sum" | "average" | "max"; +export type TimelineGrouping = "model" | "modelAccount"; +export type CompanionMenuBarMetric = "requests" | "tokens" | "cost" | "quota" | "none"; +export type CompanionChartStyle = "line" | "stackedBar"; +export type ChartHours = 6 | 24 | 72 | 168; + +export interface CompanionSettings { + menuBarMetric: CompanionMenuBarMetric; + menuBarTemplate: string | null; + showToday: boolean; + showChart: boolean; + showModels: boolean; + showCost: boolean; + showAccounts: boolean; + chartHours: ChartHours; + bucketMinutes: number; + chartStyle: CompanionChartStyle; + tokenMetric: TimelineMetric; + aggregation: TimelineAggregation; + chartGrouping: TimelineGrouping; + models: string[] | null; + hiddenProviders: string[]; +} + +export interface TimelineSeries { + id: string; + provider: string; + model: string; + accountLogLabel?: string; + total: number; + points: number[]; +} + +export interface UsageTimeline { + start: number; + end: number; + bucketSeconds: number; + buckets: number; + metric: TimelineMetric; + aggregation: TimelineAggregation; + grouping: TimelineGrouping; + series: TimelineSeries[]; + availableModels: string[]; + missingMeasurements: number; + truncated: boolean; +} + +export interface CompanionSettingsResponse { + settings: CompanionSettings; + updatedAt: number | null; + defaults: CompanionSettings; + corrupt?: boolean; + companion?: { + lastSeenAt: number | null; + kind?: "menuBar" | "desktop"; + }; +} + +export const CHART_BUCKET_MINUTES: Record = { + 6: 15, + 24: 60, + 72: 180, + 168: 360, +}; + +export function bucketMinutesForWindow(hours: ChartHours): number { + return CHART_BUCKET_MINUTES[hours]; +} + +export function formatCompanionTokens(value: number): string { + if (value < 1_000) return String(Math.round(value)); + const units = [ + [1_000_000_000_000, "T"], + [1_000_000_000, "B"], + [1_000_000, "M"], + [1_000, "K"], + ] as const; + for (let index = 0; index < units.length; index += 1) { + const [threshold, suffix] = units[index]!; + if (value >= threshold) { + const rounded = Math.round(value / threshold); + if (rounded >= 1000 && index > 0) { + const [largerThreshold, largerSuffix] = units[index - 1]!; + return `${Math.round(value / largerThreshold)}${largerSuffix}`; + } + return `${rounded}${suffix}`; + } + } + return String(Math.round(value)); +} + +export interface CompanionModelGroup { + provider: string; + models: { id: string; total: number }[]; + total: number; +} + +export function groupCompanionModels( + available: string[], + totals: Map, +): CompanionModelGroup[] { + const groups = new Map(); + for (const id of available) { + const provider = id.includes("/") ? id.slice(0, id.indexOf("/")) : id; + const group = groups.get(provider) ?? { provider, models: [], total: 0 }; + const total = totals.get(id) ?? 0; + group.models.push({ id, total }); + group.total += total; + groups.set(provider, group); + } + return Array.from(groups.values()) + .map(group => ({ + ...group, + models: group.models.toSorted((a, b) => b.total - a.total || a.id.localeCompare(b.id)), + })) + .toSorted((a, b) => b.total - a.total || a.provider.localeCompare(b.provider)); +} + +export function toggleCompanionModels( + selected: string[] | null, + available: string[], + ids: string[], + on: boolean, +): string[] | null { + const availableSet = new Set(available); + const next = new Set((selected ?? available).filter(id => availableSet.has(id))); + for (const id of ids) { + if (on) next.add(id); + else next.delete(id); + } + if (available.length > 0 && available.every(id => next.has(id))) return null; + return available.filter(id => next.has(id)); +} + +export function buildCompanionSettingsPatch( + patch: Partial, + availableModels: readonly string[] = [], +): Partial { + const next = { ...patch }; + if (typeof next.menuBarTemplate === "string" && next.menuBarTemplate.trim() === "") { + next.menuBarTemplate = null; + } + if (next.models !== undefined && availableModels.length > 0) { + const selected = next.models ?? []; + const selectedSet = new Set(selected); + const allSelected = selected.length === availableModels.length + && availableModels.every(model => selectedSet.has(model)); + if (allSelected) next.models = null; + } + return next; +} + +export function chartPolylinePoints( + points: readonly number[], + width: number, + height: number, + maxValue: number, + padding = 8, +): string { + const plotWidth = Math.max(0, width - padding * 2); + const plotHeight = Math.max(0, height - padding * 2); + const denominator = Math.max(maxValue, 1); + const divisor = Math.max(points.length - 1, 1); + return points.map((value, index) => { + const x = padding + plotWidth * index / divisor; + const y = padding + plotHeight * (1 - Math.max(0, value) / denominator); + return `${x},${y}`; + }).join(" "); +} + +export interface StackedBarRect { + x: number; + y: number; + width: number; + height: number; + seriesIndex: number; + bucketIndex: number; +} + +export function chartStackedBarRects( + series: readonly Pick[], + width: number, + height: number, + maxValue: number, + padding = 8, +): StackedBarRect[] { + const buckets = series[0]?.points.length ?? 0; + if (buckets === 0) return []; + const plotWidth = Math.max(0, width - padding * 2); + const plotHeight = Math.max(0, height - padding * 2); + const denominator = Math.max(maxValue, 1); + const gap = Math.min(3, plotWidth / Math.max(buckets * 8, 1)); + const barWidth = Math.max(0, plotWidth / buckets - gap); + const rects: StackedBarRect[] = []; + for (let bucketIndex = 0; bucketIndex < buckets; bucketIndex += 1) { + let offset = 0; + for (let seriesIndex = 0; seriesIndex < series.length; seriesIndex += 1) { + const value = Math.max(0, series[seriesIndex]?.points[bucketIndex] ?? 0); + const barHeight = plotHeight * value / denominator; + if (barHeight > 0) { + rects.push({ + x: padding + bucketIndex * (plotWidth / buckets) + gap / 2, + y: padding + plotHeight - offset - barHeight, + width: barWidth, + height: barHeight, + seriesIndex, + bucketIndex, + }); + } + offset += barHeight; + } + } + return rects; +} diff --git a/gui/src/provider-icons.ts b/gui/src/provider-icons.ts index e7906e773cd..11bc1be0c2d 100644 --- a/gui/src/provider-icons.ts +++ b/gui/src/provider-icons.ts @@ -84,6 +84,7 @@ const PROVIDER_ICON_ALIASES: Record = { parallel: "parallel.svg", sambanova: "sambanova.svg", scaleway: "scaleway.svg", + stepfun: "stepfun-color.svg", siliconflow: "siliconflow.svg", synthetic: "synthetic.svg", together: "together.svg", @@ -168,6 +169,7 @@ const PROVIDER_DISPLAY_NAMES: Record = { huggingface: "Hugging Face", "qwen-cloud": "Qwen Cloud", siliconflow: "SiliconFlow", + stepfun: "StepFun", "tencent-coding-plan": "Tencent Cloud Coding Plan", codebuddy: "CodeBuddy", "codebuddy-cn": "CodeBuddy CN", diff --git a/gui/src/stop-proxy.ts b/gui/src/stop-proxy.ts index 0798f9b8f0b..23a2e68688f 100644 --- a/gui/src/stop-proxy.ts +++ b/gui/src/stop-proxy.ts @@ -1,7 +1,21 @@ -export interface ProxyStopOutcome { - accepted: boolean; - message?: string; -} +/** + * Three answers, because the button having been pressed is not evidence that the server + * accepted anything. + * + * `accepted` means the proxy said so, or stopped answering afterwards — the normal shape of + * a clean shutdown, since the process that would send the response is the one going away. + * `rejected` means it answered and refused. `unknown` means the request's fate is genuinely + * not known: it may never have arrived, or it arrived and the answer was lost. These used to + * collapse into `accepted`, which turned "the user clicked" into "the server acted" and left + * the dashboard claiming a stop that had not happened. + * + * An unknown is resolved by READING the instance again, never by sending the mutation a + * second time: a stop that did arrive would be repeated against whatever now holds the port. + */ +export type ProxyStopOutcome = + | { status: "accepted"; message?: undefined } + | { status: "rejected"; message: string } + | { status: "unknown"; message: string }; interface ProxyStopPayload { success?: unknown; @@ -10,12 +24,20 @@ interface ProxyStopPayload { } const DEFAULT_STOP_TIMEOUT_MS = 15_000; +/** Short on purpose: this runs after the user has already waited out the stop request. */ +const DEFAULT_LIVENESS_TIMEOUT_MS = 3_000; export interface ProxyStopOptions { fetchFn?: typeof fetch; timeoutMs?: number; formatFailure?: (status: number) => string; + /** Shown when the follow-up read finds the proxy still answering. */ + formatStillRunning?: () => string; + /** Shown when neither the request nor the follow-up read settled the question. */ + formatUnknown?: () => string; mode?: "standalone" | "client"; + /** Budget for the follow-up liveness read. */ + livenessTimeoutMs?: number; } function failureMessage( @@ -28,16 +50,34 @@ function failureMessage( return formatFailure(status); } -function isAbortError(error: unknown): boolean { - return error instanceof DOMException - ? error.name === "AbortError" - : error instanceof Error && error.name === "AbortError"; +/** + * Reads `/healthz` to find out whether the runtime the stop targeted is still there. + * + * A refused connection is the answer we want: nothing is listening, so the stop took effect. + * A successful read is the opposite. Anything else stays undecided rather than being rounded + * to either. + */ +async function readLiveness( + apiBase: string, + fetchFn: typeof fetch, + timeoutMs: number, +): Promise<"gone" | "answering" | "undecided"> { + try { + const response = await fetchFn(`${apiBase}/healthz`, { + method: "GET", + cache: "no-store", + signal: AbortSignal.timeout(timeoutMs), + }); + return response.ok ? "answering" : "undecided"; + } catch { + return "gone"; + } } /** - * A dropped connection is expected once shutdown starts. A received response, however, - * is authoritative: either a non-2xx status or `{ success: false }` means the UI must - * leave its pending state and surface the server's restore error. + * A received response is authoritative: a non-2xx status or `{ success: false }` is a refusal + * the UI must surface. A lost connection is authoritative about nothing, so it is resolved by + * reading the instance rather than assumed. */ export async function requestProxyStop( apiBase: string, @@ -47,7 +87,10 @@ export async function requestProxyStop( fetchFn = fetch, timeoutMs = DEFAULT_STOP_TIMEOUT_MS, formatFailure = status => `Failed to stop proxy (HTTP ${status}).`, + formatStillRunning = () => "The proxy is still answering, so it did not stop.", + formatUnknown = () => "The proxy did not confirm the stop. Check whether it is still running.", mode = "standalone", + livenessTimeoutMs = DEFAULT_LIVENESS_TIMEOUT_MS, } = options; let response: Response; try { @@ -57,14 +100,33 @@ export async function requestProxyStop( ...(mode === "client" ? { headers: { "Content-Type": "application/json" }, body: "{}" } : {}), signal: AbortSignal.timeout(timeoutMs), }); - } catch (error) { - if (isAbortError(error)) return { accepted: true }; - return { accepted: true }; + } catch { + /* + * Timeout, abort, a refused connection and a connection dropped mid-response all land + * here and are different events. Disconnecting a client does not end the process, so + * there is nothing to re-read and the question stays open. Stopping the proxy does end + * it, so its absence afterwards is the evidence. + */ + if (mode === "client") return { status: "unknown", message: formatUnknown() }; + const liveness = await readLiveness(apiBase, fetchFn, livenessTimeoutMs); + if (liveness === "gone") return { status: "accepted" }; + if (liveness === "answering") return { status: "unknown", message: formatStillRunning() }; + return { status: "unknown", message: formatUnknown() }; } - const payload = await response.json().catch(() => null) as ProxyStopPayload | null; + let payload: ProxyStopPayload | null = null; + let bodyRead = true; + try { + payload = await response.json() as ProxyStopPayload; + } catch { + // The status line arrived and the body did not. On a refusal that is already enough to + // report; on an apparent success it is not, because `success: false` rides in the body + // this just lost. + bodyRead = false; + } if (!response.ok || payload?.success === false) { - return { accepted: false, message: failureMessage(payload, response.status, formatFailure) }; + return { status: "rejected", message: failureMessage(payload, response.status, formatFailure) }; } - return { accepted: true }; + if (!bodyRead) return { status: "unknown", message: formatUnknown() }; + return { status: "accepted" }; } diff --git a/gui/src/styles-usage-workspace.css b/gui/src/styles-usage-workspace.css index aa9e3bb45c6..91485e1a217 100644 --- a/gui/src/styles-usage-workspace.css +++ b/gui/src/styles-usage-workspace.css @@ -214,6 +214,206 @@ gap: 6px; } +.usage-companion-panel { + display: grid; + gap: 16px; + padding-top: 4px; +} +.usage-companion-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} +.usage-companion-header .panel-title { margin: 0; } +.usage-companion-header .card-sub { margin: 4px 0 0; } +.usage-companion-install { + display: grid; + gap: 10px; + padding: 12px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface); +} +.usage-companion-install summary { cursor: pointer; color: var(--text); font-size: 12px; font-weight: 600; } +.usage-companion-install-status { display: flex; align-items: center; gap: 8px; color: var(--text); font-size: 12px; } +.usage-companion-install-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--green); } +.usage-companion-install-steps { display: grid; gap: 8px; margin: 0; padding-left: 20px; color: var(--muted); font-size: 12px; } +.usage-companion-install-steps .btn { margin-left: 6px; } +.usage-companion-install-last-seen { margin: 0; } +.usage-companion-install-command { display: block; overflow-x: auto; padding: 7px 9px; border-radius: var(--radius-xs); background: var(--raised); color: var(--text); font-size: 11px; } +.usage-companion-models { display: grid; gap: 8px; } +.usage-companion-models-header { display: flex; align-items: center; justify-content: space-between; gap: 10px; } +.usage-companion-models-header > div { display: flex; align-items: baseline; gap: 8px; min-width: 0; } +.usage-companion-models-count { white-space: nowrap; } +.usage-companion-models-list { max-height: 280px; overflow-y: auto; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); } +.usage-companion-model-group + .usage-companion-model-group { border-top: 1px solid var(--border-soft); } +.usage-companion-model-group-header { position: sticky; top: 0; z-index: 1; display: flex; align-items: center; gap: 7px; padding: 8px 10px; background: color-mix(in srgb, var(--surface) 92%, var(--raised)); } +.usage-companion-model-provider { flex: 1; min-width: 0; overflow: hidden; color: var(--text); font-size: 12px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; } +.usage-companion-model-chip { padding: 2px 5px; border: 1px solid var(--border); border-radius: 999px; color: var(--muted); } +.usage-companion-model-group-header .switch { flex: 0 0 auto; } +.usage-companion-model-row { display: flex; align-items: center; gap: 8px; min-width: 0; padding: 7px 10px 7px 18px; } +.usage-companion-model-row .switch { flex: 0 0 auto; } +.usage-companion-model-row code { min-width: 0; overflow: hidden; color: var(--text); text-overflow: ellipsis; white-space: nowrap; } +.usage-companion-model-row.is-off code { color: var(--faint); text-decoration: line-through; } +.usage-companion-model-total { flex: 0 0 auto; margin-left: auto; font-variant-numeric: tabular-nums; } +.usage-companion-chart { min-width: 0; } +.usage-companion-chart svg { display: block; width: 100%; height: 160px; overflow: visible; } +.usage-companion-axis { stroke: var(--border); stroke-width: 1; } +.usage-companion-axis-label { fill: var(--muted); font-size: 10px; } +.usage-companion-legend { display: flex; flex-wrap: wrap; gap: 8px 14px; margin-top: 8px; } +.usage-companion-legend-item { display: inline-flex; align-items: center; gap: 5px; color: var(--muted); font-size: 11px; } +.usage-companion-swatch { width: 8px; height: 8px; border-radius: 50%; } +.usage-companion-chart-skeleton { + height: 160px; + border: 1px solid var(--border-soft); + background: var(--surface); + animation: pulse 1.2s ease-in-out infinite alternate; +} +.usage-companion-chart-state { display: flex; align-items: center; gap: 10px; min-height: 160px; color: var(--muted); } +.usage-companion-controls { display: grid; gap: 14px; border: 0; padding: 0; margin: 0; min-width: 0; } +.usage-companion-control { display: grid; gap: 6px; min-width: 0; } +.usage-companion-control > select, .usage-companion-control > input { + min-height: 34px; width: 100%; padding: 6px 9px; + border: 1px solid var(--border); border-radius: var(--radius-xs); + background: var(--raised); color: var(--text); font: inherit; +} +.usage-companion-control > .usage-segmented { width: fit-content; max-width: 100%; } +.field-label { color: var(--muted); font-size: 11.5px; font-weight: 550; } +.usage-companion-switches, .usage-companion-check-list { + display: grid; gap: 8px; border: 0; padding: 0; margin: 0; +} +.usage-companion-switches legend { padding: 0; margin-bottom: 2px; } +.usage-companion-switch { display: flex; align-items: center; justify-content: space-between; gap: 12px; color: var(--text); font-size: 12px; } +.usage-companion-switch .toggle { flex: 0 0 auto; } +.usage-companion-advanced { border-top: 1px solid var(--border-soft); padding-top: 12px; } +.usage-companion-advanced summary { cursor: pointer; color: var(--text); font-size: 12px; font-weight: 600; } +.usage-companion-advanced-body { display: grid; gap: 14px; padding-top: 12px; } +.usage-companion-check-list label { display: flex; align-items: center; gap: 7px; color: var(--text); font-size: 12px; } +.usage-companion-save-status { display: flex; align-items: center; gap: 8px; min-height: 26px; color: var(--muted); font-size: 11.5px; } +.usage-companion-save-status.is-error { color: var(--red); } +.usage-companion-loading { min-height: 160px; color: var(--muted); } + @media (max-width: 640px) { .usage-source-row { align-items: flex-start; flex-direction: column; } + .usage-companion-header { align-items: stretch; flex-direction: column; } + .usage-companion-header .btn { align-self: flex-start; } + .usage-companion-control > .usage-segmented { width: 100%; } + .usage-companion-control > .usage-segmented .usage-segmented-btn { flex: 1 1 0; min-width: 0; padding-inline: 6px; } +} + +/* ── Models table ─────────────────────────────────────── */ + +/* + `.tbl` is `width: 100%`, which divides the shell's width across this table's twelve columns + until an eight-digit token total folds onto a second line. Give the table the width its content + asks for and let the shell scroll sideways instead; `.tbl-wrap` is already `overflow-x: auto`, + so `min-width: 100%` is what keeps the table filling a shell wide enough to hold it. + + Every selector here is doubled as `.tbl.usage-models-tbl`, which is not decoration. This file is + `@import`ed from the top of `styles.css`, so the whole of `styles.css` cascades after it: a bare + `.usage-models-tbl { width: max-content }` ties `.tbl { width: 100% }` on specificity and loses + on source order, and the squeeze comes straight back. The rules that already lived in this file + buy the same margin with a descendant `.usw-section` prefix. +*/ +.tbl.usage-models-tbl { + --usage-models-model-col: 14rem; + --usage-models-provider-col: 11rem; + width: max-content; + min-width: 100%; +} + +.tbl.usage-models-tbl th, +.tbl.usage-models-tbl td { + white-space: nowrap; +} + +/* The share bar carries no text, so its column has no intrinsic width to size to. */ +.tbl.usage-models-tbl th:nth-child(3), +.tbl.usage-models-tbl td:nth-child(3) { + min-width: 9rem; +} + +/* + Model and provider stay legible while the numbers scroll under them. The scrollport is + `.tbl-wrap`, whose own `var(--space-3)` padding scrolls with the content, so — the same trick + the sticky header above plays with `top` — each offset is one padding step negative and the + stuck cell repaints that strip with its own background. The widths are fixed because the second + column's offset is the first column's width, and `box-sizing: border-box` is global, so the + declared width is the rendered width. A value too long for its column clips and keeps its full + text in the cell's `title`. +*/ +.tbl.usage-models-tbl th:nth-child(1), +.tbl.usage-models-tbl td:nth-child(1), +.tbl.usage-models-tbl th:nth-child(2), +.tbl.usage-models-tbl td:nth-child(2) { + position: sticky; + z-index: 2; + background: var(--surface); + overflow: hidden; + text-overflow: ellipsis; +} + +.tbl.usage-models-tbl th:nth-child(1), +.tbl.usage-models-tbl td:nth-child(1) { + left: calc(-1 * var(--space-3)); + width: var(--usage-models-model-col); + min-width: var(--usage-models-model-col); + max-width: var(--usage-models-model-col); +} + +.tbl.usage-models-tbl th:nth-child(2), +.tbl.usage-models-tbl td:nth-child(2) { + left: calc(var(--usage-models-model-col) - var(--space-3)); + width: var(--usage-models-provider-col); + min-width: var(--usage-models-provider-col); + max-width: var(--usage-models-provider-col); +} + +/* + Seam between the pinned pair and the scrolling columns, on the body rows only: the sticky + header cells already carry a `box-shadow` that repaints the header strip, and a second + declaration here would replace it rather than add to it. +*/ +.tbl.usage-models-tbl tbody td:nth-child(2) { + box-shadow: inset -1px 0 0 var(--border-soft); +} + +/* + A pinned cell has to stay opaque on hover. Two separate reasons, and missing either one lets + the scrolled columns read straight through the pinned pair: `.tbl tbody tr:hover td` outranks + the opaque fill above on its own, and `--hover` is a 3% overlay rather than a colour, so + assigning it alone replaces the surface with something almost entirely transparent. Paint the + overlay as a layer over the surface instead of in place of it. +*/ +.tbl.usage-models-tbl tbody tr:hover td:nth-child(1), +.tbl.usage-models-tbl tbody tr:hover td:nth-child(2) { + background: linear-gradient(var(--hover), var(--hover)), var(--surface); +} + +/* The corner cells sit above both the scrolling body and the rest of the sticky header row. */ +.tbl.usage-models-tbl thead th:nth-child(1), +.tbl.usage-models-tbl thead th:nth-child(2) { + z-index: 3; +} + +/* The exclusion caption is a line under the amount, never a wrap of the same line. */ +.usage-cost-note { + display: block; +} + +/* Narrower than this, two pinned columns cost more reading room than scrolling the table does. */ +@media (max-width: 720px) { + .tbl.usage-models-tbl th:nth-child(1), + .tbl.usage-models-tbl td:nth-child(1), + .tbl.usage-models-tbl th:nth-child(2), + .tbl.usage-models-tbl td:nth-child(2) { + position: static; + width: auto; + min-width: 0; + max-width: none; + } + .tbl.usage-models-tbl tbody td:nth-child(2) { + box-shadow: none; + } } diff --git a/gui/src/styles.css b/gui/src/styles.css index 28c9277c1ed..9bdd757f6ae 100644 --- a/gui/src/styles.css +++ b/gui/src/styles.css @@ -87,8 +87,8 @@ --radius-round: 50%; --radius-pill: 999px; - /* typography: product UI first, Korean-safe fallbacks, mono only for machine data */ - --font-ui: "OpenAI Sans", "Pretendard Variable", Pretendard, "Noto Sans KR", "Apple SD Gothic Neo", "Malgun Gothic", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, system-ui, sans-serif; + /* Keep product fonts first, then system UI fonts before Korean fallbacks that also cover Latin. */ + --font-ui: "OpenAI Sans", "Pretendard Variable", Pretendard, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, system-ui, "Apple SD Gothic Neo", "Noto Sans KR", "Malgun Gothic", sans-serif; --font-code: ui-monospace, "SFMono-Regular", "Cascadia Code", "JetBrains Mono", "Noto Sans Mono CJK KR", Menlo, Consolas, monospace; --font: var(--font-ui); --mono: var(--font-code); diff --git a/gui/src/styles/claude-desktop-mode-picker.css b/gui/src/styles/claude-desktop-mode-picker.css new file mode 100644 index 00000000000..ee6db473520 --- /dev/null +++ b/gui/src/styles/claude-desktop-mode-picker.css @@ -0,0 +1,22 @@ +/* ── Desktop connection mode picker (first-party default / gateway opt-in) ── */ +.claude-mode-picker { + display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 10px; + margin: 0 0 14px; padding: 10px 14px 12px; border: 1px solid var(--border); border-radius: var(--radius); + background: var(--surface); +} +.claude-mode-picker legend { padding: 0 6px; font-size: 12px; font-weight: 600; color: var(--muted); } +.claude-mode-picker:disabled { opacity: 0.6; } +.claude-mode-option { + display: grid; grid-template-columns: auto 1fr; column-gap: 10px; align-items: start; + padding: 10px 12px; border: 1px solid var(--border); border-radius: var(--radius); cursor: pointer; +} +.claude-mode-option.active { border-color: var(--accent); } +.claude-mode-option input { margin-top: 3px; } +.claude-mode-title { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; font-size: 13px; font-weight: 600; } +.claude-mode-hint { grid-column: 2; margin-top: 4px; font-size: 12px; color: var(--muted); line-height: 1.4; } +.claude-mode-default, .claude-mode-current { + padding: 1px 6px; border-radius: var(--radius-xs); font-size: 10px; font-weight: 600; letter-spacing: 0.02em; +} +.claude-mode-default { background: color-mix(in srgb, var(--accent) 12%, transparent); color: var(--accent); } +.claude-mode-current { background: color-mix(in srgb, var(--green) 15%, transparent); color: var(--green); } +.claude-mode-switch-note { grid-column: 1 / -1; font-size: 12px; color: var(--amber); } diff --git a/gui/src/styles/provider-workspace-settings.css b/gui/src/styles/provider-workspace-settings.css index 34a9e808971..5dc15873e1f 100644 --- a/gui/src/styles/provider-workspace-settings.css +++ b/gui/src/styles/provider-workspace-settings.css @@ -29,6 +29,7 @@ background: var(--raised); border-radius: var(--radius-xs); } .pwi-auth-state--error { color: var(--red); background: var(--red-soft); justify-content: space-between; } +.pwi-auth-state--stale { color: var(--amber); background: var(--amber-soft); justify-content: space-between; } .pwi-auth-state--empty { justify-content: center; } .pwi-auth-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 8px; } diff --git a/gui/src/use-codex-restart.ts b/gui/src/use-codex-restart.ts index ba6d6c7bdb5..30c85d42a92 100644 --- a/gui/src/use-codex-restart.ts +++ b/gui/src/use-codex-restart.ts @@ -2,11 +2,13 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useI18n } from "./i18n/shared"; import { requestCodexRestart } from "./codex-restart"; import type { CodexRestartCode } from "./codex-restart"; +import { confirmAction } from "./action-dialogs"; +import type { NoticeTone } from "./ui"; export interface CodexRestartController { restarting: boolean; /** - * Resolves to the response code, or null when the user declined the confirm or + * Resolves to the response code, or null when the user declined the consent gate or * the call failed. Callers that track staleness must treat BOTH `stopped` and * `nothing_running` as "no stale app-server remains" — the second is the race * where the target exited on its own, and refreshing on only the first would @@ -23,6 +25,17 @@ export interface CodexRestartOptions { * models page. */ onSettled?: (code: CodexRestartCode) => void; + /** + * Where the outcome is shown. Required, and deliberately not defaulted: this used to + * be `alert()`, which draws nothing inside the app, so every result — including a + * partial stop that left app-servers running — was reported to no one. A consumer that + * forgets to render it now fails to compile instead of failing silently. + * + * The sink has to outlive the surface that called `restart`. Enumeration can take tens + * of seconds, and a user who navigates away meanwhile is exactly the user who needs to + * be told that app-servers survived, so both consumers report through the shell. + */ + report: (message: string, tone: NoticeTone) => void; } /** True when the outcome means nothing stale is left running. */ @@ -33,14 +46,18 @@ export function isRestartSettled(code: CodexRestartCode): boolean { /** * Shared restart action for the sidebar and the models page. * - * The confirm is not ceremony: stopping an app-server can interrupt a Codex turn + * The consent gate is not ceremony: stopping an app-server can interrupt a Codex turn * that is running right now. That is precisely the consent the startup path * refuses to assume on the user's behalf (src/codex/app-server-processes.ts), * and a dashboard click is where the user gives it. + * + * It is an in-page dialog rather than `confirm()` because the app's webview implements + * no JavaScript panel delegate: `confirm()` returned false there without drawing + * anything, so this button took its early return on every click and did nothing at all. */ export function useCodexRestart( apiBase: string, - options: CodexRestartOptions = {}, + options: CodexRestartOptions, ): CodexRestartController { const { t } = useI18n(); const [restarting, setRestarting] = useState(false); @@ -48,20 +65,60 @@ export function useCodexRestart( // completion path must not touch state after unmount. const mounted = useRef(true); const onSettled = useRef(options.onSettled); + const report = useRef(options.report); + /* + * The subject the consent is about. A restart names one backend, and the dialog is + * asynchronous now, so the target can change or the surface can go away while the question + * is still on screen. Holding the current base in a ref lets the confirmed path check that + * what the user approved is still what would be stopped. + */ + const currentApiBase = useRef(apiBase); + /** The consent currently on screen, so a changed subject can withdraw it. */ + const pendingConsent = useRef(null); useEffect(() => { // Written in an effect, not during render: a ref assignment in the render // body is exactly what the react-compiler lint forbids. onSettled.current = options.onSettled; - }, [options.onSettled]); + report.current = options.report; + }, [options.onSettled, options.report]); + + useEffect(() => { + // A new base is a new subject: a consent granted for the old one no longer describes + // what would happen, so it is withdrawn rather than silently re-pointed. + currentApiBase.current = apiBase; + return () => { + pendingConsent.current?.abort(); + pendingConsent.current = null; + }; + }, [apiBase]); useEffect(() => { mounted.current = true; - return () => { mounted.current = false; }; + return () => { + mounted.current = false; + pendingConsent.current?.abort(); + pendingConsent.current = null; + }; }, []); const restart = useCallback(async (): Promise => { - if (!confirm(t("dash.codexRestartConfirm"))) return null; + const consent = new AbortController(); + pendingConsent.current?.abort(); + pendingConsent.current = consent; + const consented = await confirmAction({ + message: t("dash.codexRestartConfirm"), + confirmLabel: t("dash.codexRestart"), + tone: "danger", + signal: consent.signal, + }); + if (pendingConsent.current === consent) pendingConsent.current = null; + /* + * Confirmed for THIS base, while this surface was still mounted. Either could have + * changed while the dialog was open, and sending anyway would apply the user's approval + * to a subject they were never shown. + */ + if (!consented || !mounted.current || currentApiBase.current !== apiBase) return null; setRestarting(true); const outcome = await requestCodexRestart(apiBase, { formatFailure: status => t("dash.codexRestartFailed", { status: String(status) }), @@ -71,20 +128,21 @@ export function useCodexRestart( }); if (mounted.current) setRestarting(false); - if (!outcome.ok || !outcome.result) { - alert(outcome.message); + if (!outcome.ok) { + report.current(outcome.message, "err"); return null; } const result = outcome.result; if (result.code === "stopped") { - alert(t("dash.codexRestartDone", { count: String(result.stopped.length) })); + report.current(t("dash.codexRestartDone", { count: String(result.stopped.length) }), "ok"); } else if (result.code === "nothing_running") { - alert(t("dash.codexRestartNothing")); + report.current(t("dash.codexRestartNothing"), "ok"); } else if (result.code === "enumeration_unavailable") { - alert(t("dash.codexRestartUnknown")); + report.current(t("dash.codexRestartUnknown"), "warn"); } else { - alert(t("dash.codexRestartPartial", { count: String(result.surviving.length) })); + // Degraded, not failed: something is still running, so it must not read as success. + report.current(t("dash.codexRestartPartial", { count: String(result.surviving.length) }), "warn"); } // Only while mounted: a settled callback typically starts a refresh fetch, @@ -95,4 +153,3 @@ export function useCodexRestart( return { restarting, restart }; } - diff --git a/gui/tests/action-dialogs.test.ts b/gui/tests/action-dialogs.test.ts new file mode 100644 index 00000000000..e2646587f77 --- /dev/null +++ b/gui/tests/action-dialogs.test.ts @@ -0,0 +1,293 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { confirmAction, requestTextValue } from "../src/action-dialogs"; + +/** + * Runtime behaviour of the in-page replacements for confirm/alert/prompt. + * + * These tests assert the ABSENCE of the platform dialogs rather than stubbing them in. The + * original defect survived CI precisely because the GUI tests encoded browser dialogs as + * available — one stubbed `confirm()` to true, another forced confirmation, a third + * asserted that `alert()` existed — so a dashboard that could not draw any of them still + * looked correct. Every test here installs a throwing stub for all three: reaching one is a + * failure, not a mock. + */ +/* + * `HTMLElement` is deliberately absent. These helpers are reached from a dozen components, + * and an `instanceof HTMLElement` focus check bound every one of their callers to a realm + * that happens to expose the constructor — which most of this package's DOM tests do not. + * Leaving the global out is what keeps that from coming back. + */ +const globals = ["document", "window", "navigator", "localStorage", "confirm", "alert", "prompt"] as const; +let previous: Record<(typeof globals)[number], unknown>; +let win: Window; +let touched: string[]; + +function forbidPlatformDialogs(): void { + for (const name of ["confirm", "alert", "prompt"] as const) { + const trap = () => { touched.push(name); throw new Error(`${name}() must not be reached`); }; + Object.defineProperty(globalThis, name, { configurable: true, value: trap }); + Object.defineProperty(win, name, { configurable: true, value: trap }); + } +} + +beforeEach(() => { + previous = Object.fromEntries(globals.map(key => [key, Reflect.get(globalThis, key)])) as typeof previous; + touched = []; + win = new Window({ url: "http://localhost/" }); + Object.defineProperty(win.navigator, "language", { configurable: true, value: "en-US" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: win.document }, + window: { configurable: true, value: win }, + navigator: { configurable: true, value: win.navigator }, + localStorage: { configurable: true, value: win.localStorage }, + }); + forbidPlatformDialogs(); +}); + +afterEach(() => { + for (const key of globals) Object.defineProperty(globalThis, key, { configurable: true, value: previous[key] }); + expect(touched).toEqual([]); +}); + +/** The one dialog currently mounted, which is what every assertion below reads. */ +function openDialog(): HTMLDialogElement { + const dialog = win.document.querySelector("dialog"); + if (!dialog) throw new Error("no dialog was opened"); + return dialog as unknown as HTMLDialogElement; +} + +function buttonLabelled(text: string): HTMLButtonElement { + const match = [...openDialog().querySelectorAll("button")] + .find(button => button.textContent?.trim() === text); + if (!match) throw new Error(`no button labelled ${text}`); + return match as unknown as HTMLButtonElement; +} + +/** Lets the dialog's queued focus call run before the assertion reads activeElement. */ +const settled = () => new Promise(resolve => { queueMicrotask(resolve); }); + +test("a refusal resolves false and leaves nothing mounted", async () => { + const answer = confirmAction({ message: "Stop the proxy?" }); + await settled(); + buttonLabelled("Cancel").click(); + expect(await answer).toBe(false); + expect(win.document.querySelector("dialog")).toBeNull(); +}); + +test("accepting resolves true", async () => { + const answer = confirmAction({ message: "Stop the proxy?", confirmLabel: "Stop" }); + await settled(); + buttonLabelled("Stop").click(); + expect(await answer).toBe(true); +}); + +test("Escape is a refusal, not an unanswered close", async () => { + // A native closes on Escape without saying so. Reported as a refusal, the + // caller's early return still runs and no request is issued. + const answer = confirmAction({ message: "Remove the account?" }); + await settled(); + openDialog().dispatchEvent(new win.Event("cancel", { cancelable: true }) as unknown as Event); + expect(await answer).toBe(false); +}); + +test("the backdrop dismisses as a refusal", async () => { + const answer = confirmAction({ message: "Remove the key?" }); + await settled(); + (openDialog().querySelector(".modal-backdrop-dismiss") as unknown as HTMLButtonElement).click(); + expect(await answer).toBe(false); +}); + +test("focus returns to the control that opened the dialog", async () => { + const trigger = win.document.createElement("button"); + win.document.body.appendChild(trigger); + trigger.focus(); + + const answer = confirmAction({ message: "Revoke the device?" }); + await settled(); + expect(win.document.activeElement).not.toBe(trigger); + buttonLabelled("Cancel").click(); + await answer; + expect(win.document.activeElement).toBe(trigger); +}); + +test("a destructive action does not put the accepting button under Enter", async () => { + const danger = confirmAction({ message: "Delete it?", confirmLabel: "Delete", tone: "danger" }); + await settled(); + expect(win.document.activeElement?.textContent).toBe("Cancel"); + buttonLabelled("Cancel").click(); + await danger; + + const ordinary = confirmAction({ message: "Switch account mode?" }); + await settled(); + expect(win.document.activeElement?.textContent).toBe("OK"); + buttonLabelled("Cancel").click(); + await ordinary; +}); + +test("the message names the dialog and keeps its paragraphs", async () => { + const answer = confirmAction({ message: "Draining takes 20s.\n\nNo supervisor is running." }); + await settled(); + const dialog = openDialog(); + const named = dialog.getAttribute("aria-labelledby"); + const body = dialog.querySelector(`#${named}`); + expect(body).not.toBeNull(); + expect([...body!.querySelectorAll("p")].map(p => p.textContent)).toEqual([ + "Draining takes 20s.", + "No supervisor is running.", + ]); + buttonLabelled("Cancel").click(); + await answer; +}); + +test("text entry resolves the value and cancels to null", async () => { + const entry = requestTextValue({ message: "Display name", initialValue: "old" }); + await settled(); + const input = openDialog().querySelector("input") as unknown as HTMLInputElement; + expect(input.value).toBe("old"); + input.value = "new name"; + (openDialog().querySelector("form") as unknown as HTMLFormElement) + .dispatchEvent(new win.Event("submit", { cancelable: true, bubbles: true }) as unknown as Event); + expect(await entry).toBe("new name"); + + const cancelled = requestTextValue({ message: "Display name", initialValue: "old" }); + await settled(); + buttonLabelled("Cancel").click(); + // Null, not "": a dismissal must not be read as a request to clear the alias. + expect(await cancelled).toBeNull(); +}); + +test("a rejected value keeps the dialog open and reports beside the field", async () => { + const entry = requestTextValue({ + message: "Display name", + maxLength: 80, + validate: value => (value.trim().length > 80 ? "too long" : null), + }); + await settled(); + const dialog = openDialog(); + const input = dialog.querySelector("input") as unknown as HTMLInputElement; + const form = dialog.querySelector("form") as unknown as HTMLFormElement; + expect(input.getAttribute("maxlength")).toBe("80"); + + input.value = "x".repeat(81); + form.dispatchEvent(new win.Event("submit", { cancelable: true, bubbles: true }) as unknown as Event); + // Still mounted, still unresolved: a rejected value is not an answer. + expect(win.document.querySelector("dialog")).not.toBeNull(); + const error = dialog.querySelector("[role=alert]"); + expect(error?.textContent).toBe("too long"); + expect((error as unknown as HTMLElement).hidden).toBe(false); + expect(input.getAttribute("aria-invalid")).toBe("true"); + + // Editing clears the report and re-arms the button, so its state always describes the + // value currently in the field. + input.value = "short"; + input.dispatchEvent(new win.Event("input", { bubbles: true }) as unknown as Event); + expect((dialog.querySelector("[role=alert]") as unknown as HTMLElement).hidden).toBe(true); + expect(input.getAttribute("aria-invalid")).toBeNull(); + + form.dispatchEvent(new win.Event("submit", { cancelable: true, bubbles: true }) as unknown as Event); + expect(await entry).toBe("short"); +}); + +test("two dialogs opened in one document do not share element ids", async () => { + const first = confirmAction({ message: "First" }); + await settled(); + const firstId = openDialog().getAttribute("aria-labelledby"); + buttonLabelled("Cancel").click(); + await first; + + const second = confirmAction({ message: "Second" }); + await settled(); + expect(openDialog().getAttribute("aria-labelledby")).not.toBe(firstId); + buttonLabelled("Cancel").click(); + await second; +}); + +test("navigating away is a refusal, and leaves no dialog behind", async () => { + /* + * The dialog is mounted on , so it outlives the React subtree that opened it. + * Without this, Back/Forward while a consent dialog is open would leave it on screen and + * accepting it afterwards would resume a closed-over handler against a page the user had + * already left — removing an account from a surface they cannot see. + */ + const answer = confirmAction({ message: "Remove the account?", tone: "danger" }); + await settled(); + expect(win.document.querySelector("dialog")).not.toBeNull(); + + win.dispatchEvent(new win.Event("popstate") as unknown as Event); + expect(await answer).toBe(false); + expect(win.document.querySelector("dialog")).toBeNull(); +}); + +test("a hash change is the same refusal", async () => { + const answer = confirmAction({ message: "Revoke the device?", tone: "danger" }); + await settled(); + win.dispatchEvent(new win.Event("hashchange") as unknown as Event); + expect(await answer).toBe(false); +}); + +test("Escape is answered at the document when the dialog is not modal", async () => { + /* + * Forces the branch that merely sets `open`, because this DOM does implement + * `showModal` and would otherwise never reach it. That branch is not modal: a listener on + * the dialog element would miss Escape as soon as focus sat anywhere else, so the + * listener lives on the document for exactly this case. + */ + const dialogPrototype = win.HTMLDialogElement.prototype as unknown as { showModal?: unknown }; + const nativeShowModal = dialogPrototype.showModal; + delete dialogPrototype.showModal; + try { + const answer = requestTextValue({ message: "Display name" }); + await settled(); + win.document.dispatchEvent( + new win.KeyboardEvent("keydown", { key: "Escape", bubbles: true }) as unknown as Event, + ); + expect(await answer).toBeNull(); + expect(win.document.querySelector("dialog")).toBeNull(); + } finally { + if (nativeShowModal !== undefined) dialogPrototype.showModal = nativeShowModal; + } +}); + +test("a settled dialog stops listening for navigation", async () => { + // The window listeners must come off in finish(), or every dialog ever opened would keep + // a closure alive and a later navigation would re-enter it. + const answer = confirmAction({ message: "Stop the proxy?" }); + await settled(); + buttonLabelled("Cancel").click(); + expect(await answer).toBe(false); + // A navigation after settlement must be inert: no dialog, no second resolution, no throw. + win.dispatchEvent(new win.Event("popstate") as unknown as Event); + win.document.dispatchEvent( + new win.KeyboardEvent("keydown", { key: "Escape", bubbles: true }) as unknown as Event, + ); + expect(win.document.querySelector("dialog")).toBeNull(); +}); + +test("a withdrawn consent resolves as a refusal", async () => { + /* + * A consent names a subject. Once the dialog is asynchronous the subject can change or go + * away while the question is still on screen, and answering it then would apply the user's + * approval to something they were never shown. Withdrawing resolves it as a refusal. + */ + const withdrawal = new AbortController(); + const answer = confirmAction({ message: "Restart Codex?", signal: withdrawal.signal }); + await settled(); + expect(win.document.querySelector("dialog")).not.toBeNull(); + + withdrawal.abort(); + expect(await answer).toBe(false); + expect(win.document.querySelector("dialog")).toBeNull(); +}); + +test("a consent withdrawn before it opens is never drawn", async () => { + const withdrawal = new AbortController(); + withdrawal.abort(); + const answer = confirmAction({ message: "Restart Codex?", signal: withdrawal.signal }); + expect(await answer).toBe(false); + expect(win.document.querySelector("dialog")).toBeNull(); + + const entry = requestTextValue({ message: "Display name", signal: withdrawal.signal }); + expect(await entry).toBeNull(); + expect(win.document.querySelector("dialog")).toBeNull(); +}); diff --git a/gui/tests/app-stop.test.ts b/gui/tests/app-stop.test.ts index c0711fa0fed..633f89155a5 100644 --- a/gui/tests/app-stop.test.ts +++ b/gui/tests/app-stop.test.ts @@ -15,8 +15,8 @@ describe("App proxy stop", () => { seen.push({ url: String(input), method: String(init?.method), body: init?.body }); return response({ success: true }, init?.body ? 202 : 200); }) as typeof fetch; - expect((await requestProxyStop("http://machine", { fetchFn })).accepted).toBe(true); - expect((await requestProxyStop("http://machine", { fetchFn, mode: "client" })).accepted).toBe(true); + expect((await requestProxyStop("http://machine", { fetchFn })).status).toBe("accepted"); + expect((await requestProxyStop("http://machine", { fetchFn, mode: "client" })).status).toBe("accepted"); expect(seen).toEqual([ { url: "http://machine/api/stop", method: "POST", body: undefined }, { url: "http://machine/api/machine/disconnect", method: "POST", body: "{}" }, @@ -32,7 +32,7 @@ describe("App proxy stop", () => { formatFailure: status => `Failed to stop proxy (HTTP ${status}).`, }); - expect(outcome).toEqual({ accepted: false, message: "native Codex restore failed" }); + expect(outcome).toEqual({ status: "rejected", message: "native Codex restore failed" }); }); test("rejects an HTTP 200 cleanup failure and exposes its server message", async () => { @@ -44,18 +44,77 @@ describe("App proxy stop", () => { formatFailure: status => `Failed to stop proxy (HTTP ${status}).`, }); - expect(outcome).toEqual({ accepted: false, message: "native Codex cleanup failed" }); + expect(outcome).toEqual({ status: "rejected", message: "native Codex cleanup failed" }); }); - test("treats a stop timeout like a dropped connection", async () => { - const outcome = await requestProxyStop("", { - fetchFn: (async () => { - throw new DOMException("The operation timed out.", "AbortError"); + /* + * A lost connection is not an answer. It used to be reported as acceptance, which turned + * "the user pressed the button" into "the server acted": a stop that never arrived read + * exactly like one that succeeded. The fate of the request is settled by READING the + * instance again, never by sending the mutation a second time. + */ + test("a dropped stop is accepted only once the proxy has actually gone", async () => { + const seen: string[] = []; + const outcome = await requestProxyStop("http://machine", { + fetchFn: (async (input: RequestInfo | URL) => { + const url = String(input); + seen.push(url); + // The proxy dies mid-response, then nothing answers the follow-up read. + throw new TypeError("network error"); + }) as typeof fetch, + }); + + expect(outcome).toEqual({ status: "accepted" }); + expect(seen).toEqual(["http://machine/api/stop", "http://machine/healthz"]); + }); + + test("a proxy that still answers after a dropped stop is unknown, not accepted", async () => { + const outcome = await requestProxyStop("http://machine", { + fetchFn: (async (input: RequestInfo | URL) => { + if (String(input).endsWith("/healthz")) return response({ status: "ok" }); + throw new DOMException("The operation timed out.", "TimeoutError"); + }) as typeof fetch, + formatStillRunning: () => "still answering", + }); + + expect(outcome).toEqual({ status: "unknown", message: "still answering" }); + }); + + test("an unsettled follow-up read stays unknown", async () => { + const outcome = await requestProxyStop("http://machine", { + fetchFn: (async (input: RequestInfo | URL) => { + if (String(input).endsWith("/healthz")) return response({}, 503); + throw new DOMException("aborted", "AbortError"); }) as typeof fetch, - timeoutMs: 1, + formatUnknown: () => "not confirmed", + }); + + expect(outcome).toEqual({ status: "unknown", message: "not confirmed" }); + }); + + test("a dropped disconnect is unknown, because disconnecting ends no process", async () => { + const seen: string[] = []; + const outcome = await requestProxyStop("http://machine", { + fetchFn: (async (input: RequestInfo | URL) => { + seen.push(String(input)); + throw new TypeError("network error"); + }) as typeof fetch, + mode: "client", + formatUnknown: () => "not confirmed", + }); + + // No liveness read: the proxy answering proves nothing about a client disconnect. + expect(outcome).toEqual({ status: "unknown", message: "not confirmed" }); + expect(seen).toEqual(["http://machine/api/machine/disconnect"]); + }); + + test("a 2xx whose body cannot be read is unknown, because success: false rides in it", async () => { + const outcome = await requestProxyStop("", { + fetchFn: (async () => new Response("not json", { status: 200 })) as typeof fetch, + formatUnknown: () => "not confirmed", }); - expect(outcome).toEqual({ accepted: true }); + expect(outcome).toEqual({ status: "unknown", message: "not confirmed" }); }); test("uses the localized fallback when the server omits a message", async () => { @@ -64,10 +123,10 @@ describe("App proxy stop", () => { formatFailure: status => `HTTP ${status} stop failed`, }); - expect(outcome).toEqual({ accepted: false, message: "HTTP 503 stop failed" }); + expect(outcome).toEqual({ status: "rejected", message: "HTTP 503 stop failed" }); }); - test("App clears stopping state and alerts for every rejected stop outcome", async () => { + test("App gates the stop on an in-page dialog and reports every rejected outcome", async () => { const app = await Bun.file(new URL("../src/App.tsx", import.meta.url)).text(); const handleStopIdx = app.indexOf("const handleStop"); const brandIdx = app.indexOf("const brand"); @@ -75,10 +134,18 @@ describe("App proxy stop", () => { expect(brandIdx).toBeGreaterThan(handleStopIdx); const handler = app.slice(handleStopIdx, brandIdx); + // The consent gate is awaited, and a refusal returns before the request. It used to + // be `confirm()`, which the app's webview answers false without drawing, so this + // control did nothing there at all. + expect(handler).toContain("await confirmAction("); + expect(handler).toContain("if (!consented) return;"); expect(handler).toContain("await requestProxyStop(machineBase"); expect(handler).toContain('mode: targets.connected ? "client" : "standalone"'); - expect(handler).toContain("if (!outcome.accepted)"); + expect(handler).toContain('if (outcome.status !== "accepted")'); expect(handler).toContain("setStopping(false)"); - expect(handler).toContain("alert(outcome.message)"); + // Reported in the page, not through a platform dialog that draws nothing — and a + // refusal and an unknown are not reported in the same tone. + expect(handler).toContain('outcome.status === "rejected" ? "err" : "warn"'); + expect(handler).not.toContain("alert("); }); }); diff --git a/gui/tests/claude-desktop-mode-picker.test.tsx b/gui/tests/claude-desktop-mode-picker.test.tsx new file mode 100644 index 00000000000..68215092244 --- /dev/null +++ b/gui/tests/claude-desktop-mode-picker.test.tsx @@ -0,0 +1,261 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import ClaudeDesktop from "../src/pages/ClaudeDesktop"; +import { LanguageProvider } from "../src/i18n/provider"; +import { clearClientResourceStoresForTests } from "../src/client-resource"; + +/** + * The connection-mode picker decides which of two mutually exclusive Desktop + * configurations the apply request asks for. Mounted tests because the + * failures that matter are wiring: the radio must follow /status, a changed + * selection must reach the POST body, and the gateway-only "not active + * profile" check must not leak into first-party status. + */ + +const globals = ["document", "window", "navigator", "localStorage", "fetch", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previousGlobals: Record<(typeof globals)[number], unknown>; +let testWindow: Window; +let container: HTMLElement; +let root: Root | null = null; +let requests: { url: string; init?: RequestInit }[] = []; + +const MODEL = { + route: "prov/opus-0", + label: "Opus Model", + available: true, + contextWindow: 200_000, + effortSupported: true, + assignment: { family: "opus", alias: "alias-opus" }, +}; + +function profilePayload() { + return { + profile: { + version: 1, + assignments: { [MODEL.route]: MODEL.assignment }, + defaults: { opus: MODEL.route, fable: null, sonnet: null, haiku: null }, + }, + models: [MODEL], + rendered: [], + port: 10100, + }; +} + +function statusPayload(overrides: Record = {}) { + return { + desiredEnabled: true, + applied: true, + appliedAt: null, + stale: false, + health: { lastRequestAt: null, requestCount: 0, errorCount: 0 }, + mode: "first-party", + firstParty: { + applied: true, + stale: false, + interceptEnabled: true, + interceptRunning: true, + proxyPort: 10200, + caCertPath: "/tmp/ocx/claude-intercept/ca.pem", + }, + ...overrides, + }; +} + +function installFetch(status: Record) { + Object.defineProperty(globalThis, "fetch", { + configurable: true, + value: async (url: string, init?: RequestInit) => { + requests.push({ url: String(url), init }); + const path = String(url); + const body = path.includes("/status") + ? status + : path.endsWith("/apply") + ? { ok: true, mode: JSON.parse(String(init?.body ?? "{}")).mode } + : init?.method === "PUT" + ? { ok: true } + : profilePayload(); + return { ok: true, status: 200, json: async () => body, text: async () => JSON.stringify(body) } as unknown as Response; + }, + }); +} + +beforeEach(() => { + clearClientResourceStoresForTests(); + requests = []; + previousGlobals = Object.fromEntries(globals.map(k => [k, Reflect.get(globalThis, k)])) as typeof previousGlobals; + testWindow = new Window({ url: "http://localhost/" }); + Object.defineProperty(testWindow.navigator, "language", { configurable: true, value: "en-US" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + navigator: { configurable: true, value: testWindow.navigator }, + localStorage: { configurable: true, value: testWindow.localStorage }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + installFetch(statusPayload()); + container = testWindow.document.createElement("div") as unknown as HTMLElement; + testWindow.document.body.appendChild(container as never); +}); + +afterEach(async () => { + if (root) { + const current = root; + await act(async () => { current.unmount(); }); + root = null; + } + clearClientResourceStoresForTests(); + for (const key of globals) { + Object.defineProperty(globalThis, key, { configurable: true, value: previousGlobals[key] }); + } +}); + +async function mount() { + await act(async () => { + root = createRoot(container); + root.render(); + }); + await act(async () => { await new Promise(r => setTimeout(r, 50)); }); +} + +function radio(mode: "first-party" | "gateway"): HTMLInputElement { + const found = container.querySelector(`input[name="claude-desktop-mode"][value="${mode}"]`); + if (!found) throw new Error(`mode radio not found: ${mode}`); + return found as unknown as HTMLInputElement; +} + +function applyButton(): HTMLButtonElement { + const found = Array.from(container.querySelectorAll("button.btn-primary")) + .find(button => /apply/i.test(button.textContent ?? "")); + if (!found) throw new Error("apply button not found"); + return found as unknown as HTMLButtonElement; +} + +async function click(element: HTMLElement) { + await act(async () => { element.click(); }); +} + +test("a failed /status unlocks the picker on the default without claiming a current mode", async () => { + Object.defineProperty(globalThis, "fetch", { + configurable: true, + value: async (url: string) => { + const path = String(url); + if (path.includes("/status")) { + return { ok: false, status: 503, json: async () => ({ error: "down" }), text: async () => "down" } as unknown as Response; + } + const body = profilePayload(); + return { ok: true, status: 200, json: async () => body, text: async () => JSON.stringify(body) } as unknown as Response; + }, + }); + + await mount(); + await act(async () => { await new Promise(r => setTimeout(r, 200)); }); + + expect((container.querySelector(".claude-mode-picker") as HTMLFieldSetElement).disabled).toBe(false); + expect(radio("first-party").checked).toBe(true); + expect(radio("gateway").checked).toBe(false); + expect(container.querySelector(".claude-mode-current")).toBeNull(); + expect(container.querySelector(".claude-status-bar")?.textContent ?? "").toContain("Failed to load"); +}); + +test("the picker follows the effective mode reported by /status and shows the proxy port", async () => { + await mount(); + expect(radio("first-party").checked).toBe(true); + expect(radio("gateway").checked).toBe(false); + const firstPartyOption = radio("first-party").closest("label")!; + expect(firstPartyOption.querySelector(".claude-mode-default")).not.toBeNull(); + expect(firstPartyOption.querySelector(".claude-mode-current")).not.toBeNull(); + expect(container.querySelector(".claude-mode-switch-note")).toBeNull(); + + const bar = container.querySelector(".claude-status-bar")!; + expect(bar.className).toContain("applied"); + expect(bar.textContent ?? "").toContain("First-party: Code tab routed through the local proxy"); + expect(bar.textContent ?? "").toContain("127.0.0.1:10200"); + expect(applyButton().textContent).toBe("Save & apply"); +}); + +test("a stopped intercept proxy is surfaced in first-party mode", async () => { + installFetch(statusPayload({ + firstParty: { applied: true, stale: false, interceptEnabled: true, interceptRunning: false, proxyPort: 10200, caCertPath: "/tmp/ca.pem" }, + })); + await mount(); + expect(container.querySelector(".claude-status-bar")?.textContent ?? "").toContain("is not running"); +}); + +test("selecting the other mode flips the apply label and sends that mode in the POST body", async () => { + await mount(); + await act(async () => { + radio("gateway").click(); + }); + expect(radio("gateway").checked).toBe(true); + expect(container.querySelector(".claude-mode-switch-note")).not.toBeNull(); + expect(applyButton().textContent).toBe("Switch mode & apply"); + + await click(applyButton()); + await act(async () => { await new Promise(r => setTimeout(r, 20)); }); + + const apply = requests.find(r => r.url.endsWith("/api/claude-desktop/apply")); + expect(apply).toBeDefined(); + expect(apply!.init?.method).toBe("POST"); + expect(JSON.parse(String(apply!.init?.body))).toEqual({ mode: "gateway" }); +}); + +test("the default apply keeps the effective mode when nothing was chosen", async () => { + installFetch(statusPayload({ mode: "gateway", activeProfile: true, firstParty: undefined })); + await mount(); + expect(radio("gateway").checked).toBe(true); + await click(applyButton()); + await act(async () => { await new Promise(r => setTimeout(r, 20)); }); + const apply = requests.find(r => r.url.endsWith("/api/claude-desktop/apply")); + expect(JSON.parse(String(apply!.init?.body))).toEqual({ mode: "gateway" }); +}); + +test("activeProfile=false only demotes the status bar in gateway mode", async () => { + // First-party never writes a Desktop profile, so Desktop serving some other + // profile is irrelevant and must not paint the bar as not-applied. + installFetch(statusPayload({ activeProfile: false })); + await mount(); + expect(container.querySelector(".claude-status-bar")!.className).toContain("applied"); + expect(container.querySelector(".claude-status-bar")!.className).not.toContain("not-applied"); + + await act(async () => { root!.unmount(); root = null; }); + clearClientResourceStoresForTests(); + installFetch(statusPayload({ mode: "gateway", activeProfile: false, firstParty: undefined })); + await mount(); + expect(container.querySelector(".claude-status-bar")!.className).toContain("not-applied"); +}); + +test("no radio is checked and the picker is disabled until /status answers", async () => { + // A gateway install must never see the first-party default flash while /status is in flight. + let releaseStatus: () => void = () => {}; + const gate = new Promise(resolve => { releaseStatus = resolve; }); + const gatewayStatus = statusPayload({ mode: "gateway", activeProfile: true, firstParty: undefined }); + Object.defineProperty(globalThis, "fetch", { + configurable: true, + value: async (url: string, init?: RequestInit) => { + const path = String(url); + if (path.includes("/status")) await gate; + const body = path.includes("/status") ? gatewayStatus : profilePayload(); + return { ok: true, status: 200, json: async () => body, text: async () => JSON.stringify(body) } as unknown as Response; + }, + }); + await mount(); + expect(radio("first-party").checked).toBe(false); + expect(radio("gateway").checked).toBe(false); + expect((container.querySelector(".claude-mode-picker") as HTMLFieldSetElement).disabled).toBe(true); + expect(container.querySelector(".claude-mode-current")).toBeNull(); + expect(container.querySelector(".claude-mode-switch-note")).toBeNull(); + + releaseStatus(); + await act(async () => { await new Promise(r => setTimeout(r, 50)); }); + expect(radio("gateway").checked).toBe(true); + expect(radio("first-party").checked).toBe(false); + expect((container.querySelector(".claude-mode-picker") as HTMLFieldSetElement).disabled).toBe(false); +}); + +test("an unknown mode in /status is rejected as malformed", async () => { + installFetch(statusPayload({ mode: "proxy" })); + await mount(); + expect(container.textContent ?? "").toContain("Failed to load Claude Desktop profile."); +}); diff --git a/gui/tests/codex-account-pool-controller.test.ts b/gui/tests/codex-account-pool-controller.test.ts index 044b8156a01..3f1d5c848d4 100644 --- a/gui/tests/codex-account-pool-controller.test.ts +++ b/gui/tests/codex-account-pool-controller.test.ts @@ -21,6 +21,10 @@ test("the controller is the single data owner and exposes the agreed contract", // WP2 (260730_gui_hydration_loading_unify/010): progress is part of the contract, because a // forced quota refresh keeps `loadState` at "ready" and would otherwise be invisible. "refreshing", "initialLoading", + // #5261: for the same reason in the other direction. A warm refresh failure keeps the rows + // and keeps `loadState` at "ready", so without this the surface has no way to say that what + // it is showing predates a failed read. + "refreshFailed", ]) { expect(hook).toContain(member); } diff --git a/gui/tests/codex-account-pool-stale-refresh.test.tsx b/gui/tests/codex-account-pool-stale-refresh.test.tsx new file mode 100644 index 00000000000..0f164c71a72 --- /dev/null +++ b/gui/tests/codex-account-pool-stale-refresh.test.tsx @@ -0,0 +1,270 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import type { Root } from "react-dom/client"; +import CodexAccountPool from "../src/components/CodexAccountPool"; +import { clearClientResourceStoresForTests } from "../src/client-resource"; +import { useCodexAccountPool, type CodexAccountEntry, type CodexAccountPoolController } from "../src/hooks/useCodexAccountPool"; +import { en } from "../src/i18n/en"; +import { LanguageProvider } from "../src/i18n/provider"; + +/** + * #5261: a failed account refresh used to leave the roster looking current. + * + * Keeping the rows is deliberate — blanking a populated pool on a soft poll miss is its own + * defect — but the controller also went on reporting `ready`, so nothing distinguished a list + * the server had just confirmed from one that predated a failure. The case that surfaced it: + * add an account, the read that would bring it over fails, and the dashboard shows the older + * accounts with the new one simply absent. + * + * Both halves are held here because either alone is satisfiable without the other: a flag the + * surface never reads changes nothing a user sees, and a banner with no flag behind it never + * appears. + */ + +const globals = ["document", "window", "navigator", "localStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previous: Record<(typeof globals)[number], unknown>; +let win: Window; +let host: HTMLElement; +let root: Root | null = null; +let originalFetch: typeof globalThis.fetch; +let accountsOk = true; +let serverAccounts: unknown[] = []; +let baseCounter = 0; +let activeResponseGate: Promise | null = null; + +function row(id: string, email: string, isMain = false) { + return { id, email, isMain, paused: false, priority: 0, hasCredential: true, quota: null }; +} + +const mainAccount: CodexAccountEntry = { + id: "main", + email: "main@example.test", + isMain: true, + paused: false, + priority: 0, + hasCredential: true, + quota: null, + quotaAutoRefresh: { + fiveHourAvailable: false, + weeklyAvailable: false, + fiveHourEnabled: false, + weeklyEnabled: false, + }, +}; + +function makeController(overrides: Partial = {}): CodexAccountPoolController { + return { + accounts: [mainAccount], + activeId: null, + loadState: "ready", + refreshing: false, + refreshFailed: false, + initialLoading: false, + switchingId: null, + pauseUpdatingId: null, + priorityUpdatingId: null, + pausingExhausted: false, + activeNeedsReauth: false, + activePinnedId: null, + load: async () => true, + switchAccount: async () => ({ ok: true, activeId: null }), + setAccountPaused: async () => ({ ok: true }), + setAccountPriority: async () => ({ ok: true }), + pauseExhaustedAccounts: async () => ({ ok: true, pausedCount: 0 }), + saveAlias: async () => ({ ok: true }), + removeAccount: async () => ({ ok: true }), + syncAfterAccountAdded: async () => ({ ok: true }), + pauseRefresh: () => ({ __brand: "codex-pool-pause" }) as never, + resumeRefresh: () => {}, + subscribeLoadObserver: () => () => {}, + readLastThreshold: () => undefined, + readLastActive: () => undefined, + ...overrides, + }; +} + +beforeEach(() => { + previous = Object.fromEntries(globals.map((k) => [k, Reflect.get(globalThis, k)])) as typeof previous; + win = new Window({ url: "http://localhost/" }); + Object.defineProperty(win.navigator, "language", { configurable: true, value: "en-US" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: win.document }, + window: { configurable: true, value: win }, + navigator: { configurable: true, value: win.navigator }, + localStorage: { configurable: true, value: win.localStorage }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + + originalFetch = globalThis.fetch; + accountsOk = true; + activeResponseGate = null; + serverAccounts = [row("a1", "account-one", true)]; + Object.defineProperty(globalThis, "fetch", { + configurable: true, + value: async (url: string) => { + const path = String(url).split("/api/")[1] ?? String(url); + if (path.startsWith("usage?")) { + return { ok: true, json: async () => ({ accounts: [] }) } as unknown as Response; + } + if (path.startsWith("codex-auth/accounts")) { + if (!accountsOk) return { ok: false, status: 503 } as unknown as Response; + return { ok: true, json: async () => ({ accounts: serverAccounts }) } as unknown as Response; + } + if (path.startsWith("codex-auth/active")) { + const gate = activeResponseGate; + activeResponseGate = null; + if (gate) await gate; + return { + ok: true, + json: async () => ({ activeCodexAccountId: null, autoSwitchThreshold: 80 }), + } as unknown as Response; + } + return { ok: true, json: async () => ({}) } as unknown as Response; + }, + }); + + host = win.document.createElement("div") as unknown as HTMLElement; + win.document.body.appendChild(host as never); +}); + +afterEach(async () => { + if (root) { + const current = root; + await act(async () => { current.unmount(); }); + root = null; + } + await act(async () => { await new Promise((r) => setTimeout(r, 0)); }); + clearClientResourceStoresForTests(); + for (const key of globals) { + Object.defineProperty(globalThis, key, { configurable: true, value: previous[key] }); + } + Object.defineProperty(globalThis, "fetch", { configurable: true, value: originalFetch }); + await win.happyDOM?.close?.(); +}); + +/** A fresh apiBase each time: the controller's last-good snapshot is keyed by it. */ +async function mountController() { + baseCounter += 1; + const apiBase = `stale-${Date.now()}-${baseCounter}`; + const seen: { current: CodexAccountPoolController | null } = { current: null }; + function Probe() { + seen.current = useCodexAccountPool(apiBase, true); + return null; + } + const { createRoot } = await import("react-dom/client"); + await act(async () => { + root = createRoot(host); + root.render(); + }); + await act(async () => { await new Promise((r) => setTimeout(r, 30)); }); + return seen; +} + +async function mountPool(controller: CodexAccountPoolController) { + const { createRoot } = await import("react-dom/client"); + await act(async () => { + root = createRoot(host); + root.render( + + + , + ); + }); + await act(async () => { await new Promise((r) => setTimeout(r, 40)); }); +} + +function staleBanner(): Element | null { + return host.querySelector(".pwi-auth-state--stale"); +} + +test("a failed refresh keeps the rows and stops reporting them as current", async () => { + const seen = await mountController(); + expect(seen.current!.loadState).toBe("ready"); + expect(seen.current!.refreshFailed).toBe(false); + expect(seen.current!.accounts.map(a => a.id)).toEqual(["a1"]); + + // The server now has an account this client has never seen, and the read that would have + // brought it over fails. This is the reported shape of the defect, not a synthetic one. + serverAccounts = [row("a1", "account-one", true), row("a2", "account-two")]; + accountsOk = false; + await act(async () => { await seen.current!.load(); }); + + expect(seen.current!.refreshFailed).toBe(true); + // Still ready, and still holding the rows: blanking a populated pool on a miss is its own + // defect, so the fix is that the surface now has something to say, not that it shows less. + expect(seen.current!.loadState).toBe("ready"); + expect(seen.current!.accounts.map(a => a.id)).toEqual(["a1"]); + + accountsOk = true; + await act(async () => { await seen.current!.load(); }); + + expect(seen.current!.refreshFailed).toBe(false); + expect(seen.current!.accounts.map(a => a.id)).toEqual(["a1", "a2"]); +}); + +test("the banner clears with the rows it qualifies, not with the whole load", async () => { + // The rows are painted the moment /accounts returns, while /active can still be running on + // its own much longer budget. Clearing the flag at the settle instead would leave the rows + // that just replaced the stale ones labelled as the stale ones for that whole window. + const seen = await mountController(); + + accountsOk = false; + await act(async () => { await seen.current!.load(); }); + expect(seen.current!.refreshFailed).toBe(true); + + accountsOk = true; + serverAccounts = [row("a1", "account-one", true), row("a2", "account-two")]; + let releaseActive!: () => void; + activeResponseGate = new Promise(resolve => { releaseActive = resolve; }); + + let pending: Promise; + await act(async () => { + pending = seen.current!.load(); + await new Promise((r) => setTimeout(r, 10)); + }); + + // /accounts has landed; /active has not. + expect(seen.current!.accounts.map(a => a.id)).toEqual(["a1", "a2"]); + expect(seen.current!.refreshFailed).toBe(false); + + await act(async () => { releaseActive(); await pending!; }); + expect(seen.current!.refreshFailed).toBe(false); +}); + +test("a cold failure still replaces the surface rather than annotating an empty one", async () => { + // Non-regression: the cold path is unchanged, and this holds it there now that a second + // failure signal exists that must not take it over. + accountsOk = false; + const seen = await mountController(); + + expect(seen.current!.loadState).toBe("error"); + expect(seen.current!.accounts).toEqual([]); +}); + +test("the roster says so on screen when the rows it shows are the pre-refresh ones", async () => { + await mountPool(makeController({ refreshFailed: true })); + + const banner = staleBanner(); + expect(banner).not.toBeNull(); + expect(banner!.textContent).toContain(en["codexAuth.accountsRefreshFailed"]); + // Non-destructive: the accounts it is qualifying are still rendered underneath it. + expect(host.textContent).toContain("main@example.test"); +}); + +test("a roster whose refresh succeeded carries no banner", async () => { + // Non-regression: passes before this change too, and is here so the new banner cannot start + // appearing over a roster the server has just confirmed. + await mountPool(makeController({ refreshFailed: false })); + + expect(staleBanner()).toBeNull(); +}); + +test("a cold failure shows its own error instead of the stale banner", async () => { + // Precedence, not regression: nothing survived to qualify, so the banner would be describing + // an empty list. The cold error has to win even though both conditions hold. + await mountPool(makeController({ accounts: [], loadState: "error", refreshFailed: true })); + + expect(staleBanner()).toBeNull(); + expect(host.textContent).toContain(en["codexAuth.loadFailed"]); +}); diff --git a/gui/tests/codex-account-pool-toast-tone.test.tsx b/gui/tests/codex-account-pool-toast-tone.test.tsx index 5e64bed40a3..7410d9561d7 100644 --- a/gui/tests/codex-account-pool-toast-tone.test.tsx +++ b/gui/tests/codex-account-pool-toast-tone.test.tsx @@ -6,18 +6,30 @@ import { formatAccountPriority } from "../src/account-priority"; import CodexAccountPool from "../src/components/CodexAccountPool"; import type { CodexAccountEntry, CodexAccountPoolController } from "../src/hooks/useCodexAccountPool"; import { LanguageProvider } from "../src/i18n/provider"; +import { acceptActionDialog, actionDialogOpen } from "./helpers/action-dialog"; /** * Stale toastError must not paint a successful redeem as notice-err (PR #475). */ -const globals = ["document", "window", "navigator", "localStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; +const globals = ["document", "window", "navigator", "localStorage", "HTMLElement", + "IS_REACT_ACT_ENVIRONMENT", "confirm", "alert", "prompt"] as const; let previous: Record<(typeof globals)[number], unknown>; let win: Window; let host: HTMLElement; let root: Root | null = null; let originalFetch: typeof globalThis.fetch; -let originalConfirm: typeof window.confirm; +/** Platform dialogs reached, which must stay empty: the app's webview draws none of them. */ +let touched: string[]; + +/** Removal opens an in-page consent dialog now; answer it and let the write run. */ +async function consentToRemoval(): Promise { + expect(actionDialogOpen(win.document as unknown as Document)).toBe(true); + await act(async () => { + acceptActionDialog(win.document as unknown as Document); + await new Promise(resolve => setTimeout(resolve, 20)); + }); +} let legacyApiPayload: unknown = null; let priorityWrites: { id: string; priority: number | null }[] = []; @@ -70,6 +82,7 @@ function makeController(overrides: Partial = {}): Co activeNeedsReauth: false, activePinnedId: null, refreshing: false, + refreshFailed: false, initialLoading: false, load: async () => true, switchAccount: async () => ({ ok: true, activeId: null }), @@ -99,12 +112,17 @@ beforeEach(() => { window: { configurable: true, value: win }, navigator: { configurable: true, value: win.navigator }, localStorage: { configurable: true, value: win.localStorage }, + HTMLElement: { configurable: true, value: win.HTMLElement }, }); (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; originalFetch = globalThis.fetch; - originalConfirm = window.confirm; - window.confirm = () => true; + touched = []; + for (const name of ["confirm", "alert", "prompt"] as const) { + const trap = () => { touched.push(name); throw new Error(`${name}() must not be reached`); }; + Object.defineProperty(globalThis, name, { configurable: true, value: trap }); + Object.defineProperty(win, name, { configurable: true, value: trap }); + } Object.defineProperty(globalThis, "fetch", { configurable: true, @@ -145,7 +163,6 @@ afterEach(async () => { await act(async () => { current.unmount(); }); root = null; } - window.confirm = originalConfirm; await act(async () => { await new Promise((r) => setTimeout(r, 0)); }); for (const key of globals) { Object.defineProperty(globalThis, key, { configurable: true, value: previous[key] }); @@ -437,6 +454,7 @@ test("a saved removal with pending catalog refresh renders a warning tone", asyn removeButton!.dispatchEvent(new win.MouseEvent("click", { bubbles: true })); await new Promise(resolve => setTimeout(resolve, 20)); }); + await consentToRemoval(); const warning = host.querySelector(".codex-auth-page-head__feedback.is-warn"); expect(warning?.textContent).toContain("ocx sync"); @@ -486,7 +504,7 @@ test("successful redeem clears a stale error toast tone", async () => { ); expect(removeBtn).toBeTruthy(); await act(async () => { removeBtn!.dispatchEvent(new win.MouseEvent("click", { bubbles: true })); }); - await act(async () => { await new Promise((r) => setTimeout(r, 20)); }); + await consentToRemoval(); const errNotice = host.querySelector(".codex-auth-page-head__feedback.is-err"); expect(errNotice).toBeTruthy(); @@ -558,7 +576,7 @@ test("a pool card folds alias/id/remove behind a ⋯ disclosure and shows the or const remove = card.querySelector('button[aria-label^="Remove"]')!; expect(remove).not.toBeNull(); await act(async () => { remove.click(); }); - await act(async () => { await new Promise((r) => setTimeout(r, 0)); }); + await consentToRemoval(); expect(removed).toEqual(["pool-1"]); }); diff --git a/gui/tests/codex-restart.test.ts b/gui/tests/codex-restart.test.ts index b1d4e9cf28b..0b2a1d33de8 100644 --- a/gui/tests/codex-restart.test.ts +++ b/gui/tests/codex-restart.test.ts @@ -48,8 +48,9 @@ describe("requestCodexRestart", () => { }); test("a network failure is reported as unreachable", async () => { - // requestProxyStop reads a dropped socket as "the stop started". This route - // does not kill the process serving it, so silence means something broke. + // requestProxyStop resolves a dropped socket by re-reading the instance, because the + // process it talks to is the one going away. This route does not kill the process + // serving it, so there is nothing to re-read and silence means something broke. const outcome = await requestCodexRestart("", { fetchFn: (async () => { throw new TypeError("Failed to fetch"); @@ -219,4 +220,3 @@ describe("requestCodexRestart", () => { expect(outcome).toEqual({ ok: false, message: "malformed" }); }); }); - diff --git a/gui/tests/codex-stale-banner-dom.test.tsx b/gui/tests/codex-stale-banner-dom.test.tsx index 5b59c3c489a..ef8ffffd1aa 100644 --- a/gui/tests/codex-stale-banner-dom.test.tsx +++ b/gui/tests/codex-stale-banner-dom.test.tsx @@ -7,6 +7,8 @@ import { LanguageProvider } from "../src/i18n/provider"; import { CodexStaleBanner } from "../src/components/codex-stale-banner"; import { useCodexRestart } from "../src/use-codex-restart"; import type { CodexRestartResponse } from "../src/codex-restart"; +import type { NoticeTone } from "../src/ui"; +import { acceptActionDialog, actionDialogOpen, dismissActionDialog } from "./helpers/action-dialog"; /** * Real DOM behavior for the staleness surface. @@ -15,16 +17,39 @@ import type { CodexRestartResponse } from "../src/codex-restart"; * while a restart from the page-head button left the banner on screen — the * refresh only ran from the banner's own click handler. Source-text assertions * cannot see that, so these render the components and drive them. + * + * It also stubbed `confirm()` to true and `alert()` to a no-op, which is how the + * desktop defect stayed invisible: inside the app neither draws, so both controls + * were dead there while this file was green. Every platform dialog is now a trap — + * reaching one fails the test — and consent is answered through the real in-page + * dialog instead. */ -const globals = ["document", "window", "navigator", "localStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; +const globals = ["document", "window", "navigator", "localStorage", "HTMLElement", "IS_REACT_ACT_ENVIRONMENT", + "confirm", "alert", "prompt"] as const; let previous: Record<(typeof globals)[number], unknown>; let win: Window; let host: HTMLElement; let root: Root | null = null; let originalFetch: typeof globalThis.fetch; -let originalConfirm: typeof globalThis.confirm; -let originalAlert: typeof globalThis.alert; +/** Outcome messages the controller published, in order. */ +let reports: Array<{ message: string; tone: NoticeTone }>; +/** Platform dialogs reached, which must stay empty. */ +let touched: string[]; + +/** The document the components render into, which is where the dialog is mounted. */ +const dialogDocument = () => win.document as unknown as Document; + +/** Opens the consent dialog by clicking, then answers it. */ +async function clickAndAnswer(button: HTMLButtonElement, answer: "accept" | "dismiss"): Promise { + await act(async () => { button.click(); }); + expect(actionDialogOpen(dialogDocument())).toBe(true); + await act(async () => { + if (answer === "accept") acceptActionDialog(dialogDocument()); + else dismissActionDialog(dialogDocument()); + await Promise.resolve(); + }); +} function restartBody(overrides: Partial = {}): CodexRestartResponse { return { @@ -43,8 +68,8 @@ function restartBody(overrides: Partial = {}): CodexRestar beforeEach(() => { previous = Object.fromEntries(globals.map(k => [k, Reflect.get(globalThis, k)])) as typeof previous; originalFetch = globalThis.fetch; - originalConfirm = globalThis.confirm; - originalAlert = globalThis.alert; + reports = []; + touched = []; win = new Window({ url: "http://localhost/" }); Object.defineProperty(win.navigator, "language", { configurable: true, value: "en-US" }); Object.defineProperties(globalThis, { @@ -52,10 +77,14 @@ beforeEach(() => { window: { configurable: true, value: win }, navigator: { configurable: true, value: win.navigator }, localStorage: { configurable: true, value: win.localStorage }, + HTMLElement: { configurable: true, value: win.HTMLElement }, }); (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; - Object.defineProperty(globalThis, "confirm", { configurable: true, value: () => true }); - Object.defineProperty(globalThis, "alert", { configurable: true, value: () => {} }); + for (const name of ["confirm", "alert", "prompt"] as const) { + const trap = () => { touched.push(name); throw new Error(`${name}() must not be reached`); }; + Object.defineProperty(globalThis, name, { configurable: true, value: trap }); + Object.defineProperty(win, name, { configurable: true, value: trap }); + } host = win.document.createElement("div") as unknown as HTMLElement; win.document.body.appendChild(host as never); @@ -65,11 +94,10 @@ afterEach(() => { if (root) act(() => root!.unmount()); root = null; Object.defineProperty(globalThis, "fetch", { configurable: true, value: originalFetch }); - Object.defineProperty(globalThis, "confirm", { configurable: true, value: originalConfirm }); - Object.defineProperty(globalThis, "alert", { configurable: true, value: originalAlert }); for (const key of globals) { Object.defineProperty(globalThis, key, { configurable: true, value: previous[key] }); } + expect(touched).toEqual([]); }); /** Mirrors how Models.tsx wires the controller, banner, and head action. */ @@ -77,7 +105,11 @@ function Harness(props: { initialState: "fresh" | "stale" | "not_running" | "unknown" | null; onReload: () => void; }) { - const controller = useCodexRestart("", { onSettled: () => props.onReload() }); + const controller = useCodexRestart("", { + onSettled: () => props.onReload(), + // Models routes this to the shell, which outlives the page; the test just records it. + report: (message, tone) => { reports.push({ message, tone }); }, + }); return (