diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fd99a10a9c7..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 @@ -156,9 +161,10 @@ jobs: tar -czf "../../ocx-${RELEASE_VERSION}-${STANDALONE_TARGET}.tar.gz" ocx gui fi cd ../.. - # shasum -c in attach-release runs 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. + # 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 @@ -424,15 +430,19 @@ jobs: security delete-keychain "${OPENCODEX_SIGNING_KEYCHAIN}" fi - attach-release: + # 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: [publish, package-standalone, package-desktop] - if: ${{ inputs.dry-run != true }} - env: - UPDATER_SIGNING_CONFIGURED: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY != '' }} + needs: [validate-dispatch, package-standalone, package-desktop] timeout-minutes: 10 permissions: - contents: write + contents: read + env: + UPDATER_SIGNING_CONFIGURED: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY != '' }} steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 @@ -456,24 +466,86 @@ jobs: merge-multiple: true path: dist/release - # Generate latest.json only when the updater key is configured; then require - # signatures for all four updater platforms before publishing it. - - name: Generate updater manifest - if: env.UPDATER_SIGNING_CONFIGURED == 'true' + - name: Verify release assets env: RELEASE_VERSION: ${{ inputs.version }} run: | - bun desktop/scripts/updater-manifest.ts \ - --version "$RELEASE_VERSION" \ - --dir dist/release \ - --repo lidge-jun/opencodex \ - --out dist/release/latest.json \ - --require-all + 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: Verify the checksum before uploading + - 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: | - cd dist/release - shasum -a 256 -c ./*.sha256 + 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: @@ -485,7 +557,7 @@ jobs: gh release upload "v${RELEASE_VERSION}" dist/release/* --clobber publish: - needs: [validate-dispatch, package-standalone, package-desktop] + needs: [validate-dispatch, verify-release] runs-on: ubuntu-latest timeout-minutes: 15 permissions: @@ -676,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 @@ -692,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." @@ -701,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." @@ -709,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 @@ -755,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. @@ -805,6 +904,7 @@ jobs: env: GH_TOKEN: ${{ github.token }} RELEASE_VERSION: ${{ inputs.version }} + RESUME: ${{ inputs.resume-after-npm-publish }} run: | set -euo pipefail @@ -833,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/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 d5a2b010cf0..ec64c3de89d 100644 --- a/README.md +++ b/README.md @@ -91,24 +91,33 @@ Open **http://localhost:10100** and configure everything in the web dashboard (40+ built-ins, or any OpenAI-compatible endpoint), pick models, manage accounts. `ocx gui` re-opens the dashboard at any time. -### macOS desktop app and widget +
+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**. -Download the desktop app for macOS, Windows, or Linux from the -[latest releases](https://github.com/lidge-jun/opencodex/releases). +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`). -A native desktop app and WidgetKit extension for proxy status, usage, and provider -quotas without opening the dashboard. The snapshot model lives in [`app/`](./app) -(`MenuBarCore`). Download it from the -[releases page](https://github.com/lidge-jun/opencodex/releases) or build it locally with -`bun run prepare-sidecar && bun run prepare-widget && bunx tauri build`. +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`. -The first launch needs a right-click → Open, because the app is ad-hoc signed rather -than notarized. See the [macOS Menu Bar App guide](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/) -for the full explanation. +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. + +
-The app also includes a macOS 14+ widget for proxy status, today's usage, and quotas. +### ChatGPT account pool -It can also manage a **ChatGPT account pool** for Codex auth. Add multiple ChatGPT / Codex accounts, +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/bin/ocx.mjs b/bin/ocx.mjs index 1603e370416..fef7686638c 100755 --- a/bin/ocx.mjs +++ b/bin/ocx.mjs @@ -12,12 +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 { planUpdateRuntimeHandling } from "../src/update/runtime-ownership.mjs"; +import { + inspectPackageRuntimeLiveness, + planStoppedRuntimeRecovery, + planUpdateRuntimeHandling, +} from "../src/update/runtime-ownership.mjs"; import { inspectInstallStateBytes, - resolveOwnershipFromEvidence, + 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"; @@ -47,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 { @@ -262,27 +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 serviceStateFiles = serviceStateFilesFor(configDir(), join(homedir(), ".opencodex")); - const serviceStatePath = serviceStateFiles[0]; - const serviceWasInstalled = existsSync(serviceStatePath); - /** - * What this update may do to the runtime, decided by the SAME contract the Bun updater - * uses — every state path, the whole record shape, and absence as the only answer that - * means no claim. - * - * This used to be a local reader that inspected the anchor alone and returned "known - * unowned" whenever the `ownership` field was simply missing, including from a record that - * fails the contract outright. A takeover the Bun updater refused to disturb was therefore - * fair game here, which is an authorization gap rather than a cosmetic divergence. - */ + 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 evidence = serviceStateFiles.map(path => inspectInstallStateBytes(path, at => readFileSync(at, "utf8"))); - const resolution = resolveOwnershipFromEvidence(evidence); - if (resolution.kind === "owned") return { ownership: resolution.ownership, ownershipUnknown: false }; - return { ownership: null, ownershipUnknown: resolution.kind === "unknown" }; + 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]), + }; }; - let runtimePlan = planUpdateRuntimeHandling({ ...readOwnership(), serviceInstalled: serviceWasInstalled }); + 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 }, ); @@ -296,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"]; } /** @@ -327,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") { @@ -375,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 @@ -384,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)], { @@ -403,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 @@ -420,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) { @@ -450,7 +516,7 @@ function runPackageManagerSelfUpdate(manager) { // 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.stopRuntime) { + if (!nowOwned.mayStopRuntime) { console.warn(nowOwned.notice ?? "opencodex: the background runtime is owned elsewhere; not starting a second proxy."); return; } @@ -472,200 +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")); - - function recoverStoppedRuntimeAfterFailure() { - // Nothing was stopped under a foreign owner, so there is nothing to recover — and - // starting a proxy here would put a second one beside the runtime the app is managing. - if (!runtimePlan.stopRuntime) return; - if (!postUpdateLauncherUsable) { - console.error("opencodex: no verified active launcher remains for automatic recovery; reinstall opencodex manually."); - return; + runtimePlan = lockedPlan; + const stoppedOwnershipIdentity = ownershipIdentity(lockedOwnership); + + // 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 (runtimePlan.refreshService) { - 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()); - // Re-read at the point of action rather than trusting the plan formed above: the Windows - // tray handoff between them spawns children, so a takeover can land in the gap, and - // stopping a runtime that just changed hands is the failure this lane exists to prevent. - { - const atStop = planUpdateRuntimeHandling({ ...readOwnership(), serviceInstalled: serviceWasInstalled }); - if (atStop.notice && atStop.notice !== runtimePlan.notice) console.log(atStop.notice); - runtimePlan = atStop; - } - if (runtimePlan.stopRuntime && (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); @@ -681,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 (runtimePlan.refreshService) { + if (postInstallPlan.mayRestoreService) { console.log("Refreshing the background service with the updated files..."); refreshBackgroundServiceOrStartDirect(); - } else if (runtimePlan.stopRuntime) { + } 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 index 153293a55da..9e7fcc0eda5 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -39,7 +39,12 @@ 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. It prints the bundle paths and exits zero. The release path below is unchanged: a published +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 diff --git a/desktop/scripts/build-local.ts b/desktop/scripts/build-local.ts index 2616c7fc509..634c92ffbce 100644 --- a/desktop/scripts/build-local.ts +++ b/desktop/scripts/build-local.ts @@ -19,9 +19,22 @@ * 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 } from "node:fs"; +import { existsSync, readdirSync, statSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -34,12 +47,6 @@ const LOCAL_BUNDLES: Record = { linux: ["appimage", "deb"], }; -const bundles = LOCAL_BUNDLES[process.platform]; -if (!bundles) { - console.error(`[build:local] unsupported host platform: ${process.platform}`); - process.exit(1); -} - /** * Config merged over `tauri.conf.json` for this invocation only. * @@ -49,34 +56,106 @@ if (!bundles) { */ const LOCAL_CONFIG = JSON.stringify({ bundle: { createUpdaterArtifacts: false } }); -function run(): number { - const extra = process.argv.slice(2); - const args = [ - "tauri", "build", "--ci", - "--bundles", bundles.join(","), - "--config", LOCAL_CONFIG, - ...extra, - ]; - const result = spawnSync("bunx", args, { cwd: desktopDir, stdio: "inherit" }); - if (result.error) { - console.error(`[build:local] could not start tauri: ${result.error.message}`); - return 1; - } - return result.status ?? 1; +export interface SpawnResult { + status: number | null; + error?: Error; } -const status = run(); -if (status === 0) { - const bundleRoot = join(desktopDir, "src-tauri", "target", "release", "bundle"); - // Naming what exists is the point of the script: the previous output ended on an error line, so - // the artifacts it had already written were the least visible thing in it. - 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)) console.log(`[build:local] ${join(directory, name)}`); +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 }); } - console.log("[build:local] updater artifacts skipped; release signing is unchanged."); + // 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(); } -process.exit(status); diff --git a/desktop/scripts/collect-release-assets.ts b/desktop/scripts/collect-release-assets.ts index f946e1d4a92..2c97de39d44 100644 --- a/desktop/scripts/collect-release-assets.ts +++ b/desktop/scripts/collect-release-assets.ts @@ -11,13 +11,13 @@ import { join, resolve } from "node:path"; type BundleKind = "dmg" | "app.tar.gz" | "msi" | "appimage" | "deb"; -interface BundleSpec { +export interface BundleSpec { kind: BundleKind; dir: string; name: string; } -const bundlesByTarget: Record = { +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" }, diff --git a/desktop/scripts/updater-manifest.ts b/desktop/scripts/updater-manifest.ts index b21392fa113..e3fae9a372c 100644 --- a/desktop/scripts/updater-manifest.ts +++ b/desktop/scripts/updater-manifest.ts @@ -27,7 +27,7 @@ export interface UpdaterManifest { platforms: Record; } -const platformFiles: Record = { +export const platformFiles: Record = { "darwin-aarch64": "macos.app.tar.gz", "darwin-x86_64": "macos.app.tar.gz", "windows-x86_64": "windows-x64.msi", 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.toml b/desktop/src-tauri/Cargo.toml index 2b323a3cb3e..6808851555b 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -19,7 +19,7 @@ reqwest = { version = "=0.12.24", default-features = false, features = ["json", 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 = { version = "=2.11.6", features = ["tray-icon", "image-png", "macos-private-api"] } tauri-utils = "=2.9.3" tauri-plugin-autostart = "=2.5.0" tauri-plugin-opener = "=2.5.3" diff --git a/desktop/src-tauri/src/formatting.rs b/desktop/src-tauri/src/formatting.rs index 5f940b68fed..1d0406257ae 100644 --- a/desktop/src-tauri/src/formatting.rs +++ b/desktop/src-tauri/src/formatting.rs @@ -47,11 +47,12 @@ fn abbreviate_float(value: f64, integer: bool) -> String { 2 }; let rendered = format!("{scaled:.decimals$}"); - return format!( - "{}{}", - rendered.trim_end_matches('0').trim_end_matches('.'), - suffix - ); + let rendered = if rendered.contains('.') { + rendered.trim_end_matches('0').trim_end_matches('.') + } else { + &rendered + }; + return format!("{rendered}{suffix}"); } } format!("{value:.0}") @@ -76,4 +77,33 @@ mod tests { assert_eq!(cost(Some(12.345)), "$12.35"); assert_eq!(cost(Some(1_234.0)), "$1.23K"); } + + #[test] + fn abbreviations_preserve_integer_trailing_zeros() { + for (unit, suffix) in [ + (1_000, "K"), + (1_000_000, "M"), + (1_000_000_000, "B"), + (1_000_000_000_000, "T"), + ] { + for multiple in [10, 100, 110] { + let value = unit * multiple; + let expected = format!("{multiple}{suffix}"); + assert_eq!(tokens(Some(value)), expected); + assert_eq!(count(Some(value)), expected); + assert_eq!(cost(Some(value as f64)), format!("${expected}")); + } + } + assert_eq!(tokens(Some(9_600_000)), "10M"); + assert_eq!(count(Some(99_960_000)), "100M"); + } + + #[test] + fn fractional_trailing_zeros_are_still_trimmed() { + assert_eq!(count(Some(1_000_000)), "1M"); + assert_eq!(count(Some(1_200_000)), "1.2M"); + assert_eq!(count(Some(1_234_000)), "1.23M"); + assert_eq!(count(Some(12_340_000)), "12.3M"); + assert_eq!(cost(Some(1_200.0)), "$1.2K"); + } } diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index d24fefba790..b8a6c63e9fe 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -10,6 +10,7 @@ mod logging; #[cfg(target_os = "macos")] mod menu; mod ownership; +mod popup; mod proxy; mod resolve; mod runtime_stop; @@ -119,6 +120,7 @@ impl Default for AppState { #[tauri::command] fn show_dashboard(app: tauri::AppHandle) { + popup::hide(&app); if let Some(window) = app.get_webview_window("main") { window::show(&window); } @@ -135,10 +137,15 @@ fn hide_dashboard(app: tauri::AppHandle) { /// /// 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. +/// +/// It always answers with a state. Answering `None` put the one case the page cannot render — a +/// shell with no startup state — behind a value the page silently discards, which is a frozen +/// window with no diagnostic and no way to tell it from a slow start. #[tauri::command] -fn startup_snapshot(app: tauri::AppHandle) -> Option { +fn startup_snapshot(app: tauri::AppHandle) -> startup::Progress { app.try_state::() .map(|startup| startup.latest()) + .unwrap_or_else(startup::unavailable) } /// The named states the startup sequence moves through, in order. @@ -160,6 +167,7 @@ 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") { + popup::hide(app); window::show(&window); } })) diff --git a/desktop/src-tauri/src/popup.rs b/desktop/src-tauri/src/popup.rs new file mode 100644 index 00000000000..46bf0891bea --- /dev/null +++ b/desktop/src-tauri/src/popup.rs @@ -0,0 +1,390 @@ +use crate::{endpoint::ProxyEndpoint, window}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{Duration, Instant}; +use tauri::webview::PageLoadEvent; +#[cfg(target_os = "macos")] +use tauri::window::EffectState; +#[cfg(any(target_os = "macos", target_os = "windows"))] +use tauri::window::{Effect, EffectsBuilder}; +use tauri::{ + AppHandle, Manager, PhysicalPosition, PhysicalRect, PhysicalSize, Url, WebviewUrl, + WebviewWindow, WebviewWindowBuilder, WindowEvent, +}; + +pub const LABEL: &str = "usage-popup"; +pub const TRAY_PATH: &str = "/#/tray"; +pub const DASHBOARD_PATH: &str = "/?desktop=open#/usage"; +pub const CLOSE_PATH: &str = "/?desktop=popup-close#/tray-close"; +#[cfg(any(target_os = "macos", target_os = "windows"))] +pub const VIBRANT_SURFACE: bool = true; +#[cfg(not(any(target_os = "macos", target_os = "windows")))] +pub const VIBRANT_SURFACE: bool = false; +const TRAY_VIBRANCY_DATASET: &str = "document.documentElement.dataset.trayVibrancy"; +pub const ESCAPE_INITIALIZATION_SCRIPT: &str = r#" +(() => { + window.__OPENCODEX_TRAY_VISIBLE__ = false; + document.addEventListener("keydown", (event) => { + if (event.key !== "Escape") return; + event.preventDefault(); + event.stopPropagation(); + window.location.replace("/?desktop=popup-close#/tray-close"); + }, true); +})(); +"#; + +fn initialization_script() -> String { + let tray_vibrancy = if VIBRANT_SURFACE { "on" } else { "off" }; + format!( + r#"{TRAY_VIBRANCY_DATASET} = "{tray_vibrancy}"; +{ESCAPE_INITIALIZATION_SCRIPT}"# + ) +} + +/// How long after being shown the popup ignores losing focus. +/// +/// Closing on focus loss is what makes this feel like a menu rather than a window. The cost is +/// that a platform which hands focus back to the tray, the shell, or nothing at all right after +/// the click closes the popup in the same gesture that opened it -- the user sees a flash and no +/// window. A short grace period keeps the dismiss behaviour while making that race unreachable; +/// it is deliberately shorter than a deliberate click elsewhere. +const FOCUS_GRACE: Duration = Duration::from_millis(400); + +/// Monotonic milliseconds since process start, written when the popup is shown. +static SHOWN_AT_MS: AtomicU64 = AtomicU64::new(0); + +fn process_start() -> Instant { + use std::sync::OnceLock; + static START: OnceLock = OnceLock::new(); + *START.get_or_init(Instant::now) +} + +fn mark_shown() { + let elapsed = process_start().elapsed().as_millis() as u64; + SHOWN_AT_MS.store(elapsed, Ordering::Release); +} + +fn within_focus_grace() -> bool { + let shown = SHOWN_AT_MS.load(Ordering::Acquire); + if shown == 0 { + return false; + } + let now = process_start().elapsed().as_millis() as u64; + now.saturating_sub(shown) < FOCUS_GRACE.as_millis() as u64 +} + +const WIDTH_LOGICAL: f64 = 440.0; +const HEIGHT_LOGICAL: f64 = 700.0; +const EDGE_PHYSICAL: i64 = 8; +const GAP_PHYSICAL: i64 = 6; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PopupGeometry { + pub position: PhysicalPosition, + pub size: PhysicalSize, +} + +/// Calculates a tray-anchored physical rectangle. `anchor` and `work_area` are physical pixels; +/// `scale_factor` only converts the logical 440x700 design size, so mixed-DPI monitors stay exact. +pub fn geometry( + anchor: PhysicalPosition, + work_area: PhysicalRect, + scale_factor: f64, +) -> PopupGeometry { + let scale = if scale_factor.is_finite() && scale_factor > 0.0 { + scale_factor + } else { + 1.0 + }; + let edge = EDGE_PHYSICAL; + let gap = GAP_PHYSICAL; + let left = work_area.position.x as i64; + let top = work_area.position.y as i64; + let right = left + work_area.size.width as i64; + let bottom = top + work_area.size.height as i64; + let available_width = (right - left - edge * 2).max(1) as u32; + let available_height = (bottom - top - edge * 2).max(1) as u32; + let width = ((WIDTH_LOGICAL * scale).round() as u32).min(available_width); + let height = ((HEIGHT_LOGICAL * scale).round() as u32).min(available_height); + let width_i = width as i64; + let height_i = height as i64; + let min_x = left + edge; + let max_x = (right - edge - width_i).max(min_x); + let min_y = top + edge; + let max_y = (bottom - edge - height_i).max(min_y); + let anchor_x = anchor.x.round() as i64; + let anchor_y = anchor.y.round() as i64; + let x = (anchor_x - width_i / 2).clamp(min_x, max_x); + let below = anchor_y + gap; + let above = anchor_y - gap - height_i; + let y = if below <= max_y { below } else { above }.clamp(min_y, max_y); + + PopupGeometry { + position: PhysicalPosition::new(x as i32, y as i32), + size: PhysicalSize::new(width, height), + } +} + +pub fn show( + app: &AppHandle, + endpoint: ProxyEndpoint, + anchor: PhysicalPosition, +) -> tauri::Result<()> { + let popup = ensure(app, endpoint)?; + if let Some(monitor) = popup + .monitor_from_point(anchor.x, anchor.y) + .ok() + .flatten() + .or_else(|| popup.primary_monitor().ok().flatten()) + { + let layout = geometry(anchor, *monitor.work_area(), monitor.scale_factor()); + let _ = popup.set_size(layout.size); + let _ = popup.set_position(layout.position); + } + if popup + .url() + .map(|url| !is_tray_url(&url, endpoint)) + .unwrap_or(true) + { + popup.navigate(proxy_url(endpoint, TRAY_PATH))?; + } + let was_visible = popup.is_visible().unwrap_or(false); + mark_shown(); + popup.show()?; + popup.set_focus()?; + if !was_visible { + set_visibility(&popup, true); + } + Ok(()) +} + +pub fn toggle( + app: &AppHandle, + endpoint: ProxyEndpoint, + anchor: PhysicalPosition, +) -> tauri::Result<()> { + if app + .get_webview_window(LABEL) + .and_then(|popup| popup.is_visible().ok()) + .unwrap_or(false) + { + hide(app); + Ok(()) + } else { + show(app, endpoint, anchor) + } +} + +pub fn hide(app: &AppHandle) { + if let Some(popup) = app.get_webview_window(LABEL) { + if popup.is_visible().unwrap_or(false) { + let _ = popup.hide(); + set_visibility(&popup, false); + } + } +} + +fn ensure(app: &AppHandle, endpoint: ProxyEndpoint) -> tauri::Result { + if let Some(popup) = app.get_webview_window(LABEL) { + return Ok(popup); + } + + let app_handle = app.clone(); + let mut builder = WebviewWindowBuilder::new( + app, + LABEL, + WebviewUrl::External(proxy_url(endpoint, TRAY_PATH)), + ) + .title("OpenCodex Usage") + .inner_size(WIDTH_LOGICAL, HEIGHT_LOGICAL) + .max_inner_size(WIDTH_LOGICAL, HEIGHT_LOGICAL) + .decorations(false) + .resizable(false) + .always_on_top(true) + .skip_taskbar(true) + .visible(false) + .user_agent(&window::webview_user_agent()) + .initialization_script(initialization_script()) + .on_navigation(popup_navigation_allowed(endpoint, app_handle.clone())) + .on_page_load(|popup, payload| { + if matches!(payload.event(), PageLoadEvent::Finished) { + set_visibility(&popup, popup.is_visible().unwrap_or(false)); + } + }); + if VIBRANT_SURFACE { + builder = builder.transparent(true); + #[cfg(target_os = "macos")] + { + builder = builder.effects( + EffectsBuilder::new() + .effect(Effect::HudWindow) + .state(EffectState::Active) + .radius(12.0) + .build(), + ); + } + #[cfg(target_os = "windows")] + { + builder = builder.effects(EffectsBuilder::new().effect(Effect::Acrylic).build()); + } + } + let popup = builder.build()?; + popup.on_window_event(move |event| match event { + WindowEvent::Focused(false) if !within_focus_grace() => { + hide(&app_handle); + } + WindowEvent::CloseRequested { api, .. } => { + api.prevent_close(); + hide(&app_handle); + } + _ => {} + }); + Ok(popup) +} + +fn popup_navigation_allowed( + endpoint: ProxyEndpoint, + app: AppHandle, +) -> impl Fn(&Url) -> bool + Send + 'static { + move |url| { + if !same_origin(url, endpoint) { + return false; + } + if is_close_url(url, endpoint) { + hide(&app); + return false; + } + if is_dashboard_url(url, endpoint) { + hide(&app); + if let Some(main) = app.get_webview_window("main") { + window::show(&main); + let _ = main.navigate(url.clone()); + } + return false; + } + is_tray_url(url, endpoint) + } +} + +fn proxy_url(endpoint: ProxyEndpoint, path: &str) -> Url { + endpoint + .url(path) + .parse() + .expect("proxy endpoint URL is valid") +} + +fn same_origin(url: &Url, endpoint: ProxyEndpoint) -> bool { + url.scheme() == "http" + && url.host_str() == Some(endpoint.host) + && url.port_or_known_default() == Some(endpoint.port) +} + +/// Split one of the paths above into the query and fragment a navigation must carry. +/// +/// The matchers read the constant instead of restating it. A matcher that restated it would +/// keep answering yes after the page it names moved, and these three decide what the popup is +/// allowed to navigate to, so a stale yes is the failure that matters. +fn parts(path: &str) -> (Option<&str>, Option<&str>) { + let (before_fragment, fragment) = match path.split_once('#') { + Some((before, fragment)) => (before, Some(fragment)), + None => (path, None), + }; + ( + before_fragment.split_once('?').map(|(_, query)| query), + fragment, + ) +} + +fn matches(url: &Url, endpoint: ProxyEndpoint, path: &str) -> bool { + let (query, fragment) = parts(path); + same_origin(url, endpoint) + && url.path() == "/" + && url.query() == query + && url.fragment() == fragment +} + +fn is_tray_url(url: &Url, endpoint: ProxyEndpoint) -> bool { + matches(url, endpoint, TRAY_PATH) +} + +fn is_close_url(url: &Url, endpoint: ProxyEndpoint) -> bool { + matches(url, endpoint, CLOSE_PATH) +} + +fn is_dashboard_url(url: &Url, endpoint: ProxyEndpoint) -> bool { + // The dashboard accepts the usage page and its companion view under the same query. + let (query, _) = parts(DASHBOARD_PATH); + same_origin(url, endpoint) + && url.path() == "/" + && url.query() == query + && matches!(url.fragment(), Some("/usage") | Some("/usage/companion")) +} + +fn set_visibility(popup: &WebviewWindow, visible: bool) { + let script = format!( + "window.__OPENCODEX_TRAY_VISIBLE__ = {visible}; window.dispatchEvent(new CustomEvent('opencodex:tray-visibility', {{detail: {visible}}}));" + ); + let _ = popup.eval(script); +} + +#[cfg(test)] +mod tests { + use super::*; + + const ENDPOINT: ProxyEndpoint = ProxyEndpoint { + host: "127.0.0.1", + port: 53998, + }; + + #[test] + fn geometry_uses_physical_dpi_and_clamps_to_work_area() { + let layout = geometry( + PhysicalPosition::new(1_900.0, 1_050.0), + PhysicalRect { + position: PhysicalPosition::new(0, 0), + size: PhysicalSize::new(2_560, 1_440), + }, + 2.0, + ); + assert_eq!(layout.size, PhysicalSize::new(880, 1400)); + assert_eq!(layout.position.x, 1_460); + assert_eq!(layout.position.y, 8); + } + + #[test] + fn geometry_keeps_top_tray_below_and_clamps_left() { + let layout = geometry( + PhysicalPosition::new(-20.0, 20.0), + PhysicalRect { + position: PhysicalPosition::new(-1_280, 0), + size: PhysicalSize::new(1_280, 800), + }, + 1.0, + ); + assert_eq!(layout.position.x, -448); + assert_eq!(layout.position.y, 26); + assert_eq!(layout.size, PhysicalSize::new(440, 700)); + } + + #[test] + fn navigation_accepts_only_tray_close_and_dashboard_sentinels() { + let tray: Url = ENDPOINT.url(TRAY_PATH).parse().unwrap(); + let close: Url = ENDPOINT.url(CLOSE_PATH).parse().unwrap(); + let dashboard: Url = ENDPOINT.url(DASHBOARD_PATH).parse().unwrap(); + let external: Url = "https://example.com/#/tray".parse().unwrap(); + assert!(is_tray_url(&tray, ENDPOINT)); + assert!(is_close_url(&close, ENDPOINT)); + assert!(is_dashboard_url(&dashboard, ENDPOINT)); + assert!(!is_tray_url(&external, ENDPOINT)); + assert!(!is_tray_url( + &ENDPOINT.url("/#/usage").parse().unwrap(), + ENDPOINT + )); + } + + #[test] + fn initialization_script_matches_native_surface() { + let expected_value = if VIBRANT_SURFACE { "on" } else { "off" }; + let expected = format!(r#"{TRAY_VIBRANCY_DATASET} = "{expected_value}";"#); + assert!(initialization_script().contains(&expected)); + } +} diff --git a/desktop/src-tauri/src/startup.rs b/desktop/src-tauri/src/startup.rs index bcc08ba2279..0fccc6fddfc 100644 --- a/desktop/src-tauri/src/startup.rs +++ b/desktop/src-tauri/src/startup.rs @@ -30,12 +30,12 @@ use serde::Serialize; use std::{ path::PathBuf, sync::{ - atomic::{AtomicBool, Ordering}, + atomic::{AtomicBool, AtomicU64, Ordering}, Mutex, MutexGuard, PoisonError, }, }; use tauri::{AppHandle, Emitter, Manager}; -use tokio::time::{sleep, Duration, Instant}; +use tokio::time::{sleep, sleep_until, Duration, Instant}; /// The event the bootstrap page listens on. pub const PHASE_EVENT: &str = "startup-phase"; @@ -51,6 +51,13 @@ pub const DEADLINE: Duration = Duration::from_secs(30); const POLL: Duration = Duration::from_millis(250); +/// How long the deadline guard waits past the ceiling before speaking for a run that has not. +/// +/// The run's own failure names the endpoint, the home and how the child ended; the guard's can +/// only name where it stalled. The grace lets the run lose its own race first, so the better +/// diagnostic is the one on screen. +const SETTLE_GRACE: Duration = Duration::from_secs(2); + /// Where the launch came from. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum LaunchOrigin { @@ -91,6 +98,14 @@ pub fn shows_window(origin: LaunchOrigin, tray: TrayAvailability) -> bool { /// A named state of the startup sequence. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Phase { + /// Nothing has run yet. + /// + /// This is what the sequence's state says before its first report, and it is deliberately not + /// one of the [`PHASES`]: it is the absence of a run, not a step of one. Seeding the state + /// with `Registering` instead made "the sequence has not started" render exactly like "the + /// sequence is registering", so a shell that never began was indistinguishable from one that + /// had — on the one surface whose job is to tell those apart. + NotStarted, Registering, Resolving, Probing, @@ -103,6 +118,9 @@ pub enum Phase { /// 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. +/// +/// [`Phase::NotStarted`] is absent on purpose. It is the state of not having run, so a checklist +/// row for it would be a step that never completes. pub const PHASES: [Phase; 8] = [ Phase::Registering, Phase::Resolving, @@ -118,6 +136,7 @@ impl Phase { /// The stable identifier the bootstrap page keys on. pub fn id(self) -> &'static str { match self { + Self::NotStarted => "not-started", Self::Registering => "registering", Self::Resolving => "resolving", Self::Probing => "probing", @@ -131,6 +150,7 @@ impl Phase { pub fn label(self) -> &'static str { match self { + Self::NotStarted => "Waiting for the startup sequence to begin", 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", @@ -145,6 +165,14 @@ impl Phase { pub fn is_terminal(self) -> bool { matches!(self, Self::Ready | Self::Failed) } + + /// The phase a published id came from, for a caller that only has the wire value. + /// + /// Derived from [`PHASES`] rather than restating the mapping, so a phase cannot be resolvable + /// here and missing from the checklist. + pub fn from_id(id: &str) -> Option { + PHASES.into_iter().find(|phase| phase.id() == id) + } } /// One phase, as the bootstrap page sees it. @@ -203,6 +231,27 @@ impl Progress { } } +/// What the page is told when the sequence's own state is not registered. +/// +/// The command used to answer `None` here, and the page dropped it: `apply` returns early on a +/// falsy progress, so the surface kept its initial markup, no event ever arrived, and nothing on +/// screen distinguished that from a run still in progress. A shell that cannot find its own +/// startup state is a defect, and a defect the user can read and copy beats a window that looks +/// like it is still working. +pub fn unavailable() -> Progress { + let reason = + "the shell's startup state is not registered, so it cannot report on its own startup"; + let mut progress = Progress::new(Phase::Failed, 0); + progress.diagnostic = Some(format!( + "OpenCodex desktop {} on {}\nstate: {}\nreason: {reason}", + env!("CARGO_PKG_VERSION"), + std::env::consts::OS, + Phase::NotStarted.id(), + )); + progress.detail = Some(reason.to_owned()); + progress +} + /// Where the sequence is pointed, once the CLI has said. #[derive(Clone)] struct Target { @@ -228,6 +277,11 @@ struct Live { pub struct Startup { live: Mutex, running: AtomicBool, + /// Which run the state belongs to. + /// + /// A run's deadline guard outlives the run it was started for, and a retry that begins before + /// the old guard fires would otherwise be failed by it. + generation: AtomicU64, /// The outcome of the one-time registration, once it has happened. registered: Mutex>, } @@ -236,10 +290,11 @@ impl Startup { pub fn new() -> Self { Self { live: Mutex::new(Live { - latest: Progress::new(Phase::Registering, 0), + latest: Progress::new(Phase::NotStarted, 0), reported: Vec::new(), }), running: AtomicBool::new(false), + generation: AtomicU64::new(0), registered: Mutex::new(None), } } @@ -270,7 +325,16 @@ impl Startup { fn restart(&self) { let mut live = self.live(); live.reported.clear(); - live.latest = Progress::new(Phase::Registering, 0); + live.latest = Progress::new(Phase::NotStarted, 0); + } + + /// Whether the run has already said how it ended. + /// + /// A terminal state is the page's only promise that the screen has stopped changing, so it is + /// also what tells a late guard there is nothing left to report. + fn settled(&self) -> bool { + let phase = self.live().latest.phase; + phase == Phase::Ready.id() || phase == Phase::Failed.id() } fn publish(&self, progress: &mut Progress, failed_in: Option) { @@ -311,22 +375,82 @@ pub fn begin(app: &AppHandle) { return; } startup.restart(); + let generation = startup.generation.fetch_add(1, Ordering::AcqRel) + 1; + let started = Instant::now(); let app = app.clone(); + + // The ceiling is a promise to the page, and something has to keep it when the run does not. + // Every `return` below that reports nothing, and every step that outlives the ceiling, used to + // leave the surface on whatever it was last told — or on its own initial markup when nothing + // had been published at all — for as long as the process lived. That screen is the one a user + // cannot tell from a hung application, which is the whole thing this surface exists to avoid. + let guard = app.clone(); tauri::async_runtime::spawn(async move { - run(&app).await; + sleep_until(started + DEADLINE + SETTLE_GRACE).await; + settle( + &guard, + started, + generation, + format!( + "the startup sequence did not finish within {} seconds", + DEADLINE.as_secs() + ), + ); + }); + + tauri::async_runtime::spawn(async move { + run(&app, started).await; + settle( + &app, + started, + generation, + "the startup sequence ended without reporting a result".to_owned(), + ); if let Some(startup) = app.try_state::() { startup.running.store(false, Ordering::Release); } }); } -async fn run(app: &AppHandle) { - let started = Instant::now(); +/// Report a terminal state for a run that did not report one itself. +/// +/// Idempotent and bound to the run it was started for: a run that already said Ready or Failed is +/// left alone, and a guard whose run has been superseded by a retry says nothing. +fn settle(app: &AppHandle, started: Instant, generation: u64, reason: String) { + let Some(startup) = app.try_state::() else { + return; + }; + if startup.generation.load(Ordering::Acquire) != generation || startup.settled() { + return; + } + let stalled_in = startup.latest().phase; + let elapsed_ms = elapsed(started); + let mut progress = Progress::new(Phase::Failed, elapsed_ms); + progress.diagnostic = Some( + [ + format!( + "OpenCodex desktop {} on {}", + env!("CARGO_PKG_VERSION"), + std::env::consts::OS + ), + format!("state: {stalled_in}"), + format!("reason: {reason}"), + format!("elapsed: {elapsed_ms}ms"), + ] + .join("\n"), + ); + progress.detail = Some(reason); + emit(app, progress, Phase::from_id(stalled_in)); +} + +async fn run(app: &AppHandle, started: Instant) { let deadline = started + DEADLINE; + // Publishing comes before any lookup that can fail. A sequence that returns before it has + // said anything leaves the page unable to tell "not started" from "still going". + report(app, started, Phase::Registering, None); 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, @@ -800,10 +924,60 @@ fn elapsed(started: Instant) -> u64 { #[cfg(test)] mod tests { - use super::{shows_window, LaunchOrigin, Phase, AUTOSTART_FLAG, DEADLINE, PHASES, POLL}; + use super::{ + shows_window, unavailable, LaunchOrigin, Phase, Progress, Startup, AUTOSTART_FLAG, + DEADLINE, PHASES, POLL, + }; use crate::tray_availability::TrayAvailability; use tokio::time::Duration; + #[test] + fn not_having_started_is_not_a_step_of_the_run() { + // A checklist row for it would be a step that never completes, and resolving it out of a + // published id would name a phase the page has nowhere to draw. + assert!(!PHASES.contains(&Phase::NotStarted)); + assert_eq!(Phase::from_id(Phase::NotStarted.id()), None); + for phase in PHASES { + assert_eq!(Phase::from_id(phase.id()), Some(phase)); + } + } + + #[test] + fn a_sequence_that_has_not_run_says_so() { + // Seeding the state with Registering made "has not started" render exactly like "started, + // and registering" — on the one surface whose job is to tell those apart. + let startup = Startup::new(); + assert_eq!(startup.latest().phase, Phase::NotStarted.id()); + assert!(!startup.latest().can_retry); + assert!(!startup.settled()); + } + + #[test] + fn the_snapshot_never_answers_with_nothing() { + // The page returns early on a falsy progress, so answering None here was a window frozen + // on its own markup with no diagnostic in it and no event coming. + let progress = unavailable(); + assert_eq!(progress.phase, Phase::Failed.id()); + assert!(progress.can_retry); + assert!(progress.detail.is_some()); + assert!(progress + .diagnostic + .is_some_and(|text| text.contains("reason:"))); + } + + #[test] + fn only_a_terminal_state_settles_a_run() { + // This is what stops the deadline guard from overwriting a run that already reported, and + // what makes it speak for one that never did. + let startup = Startup::new(); + let mut running = Progress::new(Phase::Waiting, 1); + startup.publish(&mut running, None); + assert!(!startup.settled()); + let mut done = Progress::new(Phase::Ready, 2); + startup.publish(&mut done, None); + assert!(startup.settled()); + } + #[test] fn only_the_autostart_argument_marks_a_login_launch() { let user = ["/Applications/OpenCodex.app".to_owned()]; diff --git a/desktop/src-tauri/src/tray.rs b/desktop/src-tauri/src/tray.rs index 57923e19340..27f91988a48 100644 --- a/desktop/src-tauri/src/tray.rs +++ b/desktop/src-tauri/src/tray.rs @@ -1,6 +1,6 @@ use crate::{ exit::{self, ExitReason}, - formatting, + formatting, popup, proxy::ProxyClient, updater, widget, window, }; @@ -68,10 +68,19 @@ pub fn install(app: &AppHandle) -> tauri::Result<()> { )?; let install_update = MenuItem::with_id(app, "install-update", "Install update", false, None::<&str>)?; + // Every platform needs a menu path to the popup, not only Linux. + // + // On macOS the icon click cannot be the only way in: `tray-icon` assigns the menu to the + // NSStatusItem itself, so AppKit pops that menu on mouse-down before the crate's own click + // handler runs, and `show_menu_on_left_click(false)` cannot take it back. Linux tray hosts + // differ in whether a click reaches the application at all. That leaves Windows as the only + // platform where the icon alone would have worked. + let show_usage = MenuItem::with_id(app, "show-usage", "Show Usage", true, None::<&str>)?; let quit = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?; let menu = Menu::with_items( app, &[ + &show_usage, &open, &browser, &PredefinedMenuItem::separator(app)?, @@ -92,23 +101,66 @@ pub fn install(app: &AppHandle) -> tauri::Result<()> { }); } - let tray = TrayIconBuilder::with_id("main") + let builder = TrayIconBuilder::with_id("main") .icon(icon()) .icon_as_template(true) - .menu(&menu) + .menu(&menu); + // Attaching a menu makes the left click open that menu by default, which swallows the click + // before `on_tray_icon_event` can do anything visible. On macOS and Windows that left the + // usage popup with no way to open at all: the icon showed the menu, and the menu item that + // opens the popup is Linux-only. Left click is the popup, right click is the menu. + // + // Linux keeps the default. Its StatusNotifier hosts deliver no usable click event, so the + // menu is the entire interaction there and turning it off would remove the only way in. + #[cfg(not(target_os = "linux"))] + let builder = builder.show_menu_on_left_click(false); + let tray = builder .on_tray_icon_event(|tray, event| { if let TrayIconEvent::Click { button: MouseButton::Left, button_state: MouseButtonState::Up, + position, .. } = event { - if let Some(window) = tray.app_handle().get_webview_window("main") { - window::show(&window); + // The icon opens the usage popup rather than the dashboard. Reading the + // current numbers is the reason to look at a tray icon at all, and the + // dashboard remains one menu item away. With no runtime resolved there is + // nothing to report, so the window stays the answer. + let app = tray.app_handle(); + match app + .state::() + .proxy() + .map(|proxy| proxy.endpoint()) + { + Some(endpoint) => { + let _ = popup::toggle(app, endpoint, position); + } + None => { + if let Some(window) = app.get_webview_window("main") { + window::show(&window); + } + } } } }) .on_menu_event(move |app, event| match event.id().as_ref() { + "show-usage" => { + let Some(endpoint) = app + .state::() + .proxy() + .map(|proxy| proxy.endpoint()) + else { + return; + }; + // Anchor on the icon the user just clicked. A zero anchor clamps the popup into + // the top-left corner of the work area, which reads as a misplaced window rather + // than a menu, and on macOS the menu is now the ordinary way in rather than a + // fallback. Hosts that cannot report a rect still get the clamped corner, which + // is the best available answer there. + let anchor = tray_anchor(app); + let _ = popup::show(app, endpoint, anchor); + } "open-dashboard" => { if let Some(window) = app.get_webview_window("main") { window::show(&window); @@ -382,3 +434,108 @@ fn icon() -> tauri::image::Image<'static> { tauri::image::Image::from_bytes(include_bytes!("../icons/tray/icon.png")) .expect("valid tray icon") } + +/// Centre of the tray icon in physical pixels, for anchoring the popup. +/// +/// Returns the origin when the platform cannot report a rect. `popup::geometry` clamps that into +/// the work area, so the window still appears; it simply cannot point at anything. +fn tray_anchor(app: &AppHandle) -> tauri::PhysicalPosition { + app.tray_by_id("main") + .and_then(|tray| tray.rect().ok().flatten()) + .map(|rect| { + let position: tauri::PhysicalPosition = match rect.position { + tauri::Position::Physical(value) => { + tauri::PhysicalPosition::new(value.x as f64, value.y as f64) + } + tauri::Position::Logical(value) => tauri::PhysicalPosition::new(value.x, value.y), + }; + let size: tauri::PhysicalSize = match rect.size { + tauri::Size::Physical(value) => { + tauri::PhysicalSize::new(value.width as f64, value.height as f64) + } + tauri::Size::Logical(value) => tauri::PhysicalSize::new(value.width, value.height), + }; + tauri::PhysicalPosition::new( + position.x + size.width / 2.0, + position.y + size.height / 2.0, + ) + }) + .unwrap_or_else(|| tauri::PhysicalPosition::new(0.0, 0.0)) +} + +#[cfg(test)] +mod tests { + /// This file's own source, read at compile time, with the test module cut off. + /// + /// Slicing at the test attribute matters: the assertions below quote the very call names they + /// look for, so scanning the whole file would find the test's own string literals and pass + /// after the real calls were deleted. + fn production_source() -> &'static str { + include_str!("tray.rs") + .split("#[cfg(te") + .next() + .expect("source has a production half") + } + + /// A tray with a menu opens that menu on left click unless the builder says otherwise, and + /// nothing in the type system connects the two calls. The usage popup was unreachable on + /// macOS and Windows for exactly that reason, and the failure is quiet: the icon still + /// responds to the click, just with the wrong surface. The menu item that opens the popup is + /// Linux-only, so there was no second way in. + #[test] + fn attaching_a_menu_leaves_the_left_click_for_the_popup() { + let source = production_source(); + assert!( + source.contains(".menu(&menu)"), + "tray.rs no longer attaches a menu; this pairing may no longer apply" + ); + // The call site, not the name: the comments above explain why the flag is inert on + // macOS, and a bare substring matched that prose instead of the builder. + assert!( + source.contains("builder.show_menu_on_left_click(false)"), + "a tray with a menu must release the left click, or the popup cannot open" + ); + assert!( + source.contains("#[cfg(not(target_os = \"linux\"))]"), + "Linux delivers no usable click event, so it must keep the menu on left click" + ); + } + + /// macOS pops the attached menu from AppKit before the crate's click handler runs, so the + /// icon click cannot be the only way to the popup. The menu item is the path that works + /// everywhere, and platform-gating it once already left two platforms with no way in. + #[test] + fn the_usage_menu_item_is_not_platform_gated() { + let source = production_source(); + let declaration = source + .lines() + .position(|line| line.contains("let show_usage =")) + .expect("the menu no longer declares the usage item"); + let lines: Vec<&str> = source.lines().collect(); + // Every line that mentions the item: its declaration, its place in the menu, and the + // event arm. None of them may sit under a platform attribute. + let mentions = lines + .iter() + .enumerate() + .filter(|(_, line)| line.contains("show_usage") || line.contains("\"show-usage\"")) + .map(|(index, _)| index); + for index in mentions { + let previous = lines[..index] + .iter() + .rev() + .find(|line| !line.trim().is_empty()) + .copied() + .unwrap_or_default(); + assert!( + !previous.trim_start().starts_with("#[cfg("), + "the usage item is platform-gated at line {}; every platform needs a menu path \ + to the popup", + index + 1 + ); + } + assert!( + declaration > 0, + "the declaration is the first line of the file" + ); + } +} diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 83b2dc18886..4fe732e0dc7 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -9,6 +9,7 @@ }, "app": { "withGlobalTauri": true, + "macOSPrivateApi": true, "security": { "csp": "default-src 'self'; connect-src 'self' http://127.0.0.1:*; style-src 'self' 'unsafe-inline'; script-src 'self'" } diff --git a/desktop/ui/index.html b/desktop/ui/index.html index 0a10a6e4534..4a74b191615 100644 --- a/desktop/ui/index.html +++ b/desktop/ui/index.html @@ -18,6 +18,7 @@ #phases li[data-state="done"] { color: #4b8b3b; } #phases li[data-state="failed"] { color: #b3261e; font-weight: 600; } #failure { margin-top: 1.25rem; display: grid; gap: .75rem; } + #failure[hidden] { display: none; } .actions { display: flex; gap: .6rem; align-items: center; } button { border: 0; border-radius: .5rem; padding: .6rem 1rem; background: #2563eb; color: white; cursor: pointer; font: inherit; } button.secondary { background: #e3e3e8; color: #202124; } @@ -51,6 +52,173 @@

OpenCodex

- + + diff --git a/desktop/ui/main.js b/desktop/ui/main.js deleted file mode 100644 index 3f4516bda1a..00000000000 --- a/desktop/ui/main.js +++ /dev/null @@ -1,134 +0,0 @@ -// 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; - -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 invoke("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 listen("startup-phase", (event) => apply(event.payload)); - apply(await invoke("startup_snapshot")); - } catch (error) { - reportPageFailure("The startup surface could not reach the shell.", error); - } -} - -start(); diff --git a/devlog/_plan/260921_app_runtime_ownership/000_charter.md b/devlog/_plan/260921_app_runtime_ownership/000_charter.md new file mode 100644 index 00000000000..6e4b1fc88f7 --- /dev/null +++ b/devlog/_plan/260921_app_runtime_ownership/000_charter.md @@ -0,0 +1,56 @@ +# One runtime, one owner + +## What was asked + +Three things, in the user's words: + +1. Launching the app should stop the npm-installed runtime safely and bring up the app's own + runtime instead — on every platform. +2. Whatever permissions the app needs should be requested up front at first launch, the way + Karabiner does, rather than failing later. +3. Cmd+Q should leave the app in the menu bar and keep it running, not end the process. + +These are not three separate features. They are three faces of one question the codebase has never +answered: **who owns the running proxy, and how does ownership change hands.** + +## Why the current code cannot answer it + +The desktop shell decides ownership with a single boolean set once at startup. +`desktop/src-tauri/src/sidecar.rs` waits up to two seconds for anything to answer `/healthz` on the +discovered port; if something does, it returns `None` and the app is a guest, and if nothing does it +spawns the bundled sidecar and the app is the owner. `AppState::spawned_by_us` carries that answer +for the rest of the process lifetime. + +Every one of the user's three asks breaks on that boolean. + +- **Takeover has no representation at all.** There is no path from guest to owner. An existing npm + runtime is joined, never replaced, and nothing asks the user which they want. +- **Quit is `CommandChild.kill()`**, which is SIGKILL on Unix (`desktop/src-tauri/src/lib.rs`). The + CLI's own stop path restores client configuration, drains in-flight requests and clears state + files; the app's quit path does not wait for any of it. Cmd+Q reaching that code is not a + cosmetic problem — it is the destructive path firing on a keystroke the user expects to mean + "hide". +- **Permissions are never requested.** `first_run.rs` enables Start at Login once per install and + swallows every failure, which is the opposite of asking up front. + +## The gap this unit has to close + +Core already knows how to answer the ownership question. `src/server/proxy-liveness.ts` resolves a +live proxy from the pid record plus the runtime-port record, requires the `/healthz` body to +identify as opencodex, and carries back the version and the listener role. `src/service/state.ts` +records which launcher installed the service. The Rust side reimplemented a weaker version of the +same question — `discovery.rs` reads `runtime-port.json`, falls back to 10100 and then starts with +`--port 10100`, so a user on a custom `config.port` gets a different port than the one they +configured. + +So the work is mostly connection, not invention: give the shell the identity, readiness, graceful +stop and restart meaning that already exist in core, and add the one thing core does not have — +an explicit handover between two installations. + +## Status + +Interview open. The charter is recorded; the diff-level plan is not written yet because the +takeover semantics, the platform scope and the permission surface are still open questions with +the user. Evidence gathered so far is in `010_coexistence_findings.md` and +`020_windows_linux_findings.md`. + diff --git a/devlog/_plan/260921_app_runtime_ownership/010_coexistence_findings.md b/devlog/_plan/260921_app_runtime_ownership/010_coexistence_findings.md new file mode 100644 index 00000000000..fae8bbe1eac --- /dev/null +++ b/devlog/_plan/260921_app_runtime_ownership/010_coexistence_findings.md @@ -0,0 +1,73 @@ +# Coexistence with an existing npm installation + +External review of the desktop shell against an existing npm install, recorded here as claims plus +what this tree actually says. Findings are labelled **confirmed** when read directly out of the +source at `a499746395`, and **unverified** when the reasoning is sound but the behaviour was not +reproduced. + +## What happens today when an npm user launches the app + +| existing state | what the code does | consequence | +| --- | --- | --- | +| npm server already running | joins it, does not start the bundled one | the app ships a newer engine and dashboard than the one in use | +| npm server stopped, custom port | no runtime-port record, so 10100 is chosen | starts on a port the user did not configure | +| npm service autostarts at login | the app also enables its own Start at Login | two owners race at next login | +| terminal-only `OPENCODEX_HOME` | the app's environment lacks it, so a different home is read | looks like accounts disappeared | +| app started the server, then Quit | `kill()` on the child | in-flight requests and config restoration are cut off | +| server fails to start | the window and tray are created after `ensure_proxy` returns | nothing on screen explains the failure | + +## Confirmed in this tree + +- **Version split is invisible.** `sidecar.rs` accepts any successful `/healthz` and `lib.rs` then + navigates to that server's `/#/usage`. Nothing compares the app's version, the engine's version + or the dashboard's build. A user on 2.60.0 who installs a newer app keeps using 2.60.0 and has no + way to see it. +- **Port and home are guessed separately from core.** `discovery.rs` reads only + `runtime-port.json` and falls back to `DEFAULT_PORT = 10100`; `sidecar.rs` then passes + `--port 10100` explicitly rather than letting the CLI resolve `config.port`. `config_directory` + expands `~` itself instead of using the CLI's resolution. +- **Quit bypasses graceful stop.** `AppState::shutdown_child` calls `CommandChild.kill()`; there is + no `RunEvent::ExitRequested` handler, so Cmd+Q reaches it directly. +- **Ownership is a startup boolean.** `spawned_by_us` is set from whether a child was spawned, not + from whether the process now answering the port is that child. A slow-starting npm service that + wins the port after the spawn attempt would be recorded as app-owned. +- **Start at Login is enabled unconditionally on first run.** `first_run.rs` does not look for an + existing service, and it is not gated to macOS. +- **The updater does not coordinate a stop.** `updater.rs` installs and restarts with no drain of + an owned server first. + +## Confirmed, and already solved one layer down + +`src/server/proxy-liveness.ts` resolves liveness from the pid record and the runtime-port record, +requires the `/healthz` body to identify as opencodex, and returns the reported `version` and +`role`. It also carries deliberately tuned probe budgets — `START_OWNERSHIP_LIVENESS` exists +because a single unanswered 750ms probe was enough to start a duplicate proxy on Windows. The Rust +shell reimplemented the weaker form of this question and inherited the bug the comment describes. + +`src/service/state.ts` `stableLauncherEntry()` prefers the **recorded** `launcherPath` over a +fresh `PATH` walk, for a documented reason. The consequence for this unit is direct: a repair +driven from the app keeps pointing the service at the npm launcher. + +## Unverified + +- Whether the local management client can be diverted by system proxy settings. `ProxyClient` sets + a timeout and a user agent and does not disable reqwest's system-proxy default. No token exposure + was observed; the concern is that a management token rides a client that has not been forced + direct. +- Whether the 20 × 150ms start wait plus per-request timeouts produces a user-visible hang. The + arithmetic is real — `proxy.rs` sets a 4s timeout and `sidecar.rs` loops 20 times — but no + measurement was taken. +- Whether incremental local builds actually ship a stale engine. `prepare-sidecar.ts` reuses an + existing `dist/standalone//ocx` without checking that it came from the current source, + so a new app with an old engine is possible; it was not reproduced. + +## Recommended ordering from the review + +1. An identity-checked connection contract: pid, version, role and config home, custom port kept, + no connection to a foreign listener. +2. A visible startup and recovery surface: the app opens even when the server does not. +3. Coordinated stop for quit, stop and update: drain only what the app owns, never an external one. +4. Bundle consistency: app, engine and dashboard from one source, or an explicit build refusal. +5. Service coexistence, takeover and removal: one owner after login, and uninstall never touches an + existing npm install. + diff --git a/devlog/_plan/260921_app_runtime_ownership/020_windows_linux_findings.md b/devlog/_plan/260921_app_runtime_ownership/020_windows_linux_findings.md new file mode 100644 index 00000000000..509c70cf833 --- /dev/null +++ b/devlog/_plan/260921_app_runtime_ownership/020_windows_linux_findings.md @@ -0,0 +1,93 @@ +# Windows and Linux release readiness + +Second external review, covering what stands between this tree and shipping the desktop app on +Windows and Linux. Every claim below was re-read against `a499746395` before being recorded. + +## Confirmed by reading the tree + +**The Windows release job runs bash syntax under PowerShell.** +`.github/workflows/release.yml:347` — `Rename release assets` uses backslash line continuations +and `"$RELEASE_VERSION"` expansion, and carries no `shell: bash`. The workflow sets no top-level +`defaults.run.shell` either; only two other steps (lines 117 and 146) opt in explicitly. A Windows +runner defaults to PowerShell, so this step does not mean on Windows what it means elsewhere. + +**Checksums record a path the verifier cannot resolve.** +`release.yml:159` writes `sha256sum "dist/ocx--.tar.gz" > dist/....sha256`, so +the checksum file contains the path `dist/ocx-...`. `release.yml:472` then verifies with +`cd dist/release && shasum -a 256 -c ./*.sha256`, which resolves that recorded path relative to +`dist/release` — a directory that has no `dist/` inside it. + +**Publishing does not depend on packaging.** +`release.yml:483` — `publish` declares `needs: validate-dispatch` only. npm publication and the +GitHub release can proceed while desktop packaging is failing, which is how a version becomes +public with no app attached. + +**`ocx.exe` is not recognised as an opencodex process.** +`src/config/process-state.ts` `isOcxCommandLine` matches +`(?:ocx|opencodex)(?:\.cmd)?` — no `.exe`. Meanwhile `scripts/build-standalone.ts:37` and +`desktop/scripts/prepare-sidecar.ts:39` both emit `ocx.exe` on Windows targets, and the sidecar is +copied as `ocx-.exe`. This predicate feeds pid identity, so the shipped Windows binary is +the one shape the identity check does not know. + +**The Windows app origin is not in the navigation allowlist.** +`desktop/src-tauri/src/window.rs` permits the `tauri` scheme and `http://127.0.0.1:`, and +sends everything else to the external browser. Tauri serves the local app over +`http://tauri.localhost` on Windows, which lands in the external-browser branch. The policy +mismatch is confirmed; what the WebView2 first navigation actually does was not reproduced. + +**The service path filter does not cover the service directory.** +`.github/workflows/service-lifecycle.yml:7` and `release.yml:636` both key on `src/service.ts`. +The implementation is `src/service/**` — eleven files. A change to `launchd.ts` or +`windows-scheduler.ts` alone does not trigger the lifecycle workflow. + +**`desktop shell` does not exercise a real sidecar.** +`.github/workflows/ci.yml:1253` creates the sidecar with `: > "desktop/src-tauri/binaries/ocx-"` +and `chmod +x`, then runs fmt, clippy and cargo test. That is a useful Rust check and it is not +evidence that the bundled binary runs. + +**The Windows suite is out of the push gate by design.** +`ci.yml` gates `platform-windows` on `workflow_dispatch`, with a comment saying Windows +re-enters the gate once its tracked failures are fixed. So the review's observation is right, but +this is a recorded decision rather than an oversight. It still means a green push tells you nothing +about the Windows app. + +**Standalone binaries target modern x64 only.** +`scripts/build-standalone.ts` builds `bun-windows-x64` and `bun-linux-x64` with no baseline +variant. A CPU without the newer instruction set would fail as an immediate sidecar exit, which the +shell currently reports as a generic health failure. + +**Start-up failures are indistinguishable and can be slow.** +`proxy.rs` sets a 4s per-request timeout; `sidecar.rs` polls 20 times with 150ms sleeps and +discards the spawn event stream into `_events`. A failure mode where every probe times out is +arithmetically over a minute, with no exit code and no diagnostic surfaced. + +## Confirmed shape, consequence not reproduced + +- Stop treats an HTTP 200 with parseable JSON as success without reading `success: false`, and + does not wait for the backend's post-response drain before killing the child. +- Linux inherits the macOS menu-bar assumption: the window is created hidden and close always + hides, which on a desktop without a working tray leaves a running process with no way back in. +- Tray capability differs per platform — a title is macOS-only — so usage shown as tray title has + no Windows or Linux equivalent. +- `.deb` and AppImage are both shipped while the updater manifest is AppImage-shaped, and the + update code does not branch on install format. +- Rust reads `HOME` before `USERPROFILE`; Node's `homedir()` prefers `USERPROFILE` on Windows. + Under Git Bash the two can differ, which presents to a user as missing accounts. +- Windows code signing for the installer and executables is separate from the updater's minisign + key, and no Authenticode configuration was found. + +## Ordering the review proposes + +1. Release pipeline: the Windows shell, the checksum paths, and no publication before packaging. +2. Instance identity and config home: recognise `.exe`, one home for both sides, prove the + connected process is the spawned child. +3. A graceful shutdown coordinator shared by stop, quit and update. +4. Per-OS first run, window and tray behaviour. +5. Update target and install format separation, app versus CLI. +6. An installed-artifact gate: first run, coexistence, stop, update and uninstall from the real + MSI, deb and AppImage. + +The closing judgement is the one worth keeping: the Swift episode was not about Swift. Compiling, +bundling and registering each failed to prove running. The same gap is still open on Windows and +Linux. + diff --git a/devlog/_plan/260921_app_runtime_ownership/030_contradictions.md b/devlog/_plan/260921_app_runtime_ownership/030_contradictions.md new file mode 100644 index 00000000000..06c58d1d223 --- /dev/null +++ b/devlog/_plan/260921_app_runtime_ownership/030_contradictions.md @@ -0,0 +1,78 @@ +# Contradiction round 1 + +Three read-only lenses were run against the charter and the user's answers. They returned 22 +contradictions, 17 of them high. Recorded here so the plan has to answer them rather than +rediscover them. + +## The premise that did not survive + +**The verification hosts were miscounted, and that was my error.** The host I took for a Mac is +in fact the Windows machine, and the Linux one failed to resolve because I used the wrong short +name. With the right name and a permitted account all three platforms are reachable; see +`040_verification_hosts.md`. The contradiction that survives is narrower: the Linux box has no +`ocx` installed, so the npm side of the coexistence scenario does not exist there yet. + +**The sync button's silence is not a permission problem.** There are two different sync buttons and +they behave differently. The dashboard's model sync (`gui/src/pages/use-dashboard-data.ts:779`) +posts to `/api/sync` and renders both a success and a failure toast +(`dashboard-overview-sections.tsx:243`, backend at +`src/server/management/config-routes.ts:700`). The Integrations client sync +(`gui/src/pages/Integrations.tsx:73`) posts to `/api/machine/sync`, **ignores the status and the +body entirely**, and only clears a busy flag — so it cannot report anything, ever, no matter what +the server says. Neither path calls an OS elevation API. Elevating the app would not change either. + +## Ownership cannot be expressed yet + +- Ownership is the process-local `spawned_by_us` boolean; persisted service state has no consent + field and no desktop-owner field, so "asked once" and "permanent owner" cannot both be enforced + across an app restart. +- Disabling the npm service's autostart does not survive `ocx service repair` or `ocx update`: a + disabled registration still counts as installed, repair re-enables and restarts it, and the + recorded `launcherPath` still names the npm launcher. +- The app's Start at Login and the service's autostart are independent switches with no + mutual-exclusion invariant, so both can fire at the next login and race for the port. +- The app cannot prove the process answering the port is the child it spawned: any successful + health response after `spawn()` yields `Some(child)` and therefore app ownership. +- A plain `POST /api/stop` cannot perform the promised graceful takeover of a *managed* runtime. + The endpoint deliberately refuses launchd/systemd self-unload and the Windows respawn case unless + a receipt-backed `ocx stop` owns the teardown, so the app either stalls on 409 or bypasses the + drain and client-restore contract. +- Cmd+Q reaches `shutdown_child()` with no `ExitRequested` interception, and the updater's + `app.restart()` takes the same hard-kill path. + +## Elevation is the wrong tool + +Everything this app owns is per-user: the app spawns its sidecar as the current user, macOS uses +`~/Library/LaunchAgents`, Linux uses `systemctl --user`, and the Windows task is registered +`InteractiveToken` with `LeastPrivilege`. Windows already has a *conditional* elevation +fallback that only crosses UAC after an access-denied create — and which explicitly fails when a +*different* administrator supplies the credentials, because that account cannot read the staged +payload. An unconditional up-front prompt would therefore be both unnecessary and, for a standard +account, misleading. + +Separately, and worth fixing regardless: `ProxyClient` sends the admin token to `127.0.0.1` +without `no_proxy()`, and the pinned reqwest enables system proxies by default. + +## Evidence CI does not provide + +- Nothing installs an MSI, a deb or an AppImage anywhere in the repository; no `msiexec`, no + `dpkg -i`, no AppImage execution. +- The service-lifecycle workflow installs a service *from a source checkout* — it never models an + npm-installed runtime being handed to an installed app. +- That workflow's path filter names `src/service.ts` and omits both `src/service/**` and + `desktop/**`, so the ownership implementation can land green without any lifecycle evidence. +- `platform-windows` runs only on `workflow_dispatch`, by recorded decision. +- The Windows release lane builds an MSI and then runs POSIX syntax under PowerShell. +- `publish` depends only on `validate-dispatch`, so a version can go public while packaging fails. + +## Open assumptions + +1. Linux has a host (Ubuntu 24.04 GNOME) but no npm `ocx` on it, so the coexistence scenario has to be + staged there before it can be exercised. +2. `.deb` and AppImage are one "Linux" in the charter but two update contracts — the manifest + names AppImage only. +3. "Cmd+Q keeps it in the menu bar" has no literal equivalent on Windows or Linux; the portable + statement is that closing the window and quitting the window are different actions, and only the + explicit tray Quit ends the runtime. +4. Which of the two sync buttons the user pressed is not yet known. + diff --git a/devlog/_plan/260921_app_runtime_ownership/040_verification_hosts.md b/devlog/_plan/260921_app_runtime_ownership/040_verification_hosts.md new file mode 100644 index 00000000000..b2bb4b8db11 --- /dev/null +++ b/devlog/_plan/260921_app_runtime_ownership/040_verification_hosts.md @@ -0,0 +1,43 @@ +# Verification hosts + +Three machines cover the three platforms, all reachable over a private mesh. They are described +here by role only — the concrete names, addresses and accounts are operator detail and live in +scratch, not in this directory. + +| platform | what it is | npm-installed ocx already present | +| --- | --- | --- | +| macOS | the development machine, macOS 27, with the signed app installed | yes, a global install on `PATH` | +| Windows | Windows 11 25H2, reached over a POSIX shell layer | yes, both the launcher and its `.cmd` form | +| Linux | Ubuntu 24.04 LTS with a live GNOME session | no — only `npm` and `node` | + +## Why each one matters + +**The Windows box is the coexistence case, not a spare runner.** It already carries an +npm-installed `ocx` on `PATH`, which is exactly the situation the takeover has to handle. It is +also where the `isOcxCommandLine` gap becomes real: the npm launcher there is `ocx.cmd`, which +the predicate *does* match, while the app's bundled sidecar is `ocx.exe`, which it does not. Both +shapes exist on the same machine, so the predicate can be shown to be wrong rather than argued +about. + +**The Linux box has a real graphical session**, so the tray question can be answered rather than +assumed. It runs stock GNOME — both the Wayland and Xorg sessions are installed, and there is an +active seat — and stock GNOME ships **no tray** without an AppIndicator extension. That is +precisely the configuration the Windows/Linux review warned about: a window created hidden plus a +close handler that always hides leaves a running process with no way back in. `systemctl --user` +is running and FUSE is available, so the systemd user unit and the AppImage path are both testable +there. + +That box has no `ocx` yet, so the npm side of the coexistence scenario has to be staged before +the handover can be exercised there. + +## A note on what belongs here + +The first draft of this file named the mesh hostnames, an address and the SSH accounts that are +and are not permitted, and it was committed locally before being caught. `devlog/` is a public +directory in a public repository, so that was operator detail heading for publication. It has been +removed from the working tree and from history — nothing was pushed. + +Worth recording for its own sake: `bun run privacy:scan` passed on that draft. The scan covers +credentials and account identifiers, not mesh topology or login names, so passing it is not +evidence that a file is safe to publish. + diff --git a/devlog/_plan/260921_app_runtime_ownership/050_webview_dialogs.md b/devlog/_plan/260921_app_runtime_ownership/050_webview_dialogs.md new file mode 100644 index 00000000000..25916ecbb24 --- /dev/null +++ b/devlog/_plan/260921_app_runtime_ownership/050_webview_dialogs.md @@ -0,0 +1,58 @@ +# The app's webview has no JavaScript dialogs + +The reported symptom was the sidebar's proxy refresh orb: press it, nothing happens, no popup. It +is not that button, and it is not a permission. + +## What the button does + +The refresh orb beside the red power orb is `dash.codexRestart` — "Codex 모델 목록 새로고침" — and +its handler opens with a consent gate: + + if (!confirm(t("dash.codexRestartConfirm"))) return null; + +Every outcome after that is delivered by `alert()`: success, nothing-running, partial, HTTP +failure, unreachable, timeout, malformed. The confirm is deliberate and documented — stopping an +app-server can interrupt a Codex turn that is running right now, so the click is where the user +gives that consent. + +## Why nothing happens + +The app embeds wry 0.55.1 under Tauri 2.11.6. Its `WryWebViewUIDelegate` implements exactly three +`WKUIDelegate` methods: the file open panel, the media capture permission request, and window +creation for a navigation action. A search of the whole crate for +`runJavaScriptAlertPanel`, `runJavaScriptConfirmPanel` or `runJavaScriptTextInputPanel` returns +nothing. + +WKWebView does not display a JavaScript dialog when its UI delegate does not implement the matching +panel method. So inside the app `confirm()` returns `false` without ever drawing anything, and +`alert()` draws nothing at all. The handler takes its early return and the click is swallowed. +In a browser the same dashboard works, which is why this reads as "the app is broken" rather than +"the dashboard is broken". + +## It is a class, not a button + +13 `confirm` gates and 8 `alert` reports across the dashboard are inoperative inside the app. +Among them: + +- the sidebar's red power orb — `dash.stopConfirm` — so **stopping the proxy from the app does + nothing either**; +- removing a provider key, removing an account, removing a routing profile, deleting a custom + model, hiding a model, switching provider account mode; +- uninstalling the tray helper from the startup page; +- the memory observability confirmation; +- every result message the Codex refresh would have shown. + +Every one of these fails the same way: the user clicks, is silently declined, and sees nothing. +The destructive ones fail safe — nothing is destroyed — but the user cannot tell a refusal from a +no-op, and the two non-destructive ones (stop, refresh) simply never run. + +## What this means for the unit + +This is a third answer to "who owns the runtime", from an unexpected direction. The app is supposed +to become the owner, and the two controls that act on the runtime from inside the app — stop and +refresh — are both gated behind a dialog the app cannot draw. Any takeover consent prompt written +as `confirm()` would be auto-declined the same way. + +So the consent surface has to be real UI rather than a platform dialog, or the shell has to supply +the delegate methods. That choice belongs in the plan, not here. + diff --git a/devlog/_plan/260921_app_runtime_ownership/060_contradictions_round2.md b/devlog/_plan/260921_app_runtime_ownership/060_contradictions_round2.md new file mode 100644 index 00000000000..29015ede258 --- /dev/null +++ b/devlog/_plan/260921_app_runtime_ownership/060_contradictions_round2.md @@ -0,0 +1,62 @@ +# Contradiction round 2 + +Run after the four decisions were made: ask-once permanent takeover, keep the npm registration and +record an owner, per-user by default with elevation only at the point of failure, all three +platforms. Two lenses, 11 contradictions, 6 high. The weakest dimension going in was success +criteria, and that is where most of them landed. + +## Nothing here is observable yet + +- **"Ask once, then own permanently" has no durable state.** Ownership is recomputed each launch + from whether this process spawned a child; service state has no owner field and no consent field. + A restart can silently demote the app back to guest and no test would see it. +- **"Keep the registration, supersede it" has no marker either.** State records a launcher path and + a backend, and repair still prefers the recorded launcher. There is nothing to write the decision + into and nothing to assert against. +- **The quit criterion is currently inverted on macOS and undefined elsewhere.** Tray Quit calls + `app.exit`, `RunEvent::Exit` calls `shutdown_child`, and that kills the child. Windows and + Linux have no Cmd+Q equivalent named anywhere, so they could be called compliant without proving + the runtime survived their equivalent gesture. +- **No check observes an installed app taking over a real runtime.** Desktop CI builds against a + zero-byte sidecar; lifecycle CI installs a service from a source checkout and never stages an npm + install to hand over. + +## The dialog defect reaches further than one button + +- `stop-proxy.ts` treats *every* fetch exception as acceptance, so a failed stop and a successful + one are already indistinguishable before the missing alert. +- `window.prompt()` is used for alias editing on the provider and model pages. wry implements no + text input panel either, so those edits cannot be made in the app at all. +- Account, key, model, routing and tray-uninstall changes are all gated the same way. AGENTS.md + requires identity-affecting actions to sit behind an explicit gate; inside the app that gate + cannot be passed, so the action fails safe but also fails silently. + +## Two things that make this cheaper than it looks + +- **The fix already exists in the tree.** `OAuthTosWarningModal` and `ConsequenceDialog` are + in-page `` components with real consent flows. The dashboard does not need a platform + dialog; it needs to stop using one. +- **The shell is already detectable.** `gui/src/lib/desktop-shell.ts` exists and is used today + only to reroute external links, so there is a seam to branch on if a branch is wanted rather than + a straight replacement. + +## Why CI could never have caught it + +The existing GUI tests encode browser dialogs as available. `codex-stale-banner-dom.test.tsx` +stubs `confirm()` to true and `alert()` to a no-op; `memory-observability-card.test.tsx` forces +confirmation; `app-stop.test.ts` asserts that `alert()` *exists*. Each of those is reasonable on +its own and together they make the desktop failure invisible. A regression test for this has to +assert the absence of platform dialogs, not stub them in. + +## Open assumptions carried forward + +1. The Linux box has no npm `ocx`, so the coexistence scenario has to be staged there before it + can be exercised. +2. `.deb` and AppImage are one "Linux" in the charter but two update contracts; the manifest + names AppImage only. +3. "Cmd+Q keeps it in the menu bar" has no literal equivalent on Windows or Linux. The portable + statement is that closing a window and quitting the app are different actions, and only the + explicit tray Quit ends the runtime. +4. Whether the takeover consent becomes an in-page dialog or the shell gains the delegate methods + is a plan decision, not an interview one. + diff --git a/devlog/_plan/260921_app_runtime_ownership/070_decisions.md b/devlog/_plan/260921_app_runtime_ownership/070_decisions.md new file mode 100644 index 00000000000..10201892f1a --- /dev/null +++ b/devlog/_plan/260921_app_runtime_ownership/070_decisions.md @@ -0,0 +1,76 @@ +# Decisions + +Settled by the maintainer, then by an automated decider round in which each fork was given to an +independent reader with the evidence and the trade-offs and asked to choose one option and own its +cost. Each entry records the choice and the cost that was accepted with it, because the cost is the +part a later reader will want. + +## Fixed by the maintainer + +| | decision | +| --- | --- | +| Takeover | Ask once on first discovery of an existing npm runtime. On approval the app is the permanent owner. | +| The npm install | The user's service registration is kept, never deleted. A durable owner marker supersedes it and repair and update must respect it. | +| Elevation | Per-user by default. Elevate only at the point a per-user operation actually fails, which is what the Windows path already does. | +| Platforms | macOS, Windows and Linux, with a verification machine for each. | +| Quit | Cmd+Q leaves the app in the menu bar with the runtime alive. | + +## D1 — the consent and feedback surface + +**Every platform dialog leaves the dashboard.** `confirm`, `alert` and `prompt` are removed +from `gui/src` and replaced with the in-page dialog and feedback components already in the tree, +with a source guard so they cannot come back. + +The decider checked the other two platforms rather than assuming: wry leaves WebView2's script +dialog setting untouched and WebView2 enables script dialogs by default, and WebKitGTK shows +dialogs through its default handler. So implementing the macOS delegate would repair one platform +and leave the product's consent UI platform-dependent. **Cost accepted:** the macOS shell still +cannot draw an accidental future platform dialog, so repository code has to keep enforcing the ban +statically. + +## D2 — what ends the runtime + +**Window close and the OS quit gesture both hide to the tray, on all three platforms. Only the +explicit tray Quit ends the app**, and that path drains an app-owned runtime before exiting. + +Observable per platform: close and Cmd+Q on macOS, close and Alt+F4 on Windows, and the window +manager's close on Linux all leave the window hidden with both pids alive and the window +reopenable from the tray; tray Quit drains in-flight work and then ends both. **Cost accepted:** +on a Linux desktop with no tray this strands the user — which is D6. + +## D3 — where ownership lives + +**Both records, with the shared service state authoritative.** The service install state gains an +owner, an install id and a consent generation, written compare-and-swap and preserved by every +writer; the app keeps its own install identity so a reinstalled app can tell its own prior consent +from another installation's. + +The decider rejected the single-record option for a specific reason: an install id stored only in +the shared record gives the app no independent value to compare against, so a reinstalled app +cannot tell whose consent it inherited. **Cost accepted:** two records mean mismatch and orphan +recovery, and losing app-local state can force explicit re-consent, because the two writes cannot +be one atomic act. + +## D4 — how an existing managed runtime is stopped + +**The app shells out to its own bundled `ocx stop`.** The receipt-backed teardown, the drain, the +Windows respawn verification and the client-configuration restore then run exactly as they do from +a terminal, and the shell reads the exit code and the output. + +The alternatives were disqualified by the same fact: launchd and systemd can terminate the request +handler during self-unload, and the Windows respawn window can only be verified after that process +exits, so an in-process management endpoint cannot own its own teardown. **Cost accepted:** the +takeover path now depends on spawning a CLI and surfacing a human-readable result rather than a +structured one. + +## D5 — how the shell resolves the port, the home and liveness + +**It stops resolving them.** The shell asks the bundled CLI through a machine-readable resolve +command, with a strict timeout, and if the binary is slow or missing it opens a local recovery UI +and refuses to guess a home, a port or a liveness verdict. + +The reason is in the comments of the code it would otherwise duplicate: the tuned probe budgets in +the liveness path exist because small divergence produced duplicate proxies, twice. **Cost +accepted:** every launch pays one bounded process start, and startup now depends explicitly on the +bundled binary being executable — which is also why D7's recovery window has to exist first. + diff --git a/devlog/_plan/260921_app_runtime_ownership/080_decisions_round2.md b/devlog/_plan/260921_app_runtime_ownership/080_decisions_round2.md new file mode 100644 index 00000000000..e5f504c7aac --- /dev/null +++ b/devlog/_plan/260921_app_runtime_ownership/080_decisions_round2.md @@ -0,0 +1,65 @@ +# Decisions, round 2 + +## D6 — Linux without a tray + +**Detect real tray availability and branch.** Where there is no usable tray the window is shown on +launch, close really closes and quits through the graceful drain, and hide-to-tray is simply not +used. Where there is a tray, D2 applies unchanged. + +The decider found why construction success is not enough: the pinned Linux backend creates an +`AppIndicator` and returns success without checking for a StatusNotifier watcher, so +`tray::install` succeeding proves nothing about whether an icon is reachable. **Cost accepted:** +Linux behaviour becomes session-dependent, so both modes have to be supported and verified, and D2 +gains an explicit no-tray exception. + +## D7 — the startup surface + +**The window is created and shown first, always.** Resolve, liveness, takeover consent, start, +permission registration and the Start at Login decision all run inside it as named states under one +overall deadline, with a retry, the child's exit code and a copyable diagnostic. A launch that came +from login autostart starts hidden; that is the only difference. + +The retry surface already exists in `desktop/ui` — it is just created hidden and never promoted +into a real state machine. **Cost accepted:** an ordinary manual launch now shows a window even +when everything succeeds immediately. + +## D8 — the Linux update contract + +**Both formats update in place.** The pinned updater already branches between AppImage and +`.deb`, detects dpkg ownership, validates the payload and installs through package-manager +elevation, and Tauri exposes the bundle type embedded at packaging time, so the app can select the +right manifest entry rather than guess. The current mismatch is that both artifacts are collected +but only the AppImage is published as a Linux updater target. + +**Cost accepted:** Linux release and verification become a two-format matrix, and a `.deb` update +asks for package-manager authorization — which is consistent with the elevation rule, because the +prompt comes only after the user chooses Install. + +## D9 — what gates a desktop release + +**Fix the three pipeline defects, and add one installed-artifact smoke gate** that runs on a +machine per platform: install the real artifact, launch it, prove which runtime it connected to, +exercise takeover, quit, and confirm the runtime survived or drained as specified. Publication +waits for packaging and for that smoke. + +The release contract becomes package, then install-smoke on all three platforms, then publish and +attach, with a missing platform result blocking publication. **Cost accepted:** publication now +depends on three stateful GUI machines, each run needs strict rollback and cleanup, and AppImage +update behaviour, full uninstall coverage and the zero-sidecar PR job stay follow-up. + +## The observable contract this produces + +Every decision above was required to state what a test or a screenshot must show. Collected: + +- A staged npm runtime on a non-default port is drained, its registration is still present + afterwards, the desktop install id is recorded as owner with exactly one consent-generation + increment, and `/healthz` reports the bundled sidecar's pid and version on the preserved home + and port. +- Window close and the platform quit gesture each leave both pids alive with the window reopenable + from the tray; tray Quit during an in-flight request lets that request finish and then ends both. +- A second launch does not ask for consent again. +- On a Linux session with no usable tray: the dashboard appears on first launch, no tray icon is + claimed, and closing the window drains and exits rather than hiding. +- An older AppImage updates without elevation and keeps its path; an older dpkg install asks for + authorization only after Install is chosen, and cancelling leaves the old version in place. + diff --git a/devlog/_plan/260921_app_runtime_ownership/090_lanes.md b/devlog/_plan/260921_app_runtime_ownership/090_lanes.md new file mode 100644 index 00000000000..a4ff13602fd --- /dev/null +++ b/devlog/_plan/260921_app_runtime_ownership/090_lanes.md @@ -0,0 +1,85 @@ +# Lanes + +Nine decisions, split into work that can proceed in parallel. Each lane is one branch, ordered +commits, one pull request to `dev`. No native stacks, no child PR chains. + +Lane order matters in two places only. **A** publishes the CLI resolve and stop contracts that **B** +consumes, and **C** must land before **B** wires the stop shell-out, because every service-state +writer has to become preserve-and-swap before a second writer exists at all (see R4 in +`100_resolutions.md`). Everything else is independent. + +## A — the CLI contract the shell will consume (D5, D4) + +A machine-readable resolve that returns the config home, the effective port and the liveness +verdict, and a stop invocation the shell can drive and read. Both are thin surfaces over +`src/config/paths.ts`, `src/server/proxy-liveness.ts` and the existing receipt-backed stop in +`src/cli/` — the point is to expose what already exists, not to reimplement it. + +Owns: the new CLI verb and its schema, and the contract tests. Must not change the meaning of the +existing stop path. + +## B — the shell: startup, quit, tray, consent plumbing (D7, D2, D6) + +The window is created and shown first and startup runs inside it as named states with one deadline, +a retry, the child's exit code and a copyable diagnostic; login autostart starts hidden. +`ExitRequested` is intercepted so close and the quit gesture hide, and only tray Quit drains and +exits. Linux detects real tray availability and, where there is none, shows the window and lets +close mean close. + +Owns: `desktop/src-tauri/src/` and `desktop/ui/`. Consumes A's contracts. Blocked on A only for +the resolve and stop call sites; the quit and tray work can start immediately. + +## C — durable ownership (D3) + +The service install state gains an owner, an install id and a consent generation, written +compare-and-swap and preserved by every writer; repair and update learn to respect it; the app +keeps its own install identity beside it. + +Owns: `src/service/` and `src/update/`. This is the lane with the widest reader list, so it +lands early and alone. + +## D — the dashboard consent surface (D1) + +`confirm`, `alert` and `prompt` leave `gui/src` entirely, replaced with the in-page dialog +and feedback components already in the tree, with a source guard so they cannot return, and with +tests that assert the absence of platform dialogs rather than stubbing them in. + +Owns: `gui/`. Independent of every other lane. This is also the lane that unblocks the takeover +consent prompt, since a `confirm`-based prompt would be auto-declined. + +## E — the release pipeline (D9, part one) + +The Windows shell override, the checksum path, and the dependency graph so publication cannot +precede packaging. Plus the service path filter that names one file while the implementation is +eleven, and the `.exe` the process predicate does not recognise. + +Owns: `.github/workflows/` and `src/config/process-state.ts`. Touches release automation, so it +carries the explicit security review the repository requires. + +## F — the installed-artifact gate (D9, part two) and the Linux update contract (D8) + +The smoke that installs the real artifact on each platform, launches it, proves which runtime it +connected to, exercises takeover and quit, and reports. Plus publishing both AppImage and `.deb` +as distinct updater targets and selecting the right one from the bundle type. + +Owns: `desktop/scripts/` and the new workflow. Registering self-hosted runners is a maintainer +action outside the diff; the lane delivers the workflow and the drivers. + +## What every lane owes + +- A focused regression test near the existing tests for that subsystem, driven red once. +- Any new test file registered in **both** `scripts/test-layout/layout.json` and + `tests/fixtures/test-layout-expected.json`. +- No new line in a file already at its size cap; move the case to a sibling file instead. +- Exact-head CI read at the SHA, with skipped and cancelled jobs named rather than counted green. +- English in every public artifact, and no host names, addresses, accounts or absolute user paths + anywhere in the tree. + +## Who is running each lane + +C, B and D run on one model and A, E and F on another, deliberately split so a systematic blind +spot in either does not cover all six. The split as dispatched is not the one that was intended: +A, E and F went out on a third model by a dispatch error on my part. By the time it was caught, +all three had substantial work in flight — a dozen modified files between them and two commits on +F — so they were left alone rather than restarted. It is recorded here because a later reader +comparing lane quality should know the split was not what the plan says. diff --git a/devlog/_plan/260921_app_runtime_ownership/100_resolutions.md b/devlog/_plan/260921_app_runtime_ownership/100_resolutions.md new file mode 100644 index 00000000000..6014d97fa5f --- /dev/null +++ b/devlog/_plan/260921_app_runtime_ownership/100_resolutions.md @@ -0,0 +1,75 @@ +# Resolutions + +A final scan over the decided set returned sixteen items. Most were the unit's own premise restated +— "the code does not do this yet" is not a contradiction between decisions. Six were real, and each +is resolved here so no lane has to guess. + +## R1 — no tray and login autostart (D6 against D7) + +D6 shows the window where there is no usable tray; D7 starts hidden when the launch came from login +autostart. A no-tray login launch satisfies both rules and they disagree. + +**Resolved: tray availability wins over launch origin.** With no usable tray there is nowhere to +hide, so the window is shown even on a login launch. The hidden start is a property of *having a +place to be hidden in*, not of how the process was started. + +## R2 — update restart against tray-only quit (D2 against D8) + +D2 says only the tray Quit ends the app. An update installs and restarts. + +**Resolved: an update restart is a coordinated restart, not a quit.** It runs the same graceful +drain as tray Quit, then comes back. What D2 forbids is an *uncoordinated* exit — the current +`app.restart()` straight into the hard kill — not the existence of a restart. The exit path must +be able to tell a coordinated restart from a user quit gesture, which is already in D2's blast +radius. + +## R3 — AppImage update verification (D8 against D9) + +D8 makes both Linux formats update in place. D9 accepted deferring AppImage update behaviour as +follow-up. Those cannot both hold. + +**Resolved: D8 wins and D9's deferral is withdrawn.** If both formats carry an update contract, +the gate has to exercise both, so update verification for AppImage and `.deb` moves into lane F's +scope rather than after it. A gate that cannot see one of the two promised paths is not a gate. + +## R4 — ownership writes against the external stopper (D3 against D4) + +D3 wants compare-and-swap ownership fields. D4 has the app drive an external `ocx stop`, and the +service-state writers today reconstruct the whole record and overwrite it, so a concurrent repair, +update or stop would drop the ownership fields entirely. + +**Resolved, and it fixes the lane order.** Lane C lands **before** lane B wires the stop shell-out. +C's scope explicitly includes converting every writer in `orchestration.ts`, `launchd.ts`, +`systemd.ts`, `windows-ops.ts`, `windows-scheduler.ts` and `repair.ts` from +reconstruct-and-replace to preserve-and-swap, with a revision check, before any new writer exists. +A preserved field is not optional politeness here; it is the only thing that makes consent durable. + +## R5 — the dialog guard must ban the call, not the word (D1) + +A lexical ban on `confirm`, `alert` and `prompt` would reject legitimate code: an admin-token +helper, a `confirm()` method on a session object, and an executable sample string that contains +the word. + +**Resolved: the guard matches the global call form**, not the identifier. `window.confirm(` and a +bare `confirm(` at call position are banned; a method call on a receiver, a property name and a +string literal are not. The guard has to be driven red against a real global call and green against +each of those three legitimate shapes before it counts. + +## R6 — two constraints every lane inherits + +**Security review.** Lane E and lane F change GitHub Actions and release automation, which the +repository requires to have explicit security review. That is a gate on those lanes landing, not a +thing to discover at merge time. + +**The size ratchet.** `gui/src/pages/Models.tsx` has one line of headroom against its cap, and +lane D has to touch it. Additive dialog code there fails CI for that branch and for every branch cut +from `dev` afterwards. Lane D extracts to a sibling file and registers it in both test-layout maps +rather than adding a line. + +## Remaining open assumptions + +1. Registering self-hosted runners for the installed-artifact gate is a maintainer action outside + any diff; lane F delivers the workflow and the drivers and stops there. +2. The Linux verification machine has no `ocx` installed, so the npm side of the coexistence + scenario has to be staged before the handover can be exercised there. + diff --git a/devlog/_plan/260921_app_runtime_ownership/110_reaudit.md b/devlog/_plan/260921_app_runtime_ownership/110_reaudit.md new file mode 100644 index 00000000000..3e6097d6bfd --- /dev/null +++ b/devlog/_plan/260921_app_runtime_ownership/110_reaudit.md @@ -0,0 +1,124 @@ +# External re-audit of the lane branches + +Two independent reviews read the pushed lane branches at fixed SHAs and reported on the same day +the lanes were opened. Both agree the direction is right and both refuse to call it shippable. The +distinction they draw is the one worth keeping: **"better than before" and "safe in the failure +path" are not the same verdict.** + +What they credit as genuinely fixed: the Windows packaging shell, the checksum path, packaging +before publication, `.exe` process identity, the window being created before the runtime starts, +the removal of the direct `child.kill()`, the StatusNotifier probe, and the replacement of the +platform dialogs. Those are not re-listed as defects. + +## P0 — the installed gate can destroy a real installation + +`desktop/scripts/installed-gate.ts`. The preflight detects an existing service, an existing +state file in the default home, or a running app, and refuses to verify. But refusal only sets a +flag; the `finally` block then runs its cleanup unconditionally, killing processes matching the +app name and attempting a service and artifact uninstall — **including on Windows, where it can +reach the MSI removal path without the test ever having installed anything.** + +So the very situation that makes the gate refuse is the situation in which it acts. A `return` +inside `try` does not help: `finally` still runs. The preflight has to complete outside the +block that owns destructive cleanup, or the cleanup has to be limited to the exact pids, service +registrations and install results this run recorded for itself. + +Completion condition: **a run that refuses because it found an existing app, service or state +makes zero mutating calls, cleanup included.** + +## The update path still does not drain first + +The pinned updater's Windows install implementation ends in `process::exit(0)`. The lane calls +`download_and_install()` and only then asks the exit coordinator to restart, so on Windows that +second call is not reached. Removing the direct kill was real progress; it did not put the Windows +in-app update on the coordinated path. + +The order has to be: download and verify the signature, re-confirm who owns the current runtime, +drain and confirm the child actually exited, **then** install. A failed drain must refuse the +install rather than proceed. + +## A failed drain is still recorded as drained + +The exit state machine logs a drain failure and then calls the same completion path, so both a +successful and a failed drain end in the exiting or restarting branch. For a user pressing Quit +that is a defensible trade — better to leave a runtime than to refuse to close. **For a coordinated +restart it is not the same judgement.** A failed stop followed by a restart means the new app +re-attaches to the old runtime while the user believes they are on the new version. + +`DrainFailed` and `OwnershipUnknown` need to be states the restart path refuses, separately from +what the quit path tolerates. + +## Ownership is computed outside the lock it is written under + +The writer resolves ownership **before** taking the lock, then takes the lock, reads the current +record, and preserves the ownership it read earlier. A revocation that lands in between is +overwritten by the stale value. The revision check does not prevent this: the read is fresh and the +value being written is not. + +Two more in the same file: the state file is written in place rather than written and renamed, so +an interrupted write leaves half a document; and the lock is reclaimed on mtime alone, with no +holder identity, so a slow writer can delete a lock another process now owns. + +A third, and it is a different question from the CAS: **the record API takes an owner and an install +id, which cannot express "is the generation the user consented to still the current one".** An +internal retry that succeeds against a newer record has silently applied the consent to a different +subject. + +## Not stopping is not the same as safe to replace + +When ownership is unknown the update path skips stopping the runtime and skips refreshing the +service — but still proceeds to replace the package. If the live process is running out of the +files being replaced, that is a file lock on Windows and a mixed on-disk version elsewhere. + +Three decisions have to be separated: may the package be replaced, may the runtime be stopped, may +the service be restored. Unknown should block the first, not only the second and third. + +## The old CLI on the user's machine is not retrofitted + +The shipped 2.60.0 launcher calls the old `stop` before replacing the package whenever a service +or runtime record exists, and it knows nothing about an ownership field. So a user who takes +ownership in the app and then runs `ocx update` from the npm install on their `PATH` gets the +old teardown first. The protection added here is the new CLI's protection; it cannot reach backward. + +Taking permanent ownership therefore has to check the managing CLI's compatibility first, and +either upgrade it with consent or withhold the takeover and say why. + +## Smaller, each concrete + +- The resolve verb uses the default probe budget, one attempt at 750ms, and reports a timeout as + `not-found`. The start-ownership path uses 1500ms three times for exactly this reason. Alive, + absent-proven and unknown need to be three answers, and unknown must not authorise a new runtime. +- The resolve verb passes through the CLI root's automatic shim restore, so a read-only lookup made + to populate a consent screen can cause a repair side effect first. +- `if (await deps.handleStop())` still reads a now-object return as a boolean, so a failed stop + prints the downtime warning. +- The dashboard's stop client maps every fetch exception to accepted. Accepted, rejected and unknown + are different, and unknown needs a follow-up read rather than an assumption. +- A consent dialog can outlive its subject: the target can change or the surface unmount while it is + open, and the request is then sent against the captured closure. +- The dialog guard skips template literals wholesale, so `${window.confirm("...")}` inside one is + a real call it does not see. +- Attaching to a different proxy does not reset the ownership flag, so a retry that lands on a + foreign runtime can still send an owner-only stop to it. +- Tray availability is recorded from the host probe before `tray::install` runs, and an install + failure only logs. Host present, icon registered and currently reachable are three different + facts. +- The startup deadline does not wrap the registration that runs before the resolver, and the + existing-proxy budget is counted from process start, so registration can consume it. +- The Windows app origin is still not in the navigation allowlist. +- The local management client needs redirects refused and instance identity confirmed before the + token is sent, not only the system proxy disabled. +- Publication still precedes checksum, signature and manifest validation, because that validation + lives in the attach step that depends on publish. Packaging-before-publish closed a narrower gap + than the one that remains. +- The gate driver and the ownership record disagree on schema, install the wrong package spec, and + re-hardcode the macOS executable name that was removed once already. Its second-launch check + observes single-instance behaviour rather than a real relaunch, and the deb update is only + verified through cancellation, never through a successful install. + +## The three sentences both reports converge on + +**An unknown result is not turned into an absence or a success. The subject the user approved is +confirmed to be the subject being changed. An update begins only after the correct runtime is +confirmed stopped.** + diff --git a/devlog/_plan/260921_app_runtime_ownership/120_gate_runbook.md b/devlog/_plan/260921_app_runtime_ownership/120_gate_runbook.md new file mode 100644 index 00000000000..ef2d50eb13f --- /dev/null +++ b/devlog/_plan/260921_app_runtime_ownership/120_gate_runbook.md @@ -0,0 +1,90 @@ +# Operating the installed-artifact gate + +The gate from D9 part two lives in `.github/workflows/desktop-installed-gate.yml` with its +drivers under `desktop/scripts/`. It installs the real desktop artifact on one GUI machine +per platform, drives the ownership contract from `080_decisions_round2.md`, and uploads a +JSON report per job. This page is the operator procedure for its first live run. Registering +the runners is a maintainer action; nothing here is automated yet. + +## Runners + +One self-hosted runner per platform, each with a live GUI session (the gate drives real +windows and tray menus): + +| label | machine needs | +| --- | --- | +| `opencodex-gate-macos` | macOS with a desktop session; the app's tray automation uses System Events, so the runner account needs Accessibility permission for `osascript` | +| `opencodex-gate-windows` | Windows with an interactive session; PowerShell and `msiexec` (system), Git Bash for the workflow shell | +| `opencodex-gate-linux` | A desktop session with a working tray (an AppIndicator/StatusNotifier extension on GNOME), `systemctl --user`, and non-interactive dpkg rights for install/remove (`sudo -n dpkg -i/-r`) | + +Every runner also needs `gh` (artifact download) and `npm`/`node` (the gate stages the npm +runtime itself). Bun comes from the workflow's own setup action. + +Two protections are part of the design, not optional hardening: + +- Restrict each runner group so only this workflow can land on these machines. +- Add required reviewers to the `opencodex-desktop-gate` environment. Every dispatch then + waits for a maintainer approval. The jobs check out the protected `dev` branch for the + driver code — never the dispatched ref — so an approval is a review of inputs, not of + smuggled code. + +## GUI hooks + +OS automation cannot reach everything the contract needs: the in-page consent dialog, the +Windows and Linux tray, and the deb update's elevation prompt. The operator installs audited +executable files in a hooks directory on each runner and sets the repository or organization +variable `OPENCODEX_GATE_HOOKS_DIR` to that directory. Dispatch inputs then select hooks by +file name only: + +| input | the hook answers | +| --- | --- | +| `consent-hook` | the takeover consent prompt (accept) | +| `tray-click-hook` | left-clicks the tray icon | +| `tray-quit-hook` | opens the tray menu and chooses Quit | +| `tray-check-hook` | chooses Check for Updates (Linux update phases) | +| `tray-install-hook` | chooses the enabled Install update item (Linux update phases) | +| `elevate-accept-hook` | answers the deb update's authorization prompt, driving the accept path | + +macOS has built-in defaults for the tray actions; Windows and Linux have none on purpose — +without a hook, the phase that needs it fails with a diagnostic rather than guessing. Hook +files run directly, never through a shell, and the workflow accepts names, never command +text. + +## Running it + +The gate is `workflow_dispatch` only. Inputs: + +- `version` (required): the release whose artifacts are verified, e.g. `2.62.0`. The release + must already exist with its desktop assets and updater signatures attached. +- `from-version` (required): an older release, strictly lower by semver. It stages the npm + runtime that the app takes over and, on Linux, is the version the update phases start + from. +- the hook names above, as needed per runner. + +Artifacts come from the GitHub release itself, so the sequence is: publish (or draft) the +release, then dispatch the gate against it. Wiring publication to wait for a green gate is +lane E's release.yml surface and is tracked there. + +A run that finds the machine dirty refuses before touching anything: an existing service +registration, a default-home state file, a running app, or a dormant installed package all +fail `preflight-isolation`, and a refused run makes zero mutating calls. Clean the machine +or use another one; do not retry until the probe goes green. + +## Reading the report + +Each job uploads `installed-gate-report-` (also on failure). The JSON lists one entry +per phase with `status`, `detail` and `evidence`, in contract order: + +`preflight-isolation`, `runner-readiness`, `stage-npm-runtime`, `install-artifact`, +`launch-and-take-over`, `runtime-identity`, `close-gesture`, `quit-gesture`, +`relaunch-consent`, `tray-quit-drains`, `update-verify` (Linux only), `cleanup`. + +The first failing phase stops verification; cleanup always runs and its own failure fails +the report. `ok` is true only when every phase ran and passed, so a report that crashed +midway is red even if everything recorded is green. When a phase fails, its `evidence` +carries the observed state (healthz bodies, ownership records, elevation sightings, digests) +needed to tell a product defect apart from a runner problem. + +Until the takeover and consent lanes land, the takeover and gesture phases fail against +current behavior — that is the gate doing its job, and the report names which contract item +failed. diff --git a/devlog/_plan/260921_app_runtime_ownership/120_install_verification.md b/devlog/_plan/260921_app_runtime_ownership/120_install_verification.md new file mode 100644 index 00000000000..e70c17b4adc --- /dev/null +++ b/devlog/_plan/260921_app_runtime_ownership/120_install_verification.md @@ -0,0 +1,84 @@ +# 120 — Installed-artifact verification on a real Linux desktop + +First run of the D6/D8 surface against an actual GNOME desktop session rather than a +unit test. The machine is described by role only: a GNOME 24.04 workstation on an X11 +session, with a user-level npm install of the proxy already listening on the default +port, and no desktop package installed before this run. + +The tree under test is `dev` after lanes C, A, E, F and the runtime-ownership follow-up +landed. Lane B (desktop shell) and lane D (consent surface) were **not** in the tree, so +everything below is the pre-B baseline, not a verdict on them. + +## The documented local build produces nothing on Linux + +`desktop/README.md` tells a contributor to run `bun run build:local`. On Linux that asks +for `appimage,deb` in that order. AppImage bundling fails: + + Bundling OpenCodex_2.61.0_amd64.AppImage (...) + failed to bundle project `failed to run linuxdeploy` + Error failed to bundle project `failed to run linuxdeploy` + +The failure is fatal for the whole invocation, and because AppImage is requested first, +the deb is never attempted. The bundle directory is empty afterwards. A contributor +following the README gets no installable artifact and an error that names a tool they +did not invoke. Installing `libfuse2t64` and setting `APPIMAGE_EXTRACT_AND_RUN=1` did not +change the outcome, and linuxdeploy's own diagnostics are swallowed by the bundler. + +Requesting the deb on its own succeeds in 43 seconds and produces +`OpenCodex_2.61.0_amd64.deb`, which installs cleanly through `dpkg -i` and registers +`open-codex 2.61.0` with the desktop-file and icon triggers. + +Two things follow. The local path should order the Linux bundles so that a failure in the +optional format cannot destroy the installable one, and it should surface the bundler's +stderr instead of a bare "failed to run" line. This is separate from D8: the release +workflow builds the AppImage on its own runner image and is not known to be affected. + +## No tray host means no visible application at all + +The session has no `StatusNotifierWatcher` on the session bus — stock GNOME with no +AppIndicator extension, which is the exact configuration D6 was written for. The +installed app was launched from that session's environment. + +The process starts and stays alive. No window is mapped: an X client enumeration lists +the shell's own windows and the user's browser, and nothing belonging to the app. There +is no tray icon either, because there is nothing hosting one. The application is running +and completely unreachable — the user has no surface to click and no way to know it +started. That is the failure D6 describes, now observed rather than argued. + +The only line the process wrote was an updater probe failure: + + updater check failed: Could not fetch a valid release JSON from the remote + +which is accurate for a tree whose release channel has not published a manifest yet, but +it is also the only feedback a first-run user would get if they had a way to see it. + +## Ownership was not taken, and nothing was disturbed + +The pre-existing user-level runtime kept the port for the entire run: `/healthz` reported +the same pid and version before, during and after. The desktop app wrote no install-state +record into the config home. Stopping the app left the original runtime healthy and +untouched. + +That is the correct outcome for this tree — the takeover path and its consent prompt are +lane B and lane D work — and it establishes the baseline those lanes have to change. + +## Windows is blocked on code signing, not on this batch + +The Windows verification machine runs with Smart App Control enabled and code-integrity +enforcement active. A local build fails when cargo executes its first unsigned build +script, with the OS reporting that an application-control policy blocked the file. + +This is not a toolchain gap: the build tools and the Rust MSVC toolchain install fine. +It means a machine in that configuration cannot build the shell locally, and — because +the project does not sign Windows artifacts yet — probably cannot run an installer +produced anywhere else either. Windows verification therefore depends on either an +unprotected machine or on wiring Authenticode signing, and the choice belongs to the +maintainer rather than to a lane. + +## Status + +- Linux deb: built and installed. NOT VERIFIED beyond installation, because the + behaviour under test lives in lanes that have not landed. +- Linux AppImage: NOT BUILT (bundler failure above). +- Windows: NOT BUILT (blocked by application-control policy). +- Local suites, typecheck and builds of the repository itself: NOT RUN, per the batch rule. diff --git a/devlog/_plan/260921_app_runtime_ownership/130_linux_surface_findings.md b/devlog/_plan/260921_app_runtime_ownership/130_linux_surface_findings.md new file mode 100644 index 00000000000..9d15f8bb24f --- /dev/null +++ b/devlog/_plan/260921_app_runtime_ownership/130_linux_surface_findings.md @@ -0,0 +1,72 @@ +# 130 — What the installed Linux build actually does + +Follow-up to 120, after the desktop shell landed. Same machine described by role: a GNOME +workstation on an X11 session with no tray host, and a user-level npm install of the proxy already +holding the default port. + +## The no-tray case is fixed + +Before the shell landed, the installed app ran with no window and no tray icon — alive and +unreachable. With the shell in the tree, the same machine shows a real window: an X client +enumeration lists an `OpenCodex` window at 1100x720 alongside the session's own windows. That is +D6 doing what it was written to do, now observed rather than argued. + +The runtime already on the port was left alone throughout: `/healthz` reported the same pid and +version before, during and after every run, and no install-state record was written. Takeover is +gated on consent, so that is the expected shape for this tree. + +## The startup surface never runs on Linux + +The window renders, and then nothing happens. The headline stays on the markup's default, the phase +checklist stays empty, and no terminal state is ever reached. The page's JavaScript does not execute +at all. + +Narrowing it took four builds, and the order matters because three plausible causes were eliminated +by measurement rather than by reading: + +1. **The asset is served correctly.** A probe that fetches the script from the page sees + `status=200`, `content-type: text/javascript`, 5941 bytes. Not a missing asset, not a MIME + refusal. +2. **Inline script runs when the policy is removed.** With the configured `csp` deleted, an inline + probe paints immediately, and the page's own script runs to completion: the checklist renders, + the registration phase completes, and the resolve phase becomes active. +3. **Widening the policy does not help.** Naming the asset-protocol scheme and host in `script-src` + changed nothing. +4. **Neither does `'unsafe-inline'`.** This is the informative one. `'unsafe-inline'` is ignored when + a nonce or a hash appears in the same directive, so the policy the webview enforces is not the + policy in the configuration file — the directive is being rewritten into a form that admits + neither the page's script nor an inline one. + +The dashboard is unaffected because it loads from the proxy's loopback origin and carries that +origin's own headers. Only the embedded bootstrap page is dead, which is why the product looks fine +until the moment it has to explain itself — and a startup surface that cannot report is exactly the +failure class this unit exists to close. + +Raised as its own issue with the evidence chain, and handed to the desktop lane. The fix has to +admit the script legitimately rather than remove the policy, so it is a design decision about how +the embedded page is served, not a widening of sources. + +## Two defects fixed on the way + +Both were found by looking at the screen and then confirmed in source, and both landed. + +**The failure block ignored its own `hidden` attribute.** An id rule with `display: grid` outranks +the user agent's `[hidden] { display: none }`, so the Retry button and an empty read-only diagnostic +box were painted during every normal start, under a headline that still said the runtime was +starting. That is precisely the screen a user reads as a dead application with one button. Removing +it is visible in the before/after captures from the same machine. + +**The page had no deadline of its own.** `invoke` returns a promise that neither settles nor rejects +when the command never answers, so the page could sit on its first handshake forever while the +shell's own deadline ran somewhere the user could not see. The handshake is now bounded and a +timeout is reported through the existing failure path. + +## Status + +- Linux deb: built, installed, launched, and inspected on a real session. +- Window presence with no tray host: VERIFIED. +- Existing runtime left undisturbed: VERIFIED. +- Startup surface reporting on Linux: FAILS — open issue, not closed by this unit. +- Windows: the verification machine required disabling its application-control policy before the + toolchain could build at all; that is recorded separately. +- Repository suites, typecheck and builds of the repository itself: NOT RUN, per the batch rule. diff --git a/devlog/_plan/260921_app_runtime_ownership/140_tray_popup_polish.md b/devlog/_plan/260921_app_runtime_ownership/140_tray_popup_polish.md new file mode 100644 index 00000000000..b93ad351bcc --- /dev/null +++ b/devlog/_plan/260921_app_runtime_ownership/140_tray_popup_polish.md @@ -0,0 +1,78 @@ +# 140 — Tray usage popup: rustfmt, React Doctor, and a native glass surface + +The tray usage popup (#5452, carrying #5436 by JayYun98) is functionally complete and +running on macOS, but three things keep it from landing and from looking like the +WidgetKit widget it sits next to. + +## What is actually wrong + +**`desktop shell` is red on `cargo fmt --check`.** The conflict resolution left a +`matches()` body past the width limit and a double blank line before +`set_visibility`. Clippy and the Rust tests never ran because the format step gates +them. + +**React Doctor reports nine blocking findings** at `blocking: warning`. The action is +configured with `comment: false`, `review-comments: false`, `commit-status: false`, so +the findings exist only in the run's job summary. Reproduced locally with the +repository's own pinned scan, `react-doctor@0.9.11 --scope changed --base origin/dev`: + +| Rule | Location | +|---|---| +| `no-barrel-import` | `Tray.tsx:2` — `../i18n` re-exports from `./shared` | +| `no-set-state-after-await-in-effect` | `Tray.tsx:26` | +| `js-set-map-lookups` ×5 | `Tray.tsx:87` ×2, `Tray.tsx:153`, `tray-data.ts:80`, `:81` | +| `prefer-module-scope-pure-function` | `Tray.tsx:123` | +| `no-array-index-as-key` | `Tray.tsx:164` | + +**The popup is an opaque `#202022` rectangle.** The widget beside it uses the system +material, rounded numerals, and `.secondary` labels; the popup uses flat hex fills and +hairline dividers everywhere. They do not read as the same product. + +## Delivery + +One branch, `codex/260921-tray-usage-popup`, one PR to `dev` (#5452), ordered commits. + +### Native surface — `desktop/src-tauri/` + +Tauri 2.11.6 exposes `WebviewWindowBuilder::effects(WindowEffectsConfig)`, so the +vibrancy needs no extra dependency. It does need two things the tree does not have +yet: the `macos-private-api` Cargo feature on `tauri` and `app.macOSPrivateApi` in +`tauri.conf.json`. Both are required because `transparent` on macOS is a private-API +surface, confirmed from `tauri-2.11.6/src/lib.rs`. The cost is real and worth naming: +it forecloses Mac App Store submission. This app ships as a Developer ID DMG, so the +door it closes is one we are not using. + +Transparency and effects are applied on macOS and Windows only. Linux keeps the opaque +surface, because blur there belongs to the compositor and `window-vibrancy` documents +it as unsupported. + +The page has to know which surface it got, or its CSS would punch a hole in an opaque +window on Linux. A `cfg`-derived constant drives both the builder and the +initialization script, so the two cannot disagree. + +### Page — `gui/src/pages/` + +Fix all nine findings at the root rather than suppressing them. The +`no-set-state-after-await-in-effect` case is the only one that needs judgment: the +effect already guards every write with `active()`, so the fix is to make the guard +legible rather than to add one. + +Restyle to the widget's vocabulary: the system material behind a translucent panel, +rounded tabular numerals for the figures, secondary-tone labels, and dividers only +where a section genuinely changes subject. + +## Acceptance + +- `cargo fmt --check` clean; `desktop shell` green. +- The pinned React Doctor scan reports zero issues on the changed scope. +- Every job the pull_request event requested is green at the exact head, including + the aggregate `ci`. +- A screenshot of the glass popup in the PR body, since the description mentions gui. +- After landing: close #5436 as superseded with credit; the `Co-authored-by` trailer + for JayYun98 stays on the branch. + +## Not run + +Local `bun run test`, `test:changed`, `typecheck`, `build`, and `bun install` are out +of scope for this batch by standing instruction. `cargo fmt` and `cargo check` on the +desktop crate are run, under the local-build authorization given for the desktop app. diff --git a/devlog/_plan/260921_app_runtime_ownership/150_closeout.md b/devlog/_plan/260921_app_runtime_ownership/150_closeout.md new file mode 100644 index 00000000000..657dfc326cd --- /dev/null +++ b/devlog/_plan/260921_app_runtime_ownership/150_closeout.md @@ -0,0 +1,96 @@ +# 150 — Closeout + +Every lane in this unit is on `dev`, and `dev` is green at `71d02e3619` with the aggregate +`ci` check passing. This records what landed, and the two findings worth carrying forward. + +## What landed + +| Change | Commit | +|---|---| +| Lane A — CLI resolve and stop contracts (#5383) | `c2a4b1`-era, see 090 | +| Lane B — desktop shell (#5384) | see 090 | +| Lane C — ownership state (#5386, #5400, #5406) | `2fb2dfb947` and follow-ups | +| Lane D — dashboard consent surface (#5387) | see 090 | +| Lane E — release pipeline (#5388, #5405) | `34ddb4d5fd` and follow-up | +| Lane F — installed gate and Linux updates (#5391) | see 090 | +| Bootstrap surface as one page the policy can name (#5445) | `1e233a4bd1` | +| Tray usage popup, glass surface, widget vocabulary (#5452) | `8f94a6fee9` | +| Tray left click reaches the popup (#5462) | `f2ebc5a8d6` | +| Startup surface cannot wait forever (#5451) | `71d02e3619` | + +`#5436` by JayYun98 was carried rather than merged and is closed as superseded, with the +`Co-authored-by` trailer on the branch so the attribution survives the squash. Issue `#5416` +is closed by `#5445`. + +## The popup surface, and the constant that holds it together + +The popup uses the native material on macOS (active HUD window, 12-point radius) and Acrylic on +Windows, both through Tauri's own effects builder. Linux stays opaque because blur there belongs +to the compositor. + +That asymmetry is the whole design problem. A transparent stylesheet on an opaque window does not +degrade gracefully — it paints a hole where the panel should be. So the platform verdict is a +single `cfg` constant, `VIBRANT_SURFACE` in `desktop/src-tauri/src/popup.rs`, and it drives both +the transparent native builder and the `data-tray-vibrancy` attribute the page selects on. Neither +side restates the other. + +Nothing in either toolchain connects a Rust constant to a CSS attribute selector, so +`tests/gui/gui-tray-vibrancy-surface.test.ts` reads `popup.rs` and `tray.css` together and fails +if they drift. Transparent windows on macOS also require the `macos-private-api` feature and +`app.macOSPrivateApi`; that forecloses Mac App Store submission, which this Developer ID DMG +channel does not use. + +## The defect static review could not see + +The popup shipped in `#5452` with a left-click handler that could never run to a visible effect on +macOS or Windows. + +`tray-icon` calls `NSStatusItem.setMenu` whenever a menu is attached. AppKit then pops that menu +on mouse-down, before the crate's own click handler — the one that reads `menu_on_left_click` — +is reached. `show_menu_on_left_click(false)` sets an ivar that never gets consulted. The menu item +that opens the popup was Linux-only, so on the two platforms where the icon click *is* the +interaction, there was no way in at all. + +Every reading of the code says it works. The handler exists, the event fires, and the wrong +surface simply appears on top of the right one. It took building the bundle and clicking the icon. +The fix makes the menu item unconditional and anchors it on the tray icon's rect; +`show_menu_on_left_click(false)` stays because it does what it says on Windows. + +Two smaller things fell out of the same round. `cargo fmt --check` had been failing, and it gates +clippy and the Rust tests, so neither had run on the popup since it landed on its branch — a +clippy error was waiting behind it. And React Doctor is configured with no comment, no review +comment and no commit status, so its nine blocking findings existed only inside a job summary +nobody opens. + +## Carried forward + +**The bundle can ship a stale app.** `bundle/macos/OpenCodex.app.tar.gz` is not refreshed by +`build:local`, so a directory holding a fresh DMG can hold a day-old archive beside it. Local +verification has to take the `.app` out of the DMG. The same class already bit the sidecar: +`prepare-sidecar` builds the standalone binary only when the file is missing. + +**A freshly compiled standalone binary is killed on macOS** until it is re-signed with +`codesign --force -s -`; from the parent that looks like "exit no exit code". + +**The installed gate refuses a symlinked prefix.** Running the bundle from a temporary directory +is rejected because that path resolves through a symlink, which is correct and worth knowing +before blaming the build. + +**Windows installed-bundle verification is still blocked.** The verification machine has no +interactive login session, so `link.exe` dies with `0xc0000142`. That needs credentials. + +**`macos 1/2` sits close to its budget.** `platform-macos` allows 20 minutes and recent runs took +8, 13 and 14; one run crossed the line and GitHub reported the expiry as a cancellation, which +reads like infrastructure noise and is not. Rerun that job rather than widening the limit — +`gh run rerun --failed` does not act on a cancelled job, so it needs `--job`. + +**`privacy:scan` never runs on the commits that add devlog content.** The scan lives in the +`gates` job, and `gates` is gated on the `ci` paths filter, whose allowlist does not include +`devlog/**`. A devlog-only change therefore skips it and the aggregate check still goes green. + +That is the one change class where the scan matters most. `AGENTS.md` says reading `devlog/` is +"what makes a public devlog safe rather than merely visible", and this pull request — which adds +sixteen devlog files to a public repository — was proven only by a hand sweep for addresses, mesh +names, accounts and absolute user paths. The fix is not to add `devlog/**` to `ci`, which would +start the cross-platform suite for a prose edit; it is to give the privacy scan its own trigger, +the way `docs-site/**` already has its own build gate. Raised separately. diff --git a/devlog/_plan/260921_app_runtime_ownership/tray-usage-popup-glass.png b/devlog/_plan/260921_app_runtime_ownership/tray-usage-popup-glass.png new file mode 100644 index 00000000000..74cbcc9cc23 Binary files /dev/null and b/devlog/_plan/260921_app_runtime_ownership/tray-usage-popup-glass.png differ diff --git a/devlog/_plan/260921_app_runtime_ownership/tray-usage-popup.png b/devlog/_plan/260921_app_runtime_ownership/tray-usage-popup.png new file mode 100644 index 00000000000..1b1a5f22f33 Binary files /dev/null and b/devlog/_plan/260921_app_runtime_ownership/tray-usage-popup.png differ diff --git a/docs-site/src/content/docs/guides/desktop-app.md b/docs-site/src/content/docs/guides/desktop-app.md index c1405fc0c29..6ddc102dfe7 100644 --- a/docs-site/src/content/docs/guides/desktop-app.md +++ b/docs-site/src/content/docs/guides/desktop-app.md @@ -57,6 +57,28 @@ 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. +## Usage in the tray + +On macOS and Windows, click the tray icon to open a compact usage window. The tray's +**Show usage** action also opens it, including on Linux desktops whose tray does not +forward click events. On Linux the dashboard opens at startup, including when the +desktop environment does not expose a tray icon. + +The usage window shows Today and 30-day totals, the configured usage chart, a compact +model list, and provider/account limits. Quota reset countdowns sit beside their bars; +hover for the exact reset time. Existing **Menu bar & widget** settings control the +visible sections and chart. Missing measurements are not presented as zero usage. + +The tray menu shows today's request count and tokens, with estimated cost when enabled. +It uses the same local-day usage as the widget. Choose **Refresh now** to update immediately; +the app also refreshes every 60 seconds. Display preferences remain in the dashboard's +**Menu bar & widget** section. Turning off **Today** hides the summary, and turning off +**Cost** removes the cost from it. + +Unavailable or explicitly unmeasured usage is shown as `—`, not as a measured zero. +Choosing the icon-only headline clears the previous counter. Abbreviations preserve +whole-number zeros: ten million tokens is `10M`, not `1M`. + ## Updates Choose **Check for Updates…** in the tray menu to check immediately. Release builds also diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index b013cecfd2e..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. | 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 f4796581124..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 模型;维护中的目录仍作为回退。 | diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 53d81dbce0c..b28662b265a 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -5,6 +5,13 @@ import type { TKey } from "./en"; * German i18n catalog, generated from en.ts. Must match the `TKey` set (compile-checked). */ export const de: Record = { + "tray.updated": "Aktualisiert {time} · alle 60s", + "tray.today": "Heute", + "tray.input": "Eingabe", + "tray.output": "Ausgabe", + "tray.cost": "Kosten · gesch.", + "tray.cached": "{percent} Cache", + "usage.incomplete": "Einige Nutzungsdatensätze konnten nicht berücksichtigt werden. Anzahlen, Datumsangaben und Ranglisten beruhen nur auf lesbaren Datensätzen.", "models.pickerOrder.usageIncomplete": "Die Reihenfolge nach Nutzung kann wegen unvollständiger Nutzungsdaten nicht gespeichert werden. Wählen Sie eine andere Reihenfolge oder reparieren Sie zuerst den Verlauf.", "api.attribution.noRecordedUse": "Keine Nutzung in lesbaren Datensätzen", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index d6fe0a55785..f3f03e21c79 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -6,6 +6,13 @@ * `{var}` are plain interpolations. */ export const en = { + "tray.updated": "Updated {time} · every 60s", + "tray.today": "Today", + "tray.input": "Input", + "tray.output": "Output", + "tray.cost": "Cost · est.", + "tray.cached": "{percent} cached", + "usage.incomplete": "Some usage records could not be included. Counts, dates, and rankings reflect readable records only.", "models.pickerOrder.usageIncomplete": "Cannot save most-used order because usage history is incomplete. Choose another order or repair the history first.", "api.attribution.noRecordedUse": "No use in readable records", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 906f7d39462..34525f7e4a1 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -4,6 +4,13 @@ import type { TKey } from "./en"; * French i18n catalog. Must match the `TKey` set. */ export const fr: Record = { + "tray.updated": "Mis à jour {time} · toutes les 60s", + "tray.today": "Aujourd’hui", + "tray.input": "Entrée", + "tray.output": "Sortie", + "tray.cost": "Coût estimé", + "tray.cached": "{percent} en cache", + "usage.incomplete": "Certains enregistrements d’utilisation n’ont pas pu être inclus. Les totaux, dates et classements reposent uniquement sur les enregistrements lisibles.", "models.pickerOrder.usageIncomplete": "Impossible d’enregistrer l’ordre par utilisation : l’historique est incomplet. Choisissez un autre ordre ou réparez d’abord l’historique.", "api.attribution.noRecordedUse": "Aucune utilisation dans les enregistrements lisibles", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index e748ce03290..dcf3ce28623 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -4,6 +4,13 @@ import type { TKey } from "./en"; * Japanese i18n catalog; must match the `TKey` set (compile-checked). */ export const ja: Record = { + "tray.updated": "更新 {time} · 60秒ごと", + "tray.today": "今日", + "tray.input": "入力", + "tray.output": "出力", + "tray.cost": "推定費用", + "tray.cached": "キャッシュ {percent}", + "usage.incomplete": "一部の使用履歴を集計できませんでした。回数、日付、順位は読み取れる記録のみを反映しています。", "models.pickerOrder.usageIncomplete": "使用履歴が不完全なため、使用回数順を保存できません。別の順序を選ぶか、履歴を修復してください。", "api.attribution.noRecordedUse": "読み取れる記録に使用履歴なし", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index d40f976c87c..68c11214149 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -4,6 +4,13 @@ import type { TKey } from "./en"; * Korean i18n catalog; must match the `TKey` set (compile-checked). */ export const ko: Record = { + "tray.updated": "업데이트 {time} · 60초마다", + "tray.today": "오늘", + "tray.input": "입력", + "tray.output": "출력", + "tray.cost": "비용 · 추정", + "tray.cached": "캐시 {percent}", + "usage.incomplete": "일부 사용량 기록을 집계하지 못했습니다. 횟수, 날짜, 순위는 읽을 수 있는 기록만 반영합니다.", "models.pickerOrder.usageIncomplete": "사용량 이력이 불완전해 많이 사용한 순서를 저장할 수 없습니다. 다른 순서를 선택하거나 이력을 복구하세요.", "api.attribution.noRecordedUse": "읽을 수 있는 기록에 사용 내역 없음", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 0e4b1729a17..966c173fe0a 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -4,6 +4,13 @@ import type { TKey } from "./en"; * Russian i18n catalog; must match the `TKey` set (compile-checked). */ export const ru: Record = { + "tray.updated": "Обновлено {time} · каждые 60с", + "tray.today": "Сегодня", + "tray.input": "Вход", + "tray.output": "Выход", + "tray.cost": "Стоимость ≈", + "tray.cached": "Кэш {percent}", + "usage.incomplete": "Часть записей об использовании не удалось учесть. Счётчики, даты и рейтинги основаны только на читаемых записях.", "models.pickerOrder.usageIncomplete": "Нельзя сохранить порядок по частоте использования: история неполная. Выберите другой порядок или сначала восстановите историю.", "api.attribution.noRecordedUse": "В читаемых записях использование не найдено", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 2bf09d6aa98..4f1053581f2 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -5,6 +5,13 @@ import type { TKey } from "./en"; * Turkish i18n catalog. Must match the `TKey` set (compile-checked). */ export const tr: Record = { + "tray.updated": "Güncellendi {time} · her 60 sn", + "tray.today": "Bugün", + "tray.input": "Girdi", + "tray.output": "Çıktı", + "tray.cost": "Tahmini ücret", + "tray.cached": "{percent} önbellek", + "usage.incomplete": "Bazı kullanım kayıtları dahil edilemedi. Sayılar, tarihler ve sıralamalar yalnızca okunabilir kayıtlara dayanır.", "models.pickerOrder.usageIncomplete": "Kullanım geçmişi eksik olduğundan en çok kullanılan sıralaması kaydedilemiyor. Başka bir sıralama seçin veya önce geçmişi onarın.", "api.attribution.noRecordedUse": "Okunabilir kayıtlarda kullanım yok", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index b79db56b352..8cb83da7079 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -6,6 +6,13 @@ import type { TKey } from "./en"; * Technical terms and model identifiers intentionally remain English. */ export const vi: Record = { + "tray.updated": "Cập nhật {time} · mỗi 60 giây", + "tray.today": "Hôm nay", + "tray.input": "Đầu vào", + "tray.output": "Đầu ra", + "tray.cost": "Phí ước tính", + "tray.cached": "Đệm {percent}", + "usage.incomplete": "Không thể đưa một số bản ghi sử dụng vào. Số lượng, ngày tháng và thứ hạng chỉ phản ánh các bản ghi có thể đọc được.", "models.pickerOrder.usageIncomplete": "Không thể lưu thứ tự dùng nhiều nhất vì lịch sử sử dụng không đầy đủ. Hãy chọn thứ tự khác hoặc sửa lịch sử trước.", "api.attribution.noRecordedUse": "Không có lượt sử dụng trong các bản ghi có thể đọc được", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index b447f4dbcbd..c3482290e58 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2,6 +2,13 @@ import type { TKey } from "./en"; /** Traditional Chinese (Taiwan) UI strings — keys must match `en.ts` 1:1. */ export const zhTW: Record = { + "tray.updated": "更新於 {time} · 每60秒", + "tray.today": "今天", + "tray.input": "輸入", + "tray.output": "輸出", + "tray.cost": "預估費用", + "tray.cached": "快取 {percent}", + "usage.incomplete": "部分用量記錄無法納入。次數、日期和排名僅反映可讀取的記錄。", "models.pickerOrder.usageIncomplete": "用量歷史不完整,無法儲存最常用排序。請選擇其他排序或先修復歷史記錄。", "api.attribution.noRecordedUse": "可讀取的記錄中沒有使用記錄", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 533d21000f9..7655f8b09e0 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -4,6 +4,13 @@ import type { TKey } from "./en"; * Chinese i18n catalog; must match the `TKey` set (compile-checked). */ export const zh: Record = { + "tray.updated": "更新于 {time} · 每60秒", + "tray.today": "今天", + "tray.input": "输入", + "tray.output": "输出", + "tray.cost": "预估费用", + "tray.cached": "缓存 {percent}", + "usage.incomplete": "部分用量记录无法计入。次数、日期和排名仅反映可读取的记录。", "models.pickerOrder.usageIncomplete": "用量历史不完整,无法保存最常用排序。请选择其他排序或先修复历史记录。", "api.attribution.noRecordedUse": "可读取的记录中没有使用记录", diff --git a/gui/src/main.tsx b/gui/src/main.tsx index a5228663be1..59b6c56726c 100644 --- a/gui/src/main.tsx +++ b/gui/src/main.tsx @@ -1,17 +1,25 @@ import React from "react"; import ReactDOM from "react-dom/client"; -import App from "./App"; +import { lazy, Suspense } from "react"; +import { installApiAuthFetch } from "./api"; + +const isTray = window.location.hash.split("?")[0] === "#/tray"; +// Entry-point component is mounted here, never imported for fast refresh. +// oxlint-disable-next-line react/only-export-components +const Screen = lazy(() => isTray ? import("./pages/Tray") : import("./App")); +if (isTray) installApiAuthFetch(); import { LanguageProvider } from "./i18n/provider"; import "./styles.css"; import "./styles/usage-chart-accessibility.css"; import "./styles/sidebar-brand.css"; import "./styles/fast-rows-setting.css"; import "./styles/claude-desktop-mode-picker.css"; +import "./pages/tray.css"; ReactDOM.createRoot(document.getElementById("root")!).render( - + ); diff --git a/gui/src/pages/Tray.tsx b/gui/src/pages/Tray.tsx new file mode 100644 index 00000000000..610e3dfcf96 --- /dev/null +++ b/gui/src/pages/Tray.tsx @@ -0,0 +1,182 @@ +import { useEffect, useState } from 'react'; +import { useI18n } from '../i18n/shared'; +import { formatTokens } from '../format-tokens'; +import { formatProviderDisplayName } from '../provider-icons'; +import { UsageCompanionChart } from './usage-companion-chart'; +import { type CompanionSettings, type CompanionSettingsResponse, type UsageTimeline } from './usage-companion-utils'; +import { fetchTrayJson, parseTrayUsage, filterUsage, measuredTotals, finite, parseAccounts, providerSources, quotaWindows, relativeReset, type TrayProvider, type TrayTotals, type TrayUsage } from './tray-data'; + +declare global { interface Window { __OPENCODEX_TRAY_VISIBLE__?: boolean } } + +const incomplete = (data: TrayUsage | null | undefined) => data?.usageIncomplete || data?.historyTruncated || data?.entriesTruncated; + +export default function Tray() { + const { t, locale } = useI18n(); + const [settings, setSettings] = useState(null); + const [settingsError, setSettingsError] = useState(false); + const [usage, setUsage] = useState<(TrayUsage | null)[]>([null, null]); + const [usageError, setUsageError] = useState(false); + const [providers, setProviders] = useState([]); + const [quotaError, setQuotaError] = useState(false); + const [timeline, setTimeline] = useState(null); + const [chartError, setChartError] = useState(false); + const [updatedAt, setUpdatedAt] = useState(null); + const [refreshing, setRefreshing] = useState(false); + const [revision, setRevision] = useState(0); + const retry = () => setRevision(value => value + 1); + + // Every post-await state write checks both effect disposal and the request's AbortSignal. + // react-doctor-disable-next-line react-doctor/no-set-state-after-await-in-effect + useEffect(() => { + document.documentElement.classList.add('tray-document'); + let controller: AbortController | null = null; + let busy = false; + let focused = true; + let disposed = false; + const visible = () => !document.hidden && (window.__OPENCODEX_TRAY_VISIBLE__ ?? focused); + const load = async () => { + if (!visible() || busy) return; + busy = true; + setRefreshing(true); + let hadSuccess = false; + const current = new AbortController(); + controller = current; + const json = (path: string) => fetchTrayJson(path, current.signal); + const active = () => !disposed && !current.signal.aborted; + try { + // Quotas load independently: usage/settings failures must not hide account limits. + const quotas = (async () => { + try { + const sources = providerSources(await json('/api/config')); + const rows = await Promise.all(sources.map(async source => { + if (!source.path) return { name: source.name, accounts: [] }; + try { + const payload = await json>(source.path); + if (source.name === 'openai') { + try { + const selection = await json<{ activeCodexAccountId?: string | null }>('/api/codex-auth/active'); + payload.activeCodexAccountId = selection.activeCodexAccountId ?? '__main__'; + } catch { /* Missing selection is unknown, never inferred from quota. */ } + } + return { name: source.name, accounts: parseAccounts(payload) }; + } + catch { return { name: source.name, accounts: [], unavailable: true }; } + })); + if (active()) { setProviders(rows); setQuotaError(false); hadSuccess = true; } + } catch { if (active()) { setProviders([]); setQuotaError(true); } } + })(); + const metrics = (async () => { + let config: CompanionSettings; + try { + config = (await json('/api/companion/settings')).settings; + if (!config || !Array.isArray(config.hiddenProviders) || !(config.models === null || Array.isArray(config.models))) throw new Error('Invalid settings'); + if (active()) { setSettings(config); setSettingsError(false); } + } catch { if (active()) setSettingsError(true); return; } + const totals = Promise.allSettled(['/api/usage?range=today', '/api/usage?range=30d'].map(async path => { + const data = parseTrayUsage(await json(path)); + return filterUsage(data, config); + })).then(results => { + if (active()) { + hadSuccess ||= results.some(result => result.status === 'fulfilled'); + setUsage(results.map(result => result.status === 'fulfilled' ? result.value : null)); + setUsageError(results.some(result => result.status === 'rejected')); + } + }); + const chart = (async () => { + if (!config.showChart) return; + const query = new URLSearchParams({ hours: String(config.chartHours), bucketMinutes: String(config.bucketMinutes), metric: config.tokenMetric, aggregation: config.aggregation, grouping: config.chartGrouping }); + if (config.models?.length) query.set('models', config.models.join(',')); + try { + const data = await json(`/api/usage/timeline?${query}`); + const hiddenProviders = new Set(config.hiddenProviders); + const configuredModels = config.models === null ? null : new Set(config.models); + const series = data.series.filter(row => !hiddenProviders.has(row.provider) && (configuredModels === null || configuredModels.has(`${row.provider}/${row.model}`) || configuredModels.has(row.model))); + if (active()) { setTimeline({ ...data, series }); setChartError(false); } + } catch { if (active()) { setTimeline(null); setChartError(true); } } + })(); + await Promise.allSettled([totals, chart]); + })(); + await Promise.allSettled([quotas, metrics]); + } finally { + busy = false; + if (active()) { setRefreshing(false); if (hadSuccess) setUpdatedAt(Date.now()); } + if (!disposed && current.signal.aborted && visible()) void load(); + } + }; + const changed = () => { if (!visible()) { controller?.abort(); } else void load(); }; + const blur = () => { focused = false; changed(); }; + const focus = () => { focused = true; changed(); }; + const nativeVisibility = (event: Event) => { + const value = (event as CustomEvent).detail; + if (typeof value === 'boolean') window.__OPENCODEX_TRAY_VISIBLE__ = value; + changed(); + }; + void load(); + const timer = setInterval(() => void load(), 60_000); + document.addEventListener('visibilitychange', changed); + window.addEventListener('blur', blur); + window.addEventListener('focus', focus); + window.addEventListener('opencodex:tray-visibility', nativeVisibility); + return () => { + disposed = true; controller?.abort(); clearInterval(timer); + document.documentElement.classList.remove('tray-document'); + document.removeEventListener('visibilitychange', changed); + window.removeEventListener('blur', blur); window.removeEventListener('focus', focus); + window.removeEventListener('opencodex:tray-visibility', nativeVisibility); + }; + }, [revision]); + + const number = (value: unknown) => finite(value) ? formatTokens(value, locale) : '—'; + const totals = (raw: TrayTotals | undefined) => { + const data = raw ? measuredTotals(raw) : undefined; + const cached = data?.cacheReadInputTokens ?? data?.cachedInputTokens; + const percent = finite(cached) && finite(data?.inputTokens) && data.inputTokens > 0 ? `${Math.round(cached / data.inputTokens * 100)}%` : '—'; + return
+
{t('usage.card.totalTokens')}
{number(data?.totalTokens)}
+
{t('tray.input')}
{number(data?.inputTokens)} {t('tray.cached', { percent })}
+
{t('tray.output')}
{number(data?.outputTokens)}
+ {settings?.showCost &&
{t('tray.cost')}
{finite(data?.estimatedCostUsd) ? new Intl.NumberFormat(locale, { style: 'currency', currency: 'USD', maximumFractionDigits: 2 }).format(data.estimatedCostUsd) + (finite(data.pricedRequests) && finite(data.requests) && data.pricedRequests < data.requests ? '*' : '') : '—'}
} +
{t('usage.card.requests')}
{number(data?.requests)}
+ {finite(data?.requests) && data.requests > 0 && finite(data.measuredRequests) && data.measuredRequests < data.requests &&
{t('usage.card.coverage')}
{Math.round(data.measuredRequests / data.requests * 100)}%
} +
; + }; + const hiddenProviders = new Set(settings?.hiddenProviders ?? []); + return
+
OpenCodex
+ {settingsError &&

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

} + {!settings && !settingsError &&

{t('common.loading')}

} + {settings &&
+ {settings.showToday &&

{t('tray.today')}{incomplete(usage[0]) && *}

{totals(usage[0]?.summary)}
} +

{t('usage.range.30d')}{incomplete(usage[1]) && *}

{totals(usage[1]?.summary)}
+
} + {usageError &&

{t('usage.loadError')}

} + {settings?.showChart &&
} + {settings?.showModels && !!usage[0]?.models.length &&
+ {usage[0].models.map(row =>
{row.model}{t('pws.dashboard.requests', { count: number(row.requests) })}{number(measuredTotals(row).totalTokens)}
)} +
} + {(settings?.showAccounts ?? true) &&
+ {quotaError &&

{t('startup.tray.unavailable')}

} + {providers.filter(provider => !hiddenProviders.has(provider.name)).map(provider =>
+

{formatProviderDisplayName(provider.name, t)}

+ {!provider.accounts.length &&
{t(provider.unavailable ? 'startup.tray.unavailable' : 'pws.dashboard.noQuota')}
} + {provider.accounts.map(account =>
+
{account.label}{account.plan}{account.active && }
+ {account.email && account.email !== account.label &&
{account.email}
} + {!quotaWindows(account.quota).length &&
{t(account.unavailable ? 'startup.tray.unavailable' : 'pws.dashboard.noQuota')}
} + {quotaWindows(account.quota).map(window => { + const label = 'key' in window ? t(window.key!) : window.label; + const reset = relativeReset(window.reset, locale); + const percent = finite(window.percent) ? Math.min(100, window.percent) : null; + return
+ {label}{percent === null ? '—' : `${Math.round(percent)}%`} + + +
; + })} +
)} +
)} +
} +
{t('tray.updated', { time: updatedAt === null ? '—' : new Date(updatedAt).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' }) })}
+ +
; +} diff --git a/gui/src/pages/tray-data.ts b/gui/src/pages/tray-data.ts new file mode 100644 index 00000000000..a0fd1e62298 --- /dev/null +++ b/gui/src/pages/tray-data.ts @@ -0,0 +1,134 @@ +import { createBoundedFetch } from '../bounded-fetch'; +import type { CompanionSettings } from './usage-companion-utils'; +import type { AccountQuota } from '../codex-quota-utils'; +import { normalizeQuotaForPlan } from '../codex-quota-utils'; + +export type TrayTotals = Partial>; +export type TrayModel = TrayTotals & { model: string; provider: string }; +export interface TrayUsage { summary: TrayTotals; models: TrayModel[]; customWindow?: boolean; since?: number; until?: number; usageIncomplete?: boolean; historyTruncated?: boolean; entriesTruncated?: boolean } +export interface TrayAccount { unavailable?: boolean; id: string; label: string; quota: AccountQuota | null; plan?: string; active?: boolean; email?: string; status?: string; quotaFailure?: string } +export interface TrayProvider { name: string; accounts: TrayAccount[]; unavailable?: boolean } +export interface TrayProviderSource { name: string; path: string | null } +export const finite = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value) && value >= 0; +const object = (value: unknown): Record => value !== null && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; + +// Read only the safe management projection; never retain configuration credentials. +export function providerSources(value: unknown): TrayProviderSource[] { + return Object.entries(object(object(value).providers)).flatMap(([name, raw]) => { + const config = object(raw); + if (config.disabled === true) return []; + const path = name === 'openai' ? '/api/codex-auth/accounts' + : config.authMode === 'oauth' ? `/api/oauth/accounts?${new URLSearchParams({ provider: name, quota: '1' })}` + : config.hasApiKey === true && config.authMode !== 'forward' ? `/api/providers/keys?${new URLSearchParams({ name, quota: '1' })}` : null; + return [{ name, path }]; + }); +} + +export function parseAccounts(value: unknown): TrayAccount[] { + const body = object(value); + const rows = body.accounts ?? body.keys; + if (!Array.isArray(rows)) throw new Error('Invalid account roster'); + return rows.map(raw => { + const row = object(raw); + if (typeof row.id !== 'string') throw new Error('Invalid account identifier'); + const email = typeof row.email === 'string' ? maskEmail(row.email) : undefined; + const label = [row.alias, row.label, email, row.logLabel, row.id].find(item => typeof item === 'string' && item.length) as string; + const quota = row.quotaUnavailable === true || row.quotaMode === 'unsupported' || !row.quota ? null : object(row.quota) as unknown as AccountQuota; + const plan = typeof row.plan === 'string' ? row.plan : undefined; + const activeId = body.activeAccountId ?? body.activeId ?? body.activeCodexAccountId; + return { id: row.id, label, email, plan, unavailable: row.quotaUnavailable === true, active: typeof activeId === 'string' ? activeId === row.id : row.active === true, status: typeof object(row.health).status === 'string' ? object(row.health).status as string : undefined, quotaFailure: typeof row.quotaFailure === 'string' ? row.quotaFailure : undefined, quota: normalizeQuotaForPlan(quota, plan) }; + }); +} + +export function resetTimestamp(value: unknown): number | null { + if (!finite(value) || value === 0) return null; + const ms = value < 1e12 ? value * 1000 : value; + return Number.isFinite(new Date(ms).getTime()) ? ms : null; +} + +export function maskEmail(email: string): string { + const [local, domain] = email.split('@'); + if (!domain) return '•••'; + const suffix = domain.includes('.') ? domain.slice(domain.lastIndexOf('.')) : ''; + return `${local?.slice(0, 1) || '•'}•••@${domain.slice(0, 1)}•••${suffix}`; +} + +export function relativeReset(value: unknown, locale: string, now = Date.now()): { text: string; exact?: string } { + const ms = resetTimestamp(value); + if (ms === null || ms <= now) return { text: '—' }; + const minutes = Math.ceil((ms - now) / 60_000); + const unit = (n: number, name: 'day' | 'hour' | 'minute') => new Intl.NumberFormat(locale, { style: 'unit', unit: name, unitDisplay: 'narrow' }).format(n); + const text = minutes < 60 ? unit(minutes, 'minute') + : minutes < 1440 ? `${unit(Math.floor(minutes / 60), 'hour')} ${unit(minutes % 60, 'minute')}` + : minutes < 10080 ? `${unit(Math.floor(minutes / 1440), 'day')} ${unit(Math.floor(minutes % 1440 / 60), 'hour')}` + : new Intl.DateTimeFormat(locale, { month: 'short', day: 'numeric' }).format(ms); + return { text, exact: new Date(ms).toLocaleString(locale) }; +} + +export function quotaWindows(quota: AccountQuota | null) { + if (!quota) return []; + const windows = [ + { id: 'quota.fiveHourLimit', key: 'quota.fiveHourLimit' as const, percent: quota.fiveHourPercent ?? quota.shortPercent, reset: quota.fiveHourResetAt ?? quota.shortResetAt }, + { id: 'quota.weeklyLimit', key: 'quota.weeklyLimit' as const, percent: quota.weeklyPercent, reset: quota.weeklyResetAt }, + { id: 'quota.monthlyLimit', key: 'quota.monthlyLimit' as const, percent: quota.monthlyPercent, reset: quota.monthlyResetAt }, + // A provider-named window is identified by its own label; the de-duplication below is what + // keeps that unique, including against the fixed keys above. + ...(Array.isArray(quota.customWindows) ? quota.customWindows.filter(w => w && typeof w.label === 'string').map(w => ({ id: w.label, label: w.label, percent: w.percent, reset: w.resetAt })) : []), + ]; + const kept = windows.filter((w, index) => index === 0 && quota.monthlyPercent === undefined || finite(w.percent) || resetTimestamp(w.reset) !== null); + // A provider is free to report two custom windows under one label. The row identity has to + // stay unique anyway, or React reconciles two different windows onto the same row. + const seen = new Map(); + return kept.map(w => { + const taken = seen.get(w.id) ?? 0; + seen.set(w.id, taken + 1); + return taken === 0 ? w : { ...w, id: `${w.id}#${taken}` }; + }); +} + +export function filterUsage(usage: TrayUsage, settings: CompanionSettings): TrayUsage { + const hiddenProviders = new Set(settings.hiddenProviders); + const configuredModels = settings.models === null ? null : new Set(settings.models); + const models = usage.models.filter(row => !hiddenProviders.has(row.provider) + && (configuredModels === null || configuredModels.has(`${row.provider}/${row.model}`) || configuredModels.has(row.model))); + if (settings.models === null && settings.hiddenProviders.length === 0) return { ...usage, models }; + const summary: TrayTotals = {}; + for (const key of ['requests', 'totalTokens', 'inputTokens', 'outputTokens', 'cachedInputTokens', 'cacheReadInputTokens', 'estimatedCostUsd', 'measuredRequests', 'pricedRequests'] as const) { + if (models.length && models.every(row => finite(row[key]))) summary[key] = models.reduce((sum, row) => sum + row[key]!, 0); + } + return { ...usage, models, summary }; +} + +export function measuredTotals(data: TrayTotals): TrayTotals { + const next = { ...data }; + if ((data.requests ?? 0) > 0 && (data.measuredRequests === 0 || data.coverageRatio === 0)) { + for (const key of ['totalTokens', 'inputTokens', 'outputTokens', 'cachedInputTokens', 'cacheReadInputTokens'] as const) delete next[key]; + } + if ((data.requests ?? 0) > 0 && data.pricedRequests === 0) delete next.estimatedCostUsd; + return next; +} + +export async function fetchTrayJson(path: string, signal: AbortSignal): Promise { + const bounded = createBoundedFetch(20_000); + const abort = () => bounded.controller.abort(); + if (signal.aborted) abort(); + signal.addEventListener('abort', abort, { once: true }); + try { + const response = await fetch(path, { signal: bounded.signal, cache: 'no-store' }); + if (!response.ok) throw new Error(String(response.status)); + const data = await response.json() as T; + if (bounded.signal.aborted) throw new Error('Tray request cancelled'); + return data; + } finally { + bounded.clear(); + signal.removeEventListener('abort', abort); + } +} + +export function parseTrayUsage(value: unknown): TrayUsage { + const data = object(value); + if (data.error || !data.summary || typeof data.summary !== 'object' || Array.isArray(data.summary) || !Array.isArray(data.models)) { + throw new Error('Invalid usage'); + } + return data as unknown as TrayUsage; +} diff --git a/gui/src/pages/tray.css b/gui/src/pages/tray.css new file mode 100644 index 00000000000..472a057ba23 --- /dev/null +++ b/gui/src/pages/tray.css @@ -0,0 +1,181 @@ +/* Tray usage popup. + * + * The popup sits next to the WidgetKit widget in the menu bar, so it borrows that + * vocabulary: the system material behind a thin scrim, rounded tabular numerals for + * figures, secondary-tone labels, and a separator only where the subject changes. + * + * The native shell decides whether this window is translucent and tells the page by + * setting data-tray-vibrancy on . Only the glass branch may make the surface + * transparent: on Linux the window is opaque and a transparent background would show + * nothing but a hole. Every rule below therefore has an opaque default and the glass + * treatment is layered on top of it. + */ + +html.tray-document { + color-scheme: dark; + background: #1d1d1f; + + --tray-surface: #1d1d1f; + --tray-label: #f2f2f5; + --tray-label-secondary: rgba(235, 235, 245, 0.62); + --tray-label-tertiary: rgba(235, 235, 245, 0.38); + --tray-separator: rgba(235, 235, 245, 0.13); + --tray-fill: rgba(235, 235, 245, 0.11); + --tray-fill-strong: rgba(235, 235, 245, 0.18); + --tray-accent: #32d74b; + --tray-alert: #ff9f8f; + --tray-radius: 12px; + --tray-numerals: ui-rounded, 'SF Pro Rounded', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; +} + +html.tray-document[data-tray-vibrancy='on'] { + background: transparent; + /* A thin scrim over the material keeps contrast constant across wallpapers; the + * material still reads through it. Without it, light desktops wash the labels out. */ + --tray-surface: rgba(28, 28, 30, 0.52); + --tray-separator: rgba(235, 235, 245, 0.16); +} + +.tray-document body { + margin: 0; + min-width: 0; + background: var(--tray-surface); + color: var(--tray-label); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + -webkit-font-smoothing: antialiased; +} + +.tray-document[data-tray-vibrancy='on'] body { + /* The native effect rounds the window; the page has to stop painting square corners + * over it, and clip its own scrim to the same radius. */ + border-radius: var(--tray-radius); + overflow: hidden; +} + +.tray-document #root { min-height: 100vh; } + +.tray-page { + width: 100%; + max-width: 440px; + min-width: 0; + margin: 0 auto; + padding: 14px 18px 12px; + box-sizing: border-box; + font-size: 12px; + line-height: 1.45; + font-variant-numeric: tabular-nums; +} + +.tray-page header, +.tray-page footer { display: flex; align-items: center; justify-content: space-between; } +.tray-page header { padding-bottom: 12px; } +.tray-page header strong { font-size: 13px; font-weight: 600; letter-spacing: 0.01em; } + +.tray-page a { color: var(--tray-label-secondary); text-decoration: none; } +.tray-page header a { + font-size: 15px; + line-height: 24px; + width: 26px; + height: 24px; + text-align: center; + border-radius: 6px; + transition: background-color 120ms ease, color 120ms ease; +} +.tray-page a:hover, +.tray-page button:hover { color: var(--tray-label); background: var(--tray-fill); } +.tray-page :focus-visible { outline: 2px solid #9fb4ff; outline-offset: 2px; border-radius: 4px; } + +.tray-page h2 { + font-size: 11px; + font-weight: 590; + margin: 0 0 7px; + color: var(--tray-label-secondary); + letter-spacing: 0.02em; +} + +.tray-totals { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 16px; } +.tray-totals > div + div { border-left: 1px solid var(--tray-separator); padding-left: 16px; } + +.tray-page dl { margin: 0; } +.tray-page dl > div { display: flex; justify-content: space-between; align-items: baseline; gap: 6px; margin-bottom: 5px; } +.tray-page dt { color: var(--tray-label-secondary); font-size: 11px; white-space: nowrap; } +.tray-page dd { + margin: 0; + font-family: var(--tray-numerals); + font-size: 12px; + font-weight: 590; + text-align: right; + white-space: nowrap; +} +/* The headline figure carries the section, the way the widget token count does. */ +.tray-page dl > div:first-child dd { font-size: 17px; font-weight: 600; letter-spacing: -0.01em; } +.tray-page small { display: block; font-size: 10px; color: var(--tray-label-tertiary); font-weight: 400; } + +.tray-chart, +.tray-models, +.tray-providers { border-top: 1px solid var(--tray-separator); margin-top: 12px; padding-top: 12px; } +.tray-chart .usage-companion-chart { margin: 0; } +.tray-chart svg { display: block; width: 100%; height: 110px; } +.tray-chart .usage-companion-axis { stroke: var(--tray-fill-strong); } +.tray-chart .usage-companion-axis-label { fill: var(--tray-label-tertiary); font-size: 14px; } +.tray-chart .usage-companion-legend { display: flex; flex-wrap: wrap; gap: 3px 10px; font-size: 10px; color: var(--tray-label-secondary); } +.tray-chart .usage-companion-legend-item { display: inline-flex; align-items: center; gap: 4px; min-width: 0; max-width: 100%; } +.tray-chart .usage-companion-legend-item > span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.tray-chart .usage-companion-swatch { width: 5px; height: 5px; border-radius: 50%; flex-shrink: 0; } +.tray-chart .usage-companion-chart-skeleton { height: 110px; background: var(--tray-fill); border-radius: 6px; } +.tray-chart .usage-companion-chart-state { padding: 12px 0; color: var(--tray-label-secondary); font-size: 11px; } + +.tray-models > div { display: flex; justify-content: space-between; gap: 12px; padding: 3px 0; } +.tray-models > div > span:first-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.tray-models > div > span:last-child { flex-shrink: 0; color: var(--tray-label-secondary); } +.tray-model-metrics { display: flex; align-items: baseline; gap: 10px; font-size: 10px; } +.tray-model-metrics, +.tray-model-metrics > span { white-space: nowrap; flex-shrink: 0; } +.tray-model-metrics > span:last-child { + min-width: 38px; + text-align: right; + font-family: var(--tray-numerals); + font-size: 12px; + color: var(--tray-label); +} + +.tray-provider + .tray-provider { margin-top: 12px; } +.tray-provider h2 { margin-bottom: 4px; } +.tray-account { padding-left: 10px; border-left: 1px solid var(--tray-separator); margin-top: 7px; } +.tray-account-name { + display: flex; + justify-content: space-between; + gap: 8px; + font-size: 11px; + color: var(--tray-label); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + margin-bottom: 4px; +} +.tray-account-meta { flex-shrink: 0; color: var(--tray-label-tertiary); font-size: 10px; } +.tray-account-email { color: var(--tray-label-tertiary); font-size: 10px; margin: -2px 0 4px; } + +.tray-quota { display: grid; grid-template-columns: 94px 32px minmax(35px, 1fr) 78px; align-items: center; gap: 7px; margin-top: 4px; font-size: 10px; } +.tray-quota > span:first-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--tray-label-secondary); } +.tray-quota > span:nth-child(2) { text-align: right; white-space: nowrap; font-family: var(--tray-numerals); color: var(--tray-label); } +.tray-quota time { text-align: right; white-space: nowrap; color: var(--tray-label-tertiary); overflow: hidden; text-overflow: ellipsis; } +.tray-bar { height: 4px; background: var(--tray-fill-strong); overflow: hidden; border-radius: 2px; } +.tray-bar i { display: block; height: 100%; background: var(--tray-accent); border-radius: inherit; } + +.tray-missing { color: var(--tray-label-tertiary); font-size: 11px; } +.tray-error { color: var(--tray-alert); font-size: 11px; } + +.tray-page button { border: 0; background: transparent; color: inherit; font: inherit; cursor: pointer; text-decoration: underline; border-radius: 4px; } + +.tray-refresh { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin-top: 12px; font-size: 10px; } +.tray-refresh > span { color: var(--tray-label-tertiary); font-size: 10px; } +.tray-refresh button { padding: 0 2px; text-decoration: none; color: var(--tray-label-secondary); } +.tray-refresh button:disabled { opacity: 0.5; cursor: default; } + +.tray-page footer { border-top: 1px solid var(--tray-separator); margin-top: 8px; padding-top: 10px; } +.tray-page footer a { display: flex; width: 100%; justify-content: space-between; font-size: 11px; } + +@media (prefers-reduced-transparency: reduce) { + html.tray-document[data-tray-vibrancy='on'] { background: #1d1d1f; --tray-surface: #1d1d1f; } +} diff --git a/gui/tests/tray-data.test.ts b/gui/tests/tray-data.test.ts new file mode 100644 index 00000000000..dbcb03b11e2 --- /dev/null +++ b/gui/tests/tray-data.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, test } from 'bun:test'; +import { fetchTrayJson, parseTrayUsage, filterUsage, measuredTotals, parseAccounts, providerSources, quotaWindows, relativeReset, resetTimestamp } from '../src/pages/tray-data'; +import type { CompanionSettings } from '../src/pages/usage-companion-utils'; + +describe('tray data', () => { + const now = Date.UTC(2026, 8, 21, 0); + test('reset accepts seconds/milliseconds and rejects missing, invalid and expired observations', () => { + for (const value of [undefined, null, 0, -1, NaN, Infinity, '2026-10-01', 9e18]) expect(resetTimestamp(value)).toBeNull(); + expect(resetTimestamp(now / 1000)).toBe(now); + expect(resetTimestamp(now)).toBe(now); + expect(relativeReset(now, 'en', now)).toEqual({ text: '—' }); + expect(relativeReset(now - 1, 'en', now)).toEqual({ text: '—' }); + expect(relativeReset(now + 42 * 60000, 'en', now).text).toBe('42m'); + expect(relativeReset(now + 297 * 60000, 'en', now).text).toBe('4h 57m'); + expect(relativeReset(now + 35 * 3600000, 'en', now).text).toBe('1d 11h'); + const long = relativeReset(now + 30 * 86400000, 'en', now); + expect(long.text).toBe(new Intl.DateTimeFormat('en', { month: 'short', day: 'numeric' }).format(now + 30 * 86400000)); + expect(long.exact).toBeDefined(); + }); + test('projects providers and preserves actual account identity without exposing email', () => { + expect(providerSources({ providers: { openai: {}, oauth: { authMode: 'oauth' }, key: { hasApiKey: true }, off: { disabled: true } } }).map(p => p.path)).toEqual(['/api/codex-auth/accounts', '/api/oauth/accounts?provider=oauth"a=1', '/api/providers/keys?name=key"a=1']); + const [account] = parseAccounts({ activeAccountId: 'a', accounts: [{ id: 'a', email: 'person@example.com', plan: 'plus', quota: { weeklyPercent: 35, updatedAt: now } }] }); + expect(account.label).toBe('p•••@e•••.com'); + expect(account.active).toBe(true); + expect(account.plan).toBe('plus'); + expect(quotaWindows(account.quota)).toHaveLength(2); + expect(quotaWindows(account.quota)[0].percent).toBeUndefined(); + expect(parseAccounts({ keys: [{ id: 'k', label: 'Work', quotaUnavailable: true, quota: { weeklyPercent: 0 } }] })[0].quota).toBeNull(); + expect(parseAccounts({ keys: [{ id: 'k', quotaUnavailable: true }] })[0].unavailable).toBe(true); + expect(parseAccounts({ keys: [{ id: 'k', quotaMode: 'unsupported' }] })[0].unavailable).toBe(false); + expect(() => parseAccounts({ accounts: [{}] })).toThrow(); + const free = parseAccounts({ accounts: [{ id: 'f', plan: 'free', quota: { weeklyPercent: 1, shortPercent: 2, monthlyPercent: 3 } }] })[0]; + expect(quotaWindows(free.quota)).toHaveLength(1); + }); + test('unmeasured/unpriced nonzero requests are unknown, explicit zero stays zero, filters preserve missing', () => { + expect(measuredTotals({ requests: 3, measuredRequests: 0, pricedRequests: 0, totalTokens: 0, estimatedCostUsd: 0 })).toEqual({ requests: 3, measuredRequests: 0, pricedRequests: 0 }); + expect(measuredTotals({ requests: 0, measuredRequests: 0, totalTokens: 0 }).totalTokens).toBe(0); + const settings = { models: ['x/a'], hiddenProviders: [] } as unknown as CompanionSettings; + const result = filterUsage({ summary: { totalTokens: 40 }, models: [{ provider: 'x', model: 'a', requests: 1 }, { provider: 'y', model: 'a', totalTokens: 40 }] }, settings); + expect(result.summary.totalTokens).toBeUndefined(); + expect(result.summary.requests).toBe(1); + expect(result.models).toHaveLength(1); + }); +}); + +test('tray rejects HTTP-200 read failures before filtering but accepts genuine zero usage', () => { + const zero = { summary: { requests: 0, totalTokens: 0 }, models: [] }; + expect(() => parseTrayUsage({ ...zero, error: 'read_failed' })).toThrow('Invalid usage'); + expect(parseTrayUsage(zero)).toEqual(zero); + expect(() => parseTrayUsage(null)).toThrow(); +}); + +test('tray fetch works without AbortSignal static helpers and forwards cancellation', async () => { + const any = Object.getOwnPropertyDescriptor(AbortSignal, 'any')!; + const timeout = Object.getOwnPropertyDescriptor(AbortSignal, 'timeout')!; + const originalFetch = globalThis.fetch; + Object.defineProperty(AbortSignal, 'any', { configurable: true, value: undefined }); + Object.defineProperty(AbortSignal, 'timeout', { configurable: true, value: undefined }); + try { + globalThis.fetch = (async () => Response.json({ ok: true })) as typeof fetch; + expect(await fetchTrayJson('/api/config', new AbortController().signal)).toEqual({ ok: true }); + globalThis.fetch = ((_path, init) => new Promise((_resolve, reject) => { + const signal = init!.signal!; + const fail = () => reject(new Error('cancelled')); + if (signal.aborted) fail(); + else signal.addEventListener('abort', fail, { once: true }); + })) as typeof fetch; + const controller = new AbortController(); + const pending = fetchTrayJson('/api/config', controller.signal); + controller.abort(); + await expect(pending).rejects.toThrow('cancelled'); + await expect(fetchTrayJson('/api/config', controller.signal)).rejects.toThrow('cancelled'); + } finally { + globalThis.fetch = originalFetch; + Object.defineProperty(AbortSignal, 'any', any); + Object.defineProperty(AbortSignal, 'timeout', timeout); + } +}); diff --git a/readme/README.fr.md b/readme/README.fr.md index 446bdaaf104..be965be6d9a 100644 --- a/readme/README.fr.md +++ b/readme/README.fr.md @@ -92,23 +92,37 @@ Ouvrez **http://localhost:10100** et configurez tout dans le tableau de bord web fournisseurs (plus de 40 intégrés, ou n'importe quel point de terminaison compatible OpenAI), choisissez les modèles, gérez les comptes. `ocx gui` rouvre le tableau de bord à tout moment. -### Application macOS dans la barre des menus -Téléchargez l’application de bureau pour macOS, Windows ou Linux depuis la -[page des releases](https://github.com/lidge-jun/opencodex/releases). - -Un compagnon natif pour l’état du proxy, l’utilisation et les quotas des fournisseurs sans ouvrir -le tableau de bord. Le code source se trouve dans [`app/`](../app) (Swift + AppKit, sans dépendance -tierce). Téléchargez-le depuis la -[page des releases](https://github.com/lidge-jun/opencodex/releases) ou compilez-le localement avec +
+Application de bureau et widget macOS — bêta + +Une application native qui reprend le même tableau de bord, accompagnée d’une extension WidgetKit qui +affiche l’état du proxy, l’utilisation du jour et les quotas des fournisseurs sans ouvrir de +navigateur. Le proxy ne change pas : l’application détecte une instance en cours d’exécution ou +démarre le sidecar `ocx` inclus, tandis que le tableau de bord reste accessible à l’adresse +**http://localhost:10100**. + +Cette version est en bêta. Les versions distribuées sont signées pour en garantir l’intégrité, mais ne sont pas +notariées : macOS demande donc un clic droit → **Ouvrir** au premier lancement, et Windows +SmartScreen affiche un avertissement pour le programme d’installation. Le widget nécessite macOS 14 +ou une version ultérieure ; le modèle de données des instantanés qu’il affiche se trouve dans [`app/`](../app) +(`MenuBarCore`). + +Téléchargez l’application depuis la [dernière version publiée](https://github.com/lidge-jun/opencodex/releases), +ou compilez-la localement avec `bun run prepare-sidecar && bun run prepare-widget && bunx tauri build`. -Le premier lancement nécessite un clic droit → Ouvrir, car l’application est signée ad hoc et non -notarisée. Consultez le [guide de l’application macOS dans la barre des menus](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/) -pour l’explication complète. +Les emplacements d’installation, les fichiers de service et tous les autres éléments écrits sur le +disque sont répertoriés dans [`AGENTS_INSTALL.md`](../AGENTS_INSTALL.md#where-things-are-installed). +Le [guide de l’application de bureau](https://lidge-jun.github.io/opencodex/guides/desktop-app/) et le +[guide de l’application macOS dans la barre des menus](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/) +détaillent l’installation sur chaque plateforme et le message de Gatekeeper. + +
+ +### Groupe de comptes ChatGPT -L’application inclut également un widget macOS 14+ affichant l’état du proxy, l’utilisation du jour et les quotas. -Il peut également gérer un **groupe de comptes ChatGPT** pour l'authentification Codex. Ajoutez plusieurs +opencodex peut également gérer un **groupe de comptes ChatGPT** pour l'authentification Codex. Ajoutez plusieurs comptes ChatGPT / Codex et actualisez leurs quotas 5 h / hebdomadaires / 30 j dans le tableau de bord. Avec le routage par quota, les nouvelles sessions peuvent utiliser le compte opérationnel le moins sollicité ; les modes round-robin et fill-first appliquent leurs propres politiques. Les fils Codex existants restent diff --git a/readme/README.ja.md b/readme/README.ja.md index 7d8369c0285..9d32a7bfc7f 100644 --- a/readme/README.ja.md +++ b/readme/README.ja.md @@ -90,28 +90,41 @@ ocx start # プロキシとダッシュボードが loca **http://localhost:10100** を開き、Web ダッシュボードですべて設定します。プロバイダーの追加(40 以上の 組み込み、または任意の OpenAI 互換エンドポイント)、モデルの選択、アカウントの管理はここで行います。 `ocx gui` でいつでもダッシュボードを開き直せます。 -Codex 認証用の **ChatGPT アカウントプール**も管理できます。ChatGPT / Codex のアカウントを複数追加し、 -5 時間 / 週間 / 30 日のクォータをダッシュボードで更新します。クォータルーティングでは、新しいセッション -が使用量の最も少ない健全なアカウントを使えます。ラウンドロビンと fill-first はそれぞれの方針に従います。 -既存の Codex スレッドは通常、開始したアカウントとの affinity を保つので、長い SSH・tmux・モバイル接続 -のセッションが会話の途中でアカウントを乗り換えることはありません。ただしクォータの再評価、failover、 -アカウントの除外、affinity の失効、401/403 や 429 からの復帰では再バインドされることがあります。ふだん -は使わず他が尽きたときだけ回したいアカウント(多くは Codex Desktop のログイン)があるなら、アカウント -に選択順を指定してください。 - -### macOS メニューバーアプリ - -macOS、Windows、Linux 向けのデスクトップアプリを[リリースページ](https://github.com/lidge-jun/opencodex/releases)からダウンロードできます。 - -ダッシュボードを開かずにプロキシの状態、使用量、プロバイダーのクォータを確認できるネイティブ -コンパニオンです。ソースは [`app/`](../app)(Swift + AppKit、サードパーティ依存なし)にあります。 -[リリースページ](https://github.com/lidge-jun/opencodex/releases)からダウンロードするか、 + +
+デスクトップアプリと macOS ウィジェット — ベータ版 + +同じダッシュボードを包むネイティブアプリに、ブラウザーを開かなくてもプロキシの状態、今日の使用量、 +プロバイダーのクォータを確認できる WidgetKit 拡張を加えたものです。プロキシ自体は変わりません。アプリは +起動中のプロキシを見つけるか、同梱の `ocx` サイドカーを起動し、ダッシュボードは引き続き +**http://localhost:10100** で開きます。 + +現在はベータ版です。ビルドは改ざん検知のため署名されていますが公証はされていないため、macOS では +初回起動時に右クリックして「開く」を選ぶ必要があり、Windows ではインストーラーに SmartScreen の警告が +表示されます。ウィジェットには macOS 14 以降が必要です。表示に使うスナップショットモデルは +[`app/`](../app)(`MenuBarCore`)にあります。 + +[最新リリース](https://github.com/lidge-jun/opencodex/releases)からダウンロードするか、 `bun run prepare-sidecar && bun run prepare-widget && bunx tauri build` でローカルビルドできます。 -アプリは未公証のアドホック署名のため、初回起動時は右クリックして「開く」を選択してください。 -詳しくは [macOS メニューバーアプリガイド](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/)をご覧ください。 +インストール先、サービスファイルなどディスクに書き込まれるものは +[`AGENTS_INSTALL.md`](../AGENTS_INSTALL.md#where-things-are-installed) にまとめています。 +[デスクトップアプリガイド](https://lidge-jun.github.io/opencodex/guides/desktop-app/)と +[macOS メニューバーアプリガイド](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/)では、 +プラットフォーム別のインストール手順と Gatekeeper の確認画面を説明しています。 + +
+ +### ChatGPT アカウントプール -macOS 14 以降では、プロキシの状態、今日の使用量、クォータを表示するウィジェットも利用できます。 +opencodex では、Codex 認証用の **ChatGPT アカウントプール**も管理できます。ChatGPT / Codex のアカウントを +複数追加し、5 時間 / 週間 / 30 日のクォータをダッシュボードで更新します。クォータルーティングでは、新しい +セッションが使用量の最も少ない健全なアカウントを使えます。ラウンドロビンと fill-first はそれぞれの方針に +従います。既存の Codex スレッドは通常、開始したアカウントとの affinity を保つので、長い SSH・tmux・ +モバイル接続のセッションが会話の途中でアカウントを乗り換えることはありません。ただしクォータの再評価、 +failover、アカウントの除外、affinity の失効、401/403 や 429 からの復帰では再バインドされることがあります。 +ふだんは使わず他が尽きたときだけ回したいアカウント(多くは Codex Desktop のログイン)があるなら、 +アカウントに選択順を指定してください。 ### スポンサー diff --git a/readme/README.ko.md b/readme/README.ko.md index c1bafb47fe7..1dc97b5b1fc 100644 --- a/readme/README.ko.md +++ b/readme/README.ko.md @@ -89,26 +89,38 @@ ocx start # 프록시 + 대시보드: localhost:10100 **http://localhost:10100**을 열고 웹 대시보드에서 전부 설정하세요. 프로바이더 추가(내장 40개 이상, 또는 OpenAI 호환 엔드포인트), 모델 선택, 계정 관리까지 모두 여기서 합니다. `ocx gui`로 대시보드를 언제든 다시 엽니다. -Codex 인증용 **ChatGPT 계정 풀**도 관리합니다. ChatGPT / Codex 계정을 여러 개 넣고, 대시보드에서 -5시간 / 주간 / 30일 쿼터를 갱신합니다. 쿼터 라우팅을 켜면 새 세션은 사용량이 가장 적은 정상 계정을 쓰고, -round-robin과 fill-first는 각자 정책을 따릅니다. 기존 Codex 스레드는 기본적으로 시작한 계정에 붙어 -있어서, 긴 SSH·tmux·모바일 세션이 대화 도중에 계정을 바꾸지 않습니다. 다만 쿼터 재평가, failover, -계정 제외, affinity 만료, 401/403·429 복구가 일어나면 다시 묶일 수 있습니다. Codex Desktop 로그인처럼 -다른 계정이 소진된 뒤에만 쓰고 싶은 계정이 있으면, 계정에 선택 순서를 지정하세요. -### macOS 메뉴 막대 앱 +
+데스크톱 앱과 macOS 위젯 — 베타 + +같은 대시보드를 감싼 네이티브 앱과, 브라우저를 열지 않고 프록시 상태·오늘의 사용량·프로바이더 +쿼터를 보여 주는 WidgetKit 확장입니다. 프록시 자체는 그대로입니다. 앱은 실행 중인 프록시를 찾거나 +번들된 `ocx` 사이드카를 시작하며, 대시보드는 계속 **http://localhost:10100**에서 열립니다. + +현재 베타 버전입니다. 빌드는 무결성을 확인할 수 있도록 서명되어 있지만 공증되지는 않았습니다. +따라서 macOS에서는 처음 실행할 때 마우스 오른쪽 버튼을 클릭한 뒤 **열기**를 선택해야 하고, +Windows 설치 파일에는 SmartScreen 경고가 표시됩니다. 위젯은 macOS 14 이상에서 쓸 수 있으며, +위젯이 그리는 스냅샷 모델은 [`app/`](../app)의 `MenuBarCore`에 있습니다. -macOS, Windows, Linux용 데스크톱 앱은 [릴리스 페이지](https://github.com/lidge-jun/opencodex/releases)에서 다운로드하세요. +[최신 릴리스](https://github.com/lidge-jun/opencodex/releases)에서 다운로드하거나 +`bun run prepare-sidecar && bun run prepare-widget && bunx tauri build`로 직접 빌드하세요. -대시보드를 열지 않고 프록시 상태, 사용량, 제공자 쿼터를 확인하는 네이티브 동반 앱입니다. -소스는 [`app/`](../app)에 있으며 Swift + AppKit으로 작성되었고 서드파티 의존성이 없습니다. -[릴리스 페이지](https://github.com/lidge-jun/opencodex/releases)에서 다운로드하거나 -`bun run prepare-sidecar && bun run prepare-widget && bunx tauri build`로 직접 빌드할 수 있습니다. +설치 위치, 서비스 파일을 비롯해 디스크에 쓰는 항목은 +[`AGENTS_INSTALL.md`](../AGENTS_INSTALL.md#where-things-are-installed)에 정리되어 있습니다. +[데스크톱 앱 가이드](https://lidge-jun.github.io/opencodex/guides/desktop-app/)와 +[macOS 메뉴 막대 앱 가이드](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/)에서 +플랫폼별 설치 방법과 Gatekeeper 안내를 확인할 수 있습니다. + +
-앱은 공증되지 않은 애드혹 서명이므로 처음 실행할 때 마우스 오른쪽 버튼을 클릭하고 열기를 선택하세요. -자세한 내용은 [macOS 메뉴 막대 앱 가이드](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/)를 참조하세요. +### ChatGPT 계정 풀 -macOS 14 이상에서는 프록시 상태, 오늘의 사용량과 쿼터를 보여 주는 위젯도 포함됩니다. +opencodex는 Codex 인증용 **ChatGPT 계정 풀**도 관리합니다. ChatGPT / Codex 계정을 여러 개 넣고, +대시보드에서 5시간 / 주간 / 30일 쿼터를 갱신합니다. 쿼터 라우팅을 켜면 새 세션은 사용량이 가장 적은 +정상 계정을 쓰고, round-robin과 fill-first는 각자 정책을 따릅니다. 기존 Codex 스레드는 기본적으로 +시작한 계정에 붙어 있어서 긴 SSH·tmux·모바일 세션이 대화 도중에 계정을 바꾸지 않습니다. 다만 쿼터 +재평가, failover, 계정 제외, affinity 만료, 401/403·429 복구가 일어나면 다시 묶일 수 있습니다. +Codex Desktop 로그인처럼 다른 계정이 소진된 뒤에만 쓰고 싶은 계정이 있으면 계정에 선택 순서를 지정하세요. ### 스폰서 diff --git a/readme/README.ru.md b/readme/README.ru.md index e470afd26c2..e12ab125df8 100644 --- a/readme/README.ru.md +++ b/readme/README.ru.md @@ -92,7 +92,34 @@ ocx start # прокси + панель управлен Откройте **http://localhost:10100** и настройте всё в веб-панели: добавьте провайдеров (40+ встроенных или любой OpenAI-совместимый endpoint), выберите модели, управляйте аккаунтами. `ocx gui` в любой момент снова откроет панель. -Кроме того, он умеет управлять **пулом аккаунтов ChatGPT** для аутентификации Codex. Добавьте + +
+Настольное приложение и виджет macOS — бета-версия + +Это нативная оболочка вокруг той же панели управления с расширением WidgetKit, которое +показывает состояние прокси, расход за сегодня и квоты провайдеров без открытия браузера. +Сам прокси не меняется: приложение находит уже запущенный экземпляр либо запускает встроенный +сайдкар `ocx`, а панель по-прежнему доступна по адресу **http://localhost:10100**. + +Это бета-версия. Сборки подписаны для проверки целостности, но не нотариализованы, поэтому +при первом запуске macOS просит нажать правой кнопкой мыши и выбрать **«Открыть»**, а Windows +SmartScreen предупреждает об установщике. Для виджета нужна macOS 14 или новее; его модель +снимков находится в [`app/`](../app) (`MenuBarCore`). + +Скачайте приложение из [последнего релиза](https://github.com/lidge-jun/opencodex/releases) или +соберите локально командой `bun run prepare-sidecar && bun run prepare-widget && bunx tauri build`. + +Пути установки, файлы служб и всё остальное, что записывается на диск, перечислены в +[`AGENTS_INSTALL.md`](../AGENTS_INSTALL.md#where-things-are-installed). В +[руководстве по настольному приложению](https://lidge-jun.github.io/opencodex/guides/desktop-app/) и +[руководстве по приложению macOS в строке меню](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/) +описаны установка на каждой платформе и запрос Gatekeeper. + +
+ +### Пул аккаунтов ChatGPT + +opencodex также умеет управлять **пулом аккаунтов ChatGPT** для аутентификации Codex. Добавьте несколько аккаунтов ChatGPT / Codex и обновляйте их квоты за 5 ч / неделю / 30 дней в панели. При маршрутизации по квоте новые сессии могут использовать работоспособный аккаунт с наименьшим использованием; round-robin и fill-first применяют свои политики. Существующие треды Codex @@ -103,21 +130,6 @@ ocx start # прокси + панель управлен них — обычно вход Codex Desktop — должен использоваться только после того, как остальные исчерпаны. -### Приложение macOS в строке меню - -Скачайте настольное приложение для macOS, Windows или Linux со -[страницы релизов](https://github.com/lidge-jun/opencodex/releases). - -Нативный компаньон для состояния прокси, использования и квот провайдеров без открытия панели. -Исходный код находится в [`app/`](../app) (Swift + AppKit, без сторонних зависимостей). -Скачайте его со [страницы релизов](https://github.com/lidge-jun/opencodex/releases) или -соберите локально командой `bun run prepare-sidecar && bun run prepare-widget && bunx tauri build`. - -При первом запуске нажмите правой кнопкой мыши и выберите «Открыть»: приложение подписано ad hoc, -но не нотариализовано. Подробности — в [руководстве по приложению macOS в строке меню](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/). - -Приложение также включает виджет для macOS 14+, показывающий состояние прокси, расход за сегодня и квоты. - ### Спонсоры Спонсоры позволяют поддерживать opencodex при каждом изменении вышестоящих протоколов. Интересно? diff --git a/readme/README.tr.md b/readme/README.tr.md index ea79384a412..083e8e46370 100644 --- a/readme/README.tr.md +++ b/readme/README.tr.md @@ -90,28 +90,42 @@ Arka planda çalıştırmak için `ocx service` kullanın. **http://localhost:10100** adresini açın ve her şeyi web kontrol panelinden yapılandırın: sağlayıcı ekleyin (40'tan fazla hazır sağlayıcı ya da herhangi bir OpenAI uyumlu uç nokta), model seçin, hesap yönetin. `ocx gui` paneli istediğiniz zaman yeniden açar. -Codex kimlik doğrulaması için bir **ChatGPT hesap havuzu** da yönetebilir. Birden fazla ChatGPT / Codex -hesabı ekleyin, 5 saatlik / haftalık / 30 günlük kotalarını panelden tazeleyin. Kota yönlendirmesinde -yeni oturumlar en az kullanılan sağlıklı hesabı kullanabilir; round-robin ve fill-first kendi -politikalarını izler. Mevcut Codex dizileri normalde onları başlatan hesaba bağlı kalır, böylece uzun -SSH, tmux ya da mobil oturumlar konuşmanın ortasında hesap değiştirmez — ancak kota yeniden -değerlendirmesi, failover, hesabın devre dışı bırakılması, bağlılığın süresinin dolması ya da 401/403 ve -429 toparlanması bu bağı yeniden kurabilir. Yalnızca diğerleri tükendiğinde kullanılmasını istediğiniz -bir hesap varsa — genellikle Codex Desktop girişiniz — hesaplara bir seçim sırası verin. -### macOS menü çubuğu uygulaması +
+Masaüstü uygulaması ve macOS widget'ı — beta + +Aynı kontrol panelini sarmalayan yerel uygulamaya ek olarak, tarayıcı açmadan proxy durumunu, +bugünkü kullanımı ve sağlayıcı kotalarını gösteren bir WidgetKit uzantısı sunulur. Proxy'nin çalışma +şekli değişmez: uygulama çalışan bir proxy bulur ya da paketlenmiş `ocx` sidecar'ını başlatır; +kontrol paneli yine **http://localhost:10100** adresinde kalır. -macOS, Windows veya Linux masaüstü uygulamasını [sürümler sayfasından](https://github.com/lidge-jun/opencodex/releases) indirin. +Bu bir beta sürümüdür. Derlemeler bütünlük için imzalanır ancak noter tasdikli değildir; bu nedenle +macOS ilk açılışta sağ tıklayıp **Aç**'ı seçmenizi ister, Windows SmartScreen ise yükleyici için uyarı +gösterir. Widget için macOS 14 veya üzeri gerekir; görüntülediği anlık görüntü modeli +[`app/`](../app) dizinindedir (`MenuBarCore`). -Panoyu açmadan proxy durumunu, kullanımı ve sağlayıcı kotalarını gösteren yerel yardımcı uygulama. -Kaynak kodu [`app/`](../app) konumundadır (Swift + AppKit, üçüncü taraf bağımlılığı yoktur). -[Sürümler sayfasından](https://github.com/lidge-jun/opencodex/releases) indirin veya -`bun run prepare-sidecar && bun run prepare-widget && bunx tauri build` ile yerel olarak derleyin. +Uygulamayı [en güncel sürümden](https://github.com/lidge-jun/opencodex/releases) indirin veya +`bun run prepare-sidecar && bun run prepare-widget && bunx tauri build` komutuyla yerel olarak derleyin. -Uygulama noter tasdikli olmadığından ve ad hoc imzalandığından ilk açılışta sağ tıklayıp Aç'ı seçin. -Ayrıntılar için [macOS menü çubuğu uygulaması kılavuzuna](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/) bakın. +Kurulum konumları, servis dosyaları ve diske yazılan diğer her şey +[`AGENTS_INSTALL.md`](../AGENTS_INSTALL.md#where-things-are-installed) dosyasında listelenir. +[Masaüstü uygulaması kılavuzu](https://lidge-jun.github.io/opencodex/guides/desktop-app/) ve +[macOS menü çubuğu uygulaması kılavuzu](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/), +platforma göre kurulumu ve Gatekeeper istemini açıklar. + +
-Uygulama ayrıca proxy durumunu, bugünkü kullanımı ve kotaları gösteren macOS 14+ widget'ını içerir. +### ChatGPT hesap havuzu + +opencodex, Codex kimlik doğrulaması için bir **ChatGPT hesap havuzu** da yönetebilir. Birden fazla +ChatGPT / Codex hesabı ekleyin, 5 saatlik / haftalık / 30 günlük kotalarını panelden tazeleyin. Kota +yönlendirmesinde yeni oturumlar en az kullanılan sağlıklı hesabı kullanabilir; round-robin ve +fill-first kendi politikalarını izler. Mevcut Codex dizileri normalde onları başlatan hesaba bağlı +kalır, böylece uzun SSH, tmux ya da mobil oturumlar konuşmanın ortasında hesap değiştirmez — ancak +kota yeniden değerlendirmesi, failover, hesabın devre dışı bırakılması, bağlılığın süresinin dolması +ya da 401/403 ve 429 toparlanması bu bağı yeniden kurabilir. Yalnızca diğerleri tükendiğinde +kullanılmasını istediğiniz bir hesap varsa — genellikle Codex Desktop girişiniz — hesaplara bir seçim +sırası verin. ### Sponsorlar diff --git a/readme/README.zh-CN.md b/readme/README.zh-CN.md index 18e0c506c07..e4cd96d518e 100644 --- a/readme/README.zh-CN.md +++ b/readme/README.zh-CN.md @@ -89,7 +89,33 @@ ocx start # 代理 + 仪表板:localhost:10100 打开 **http://localhost:10100**,在 Web 仪表板中完成所有配置 —— 添加提供商 (40 多个内置,或任意 OpenAI 兼容端点)、选择模型、管理账户。随时运行 `ocx gui` 可重新打开仪表板。 -它还能为 Codex 认证管理一个 **ChatGPT 账户池**。添加多个 ChatGPT / Codex 账户, + +
+桌面应用与 macOS 小组件 —— 测试版 + +它是同一套仪表板的原生外壳,另带 WidgetKit 扩展,无需打开浏览器即可查看代理状态、 +今日用量和提供商配额。代理本身没有变化:应用会连接已在运行的代理;若未发现, +则启动内置的 `ocx` sidecar。仪表板仍位于 **http://localhost:10100**。 + +桌面应用目前仍处于测试阶段。构建已签名以保障完整性,但尚未公证,因此 macOS +首次启动时需要右键点击并选择“打开”,Windows SmartScreen 也会对安装程序发出警告。 +小组件需要 macOS 14 或更高版本;它所呈现的快照模型位于 [`app/`](../app) +(`MenuBarCore`)。 + +请从[最新发布版本](https://github.com/lidge-jun/opencodex/releases)下载,或使用 +`bun run prepare-sidecar && bun run prepare-widget && bunx tauri build` 在本地构建。 + +安装位置、服务文件以及写入磁盘的其他内容均列在 +[`AGENTS_INSTALL.md`](../AGENTS_INSTALL.md#where-things-are-installed) 中。 +[桌面应用指南](https://lidge-jun.github.io/opencodex/guides/desktop-app/)和 +[macOS 菜单栏应用指南](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/) +介绍了各平台的安装方式和 Gatekeeper 提示。 + +
+ +### ChatGPT 账户池 + +opencodex 还能为 Codex 认证管理一个 **ChatGPT 账户池**。添加多个 ChatGPT / Codex 账户, 在仪表板中刷新它们的 5 小时 / 每周 / 30 天配额。在配额路由下,新会话可以使用 使用量最低的健康账户;round-robin 和 fill-first 则各自使用自己的策略。现有 Codex 线程通常会保持对启动它的账户的亲和性,因此长时间的 SSH、tmux 或移动端连接的会话 @@ -97,19 +123,6 @@ ocx start # 代理 + 仪表板:localhost:10100 401/403 与 429 恢复,仍可能重新绑定。给账户设定选择顺序,以便其中某个账户 —— 通常是你的 Codex Desktop 登录 —— 只在其他账户耗尽后才被选中。 -### macOS 菜单栏应用 - -请从[发布页面](https://github.com/lidge-jun/opencodex/releases)下载 macOS、Windows 或 Linux 桌面应用。 - -无需打开仪表板即可查看代理状态、用量和提供商配额的原生伴侣应用。源代码位于 -[`app/`](../app)(Swift + AppKit,无第三方依赖)。请从[发布页面](https://github.com/lidge-jun/opencodex/releases) -下载,或使用 `bun run prepare-sidecar && bun run prepare-widget && bunx tauri build` 在本地构建。 - -应用采用未公证的临时签名,首次启动时请右键点击并选择“打开”。详情请参阅 -[macOS 菜单栏应用指南](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/)。 - -应用还包含适用于 macOS 14 及更高版本的小组件,可显示代理状态、今日用量和配额。 - ### 赞助商 赞助商支撑 opencodex 跟上每一次上游协议变更。有兴趣? diff --git a/readme/README.zh-TW.md b/readme/README.zh-TW.md index 828cb1fed64..70b0cae1276 100644 --- a/readme/README.zh-TW.md +++ b/readme/README.zh-TW.md @@ -88,25 +88,37 @@ ocx start # 代理 + 儀表板位於 localhost:10100 開啟 **http://localhost:10100**,在網頁儀表板完成所有設定——新增供應商 (40+ 內建,或任何 OpenAI 相容端點)、挑選模型、管理帳號。隨時可用 `ocx gui` 重新開啟儀表板。 -它也能為 Codex 認證管理 **ChatGPT 帳號池**。新增多個 ChatGPT / Codex 帳號, -在儀表板重新整理 5 小時/每週/30 天配額。在配額路由下,新會話可使用 -使用量最低的健康帳號;round-robin 與 fill-first 則各自套用自己的策略。既有 Codex -執行緒通常會維持對啟動帳號的親和性,因此長時間的 SSH、tmux 或 -行動裝置連線的會話不會在對話中途跳帳號——但配額重新評估、failover、 -帳號排除、親和性到期,或 401/403 與 429 復原,仍可能重新綁定。當其中一個帳號——通常是你的 Codex Desktop 登入——只應在其他帳號用盡後才被用到時,請為帳號設定選取順序。 -### macOS 選單列應用程式 +
+桌面應用程式與 macOS 小工具——Beta 版 + +它是同一套儀表板的原生外殼,另附 WidgetKit 擴充套件,無需開啟瀏覽器就能查看代理狀態、 +今日用量與供應商配額。代理本身沒有改變:應用程式會尋找正在執行的代理,找不到便啟動隨附的 +`ocx` sidecar;儀表板仍位於 **http://localhost:10100**。 -請從[發行頁面](https://github.com/lidge-jun/opencodex/releases)下載 macOS、Windows 或 Linux 桌面應用程式。 +目前仍是 Beta 版。建置會簽章以確保完整性,但尚未經公證,因此 macOS 在首次啟動時需要按右鍵 → **開啟**, +Windows SmartScreen 則會對安裝程式顯示警告。小工具需要 macOS 14 或更新版本;它所呈現的快照模型位於 +[`app/`](../app)(`MenuBarCore`)。 -無需開啟儀表板即可查看代理狀態、用量與供應商配額的原生伴侶應用程式。原始碼位於 -[`app/`](../app)(Swift + AppKit,沒有第三方相依套件)。請從[發行頁面](https://github.com/lidge-jun/opencodex/releases) -下載,或使用 `bun run prepare-sidecar && bun run prepare-widget && bunx tauri build` 在本機建置。 +請從[最新發行版](https://github.com/lidge-jun/opencodex/releases)下載,或使用 +`bun run prepare-sidecar && bun run prepare-widget && bunx tauri build` 在本機建置。 -應用程式未經公證且使用 ad hoc 簽章,首次啟動時請按右鍵並選擇「開啟」。詳情請參閱 -[macOS 選單列應用程式指南](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/)。 +安裝位置、服務檔案,以及其他寫入磁碟的內容,都列在 +[`AGENTS_INSTALL.md`](../AGENTS_INSTALL.md#where-things-are-installed)。 +[桌面應用程式指南](https://lidge-jun.github.io/opencodex/guides/desktop-app/) 與 +[macOS 選單列應用程式指南](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/) +說明各平台的安裝方式與 Gatekeeper 提示。 -應用程式也包含 macOS 14 以上的小工具,可顯示代理狀態、今日用量與配額。 +
+ +### ChatGPT 帳號池 + +opencodex 也能為 Codex 認證管理 **ChatGPT 帳號池**。新增多個 ChatGPT / Codex 帳號, +在儀表板重新整理 5 小時/每週/30 天配額。在配額路由下,新會話可使用 +使用量最低的健康帳號;round-robin 與 fill-first 則各自套用自己的策略。既有 Codex +執行緒通常會維持對啟動帳號的親和性,因此長時間的 SSH、tmux 或 +行動裝置連線的會話不會在對話中途跳帳號——但配額重新評估、failover、 +帳號排除、親和性到期,或 401/403 與 429 復原,仍可能重新綁定。當其中一個帳號——通常是你的 Codex Desktop 登入——只應在其他帳號用盡後才被用到時,請為帳號設定選取順序。 ### 贊助 diff --git a/readme/i18n-manifest.json b/readme/i18n-manifest.json index a5e76f90b73..1bdee0719bc 100644 --- a/readme/i18n-manifest.json +++ b/readme/i18n-manifest.json @@ -6,43 +6,43 @@ "file": "readme/README.fr.md", "label": "Français", "docsPath": "fr", - "sourceSha256": "44610e2f78949ba13c99682e59e3366cbe38e8c853ddc9dfe470a327f53118d2" + "sourceSha256": "10c088099257df8813301d057990780394aae922c2703337c8326c9e64a835c3" }, "ko": { "file": "readme/README.ko.md", "label": "한국어", "docsPath": "ko", - "sourceSha256": "44610e2f78949ba13c99682e59e3366cbe38e8c853ddc9dfe470a327f53118d2" + "sourceSha256": "10c088099257df8813301d057990780394aae922c2703337c8326c9e64a835c3" }, "zh-CN": { "file": "readme/README.zh-CN.md", "label": "简体中文", "docsPath": "zh-cn", - "sourceSha256": "44610e2f78949ba13c99682e59e3366cbe38e8c853ddc9dfe470a327f53118d2" + "sourceSha256": "10c088099257df8813301d057990780394aae922c2703337c8326c9e64a835c3" }, "zh-TW": { "file": "readme/README.zh-TW.md", "label": "繁體中文", "docsPath": "zh-tw", - "sourceSha256": "44610e2f78949ba13c99682e59e3366cbe38e8c853ddc9dfe470a327f53118d2" + "sourceSha256": "10c088099257df8813301d057990780394aae922c2703337c8326c9e64a835c3" }, "ru": { "file": "readme/README.ru.md", "label": "Русский", "docsPath": "ru", - "sourceSha256": "44610e2f78949ba13c99682e59e3366cbe38e8c853ddc9dfe470a327f53118d2" + "sourceSha256": "10c088099257df8813301d057990780394aae922c2703337c8326c9e64a835c3" }, "ja": { "file": "readme/README.ja.md", "label": "日本語", "docsPath": "ja", - "sourceSha256": "44610e2f78949ba13c99682e59e3366cbe38e8c853ddc9dfe470a327f53118d2" + "sourceSha256": "10c088099257df8813301d057990780394aae922c2703337c8326c9e64a835c3" }, "tr": { "file": "readme/README.tr.md", "label": "Türkçe", "docsPath": "tr", - "sourceSha256": "44610e2f78949ba13c99682e59e3366cbe38e8c853ddc9dfe470a327f53118d2" + "sourceSha256": "10c088099257df8813301d057990780394aae922c2703337c8326c9e64a835c3" } } } diff --git a/scripts/build-standalone.ts b/scripts/build-standalone.ts index 7b71df0bc43..f75fcc32251 100644 --- a/scripts/build-standalone.ts +++ b/scripts/build-standalone.ts @@ -1,14 +1,7 @@ import { createHash } from "node:crypto"; import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join, resolve } from "node:path"; - -const targets = new Set([ - "bun-darwin-arm64", - "bun-darwin-x64", - "bun-windows-x64", - "bun-linux-x64", - "bun-linux-arm64", -]); +import { isStandaloneTarget, standaloneExecutableName } from "./standalone-targets"; function hostTarget(): string { const platform = process.platform === "darwin" ? "darwin" : process.platform === "win32" ? "windows" : "linux"; @@ -22,7 +15,7 @@ function argumentValue(name: string): string | undefined { } const target = argumentValue("--target") ?? hostTarget(); -if (!targets.has(target)) { +if (!isStandaloneTarget(target)) { throw new Error(`Unsupported standalone target: ${target}`); } @@ -34,7 +27,7 @@ if (!existsSync(join(guiDist, "index.html"))) { const output = resolve(argumentValue("--out") ?? join(repoRoot, "dist", "standalone", target)); mkdirSync(output, { recursive: true }); -const executable = join(output, target.startsWith("bun-windows-") ? "ocx.exe" : "ocx"); +const executable = join(output, standaloneExecutableName(target)); const result = Bun.spawnSync([ process.execPath, "build", diff --git a/scripts/standalone-targets.ts b/scripts/standalone-targets.ts new file mode 100644 index 00000000000..27b9a635b1e --- /dev/null +++ b/scripts/standalone-targets.ts @@ -0,0 +1,29 @@ +/** + * Standalone binary target metadata — the single source for the standalone build + * matrix. scripts/build-standalone.ts builds from this list, the release workflow's + * package-standalone matrix must stay equal to it, and the pre-publication + * verifier derives its expected standalone assets from it. + */ +export const standaloneTargets = [ + "bun-darwin-arm64", + "bun-darwin-x64", + "bun-windows-x64", + "bun-linux-x64", + "bun-linux-arm64", +] as const; + +export function isStandaloneTarget(value: string): boolean { + return (standaloneTargets as readonly string[]).includes(value); +} + +export function standaloneExecutableName(target: string): string { + return target.startsWith("bun-windows-") ? "ocx.exe" : "ocx"; +} + +export function standaloneArchiveExtension(target: string): string { + return target.startsWith("bun-windows-") ? "zip" : "tar.gz"; +} + +export function standaloneArchiveName(version: string, target: string): string { + return `ocx-${version}-${target}.${standaloneArchiveExtension(target)}`; +} diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 41daa0cd488..0cbb0fc7b38 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -183,6 +183,7 @@ "provider-egress-fetch.test.ts": "responses", "provider-egress-management-validation.test.ts": "server", "start-args.test.ts": "cli", + "start-ownership-publication.test.ts": "cli", "responses-core-modules.test.ts": "responses", "responses-passthrough-transient-policy.test.ts": "responses", "responses-spend-ledger-wiring.test.ts": "responses", @@ -1379,6 +1380,7 @@ "server-xai-header-parity.test.ts": "server", "server-xai-oauth-401-replay.test.ts": "server", "server-xai-responses-streaming.test.ts": "server", + "service-ownership-compatibility.test.ts": "service", "service-ownership-handover.test.ts": "service", "service-ownership-state.test.ts": "service", "service-probe-docker.test.ts": "service", @@ -1618,6 +1620,7 @@ "ci-structure-gate.test.ts": "ci-workflows", "responses-code-mode-patch-compile.test.ts": "responses", "gui-codex-usage-score-parity.test.ts": "gui", + "gui-tray-vibrancy-surface.test.ts": "gui", "web-search-sidecar-429.test.ts": "web-search", "management-google-tool-schema-policy.test.ts": "server", "codex-shim-destroyed-probe.test.ts": "codex-integration", diff --git a/src/adapters/openai-responses/passthrough.ts b/src/adapters/openai-responses/passthrough.ts index cadcb2a38c2..0bef9c1cf06 100644 --- a/src/adapters/openai-responses/passthrough.ts +++ b/src/adapters/openai-responses/passthrough.ts @@ -329,11 +329,11 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): outBody = stripUnsupportedReasoningSummaryDelivery(outBody, parsed.modelId); // #4587: on a bridged provider, hand the destination back the search call and result the // proxy executed on its behalf, in place of the hosted cell the caller replays. Scoped to - // this destination and recorded by the bridge itself, so a provider without the opt-in - // computes no identity and keeps the body reference it already had. This runs before the - // query backfill below because a restored cell is no longer a web_search_call to repair. + // its exact conversation and serving identity and recorded by the bridge itself, so a + // provider without the opt-in computes no identity and keeps the body reference it already + // had. This runs before query backfill because a restored cell is no longer one to repair. if (provider.webSearchBridge?.enabled === true) { - outBody = restoreBridgedWebSearchCalls(outBody, bridgeSearchReplayScope(provider.baseUrl)); + outBody = restoreBridgedWebSearchCalls(outBody, bridgeSearchReplayScope(parsed._reasoningReplayScope)); } // Repair stored history from before the bridge emitted both keys, in either // direction: a conversation that already recorded a web_search_call replays it diff --git a/src/cli/index.ts b/src/cli/index.ts index b9fe221eb51..88ab11392d7 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -67,7 +67,7 @@ import { quarantinePendingTeardown, } from "../config/pending-teardown"; import { collectStatus, deadProxyRoutingAdviceLines, detectMissingCodexCatalogPath, hubStatusLines, missingCodexCatalogLines, remoteHubBannerLine, remoteHubStatusLines, unusedProxyWarningLines } from "./status"; -import { endpointsToProve, everyEndpointProvenDown, sharedTeardownAuthorized, type UninstallObservation } from "./uninstall-plan"; +import { endpointsToProve, everyEndpointProvenDownAsync, sharedTeardownAuthorized, type UninstallObservation } from "./uninstall-plan"; import { takeFlag } from "./runtime-api"; import { parseStartOptions, StartArgsError } from "./start-args"; @@ -85,7 +85,14 @@ import { SpendLedgerOwnerError } from "../lib/spend-ledger-owner"; import { redactUrlForLog } from "../lib/redact"; import { dispatchCommand, decideBusyPreferredPort, decideStartWithLiveOwner } from "./dispatch"; import { AuxiliaryListenerBindError, findAvailablePort, isAddrInUse, PortUnavailableError, shouldPersistSelectedPort, waitForPortAvailable } from "../server/ports"; -import { findLiveProxy, probeHostname, probePortOwner, START_OWNERSHIP_LIVENESS, type LiveProxy } from "../server/proxy-liveness"; +import { + findLiveProxy, + probeEndpointLiveness, + probeHostname, + probePortOwner, + START_OWNERSHIP_LIVENESS, + type LiveProxy, +} from "../server/proxy-liveness"; import { createReadinessGate } from "../server/readiness"; import { isApiAuthRequired } from "../server/auth-cors"; import { runReady, type ReadyArgs } from "./ready"; @@ -94,7 +101,8 @@ import { summarizeStopRun, type StopOutcome, type StopRunRecord } from "./stop-r import { runCli } from "./root"; import { isProcessAlive, ProxyOwnershipRefusedError, refusalNextStep, stopProxy } from "../lib/process-control"; import { startupDataPlaneToken } from "../lib/service-secrets"; -import { assertNotAdminToken, diagnoseService, isServiceOwnershipError, proxyStillLiveAfterStop, serviceCommand, serviceEnvironmentOwnedHere, serviceStartableFromTray, serviceStatusSummary, stopServiceIfInstalledDetailed, uninstallServiceIfInstalled, uninstallServiceDetailed } from "../service"; +import { assertNotAdminToken, diagnoseService, isServiceOwnershipError, proxyStillLiveAfterStop, serviceCommand, serviceEnvironmentOwnedHere, serviceStartableFromTray, serviceStatePaths, serviceStatusSummary, stopServiceIfInstalledDetailed, uninstallServiceIfInstalled, uninstallServiceDetailed } from "../service"; +import { acquireOwnershipMutationLease } from "../service/ownership-mutation-lease.mjs"; import { formatStartupRoutingDetail, startupHealthSummary } from "../codex/autostart-health"; import { injectSystemEnv, reconcileShellHook, revertSystemEnv, uninstallShellHook } from "../server/system-env"; import { buildDesktop3pRegistry } from "../claude/desktop-3p"; @@ -103,6 +111,10 @@ import { startHistoryMigrationGuardian } from "../codex/history-migration-guardi import { maybeShowStarPrompt } from "./star-prompt"; import { scheduleCatalogPrewarm } from "./catalog-prewarm"; import { maybeShowUpdatePrompt } from "../update/notify"; +import { + bindAndPublishStartOwnership, + StartOwnershipRollbackUncertainError, +} from "./start-ownership-publication"; import { syncModelsToCodex } from "../codex/sync"; import { HUB_GATED_SKIP_MESSAGE, @@ -221,6 +233,13 @@ function startArgv(port?: number): string[] { return selfLaunchArgv(args); } +class StartCommandExit extends Error { + constructor(readonly exitCode: number) { + super(`start command exited with code ${exitCode}`); + this.name = "StartCommandExit"; + } +} + async function chooseListenPort( requestedPort?: number, options: { sibling?: boolean } = {}, @@ -293,17 +312,17 @@ async function chooseListenPort( // Same contract as the pre-bind owner check: the wrapper's retry loop terminates // on a zero exit, and the port it was asked to serve is already served. console.log(`Proxy already running (PID ${holder?.pid ?? "unknown"}, port ${preferred}); service wrapper staying out of the way.`); - process.exit(0); + throw new StartCommandExit(0); } if (decision === "refuse-live-proxy") { console.error(`⚠️ Proxy already running (PID ${holder?.pid ?? "unknown"}, port ${preferred}). Use 'ocx stop' first.`); - process.exit(1); + throw new StartCommandExit(1); } if (decision === "refuse-unidentified-holder") { console.error(`❌ Port ${preferred} is busy and its holder did not identify as opencodex.`); console.error(" Starting on another port would leave Codex pointed at a proxy you did not ask for."); console.error(" Stop whatever holds that port, or start on a free one with 'ocx start --port '."); - process.exit(1); + throw new StartCommandExit(1); } if (preferred > 0) { console.log(`⚠️ Port ${preferred} is busy; starting opencodex on ${selected}.`); @@ -318,7 +337,7 @@ async function chooseListenPort( if (err instanceof PortUnavailableError) { console.error(`❌ ${err.message}`); console.error(" Stop whatever holds that port, or change config.port, then retry."); - process.exit(1); + throw new StartCommandExit(1); } throw err; } @@ -448,53 +467,99 @@ async function handleStart(options: { block?: boolean } = {}) { // live daemon holding resources while it overwrites its own binary. await maybeShowUpdatePrompt(); - // Port selection is check-then-bind: a concurrent `ocx start`/`ensure` can win the port - // between the probe and Bun.serve. Soft starts may re-pick; hard-pinned `--port` retries - // the same port only (never hop — that was the remaining PR #152 gap). - let port = await chooseListenPort(requestedPort, { sibling: siblingStart }); - const { drainAndShutdown, isRecyclingForExit, startServer } = await import("../server"); - // One private readiness gate for this startServer invocation, captured by the - // listener's closure. handleStart owns it and transitions it after the - // post-startup sync settles. A second startServer in the same process would - // get its own gate and could never reset/mutate this one. - const readinessGate = createReadinessGate(); - let server: ReturnType; - const localAttestationSecret = createLocalAttestationSecret(); - for (let attempt = 0; ; attempt++) { - try { - server = startServer(port, { localAttestationSecret, readinessGate }); - // Prewarm the live provider model cache as soon as the port is bound so the - // first GUI /v1/models (and syncModelsToCodex below) share one discovery flight - // instead of racing duplicate upstream /models fetches. - scheduleCatalogPrewarm(); - break; - } catch (err) { - if (err instanceof SpendLedgerOwnerError) { - console.error(`❌ ${err.message}`); - process.exit(1); - } - if (err instanceof AuxiliaryListenerBindError || !isAddrInUse(err) || attempt >= 2) throw err; - if (requestedPort !== undefined) { - console.log(`⚠️ Port ${port} was taken while starting; waiting to retry the same port...`); - const hostname = loadConfig().hostname ?? "127.0.0.1"; - const freed = await waitForPortAvailable(port, hostname, { timeoutMs: 3_000, intervalMs: 50 }); - if (!freed) { - console.error(`❌ Port ${port} stayed busy; refusing to hop to an ephemeral port.`); - process.exit(1); + type StartServerModule = typeof import("../server"); + type BoundStart = { + server: ReturnType; + serverModule: StartServerModule; + port: number; + readinessGate: ReturnType; + localAttestationSecret: string; + config: ReturnType; + }; + let boundStart: BoundStart; + try { + boundStart = await bindAndPublishStartOwnership({ + acquireLease: () => acquireOwnershipMutationLease(serviceStatePaths()), + bind: async () => { + // The earlier probe owned journal cleanup. This one owns the bind decision: an + // updater may have stopped the old runtime and acquired this lease for replacement. + const fencedLive = await findLiveProxy(START_OWNERSHIP_LIVENESS); + if (fencedLive) { + const decision = decideStartWithLiveOwner({ + livePort: fencedLive.port, + requestedPort, + ocxService: process.env.OCX_SERVICE, + }); + if (decision === "service-stay-out") { + console.log(`Proxy already running (PID ${fencedLive.pid ?? "unknown"}, port ${fencedLive.port}); service wrapper staying out of the way.`); + throw new StartCommandExit(0); + } + if (decision === "refuse") { + console.error(`⚠️ Proxy appeared before bind (PID ${fencedLive.pid ?? "unknown"}, port ${fencedLive.port}). Use 'ocx stop' first.`); + throw new StartCommandExit(1); + } + siblingStart = true; } - continue; - } - console.log(`⚠️ Port ${port} was taken while starting; picking another...`); - port = await chooseListenPort(requestedPort, { sibling: siblingStart }); - } + + // Port selection is check-then-bind. The lease prevents every cooperating start or + // updater from turning that check into a different ownership decision. + let port = await chooseListenPort(requestedPort, { sibling: siblingStart }); + const serverModule = await import("../server"); + const readinessGate = createReadinessGate(); + const localAttestationSecret = createLocalAttestationSecret(); + const config = loadConfig(); + let server: ReturnType; + for (let attempt = 0; ; attempt++) { + try { + server = serverModule.startServer(port, { localAttestationSecret, readinessGate }); + break; + } catch (err) { + try { await serverModule.waitForFailedStartRollback(err); } + catch (rollbackError) { + throw new StartOwnershipRollbackUncertainError([err, rollbackError]); + } + if (err instanceof SpendLedgerOwnerError) { + console.error(`❌ ${err.message}`); + throw new StartCommandExit(1); + } + if (err instanceof AuxiliaryListenerBindError || !isAddrInUse(err) || attempt >= 2) throw err; + if (requestedPort !== undefined) { + console.log(`⚠️ Port ${port} was taken while starting; waiting to retry the same port...`); + const hostname = config.hostname ?? "127.0.0.1"; + const freed = await waitForPortAvailable(port, hostname, { timeoutMs: 3_000, intervalMs: 50 }); + if (!freed) { + console.error(`❌ Port ${port} stayed busy; refusing to hop to an ephemeral port.`); + throw new StartCommandExit(1); + } + continue; + } + console.log(`⚠️ Port ${port} was taken while starting; picking another...`); + port = await chooseListenPort(requestedPort, { sibling: siblingStart }); + } + } + return { server, serverModule, port, readinessGate, localAttestationSecret, config }; + }, + writePid: () => writePid(process.pid), + writeRuntime: bound => writeRuntimePort({ + pid: process.pid, + port: bound.port, + hostname: bound.config.hostname, + attestationSecret: bound.localAttestationSecret, + }), + stopBound: bound => bound.server.stop(true), + removeRuntime: () => removeRuntimePortIfPidIs(process.pid), + removePid: () => removePidIfValueIs(process.pid), + }); + } catch (error) { + if (error instanceof StartCommandExit) { process.exitCode = error.exitCode; return; } + throw error; } - // A single request's streaming error must never crash the daemon serving every - // other Codex session — capture the full stack to crash.log and stay up. - installCrashGuards(); - writePid(process.pid); - const config = loadConfig(); - writeRuntimePort({ pid: process.pid, port, hostname: config.hostname, attestationSecret: localAttestationSecret }); + const { server, serverModule, port, readinessGate, config } = boundStart; + const { drainAndShutdown, isRecyclingForExit } = serverModule; + // Records are visible now; background work may observe this runtime without a gap. + scheduleCatalogPrewarm(); + installCrashGuards(); // No pre-emptive snapshot here. `injectCodexConfig` journals the exact bytes it // is about to transform; snapshotting earlier only captured a baseline that could // already be stale by the time injection ran (#477). @@ -926,6 +991,12 @@ async function restoreSharedClientStateAfterStop(): Promise<{ historyOnly: boole } async function handleStop() { + const lease = acquireOwnershipMutationLease(serviceStatePaths()); + try { return await handleStopUnlocked(); } + finally { lease.release(); } +} + +async function handleStopUnlocked() { // The receipt must name the endpoint the owner was stopping — an obligation nobody can // locate cannot be proven discharged. Only the runtime record knows it; a proxy started // with an explicit --port is not on the configured one. @@ -957,8 +1028,7 @@ async function handleStop() { // An obligation that cannot name its endpoint cannot be proven discharged. if (!endpoint) return false; try { - const { probeProxyLiveness } = await import("../update/proxy-liveness-probe.mjs"); - return probeProxyLiveness(endpoint.port, endpoint.hostname) === "dead"; + return await probeEndpointLiveness(endpoint) === "dead"; } catch { // A probe that could not run is not evidence of absence. return false; @@ -1372,11 +1442,10 @@ async function handleUninstall() { /** Definitive "nothing is answering" on the endpoint this home would serve. */ const proxyEndpointProvenDown = async (): Promise => { try { - const { probeProxyLiveness } = await import("../update/proxy-liveness-probe.mjs"); // Every candidate, not just the preferred one: a stale runtime record pointing at a // closed port would otherwise "prove" a live proxy on the configured port is gone. const endpoints = endpointsToProve(readRuntimePort(), loadConfig()); - return everyEndpointProvenDown(endpoints, e => probeProxyLiveness(e.port, e.hostname)); + return await everyEndpointProvenDownAsync(endpoints, probeEndpointLiveness); } catch { return false; } diff --git a/src/cli/resolve.ts b/src/cli/resolve.ts index a4dcc851645..87c3fa69174 100644 --- a/src/cli/resolve.ts +++ b/src/cli/resolve.ts @@ -37,9 +37,14 @@ import { readConfigDiagnostics, type ConfigDiagnostics } from "../config"; import { getConfigDir } from "../config/paths"; import { readRuntimePort } from "../config/process-state"; import { packageVersion } from "../lib/package-version"; -import { findLiveProxy, START_OWNERSHIP_LIVENESS, type LiveProxy } from "../server/proxy-liveness"; -import { endpointsToProve, everyEndpointProvenDown, type ProbeEndpoint } from "./uninstall-plan"; -import { probeProxyLiveness } from "../update/proxy-liveness-probe.mjs"; +import { + findLiveProxy, + probeEndpointLiveness, + START_OWNERSHIP_LIVENESS, + type EndpointLiveness, + type LiveProxy, +} from "../server/proxy-liveness"; +import { endpointsToProve, everyEndpointProvenDownAsync, type ProbeEndpoint } from "./uninstall-plan"; /** Wire version of the resolve document. Bump only on an incompatible shape change. */ export const RESOLVE_SCHEMA = "ocx-resolve/1"; @@ -103,8 +108,8 @@ export interface ResolveIo { findLive?: () => Promise; /** Runtime-port record reader; production default is readRuntimePort. */ readRuntime?: () => { port?: number; hostname?: string } | null; - /** Tri-state endpoint probe; production default is the updater's probeProxyLiveness. */ - probeEndpoint?: (endpoint: ProbeEndpoint) => "live" | "dead" | "unknown"; + /** Tri-state endpoint probe; production default runs in-process for compiled standalone binaries. */ + probeEndpoint?: (endpoint: ProbeEndpoint) => EndpointLiveness | Promise; cliVersion?: () => string; stdout?: { log: (s: string) => void }; stderr?: { error: (s: string) => void }; @@ -174,11 +179,7 @@ export async function runResolve(args: ResolveArgs, io: ResolveIo = {}): Promise const readDiagnostics = io.readDiagnostics ?? readConfigDiagnostics; const findLive = io.findLive ?? (() => findLiveProxy(START_OWNERSHIP_LIVENESS)); const readRuntime = io.readRuntime ?? readRuntimePort; - // The updater's tri-state probe takes (port, hostname) and is plain .mjs (untyped); - // adapt it to the endpoint-shaped seam here. Its own return vocabulary is the - // closed "live" | "dead" | "unknown" set. - const probeEndpoint = io.probeEndpoint - ?? ((endpoint: ProbeEndpoint) => probeProxyLiveness(endpoint.port, endpoint.hostname) as "live" | "dead" | "unknown"); + const probeEndpoint = io.probeEndpoint ?? probeEndpointLiveness; const cliVersion = io.cliVersion ?? packageVersion; const configHome = configDir(); let diagnostics: ConfigDiagnostics; @@ -212,7 +213,7 @@ export async function runResolve(args: ResolveArgs, io: ResolveIo = {}): Promise // authorise starting a second runtime. let provenDown = false; try { - provenDown = everyEndpointProvenDown(endpointsToProve(readRuntime(), diagnostics.config), probeEndpoint); + provenDown = await everyEndpointProvenDownAsync(endpointsToProve(readRuntime(), diagnostics.config), probeEndpoint); } catch { // A probe that cannot run is not evidence of absence. provenDown = false; diff --git a/src/cli/start-ownership-publication.ts b/src/cli/start-ownership-publication.ts new file mode 100644 index 00000000000..33cdb428655 --- /dev/null +++ b/src/cli/start-ownership-publication.ts @@ -0,0 +1,56 @@ +export interface StartOwnershipLease { + release(): void; +} + +export class StartOwnershipRollbackUncertainError extends AggregateError { + constructor(errors: Iterable) { + super(errors, "start listener rollback could not be proven complete"); + this.name = "StartOwnershipRollbackUncertainError"; + } +} + +export interface StartOwnershipPublicationDeps { + acquireLease(): StartOwnershipLease; + bind(): Promise; + writePid(bound: TBound): void; + writeRuntime(bound: TBound): void; + stopBound(bound: TBound): void | Promise; + removeRuntime(): void; + removePid(): void; +} + +/** Bind and publish PID/runtime ownership as one lease-protected transaction. */ +export async function bindAndPublishStartOwnership( + deps: StartOwnershipPublicationDeps, +): Promise { + const lease = deps.acquireLease(); + let bound: TBound; + let releaseLease = true; + try { + try { bound = await deps.bind(); } + catch (error) { + if (error instanceof StartOwnershipRollbackUncertainError) releaseLease = false; + throw error; + } + try { + deps.writePid(bound); + deps.writeRuntime(bound); + } catch (error) { + const failures: unknown[] = [error]; + let stopFailed = false; + try { await deps.stopBound(bound); } + catch (failure) { stopFailed = true; failures.push(failure); } + try { deps.removeRuntime(); } catch (failure) { failures.push(failure); } + try { deps.removePid(); } catch (failure) { failures.push(failure); } + if (stopFailed) { + releaseLease = false; + throw new StartOwnershipRollbackUncertainError(failures); + } + if (failures.length > 1) throw new AggregateError(failures, "start ownership publication rollback failed"); + throw error; + } + return bound; + } finally { + if (releaseLease) lease.release(); + } +} diff --git a/src/cli/status-probes.ts b/src/cli/status-probes.ts index d3848c95366..d4196ba5cff 100644 --- a/src/cli/status-probes.ts +++ b/src/cli/status-probes.ts @@ -1,5 +1,5 @@ import { readPidFileValue, readRuntimePort } from "../config/process-state"; -import { isOpencodexHealthz, probeHostname } from "../server/proxy-liveness"; +import { isConnectionRefused, isOpencodexHealthz, probeHostname } from "../server/proxy-liveness"; import { directLocalHttpFetch } from "../server/direct-local-http"; import { isProcessAlive } from "../lib/process-control"; @@ -26,23 +26,7 @@ export function proxyHealthFailureReason(error: unknown, signal: AbortSignal): " : "unreachable"; } -/** - * "Nothing is listening" is narrower than "the probe failed". `unreachable` covers every - * non-abort failure, including a socket that was ACCEPTED and then reset — which is what - * an in-flight start looks like mid-bind. Only a connect-phase refusal proves the port is - * free, so this reads the underlying errno instead of the display string. - */ -export function isConnectionRefused(error: unknown): boolean { - for (let current: unknown = error, depth = 0; current instanceof Error && depth < 4; depth++) { - const code = (current as { code?: unknown }).code; - if (code === "ECONNREFUSED" || code === "ConnectionRefused") return true; - // Bun surfaces the refusal as a plain message on some platforms; the errno name is - // still the discriminator, not a substring of arbitrary prose. - if (typeof code === "string" && code.endsWith("ECONNREFUSED")) return true; - current = (current as { cause?: unknown }).cause; - } - return false; -} +export { isConnectionRefused } from "../server/proxy-liveness"; /** * A proxy killed by a native trap or SIGKILL never runs the exit cleanup that removes diff --git a/src/cli/uninstall-plan.ts b/src/cli/uninstall-plan.ts index 0e1df1cd2df..4bfb9d4a7a6 100644 --- a/src/cli/uninstall-plan.ts +++ b/src/cli/uninstall-plan.ts @@ -84,3 +84,12 @@ export function everyEndpointProvenDown( if (endpoints.length === 0) return false; return endpoints.every(e => probe(e) === "dead"); } + +export async function everyEndpointProvenDownAsync( + endpoints: readonly ProbeEndpoint[], + probe: (e: ProbeEndpoint) => Promise<"live" | "dead" | "unknown"> | "live" | "dead" | "unknown", +): Promise { + if (endpoints.length === 0) return false; + const results = await Promise.all(endpoints.map(e => probe(e))); + return results.every(result => result === "dead"); +} diff --git a/src/codex/app-server-processes.ts b/src/codex/app-server-processes.ts index 355beaa9e50..a33a1091fd6 100644 --- a/src/codex/app-server-processes.ts +++ b/src/codex/app-server-processes.ts @@ -786,6 +786,58 @@ function catalogStatusFromProcesses( return { state: stale ? "stale" : "fresh", processes: withStarts, catalogMtimeMs }; } +interface ComputedCodexAppServerCatalogStatus { + status: CodexAppServerCatalogStatus; + processes: CodexAppServerProcess[]; +} + +/** + * Compute one catalog-state observation while retaining the command lines from the + * same process enumeration. The public collector deliberately exposes only the + * identity and timestamp projection; post-write warning code also needs the matched + * process records, and re-enumerating there creates a race between classification and + * reporting (as well as a second expensive Windows CIM walk). + */ +function computeCodexAppServerCatalogStatus( + io: CodexAppServerProcessIo, +): ComputedCodexAppServerCatalogStatus { + const platform = io.platform ?? process.platform; + const getuid = io.getuid ?? (() => { + try { + return typeof process.getuid === "function" ? process.getuid() : undefined; + } catch { + return undefined; + } + }); + let snapshots: ProcessSnapshot[]; + let enumerationFailed = false; + const enumerate = io.listSnapshots ?? (() => defaultListSnapshots(platform, getuid)); + try { + snapshots = enumerate(); + } catch { + // A failed process read is unknown, never proof that nothing is running. + snapshots = []; + enumerationFailed = true; + } + const processes = codexAppServerProcessesFromSnapshots(snapshots); + if (processes.length === 0) { + return { + processes, + status: enumerationFailed + ? { state: "unknown", processes: [], catalogMtimeMs: null } + : { state: "not_running", processes: [], catalogMtimeMs: null }, + }; + } + const catalogMtimeMs = (io.catalogMtimeMs ?? defaultCatalogMtimeMs)(); + const starts = io.readStartMs + ? new Map(processes.map(proc => [proc.pid, io.readStartMs!(proc.pid)] as const)) + : readProcessStartMsBatch(processes.map(proc => proc.pid), platform); + return { + processes, + status: catalogStatusFromProcesses(processes, catalogMtimeMs, starts), + }; +} + // Short TTL: process listing + stat run once per window even under per-turn // guidance calls (#857). let catalogStateCache: { atMs: number; status: CodexAppServerCatalogStatus } | null = null; @@ -889,41 +941,7 @@ export function collectCodexAppServerCatalogState( && now - catalogStateCache.atMs < catalogStateTtlMs(catalogStateCache.status.state)) { return catalogStateCache.status; } - const compute = (): CodexAppServerCatalogStatus => { - const platform = io.platform ?? process.platform; - const getuid = io.getuid ?? (() => { - try { - return typeof process.getuid === "function" ? process.getuid() : undefined; - } catch { - return undefined; - } - }); - let snapshots: ProcessSnapshot[]; - let enumerationFailed = false; - const enumerate = io.listSnapshots ?? (() => defaultListSnapshots(platform, getuid)); - try { - snapshots = enumerate(); - } catch { - // Enumeration failure must never read as "nothing running" — that would let - // positive model guidance through on guesswork (#857). The injected seam gets - // the same contract as the default path: whoever enumerates, a failure to read - // the process list is unknown, not an empty machine. - snapshots = []; - enumerationFailed = true; - } - const processes = codexAppServerProcessesFromSnapshots(snapshots); - if (processes.length === 0) { - return enumerationFailed - ? { state: "unknown", processes: [], catalogMtimeMs: null } - : { state: "not_running", processes: [], catalogMtimeMs: null }; - } - const catalogMtimeMs = (io.catalogMtimeMs ?? defaultCatalogMtimeMs)(); - const starts = io.readStartMs - ? new Map(processes.map(proc => [proc.pid, io.readStartMs!(proc.pid)] as const)) - : readProcessStartMsBatch(processes.map(proc => proc.pid), platform); - return catalogStatusFromProcesses(processes, catalogMtimeMs, starts); - }; - const status = compute(); + const status = computeCodexAppServerCatalogStatus(io).status; if (fullyDefault) { catalogStateCache = { atMs: now, status }; } @@ -1228,16 +1246,30 @@ export function afterCatalogWriteHandleAppServers( options: AfterCatalogWriteAppServerOptions, ): AfterCatalogWriteAppServerResult { const excluded = new Set(options.excludePids ?? []); + const hint = STALE_CODEX_APP_SERVER_HINT; + if (!options.restart) { + // A running process is not necessarily stale. Classify the exact process + // enumeration that supplies the warning, and stay quiet when freshness cannot be + // established rather than presenting an unknown observation as a known mismatch. + const observed = computeCodexAppServerCatalogStatus(options.io ?? {}); + const starts = new Map(observed.status.processes.map(process => [process.pid, process.startedAtMs])); + const catalogMtimeMs = observed.status.catalogMtimeMs; + const processes = observed.processes.filter(process => !excluded.has(process.pid)); + const staleProcesses = catalogMtimeMs === null + ? [] + : processes.filter(process => { + const startedAtMs = starts.get(process.pid); + return startedAtMs !== null && startedAtMs !== undefined && startedAtMs <= catalogMtimeMs; + }); + if (staleProcesses.length === 0) return { processes, warned: false, hint }; + options.log?.error(formatStaleCodexAppServerWarning(staleProcesses)); + return { processes: staleProcesses, warned: true, hint }; + } const processes = listCodexAppServerProcesses(options.io) .filter(process => !excluded.has(process.pid)); - const hint = STALE_CODEX_APP_SERVER_HINT; if (processes.length === 0) { return { processes, warned: false, hint }; } - if (!options.restart) { - options.log?.error(formatStaleCodexAppServerWarning(processes)); - return { processes, warned: true, hint }; - } options.log?.log( `Stopping Codex app-server process(es): ${processes.map(process => process.pid).join(", ")} ` + "(active turns may be interrupted).", diff --git a/src/providers/model-rename-migration.ts b/src/providers/model-rename-migration.ts index 0606fe3dcd9..0c66e803de3 100644 --- a/src/providers/model-rename-migration.ts +++ b/src/providers/model-rename-migration.ts @@ -45,6 +45,52 @@ export interface ModelRename { * but lose the reasoning picker entirely. */ dropReasoningEffortMap?: boolean; + /** + * Remove both the retired id and its replacement from `noReasoningModels`. + * + * Use this only when the rename also marks a capability change: carrying the old + * no-reasoning classification onto a newly adjustable alias would keep the picker + * disabled after the model id itself was repaired. + */ + dropNoReasoningModels?: boolean; + /** Refresh exact registry defaults already saved under the replacement id. */ + targetSeedRefresh?: { + contextWindow?: { from: number; to: number }; + reasoning?: { + fromEfforts: readonly string[]; + toEfforts: readonly string[]; + defaultEffort: string; + effortMap: Readonly>; + }; + }; +} + +const KIMI_K28_ALIAS = "kimi-for-coding"; +const KIMI_RETIRED_CODING_IDS = [ + "kimi-k2.7-code", + "kimi-k2.7-code-highspeed", + "kimi-k2.6", + "kimi-k2.5", +] as const; +const KIMI_K28_TARGET_SEED_REFRESH: NonNullable = { + contextWindow: { from: 262_144, to: 1_048_576 }, + reasoning: { + fromEfforts: [], + toEfforts: ["low", "high", "max"], + defaultEffort: "max", + effortMap: { none: "none", low: "low", medium: "high", high: "high", xhigh: "max", max: "max" }, + }, +}; + +function kimiCodingRenames(provider: "kimi" | "kimi-code", endpoint: string): ModelRename[] { + return KIMI_RETIRED_CODING_IDS.map(from => ({ + provider, + from, + to: KIMI_K28_ALIAS, + reason: `Moonshot retired the k2.x coding ids from the ${endpoint}; kimi-for-coding is the stable alias the endpoint still serves`, + dropNoReasoningModels: true, + targetSeedRefresh: KIMI_K28_TARGET_SEED_REFRESH, + })); } /** @@ -65,6 +111,14 @@ export const MODEL_RENAMES: readonly ModelRename[] = [ to: "qwen3.8-max", reason: "Alibaba shipped Qwen3.8-Max as stable and documents the preview endpoint as liable to be taken offline once preview concludes", }, + // Kimi coding renames. Moonshot retired the k2.x ids from the subscription/coding + // endpoint when K2.8 Preview shipped (live /coding/v1/models lists only + // kimi-for-coding[-highspeed], k3, k3-256k); kimi-for-coding is the stable alias the + // endpoint still serves and currently routes to K2.8 Preview. The registry picker no + // longer seeds the retired ids, so a saved defaultModel naming one is a dead selection + // rather than a merely outdated one. + ...kimiCodingRenames("kimi", "subscription endpoint after K2.8 Preview shipped"), + ...kimiCodingRenames("kimi-code", "coding endpoint after K2.8 Preview shipped"), // Antigravity Flash generations. Google takes the previous Flash model off Cloud Code // Assist almost immediately when the next ships, so a saved 3.6 (or older 3.5) id is a // dead selection rather than a merely outdated one. Routing already redirects these ids @@ -133,6 +187,18 @@ function renameInList(value: unknown, from: string, to: string): string[] | null return next; } +function dropRenamedIdsFromList( + value: unknown, + from: string, + to: string, + dropStaleTarget: boolean, +): string[] | null { + if (!Array.isArray(value)) return null; + const hasRetired = value.includes(from); + if (!hasRetired && !(dropStaleTarget && value.includes(to))) return null; + return value.filter(entry => typeof entry === "string" && entry !== from && entry !== to); +} + function renameInRecord(value: unknown, from: string, to: string): Record | null { if (!value || typeof value !== "object" || Array.isArray(value)) return null; const record = value as Record; @@ -160,6 +226,42 @@ function dropFromRecord(value: unknown, from: string): Record | return next; } +function sameStringArray(value: unknown, expected: readonly string[]): boolean { + return Array.isArray(value) + && value.length === expected.length + && value.every((entry, index) => entry === expected[index]); +} + +function targetReasoningMatchesStaleSeed(row: Record, rename: ModelRename): boolean { + const reasoning = rename.targetSeedRefresh?.reasoning; + const efforts = row.modelReasoningEfforts as Record | undefined; + return !!reasoning && !!efforts && sameStringArray(efforts[rename.to], reasoning.fromEfforts); +} + +/** Refresh only exact defaults emitted by the previous registry; preserve user overrides. */ +function refreshTargetSeed(row: Record, rename: ModelRename): boolean { + const refresh = rename.targetSeedRefresh; + if (!refresh) return false; + let changed = false; + + const windows = row.modelContextWindows as Record | undefined; + if (windows && refresh.contextWindow && windows[rename.to] === refresh.contextWindow.from) { + windows[rename.to] = refresh.contextWindow.to; + changed = true; + } + + const reasoning = refresh.reasoning; + const efforts = row.modelReasoningEfforts as Record | undefined; + if (!reasoning || !efforts || !sameStringArray(efforts[rename.to], reasoning.fromEfforts)) return changed; + efforts[rename.to] = [...reasoning.toEfforts]; + + const defaults = (row.modelDefaultReasoningEfforts ??= {}) as Record; + if (!(rename.to in defaults)) defaults[rename.to] = reasoning.defaultEffort; + const maps = (row.modelReasoningEffortMap ??= {}) as Record; + if (!(rename.to in maps)) maps[rename.to] = { ...reasoning.effortMap }; + return true; +} + /** * `provider/model` rows in the top-level `disabledModels` list. * @@ -281,7 +383,14 @@ export function projectModelRenames( let touched = false; for (const field of MODEL_ID_LISTS) { if (isRegistryResidue(seed, field, row[field], rename)) continue; - const next = renameInList(row[field], rename.from, rename.to); + const next = rename.dropNoReasoningModels && field === "noReasoningModels" + ? dropRenamedIdsFromList( + row[field], + rename.from, + rename.to, + targetReasoningMatchesStaleSeed(row, rename), + ) + : renameInList(row[field], rename.from, rename.to); if (!next) continue; row[field] = next; touched = true; @@ -300,6 +409,7 @@ export function projectModelRenames( touched = true; } if (renameDisabledModels(config, rename)) touched = true; + if (touched && refreshTargetSeed(row, rename)) touched = true; if (touched) { changed = true; diff --git a/src/providers/registry/entries-core.ts b/src/providers/registry/entries-core.ts index f16f9f15687..3a956081ddc 100644 --- a/src/providers/registry/entries-core.ts +++ b/src/providers/registry/entries-core.ts @@ -55,7 +55,7 @@ import { deepseekThinkingEffortsFor, deepseekReasoningMapFor, KIMI_K3_STANDARD_CONTEXT_WINDOW, - KIMI_CODING_MODELS, + KIMI_CODING_LIVE_MODELS, KIMI_THINKING_MODELS, KIMI_CODING_NO_REASONING_MODELS, KIMI_CODING_K3_REASONING_EFFORTS, @@ -444,8 +444,14 @@ export const PROVIDER_REGISTRY_CORE: readonly ProviderRegistryEntry[] = [ oauthId: "kimi", jawcodeBundle: "moonshot", note: "Log in with your Kimi account", - models: KIMI_CODING_MODELS, - defaultModel: "kimi-k2.7-code", + // 260921: the retired k2.x ids stay out of the picker — live /coding/v1/models lists + // only kimi-for-coding[-highspeed], k3, k3-256k. Saved rows still naming kimi-k2.7-code + // are repaired by MODEL_RENAMES in model-rename-migration.ts. + models: KIMI_CODING_LIVE_MODELS, + // 260921: kimi-k2.7-code was retired from the subscription endpoint (live /models lists + // only kimi-for-coding[-highspeed], k3, k3-256k). The kimi-for-coding alias is the + // stable ID and currently routes to K2.8 Preview. + defaultModel: "kimi-for-coding", modelContextWindows: KIMI_CODING_MODEL_CONTEXT_WINDOWS, modelInputModalities: KIMI_CODING_MODEL_INPUT_MODALITIES, // K3 accepts low/high/max; Codex aliases are normalized by the model-scoped wire map. @@ -1258,4 +1264,3 @@ export const PROVIDER_REGISTRY_CORE: readonly ProviderRegistryEntry[] = [ note: "Serverless Inference subscription API. Live discovery exposes only kimi-k2-instruct because Vultr documents it as the sole tool-calling model.", }, ]; - diff --git a/src/providers/registry/entries-extended.ts b/src/providers/registry/entries-extended.ts index 48ebd4242d7..3dc0ff10b46 100644 --- a/src/providers/registry/entries-extended.ts +++ b/src/providers/registry/entries-extended.ts @@ -72,10 +72,10 @@ import { VOLCENGINE_PLAN_TEXT_ONLY_MODELS, ALIBABA_INTL_TOKEN_PLAN_INPUT_MODALITIES, KIMI_API_MODELS, - KIMI_CODING_MODELS, KIMI_THINKING_MODELS, KIMI_CODING_NO_REASONING_MODELS, KIMI_API_NO_REASONING_MODELS, + KIMI_CODING_LIVE_MODELS, KIMI_CODING_REASONING_EFFORTS, KIMI_CODING_DEFAULT_REASONING_EFFORTS, KIMI_CODING_REASONING_EFFORT_MAPS, @@ -1013,13 +1013,17 @@ export const PROVIDER_REGISTRY_EXTENDED: readonly ProviderRegistryEntry[] = [ }, { id: "kimi-code", label: "Kimi (coding)", baseUrl: "https://api.kimi.com/coding/v1", adapter: "openai-chat", authKind: "key", - dashboardUrl: "https://platform.moonshot.cn/console/api-keys", defaultModel: "kimi-k2.7-code", + // 260921: kimi-k2.7-code was retired from the coding endpoint; the kimi-for-coding alias + // is the stable ID and currently routes to K2.8 Preview (same as the OAuth preset). + dashboardUrl: "https://platform.moonshot.cn/console/api-keys", defaultModel: "kimi-for-coding", modelSuffixBracketStrip: true, // API-key form of the same Kimi Code Plan transport; keep cache affinity identical to OAuth. promptCacheKey: true, // Keep Responses tool-result adjacency aligned with the OAuth preset (#4726). requiresAdjacentResponsesToolResults: true, - models: KIMI_CODING_MODELS, + // 260921: same live-id picker as the OAuth preset — the retired k2.x ids are repaired + // in saved configs by MODEL_RENAMES, not offered on fresh installs. + models: KIMI_CODING_LIVE_MODELS, modelContextWindows: KIMI_CODING_MODEL_CONTEXT_WINDOWS, modelInputModalities: KIMI_CODING_MODEL_INPUT_MODALITIES, noReasoningModels: KIMI_CODING_NO_REASONING_MODELS, diff --git a/src/providers/registry/model-seeds.ts b/src/providers/registry/model-seeds.ts index c3ee348b6ee..1d6884aabfb 100644 --- a/src/providers/registry/model-seeds.ts +++ b/src/providers/registry/model-seeds.ts @@ -642,11 +642,26 @@ export const ALIBABA_TOKEN_PLAN_PRESERVE_REASONING = [ export const KIMI_K3_STANDARD_CONTEXT_WINDOW = 262_144; export const KIMI_K3_1M_CONTEXT_WINDOW = 1_048_576; export const KIMI_CODING_K3_MODELS = ["k3", "k3[1m]"]; +// 260921 Kimi K2.8: `kimi-for-coding` is the stable subscription alias Moonshot re-points +// at each coding release. Live GET /coding/v1/models lists only kimi-for-coding[-highspeed], +// k3, k3-256k — the k2.x ids are retired from the subscription endpoint. Since K2.8 Preview +// the alias serves an adjustable low/high/max thinking ladder (same wire map as k3) and a +// 1M context ceiling. Verified live 260921: 350K-token request accepted; upstream rejects +// with "model token limit: 1048576" beyond that. +// Evidence: https://www.kimi.com/code/docs/en/kimi-code/models.html +export const KIMI_CODING_K28_MODELS = ["kimi-for-coding"]; +export const KIMI_CODING_LIVE_MODELS = [...KIMI_CODING_K3_MODELS, ...KIMI_CODING_K28_MODELS]; +export const KIMI_CODING_ADJUSTABLE_THINKING_MODELS = [...KIMI_CODING_LIVE_MODELS]; export const KIMI_LEGACY_API_MODELS = ["kimi-k2.7-code", "kimi-k2.7-code-highspeed", "kimi-k2.6", "kimi-k2.5"]; export const KIMI_API_MODELS = ["kimi-k3", ...KIMI_LEGACY_API_MODELS]; -export const KIMI_CODING_MODELS = [...KIMI_CODING_K3_MODELS, ...KIMI_LEGACY_API_MODELS, "kimi-for-coding"]; -export const KIMI_THINKING_MODELS = KIMI_CODING_MODELS; -export const KIMI_CODING_NO_REASONING_MODELS = KIMI_CODING_MODELS.filter(id => !KIMI_CODING_K3_MODELS.includes(id)); +// Every kimi coding preset record - picker, context windows, locked-parameter lists - +// derives from the live ids only. seeding a retired id in a metadata list would re-arm +// the model-rename migration on every boot (#5066): the list holds the retired id but +// not the live alias, so the residue guard cannot skip it. The retired ids survive only +// in KIMI_LEGACY_API_MODELS (moonshot platform API records); model-rename-migration +// repairs saved rows still naming them. +export const KIMI_THINKING_MODELS = KIMI_CODING_LIVE_MODELS; +export const KIMI_CODING_NO_REASONING_MODELS = KIMI_CODING_LIVE_MODELS.filter(id => !KIMI_CODING_ADJUSTABLE_THINKING_MODELS.includes(id)); export const KIMI_API_NO_REASONING_MODELS = KIMI_API_MODELS.filter(id => id !== "kimi-k3"); export const KIMI_CODING_K3_REASONING_EFFORTS = ["low", "high", "max"]; export const KIMI_CODING_K3_REASONING_EFFORT_MAP: Record = { @@ -658,19 +673,19 @@ export const KIMI_CODING_K3_REASONING_EFFORT_MAP: Record = { max: "max", }; export const KIMI_CODING_REASONING_EFFORTS = Object.fromEntries( - KIMI_CODING_MODELS.map(id => [id, KIMI_CODING_K3_MODELS.includes(id) ? KIMI_CODING_K3_REASONING_EFFORTS : []]), + KIMI_CODING_LIVE_MODELS.map(id => [id, KIMI_CODING_ADJUSTABLE_THINKING_MODELS.includes(id) ? KIMI_CODING_K3_REASONING_EFFORTS : []]), ); export const KIMI_CODING_DEFAULT_REASONING_EFFORTS = Object.fromEntries( - KIMI_CODING_K3_MODELS.map(id => [id, "max"]), + KIMI_CODING_ADJUSTABLE_THINKING_MODELS.map(id => [id, "max"]), ); export const KIMI_CODING_REASONING_EFFORT_MAPS = Object.fromEntries( - KIMI_CODING_K3_MODELS.map(id => [id, KIMI_CODING_K3_REASONING_EFFORT_MAP]), + KIMI_CODING_ADJUSTABLE_THINKING_MODELS.map(id => [id, KIMI_CODING_K3_REASONING_EFFORT_MAP]), ); export const KIMI_API_REASONING_EFFORTS = Object.fromEntries( KIMI_API_MODELS.map(id => [id, id === "kimi-k3" ? ["max"] : []]), ); -export const KIMI_LOCKED_PARAMETER_MODELS = KIMI_CODING_MODELS; -export const KIMI_AUTO_TOOL_CHOICE_ONLY_MODELS = ["kimi-k2.7-code", "kimi-k2.7-code-highspeed", "kimi-for-coding"]; +export const KIMI_LOCKED_PARAMETER_MODELS = KIMI_CODING_LIVE_MODELS; +export const KIMI_AUTO_TOOL_CHOICE_ONLY_MODELS = ["kimi-for-coding"]; export const KIMI_API_MODEL_CONTEXT_WINDOWS: Record = Object.fromEntries( KIMI_API_MODELS.map(id => [id, id === "kimi-k3" ? KIMI_K3_1M_CONTEXT_WINDOW : 262_144]), ); @@ -758,10 +773,10 @@ export const NVIDIA_NIM_NO_VISION_MODELS = [ "poolside/laguna-xs-2.1", "z-ai/glm-5.3", "z-ai/glm-5.2", ]; export const KIMI_CODING_MODEL_CONTEXT_WINDOWS: Record = Object.fromEntries( - KIMI_CODING_MODELS.map(id => [id, id === "k3[1m]" ? KIMI_K3_1M_CONTEXT_WINDOW : KIMI_K3_STANDARD_CONTEXT_WINDOW]), + KIMI_CODING_LIVE_MODELS.map(id => [id, (id === "k3[1m]" || KIMI_CODING_K28_MODELS.includes(id)) ? KIMI_K3_1M_CONTEXT_WINDOW : KIMI_K3_STANDARD_CONTEXT_WINDOW]), ); export const KIMI_CODING_MODEL_INPUT_MODALITIES = Object.fromEntries( - KIMI_CODING_K3_MODELS.map(id => [id, ["text", "image"]]), + KIMI_CODING_ADJUSTABLE_THINKING_MODELS.map(id => [id, ["text", "image"]]), ); export const NEURALWATT_REASONING_HISTORY_MODELS = [ "glm-5.3", "glm-5.3-short", "glm-5.3-flash", diff --git a/src/responses/bridge-search-replay-cache.ts b/src/responses/bridge-search-replay-cache.ts index 6de24cdce39..07213682c33 100644 --- a/src/responses/bridge-search-replay-cache.ts +++ b/src/responses/bridge-search-replay-cache.ts @@ -14,9 +14,9 @@ * what `appendBridgeSearchTurn` would have written onto a continuation leg, so a replayed turn * and a continued turn show the destination the same conversation. * - * Scope. Entries are keyed by the upstream destination in addition to the cell id. The cell id is - * a v4 UUID minted here, so it cannot collide across conversations, but an unscoped key would let - * a history replayed against a DIFFERENT provider resurrect a call that provider never made. + * Scope. Entries are keyed by the exact conversation and serving identity in addition to the cell + * id. The cell id is a v4 UUID minted here, but possession of a client-visible id is not authority + * to recover result text under another provider, model, destination, or credential. * * Bounds and privacy. Result text is web content the caller already received, but it is still * request-derived data: it lives in memory only, is never logged, serialized, or exported, and is @@ -26,7 +26,7 @@ * alone. Neither re-running the search nor inventing a result is an acceptable recovery. */ -import { reasoningReplayDestinationIdentity } from "./reasoning-replay-cache"; +import type { OcxReasoningReplayScopeRef } from "../types"; const MAX_ENTRIES = 64; const MAX_TOTAL_BYTES = 512 * 1024; @@ -58,14 +58,24 @@ let clockForTests: (() => number) | null = null; const now = (): number => clockForTests?.() ?? Date.now(); /** - * Identify the upstream destination a bridged search belongs to. + * Identify the exact conversation and upstream binding a bridged search belongs to. * - * Reuses the salted process-local destination digest the reasoning replay cache already defines, - * so both stores agree on what "the same upstream" means and neither invents a second notion of - * destination identity. + * The serving route binds this holder only after provider, model, and physical credential + * selection. A missing conversation or binding fails closed: a cell id is client-visible and is + * not itself authority to recover another request's retained result. */ -export function bridgeSearchReplayScope(baseUrl: string | undefined): string | undefined { - return reasoningReplayDestinationIdentity(baseUrl); +export function bridgeSearchReplayScope(scope: OcxReasoningReplayScopeRef | undefined): string | undefined { + const identity = scope?.current; + if (!scope?.clientPrincipalId || !scope.clientThreadId || !identity) return undefined; + return JSON.stringify([ + scope.clientPrincipalId, + scope.clientThreadId, + identity.providerName, + identity.providerDestinationIdentity, + identity.adapterName, + identity.modelId, + identity.credentialIdentity, + ]); } function keyFor(scope: string, cellItemId: string): string { diff --git a/src/responses/function-call-compat.ts b/src/responses/function-call-compat.ts index c4888cf1f7a..fc26675dc16 100644 --- a/src/responses/function-call-compat.ts +++ b/src/responses/function-call-compat.ts @@ -33,6 +33,38 @@ function namespaceOf(value: unknown): string | undefined { return typeof value === "string" && value !== "functions" ? value : undefined; } +/** + * Repair one provider spelling drift for Codex's flat shell bridge. + * + * This is schema-bound rather than a general alias: `input` is also a legitimate + * argument name for arbitrary caller and MCP tools. Only the exact bare + * `exec_command` declaration can establish that its sole string input has one faithful + * reading as the required string `cmd` member. + */ +function repairExecCommandInput( + argumentsText: string, + schema: FunctionCallRepairSchema, +): string { + if (schema.namespace !== undefined || schema.name !== "exec_command") return argumentsText; + const parameters = schema.parameters; + if (!isObject(parameters) || parameters.type !== "object" + || !Array.isArray(parameters.required) || !parameters.required.includes("cmd") + || !isObject(parameters.properties) || !isObject(parameters.properties.cmd) + || parameters.properties.cmd.type !== "string") return argumentsText; + let parsed: unknown; + try { + parsed = JSON.parse(argumentsText); + } catch { + return argumentsText; + } + if (!isObject(parsed)) return argumentsText; + const keys = Object.keys(parsed); + if (keys.length !== 1 || keys[0] !== "input" || typeof parsed.input !== "string") { + return argumentsText; + } + return JSON.stringify({ cmd: parsed.input }); +} + function selectorAllows( selector: unknown, lowered: unknown, @@ -131,7 +163,12 @@ function repairItem(item: unknown, schemas: FunctionCallRepairSchemas, completed if (unsafe) return item; } catch { return item; } } - const argumentsText = coerceIntegerToolArguments(raw || "{}", schema.parameters, schema.namespace ? undefined : schema.name); + const integerRepaired = coerceIntegerToolArguments( + raw || "{}", + schema.parameters, + schema.namespace ? undefined : schema.name, + ); + const argumentsText = repairExecCommandInput(integerRepaired, schema); return argumentsText === raw ? item : { ...item, arguments: argumentsText }; } diff --git a/src/server/index.ts b/src/server/index.ts index 5ab9dccb34f..61fbcec7d8a 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -201,12 +201,13 @@ import { detectInstall } from "../update/index"; import { createServeOptions, type ServerIngress } from "./index/serve-options"; import { createClaudeInterceptLifecycle } from "./index/claude-intercept-lifecycle"; import { inspectStartupOwnership, resolveInboundBodyLimitWithWarning, setStartupCacheInvalidationWrite, warnAgentTaskRecoveryStartup, warnPlaintextV2AgentMessagesStartup, type StartServerDeps } from "./index/startup-warnings"; -import { acquireSpendLedgerServerLifecycle, type SpendLedgerServerLifecycle } from "./index/spend-ledger-lifecycle"; +import { acquireSpendLedgerServerLifecycle, recordFailedStartRollback, type SpendLedgerServerLifecycle } from "./index/spend-ledger-lifecycle"; +export { waitForFailedStartRollback } from "./index/spend-ledger-lifecycle"; export function startServer(port?: number, deps: StartServerDeps = {}): Server { const spendLedgerLifecycle = acquireSpendLedgerServerLifecycle(getConfigDir()); try { return startServerWithSpendLedgerOwner(port, deps, spendLedgerLifecycle); } - catch (error) { spendLedgerLifecycle.releaseAfterFailedStart(); throw error; } + catch (error) { recordFailedStartRollback(error, spendLedgerLifecycle.releaseAfterFailedStart()); throw error; } } function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartServerDeps, spendLedgerLifecycle: SpendLedgerServerLifecycle): Server { @@ -777,16 +778,16 @@ function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartSe } }, ], - async () => { + async listenersStopped => { try { await backgroundLifecycle.release(); await releaseNativeMainStartupLifecycle(server); } finally { // icacls.exe from hardenConfigDir() holds the config dir open; a caller that // removes the dir right after stop() settles would hit EPERM/EBUSY on Windows - // otherwise. Runs even when an earlier release rejected — that rejection still - // propagates, but not before the child is drained. - try { spendLedgerLifecycle.release(); } + // otherwise. Config hardening still flushes when an earlier release rejects. The + // spend owner is retained when a listener stop failed because the socket may live. + try { if (listenersStopped) spendLedgerLifecycle.release(); } finally { await flushConfigDirHardening(startupConfigDir); } } }, diff --git a/src/server/index/spend-ledger-lifecycle.ts b/src/server/index/spend-ledger-lifecycle.ts index d7250642470..ddcc70a6a3b 100644 --- a/src/server/index/spend-ledger-lifecycle.ts +++ b/src/server/index/spend-ledger-lifecycle.ts @@ -8,11 +8,27 @@ import { spendPolicyFromConfig, } from "../../lib/spend-reservation-ledger"; +const failedStartRollbacks = new WeakMap>(); + +export function recordFailedStartRollback(error: unknown, rollback: Promise): void { + if ((typeof error === "object" && error !== null) || typeof error === "function") { + failedStartRollbacks.set(error, rollback); + } +} + +/** Keep an outer ownership lease until a synchronous start failure has closed every listener. */ +export function waitForFailedStartRollback(error: unknown): Promise { + if ((typeof error === "object" && error !== null) || typeof error === "function") { + return failedStartRollbacks.get(error) ?? Promise.resolve(); + } + return Promise.resolve(); +} + export interface SpendLedgerServerLifecycle { configure(spend: OcxSpendConfig | undefined): void; track }>(server: T): T; release(): void; - releaseAfterFailedStart(): void; + releaseAfterFailedStart(): Promise; } /** Acquire before config loading so every later startup failure has one rollback owner. */ @@ -39,28 +55,38 @@ export function acquireSpendLedgerServerLifecycle(configDir: string): SpendLedge return server; }, release, - releaseAfterFailedStart(): void { + releaseAfterFailedStart(): Promise { // Every listener that came up is stopped, newest first, and the lease is held until those // stops have actually SETTLED. Bun's Server.stop(true) returns a promise that resolves // once connections are closed, so discarding it handed the state directory back while a // listener could still be serving, which is the one thing single-writer ownership exists // to prevent. // - // This stays synchronous and returns void on purpose: startServer must not become async, - // so the wait is a continuation rather than an await. Rollback failures are contained - // because the startup error that brought us here is the one worth reporting. + // Listener shutdown starts synchronously, while the returned promise lets a caller that + // owns a broader mutation lease keep it until every close has settled. startServer itself + // remains synchronous. Rollback failures are reported beside the startup error by the + // outer ownership transaction, without claiming the listener is gone. const settling: Promise[] = []; + const failures: unknown[] = []; for (const stop of failedStartStops.splice(0).reverse()) { try { const pending = stop(); if (pending !== undefined) settling.push(Promise.resolve(pending)); - } catch { /* a rollback failure must not replace the startup error that caused it */ } + } catch (failure) { failures.push(failure); } } const finish = (): void => { try { release(); } catch { /* same: the startup error is the one that matters */ } }; - if (settling.length === 0) { finish(); return; } - void Promise.allSettled(settling).then(finish); + if (settling.length === 0) { + if (failures.length > 0) return Promise.reject(new AggregateError(failures, "failed-start listener rollback was uncertain")); + finish(); + return Promise.resolve(); + } + return Promise.allSettled(settling).then(results => { + for (const result of results) if (result.status === "rejected") failures.push(result.reason); + if (failures.length > 0) throw new AggregateError(failures, "failed-start listener rollback was uncertain"); + finish(); + }); }, }; } diff --git a/src/server/lifecycle.ts b/src/server/lifecycle.ts index c3ab23e641b..fc757cc0670 100644 --- a/src/server/lifecycle.ts +++ b/src/server/lifecycle.ts @@ -357,12 +357,12 @@ export function getServerListenPort(): number | undefined { * caller sees the same result before a replacement binds the port. Swallowing it would let * `drainAndShutdown` report success while a socket is still held. * - * `always` runs after the listeners regardless of their outcome, and its own failure joins the - * reported set rather than replacing it. + * `always` runs after the listeners regardless of their outcome and receives whether every + * listener stop succeeded. Its own failure joins the reported set rather than replacing it. */ export async function runListenerShutdown( steps: Array<() => Promise>, - always: () => Promise, + always: (listenersStopped: boolean) => Promise, ): Promise { const failures: unknown[] = []; // Close admission and start connection-owner cleanup before waiting for any drain. @@ -372,7 +372,7 @@ export async function runListenerShutdown( if (result.status === "rejected") failures.push(result.reason); } try { - await always(); + await always(failures.length === 0); } catch (error) { failures.push(error); } diff --git a/src/server/proxy-liveness.ts b/src/server/proxy-liveness.ts index 594be867b02..98202e42d86 100644 --- a/src/server/proxy-liveness.ts +++ b/src/server/proxy-liveness.ts @@ -32,6 +32,8 @@ export interface HealthzIdentity { guiPairCapability?: unknown; } +export type EndpointLiveness = "live" | "dead" | "unknown"; + export interface LivenessIo { fetchFn?: typeof fetch; readPidFn?: () => number | null; @@ -85,6 +87,11 @@ export const START_OWNERSHIP_LIVENESS: Pick Promise; + export interface LiveProxy { pid: number | null; port: number; @@ -148,6 +155,74 @@ export function isOpencodexHealthz(body: HealthzIdentity | null): boolean { return body.status === "ok" && typeof body.version === "string" && typeof body.uptime === "number"; } +/** + * "Nothing is listening" is narrower than "the probe failed". Only a connect-phase refusal + * proves the endpoint is free; a timeout, reset, or other transport failure leaves the + * question open. + */ +export function isConnectionRefused(error: unknown): boolean { + const visit = (current: unknown, depth: number): boolean => { + if (depth >= 4) return false; + if (current === null || (typeof current !== "object" && typeof current !== "function")) return false; + const record = current as { code?: unknown; cause?: unknown; errors?: unknown }; + if (record.code === "ECONNREFUSED" || record.code === "ConnectionRefused") return true; + if (typeof record.code === "string" && record.code.endsWith("ECONNREFUSED")) return true; + if (Array.isArray(record.errors) && record.errors.length > 0) { + // One connect attempt fanned out over several addresses reports a single AggregateError. + // Only a unanimous refusal proves the endpoint is free: a bundle that mixes ECONNREFUSED + // with a timeout means one address answered nothing at all, and an address whose state is + // unreadable is unknown, not absence. Collapsing it to "refused" is how a second runtime + // gets started on a port that already has one. + return record.errors.every(error => visit(error, depth + 1)); + } + return visit(record.cause, depth + 1); + }; + return visit(error, 0); +} + +async function classifyHealthz( + url: string, + fetchFn: LivenessFetch, + timeoutMs: number, +): Promise { + try { + const response = await fetchFn(url, { signal: AbortSignal.timeout(timeoutMs) }); + if (response.status !== 200) return "unknown"; + const body = (await response.json().catch(() => undefined)) as HealthzIdentity | null | undefined; + if (body === undefined) return "unknown"; + return isOpencodexHealthz(body) ? "live" : "dead"; + } catch (error) { + return isConnectionRefused(error) ? "dead" : "unknown"; + } +} + +/** + * Tri-state probe of one endpoint, the in-process counterpart of + * `src/update/proxy-liveness-probe.mjs`. Only a connect-phase refusal or a clean 200 that is + * not ours proves "dead"; a timeout, reset, non-200 or unreadable body leaves the question + * open. Loopback endpoints are checked on both IPv4 and IPv6 because a listener may bind only + * one family. Runs in-process because a compiled standalone binary cannot fork `execPath -e`. + */ +export async function probeEndpointLiveness( + endpoint: { port: number; hostname?: string }, + io: Pick = {}, +): Promise { + if (!Number.isFinite(endpoint.port) || endpoint.port <= 0 || endpoint.port > 65535) return "dead"; + const fetchFn = io.fetchFn ?? directLocalHttpFetch; + const timeoutMs = io.timeoutMs ?? 1500; + let sawUnknown = false; + for (const hostname of loopbackProbeHosts(endpoint.hostname)) { + const result = await classifyHealthz( + `http://${hostname}:${endpoint.port}/healthz`, + fetchFn, + timeoutMs, + ); + if (result === "live") return "live"; + if (result === "unknown") sawUnknown = true; + } + return sawUnknown ? "unknown" : "dead"; +} + /** Identity-checked /healthz probe; null when unreachable, non-OK, or not our proxy. */ export async function proxyIdentityAt( port: number, diff --git a/src/server/responses/passthrough-delivery.ts b/src/server/responses/passthrough-delivery.ts index 969cd84e589..b4941fbd6dd 100644 --- a/src/server/responses/passthrough-delivery.ts +++ b/src/server/responses/passthrough-delivery.ts @@ -417,10 +417,9 @@ export async function deliverPassthroughResponse( describeImages: requiresVisionPreprocessing(config, route.provider, route.modelId, route.providerName), sidecar: config.webSearchSidecar, }), - // Scope the executed-search memo to this exact upstream (#4587). The Responses adapter - // derives the same scope from the same base URL before the NEXT turn is dispatched, so - // a replayed hosted cell can be turned back into the destination's own call and result. - destinationScope: bridgeSearchReplayScope(route.provider.baseUrl), + // Snapshot the bound conversation, provider, model, destination, and credential. The + // next turn must match every dimension before its hosted cell can recover this result. + destinationScope: bridgeSearchReplayScope(parsed._reasoningReplayScope), // Appending a search result can push the continuation past the ceiling the first leg // was admitted under, so the same limit is re-applied before every later send. checkOutboundBody: (continuationBody: string) => { diff --git a/src/server/responses/request-prepare.ts b/src/server/responses/request-prepare.ts index 867f816414d..c9f04d73d73 100644 --- a/src/server/responses/request-prepare.ts +++ b/src/server/responses/request-prepare.ts @@ -20,6 +20,7 @@ import { sessionIdHeaderFromRequest, reasoningReplayConversationIdFromResponsesRequest, } from "../request-log-conversation"; +import { contextPrincipalIdOf } from "../auth-cors"; import { isShadowSourceModel, shadowSourceModelPrefix, @@ -407,6 +408,11 @@ export async function prepareResponsesRequest( parsed._reasoningReplayScope = { clientThreadId: reasoningReplayConversationId }; } } + if (parsed._reasoningReplayScope) { + const clientPrincipalId = contextPrincipalIdOf(options.admission) + ?? (options.admission?.kind === "loopback" ? "loopback" : undefined); + parsed._reasoningReplayScope = { ...parsed._reasoningReplayScope, clientPrincipalId }; + } // Prefer a pre-populated id (routed Claude) over Responses headers that may be // absent or synthetically injected (session_id from prompt_cache_key). if (!logCtx.conversationId) { diff --git a/src/server/responses/request-transport.ts b/src/server/responses/request-transport.ts index e6834c7e941..2b302402f38 100644 --- a/src/server/responses/request-transport.ts +++ b/src/server/responses/request-transport.ts @@ -445,6 +445,11 @@ export async function prepareResponsesTransport( return response; } const nextAdapter = await refreshDispatchAdapter(requestParsed); + // Rebind before rebuilding: the rebuild's bridged-search restore and continuation + // restore key on the serving identity, which must be the refreshed route's, not the + // credential whose selection just lapsed. + bindRouteReasoningReplayScope({ parsed: requestParsed, providerName: route.providerName, provider: route.provider, + adapterName: nextAdapter.name, oauthCredentialSnapshot: replayOAuthCredentialSnapshot }); const rebuilt = await nextAdapter.buildRequest(requestParsed, { headers: requestState.selectedForwardHeaders, translatorBudget, ...(imageTierBias > 0 ? { imageTierBias } : {}), @@ -467,8 +472,6 @@ export async function prepareResponsesTransport( sameTargetToken = transportToken; destination = rebuilt.url; dispatchInit = { ...dispatchInit, method: rebuilt.method, headers, body: rebuilt.body }; - bindRouteReasoningReplayScope({ parsed: requestParsed, providerName: route.providerName, provider: route.provider, - adapterName: nextAdapter.name, oauthCredentialSnapshot: replayOAuthCredentialSnapshot }); // The next iteration validates synchronously and calls fetch in that same turn. } throw new Error("OAuth account selection changed repeatedly before dispatch"); diff --git a/src/service.ts b/src/service.ts index ebeca0258cd..79cfff6efda 100644 --- a/src/service.ts +++ b/src/service.ts @@ -6,8 +6,12 @@ * restore it via the command. */ -export type { ServiceBackend, ServiceInstallState, ServiceStateEvidence, ServiceOwner, ServiceOwnership, ServiceOwnershipResolution, ServiceStateSwapDeps } from "./service/state"; -export { SERVICE_MANAGED_ENV, stableLauncherEntry, serviceLogPath, serviceStatePathsForOpenCodexHome, parseServiceInstallState, parseServiceOwnership, inspectServiceStateEvidence, currentServiceHomes, serviceHomeMatches, readServiceBackend, serviceReinstallArgs, serviceInstallArgs, ServiceStateConflictError, swapServiceInstallState, serviceOwnership, resolveServiceOwnership, desktopOwnsService, ownershipGrantedTo, recordServiceOwner, releaseServiceOwner } from "./service/state"; +export type { ServiceBackend, ServiceInstallState, ServiceStateEvidence, ServiceStateResolution, ServiceOwner, ServiceOwnership, ServiceOwnershipSubject, ServiceOwnershipResolution, ServiceStateSwapDeps, RecordServiceOwnerRequest, RecordServiceOwnerDeps, ReleaseServiceOwnerDeps, RemoveServiceStateDeps } from "./service/state"; +export { SERVICE_MANAGED_ENV, SERVICE_OWNERSHIP_PROTOCOL_VERSION, SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION, stableLauncherEntry, serviceLogPath, serviceStatePaths, serviceStatePathsForOpenCodexHome, parseServiceInstallState, parseServiceOwnership, inspectServiceStateEvidence, resolveServiceState, currentServiceHomes, serviceHomeMatches, readServiceBackend, serviceReinstallArgs, serviceInstallArgs, ServiceStateConflictError, ServiceOwnershipSubjectMismatchError, ServiceOwnershipSubjectUnknownError, ServiceTakeoverCompatibilityChangedError, swapServiceInstallState, removeServiceInstallStateRecords, serviceOwnership, resolveServiceOwnership, sameServiceOwnershipSubject, desktopOwnsService, ownershipGrantedTo, recordServiceOwner, releaseServiceOwner } from "./service/state"; +export type { OwnershipMutationLeaseOptions, OwnershipMutationLease } from "./service/ownership-mutation-lease.mjs"; +export { acquireOwnershipMutationLease, withOwnershipMutationLease } from "./service/ownership-mutation-lease.mjs"; +export type { ManagingCliRole, ManagingCliObservation, RegisteredManagingCliInvocation, ServiceTakeoverCompatibilityInput, ServiceTakeoverCompatibility } from "./service/ownership-compatibility"; +export { registeredManagingCliInvocation, assessServiceTakeoverCompatibility, sameServiceTakeoverCompatibility } from "./service/ownership-compatibility"; export type { ServiceApiTokenOrigin, ProvisionedServiceApiToken } from "./service/guards"; export { ServiceOwnershipError, isServiceOwnershipError, serviceEnvironmentOwnedHere, assertServiceEnvironmentMatchesInstall, serviceRetryCommand, assertNotAdminToken, assertServiceAuthEnvironment, writeServiceApiTokenFile, assertLiveServiceManagerAllowed } from "./service/guards"; export { resolveServiceListenPort, installedServiceListenPort, SERVICE_INSTALL_HEALTH_MS, SERVICE_INSTALL_HEALTH_WINDOWS_MS, serviceInstallHealthMs, confirmServiceServing, reportServiceServing, resolvedProxyEnv } from "./service/health"; diff --git a/src/service/cli.ts b/src/service/cli.ts index 2dbce4599d3..1322b9948b4 100644 --- a/src/service/cli.ts +++ b/src/service/cli.ts @@ -234,6 +234,15 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise string): InstallStateEvidence; -export declare function resolveOwnershipFromEvidence( - evidence: readonly { path: string; kind: string; reason?: string; state?: unknown }[], -): OwnershipResolution; -export declare function serviceStateFilesFor(opencodexHomeDir: string, defaultHomeDir: string): string[]; +export type OwnershipResolution = + | { readonly kind: "none"; readonly revision: number; readonly needsRepair?: boolean } + | { readonly kind: "owned"; readonly ownership: { owner: string; installId: string; consentGeneration: number }; readonly revision: number } + | { readonly kind: "unknown"; readonly reason: string }; +export declare const SERVICE_STATE_FILE: "service-state.json"; +export declare function parseOwnershipClaim(value: unknown): import("./state-record.mjs").ServiceOwnershipRecord | null; +export declare function parseInstallStateRecord(value: unknown): import("./state-record.mjs").ServiceInstallStateRecord | null; +export declare function inspectInstallStateBytes(path: string, read: (path: string) => string): ServiceStateRecordEvidence; +export declare function resolveOwnershipFromEvidence(evidence: readonly ServiceStateRecordEvidence[]): OwnershipResolution; +export declare function serviceStateFilesFor(opencodexHomeDir: string, defaultHomeDir: string, platform?: NodeJS.Platform): string[]; diff --git a/src/service/install-state-contract.mjs b/src/service/install-state-contract.mjs index b07cb7cc901..b9baafa6186 100644 --- a/src/service/install-state-contract.mjs +++ b/src/service/install-state-contract.mjs @@ -1,141 +1,34 @@ -/** - * The service install-state contract, shared by both runtimes. - * - * `src/service/state.ts` is the authoritative reader and `bin/ocx.mjs` is the Node launcher - * that cannot import TypeScript. They used to validate the record separately, and the - * launcher's copy was weaker in two ways that mattered: it inspected only the anchor path, - * and it returned "known unowned" for any record whose `ownership` field was simply absent — - * including a record that fails the contract outright, such as one with no homes or an - * unsupported version. A takeover the Bun updater refused to disturb was therefore fair game - * for the npm and pnpm lane. - * - * This module is the one algorithm. Both sides import it, so the two lanes cannot answer the - * same question differently. - */ -import { join, resolve } from "node:path"; +/** Compatibility surface for the shared install-state contract landed before C2 hardening. */ +import { + inspectServiceStateRecords, + parseServiceInstallStateRecord, + parseServiceOwnershipRecord, + SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION, + SERVICE_OWNERSHIP_PROTOCOL_VERSION, + selectAuthoritativeServiceState, + serviceStateFingerprint, + serviceStatePathsForHomes, +} from "./state-record.mjs"; -function isNonNegativeInteger(value) { - return typeof value === "number" && Number.isInteger(value) && value >= 0; -} - -/** - * Validate an ownership claim read off disk. - * - * Returns the ORIGINAL object rather than a rebuilt one: a newer writer may carry fields - * this version does not know about, and rebuilding would drop them on the next preserve — - * the same lost-field failure the record exists to stop. - */ -export function parseOwnershipClaim(value) { - if (!value || typeof value !== "object" || Array.isArray(value)) return null; - if (value.owner !== "cli" && value.owner !== "desktop") return null; - if (typeof value.installId !== "string" || value.installId.length === 0) return null; - if (!isNonNegativeInteger(value.consentGeneration)) return null; - return value; -} - -/** Validate a whole install record. Null means the bytes are not a record this tree wrote. */ -export function parseInstallStateRecord(value) { - if (!value || typeof value !== "object" || Array.isArray(value)) return null; - if (value.version !== 1 && value.version !== 2) return null; - if (typeof value.codexHome !== "string" || value.codexHome.length === 0) return null; - if (typeof value.opencodexHome !== "string" || value.opencodexHome.length === 0) return null; - for (const key of ["codexSqliteHome", "bunPath", "launcherPath", "winswVersion", "winswSha256"]) { - if (value[key] !== undefined && (typeof value[key] !== "string" || value[key].length === 0)) return null; - } - // cliPath is the one path that is legitimately null: cliEntry() returns null for a - // standalone binary, and the writer stores it. - if (value.cliPath !== undefined && value.cliPath !== null - && (typeof value.cliPath !== "string" || value.cliPath.length === 0)) return null; - if (value.revision !== undefined && !isNonNegativeInteger(value.revision)) return null; - if (value.consentGenerationCeiling !== undefined && !isNonNegativeInteger(value.consentGenerationCeiling)) return null; - // A malformed ownership claim invalidates the whole record instead of being dropped: - // silently discarding it is precisely the demotion this field exists to prevent, and a - // reader that cannot trust the claim must not be told the runtime is unowned. - if (value.ownership !== undefined && parseOwnershipClaim(value.ownership) === null) return null; - if (value.version === 1) { - if (value.backend !== undefined) return null; - } else if (value.backend !== "scheduler" && value.backend !== "native") { - return null; - } - return value; -} +export const SERVICE_STATE_FILE = "service-state.json"; +export const parseOwnershipClaim = parseServiceOwnershipRecord; +export const parseInstallStateRecord = parseServiceInstallStateRecord; +export const serviceStateFilesFor = serviceStatePathsForHomes; +export { + SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION, + SERVICE_OWNERSHIP_PROTOCOL_VERSION, + selectAuthoritativeServiceState, + serviceStateFingerprint, +}; -/** - * Classify one state path's bytes. `read` returns the text, or throws; an ENOENT throw is - * absence and every other throw is a failure to ask. - * - * Absent, unreadable and invalid are three different answers. Collapsing them is how a - * locked-down or truncated record becomes permission to reactivate the npm launcher. - */ export function inspectInstallStateBytes(path, read) { - let raw; - try { - raw = read(path); - } catch (error) { - const code = error && typeof error === "object" && "code" in error ? String(error.code) : ""; - if (code === "ENOENT") return { path, kind: "absent" }; - return { path, kind: "unreadable", reason: code || String(error) }; - } - let parsed; - try { - parsed = JSON.parse(raw); - } catch { - return { path, kind: "invalid" }; - } - const state = parseInstallStateRecord(parsed); - return state ? { path, kind: "valid", state } : { path, kind: "invalid" }; + return inspectServiceStateRecords([path], read)[0]; } -/** - * What every state path, together, says about who owns the runtime. - * - * An unknown resolution is the answer that matters. A single null for "absent, unreadable or - * malformed" lets a caller read a permissions error as "the CLI owns it" and re-enable the - * npm launcher over a consented takeover. Absence is the only thing that may mean no claim. - */ export function resolveOwnershipFromEvidence(evidence) { - for (const entry of evidence) { - // Any path. A claim we are not allowed to look at is still a claim. - if (entry.kind === "unreadable") { - return { kind: "unknown", reason: `a service state path could not be read (${entry.reason})` }; - } - } - // Only the ANCHOR's corruption is fatal. The second path is the legacy default-home entry - // kept so an install made before OPENCODEX_HOME existed can still be found; unrelated junk - // left there by an old version must not be able to block every repair on this machine. - if (evidence[0] && evidence[0].kind === "invalid") { - return { kind: "unknown", reason: "the service install record is present but not valid" }; - } - const claims = []; - for (const entry of evidence) { - if (entry.kind === "valid" && entry.state.ownership) claims.push(entry.state.ownership); - } - const first = claims[0]; - if (first === undefined) return { kind: "none" }; - if (claims.some(claim => claim.owner !== first.owner || claim.installId !== first.installId)) { - return { kind: "unknown", reason: "the service state paths name different owners" }; - } - // Same claim in both places; the higher generation is the later write. - let best = first; - for (const claim of claims) if (claim.consentGeneration > best.consentGeneration) best = claim; - return { kind: "owned", ownership: best }; + const selected = selectAuthoritativeServiceState(evidence); + if (selected.kind === "unknown" || selected.kind === "none") return selected; + return selected.state.ownership + ? { kind: "owned", ownership: selected.state.ownership, revision: selected.revision } + : { kind: "none", revision: selected.revision }; } - -export const SERVICE_STATE_FILE = "service-state.json"; - -/** - * The state files to consult, in the order every reader resolves them: this OpenCodex home - * first, then the legacy default home kept for installs made before OPENCODEX_HOME existed. - * - * Shared so the launcher cannot inspect a shorter list than the authoritative reader — which - * it did, seeing only the anchor and never the legacy claim beside it. - */ -export function serviceStateFilesFor(opencodexHomeDir, defaultHomeDir) { - const anchor = join(opencodexHomeDir, SERVICE_STATE_FILE); - const legacy = join(defaultHomeDir, SERVICE_STATE_FILE); - const same = process.platform === "win32" - ? resolve(anchor).toLowerCase() === resolve(legacy).toLowerCase() - : resolve(anchor) === resolve(legacy); - return same ? [anchor] : [anchor, legacy]; -} - diff --git a/src/service/orchestration.ts b/src/service/orchestration.ts index 9a40ba2479e..92f374b3c34 100644 --- a/src/service/orchestration.ts +++ b/src/service/orchestration.ts @@ -11,7 +11,7 @@ import type { ServiceDiagnostic } from "./diagnostics"; import { assertServiceEnvironmentMatchesInstall } from "./guards"; import { runLaunchctl, launchdEvictionTargets, launchctlBootoutBenign, probeLaunchdLoadState, installLaunchd, startLaunchd, stopLaunchd, statusLaunchd, uninstallLaunchd } from "./launchd"; import { assertSchedulerRegistrationBeforeStart } from "./repair"; -import { SERVICE_MANAGED_ENV, TASK, plistPath, serviceStatePaths, writeServiceInstallState } from "./state"; +import { SERVICE_MANAGED_ENV, TASK, plistPath, removeServiceInstallStateRecords, writeServiceInstallState } from "./state"; import type { ServiceBackend } from "./state"; import { unitPath, isSystemd, installSystemd, startSystemd, stopSystemd, statusSystemd, uninstallSystemd, systemdServiceInstallCleanupOps } from "./systemd"; import { writeWindowsSchedulerAssets, stageWindowsSchedulerRegistrationXml, removeWindowsSchedulerRegistrationStage, registerFreshWindowsSchedulerTask, recordWindowsSchedulerOwnership, removeNativeWindowsServiceForScheduler, installWindows, installWindowsNative, startWindows, isWindowsSchedulerEndBenign, stopWindows, stopWindowsChecked, statusWindows, statusWindowsXml, killWindowsServiceWrapperProcesses, uninstallWindows, classifyWindowsServiceStop } from "./windows-ops"; @@ -527,9 +527,7 @@ export function stopServiceIfInstalledDetailed(): ServiceStopOutcome { /** Delete install-state files; stale state would make `ocx update` "reinstall" a service that no longer exists. */ export function removeServiceInstallState(): void { - for (const path of serviceStatePaths()) { - try { if (existsSync(path)) unlinkSync(path); } catch { /* best-effort */ } - } + removeServiceInstallStateRecords(); } type UninstallServiceHooksForTests = { diff --git a/src/service/ownership-compatibility.ts b/src/service/ownership-compatibility.ts new file mode 100644 index 00000000000..fe089e44758 --- /dev/null +++ b/src/service/ownership-compatibility.ts @@ -0,0 +1,164 @@ +import { createHash } from "node:crypto"; +import { isAbsolute } from "node:path"; +import { parseStrictSemver } from "../lib/strict-semver"; +import type { + ServiceInstallState, + ServiceOwnershipSubject, +} from "./state"; +import { + SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION, + SERVICE_OWNERSHIP_PROTOCOL_VERSION, +} from "./install-state-contract.mjs"; + +export type ManagingCliRole = "service-registration" | "path"; + +export type ManagingCliObservation = + | { readonly status: "absent" } + | { readonly status: "unknown"; readonly reason: string } + | { readonly status: "observed"; readonly version: string; readonly identity: string }; + +export type RegisteredManagingCliInvocation = + | { readonly status: "absent" } + | { readonly status: "unknown"; readonly reason: string } + | { readonly status: "resolved"; readonly executable: string; readonly args: readonly string[] }; + +/** Resolve the exact command baked into the preserved service registration. */ +export function registeredManagingCliInvocation( + state: ServiceInstallState | null, +): RegisteredManagingCliInvocation { + if (!state) return { status: "absent" }; + if (state.launcherPath) { + return isAbsolute(state.launcherPath) + ? { status: "resolved", executable: state.launcherPath, args: [] } + : { status: "unknown", reason: "the recorded service launcher is not absolute" }; + } + if (!state.bunPath || !isAbsolute(state.bunPath)) { + return { status: "unknown", reason: "the registered service executable is missing or not absolute" }; + } + if (state.cliPath === null) return { status: "resolved", executable: state.bunPath, args: [] }; + if (typeof state.cliPath === "string" && isAbsolute(state.cliPath)) { + return { status: "resolved", executable: state.bunPath, args: [state.cliPath] }; + } + return { status: "unknown", reason: "the registered service CLI path is missing or not absolute" }; +} + +export interface ServiceTakeoverCompatibilityInput { + readonly state: ServiceInstallState | null; + readonly subject: ServiceOwnershipSubject; + readonly managers: Readonly>; +} + +export type ServiceTakeoverCompatibility = + | { + readonly kind: "supported"; + readonly protocolVersion: typeof SERVICE_OWNERSHIP_PROTOCOL_VERSION; + readonly minimumCliVersion: typeof SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION; + /** Opaque binding over the approved subject and both managing-CLI observations. */ + readonly token: string; + } + | { + readonly kind: "blocked"; + readonly reason: + | "managing-cli-unknown" + | "managing-cli-unsupported" + | "managing-cli-unobserved" + | "service-protocol-unsupported"; + readonly detail: string; + readonly minimumCliVersion: typeof SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION; + }; + +function comparePrerelease(left: readonly (bigint | string)[], right: readonly (bigint | string)[]): number { + if (left.length === 0 || right.length === 0) return left.length === right.length ? 0 : left.length === 0 ? 1 : -1; + for (let index = 0; index < Math.max(left.length, right.length); index += 1) { + const a = left[index]; + const b = right[index]; + if (a === undefined || b === undefined) return a === b ? 0 : a === undefined ? -1 : 1; + if (a === b) continue; + if (typeof a === "bigint" && typeof b === "bigint") return a < b ? -1 : 1; + if (typeof a === "bigint") return -1; + if (typeof b === "bigint") return 1; + return a < b ? -1 : 1; + } + return 0; +} + +function versionSupportsOwnership(value: string): boolean { + const actual = parseStrictSemver(value); + const minimum = parseStrictSemver(SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION); + if (!actual || !minimum) return false; + for (let index = 0; index < actual.core.length; index += 1) { + if (actual.core[index] !== minimum.core[index]) return actual.core[index]! > minimum.core[index]!; + } + return comparePrerelease(actual.prerelease, minimum.prerelease) >= 0; +} + +function compatibilityToken(input: ServiceTakeoverCompatibilityInput): string { + return createHash("sha256").update(JSON.stringify({ + subject: input.subject, + protocolVersion: input.state?.ownershipProtocolVersion ?? null, + managers: { + "service-registration": input.managers["service-registration"], + path: input.managers.path, + }, + })).digest("hex"); +} + +/** + * Decide whether permanent desktop ownership can be offered. + * + * Both managing surfaces are mandatory observations. `absent` is a trustworthy answer; + * `unknown` is not. An observed service registration additionally needs the protocol marker + * written by a CLI whose start/repair/update paths honor the ownership claim. This is what + * keeps the preserved registration from starting an older runtime at the next login. + */ +export function assessServiceTakeoverCompatibility( + input: ServiceTakeoverCompatibilityInput, +): ServiceTakeoverCompatibility { + const observed = Object.entries(input.managers) as Array<[ManagingCliRole, ManagingCliObservation]>; + const unknown = observed.find(([, manager]) => manager.status === "unknown"); + if (unknown) return { + kind: "blocked", + reason: "managing-cli-unknown", + detail: `${unknown[0]} compatibility could not be determined`, + minimumCliVersion: SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION, + }; + const present = observed.filter(([, manager]) => manager.status === "observed") as Array<[ + ManagingCliRole, + Extract, + ]>; + if (present.length === 0) return { + kind: "blocked", + reason: "managing-cli-unobserved", + detail: "no managing OpenCodex CLI installation was observed", + minimumCliVersion: SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION, + }; + const unsupported = present.find(([, manager]) => !versionSupportsOwnership(manager.version)); + if (unsupported) return { + kind: "blocked", + reason: "managing-cli-unsupported", + detail: `${unsupported[0]} uses OpenCodex ${unsupported[1].version}; ${SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION} or later is required`, + minimumCliVersion: SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION, + }; + if (input.managers["service-registration"].status === "observed" + && input.state?.ownershipProtocolVersion !== SERVICE_OWNERSHIP_PROTOCOL_VERSION) { + return { + kind: "blocked", + reason: "service-protocol-unsupported", + detail: "the preserved service registration was not written by an ownership-aware CLI", + minimumCliVersion: SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION, + }; + } + return { + kind: "supported", + protocolVersion: SERVICE_OWNERSHIP_PROTOCOL_VERSION, + minimumCliVersion: SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION, + token: compatibilityToken(input), + }; +} + +export function sameServiceTakeoverCompatibility( + left: ServiceTakeoverCompatibility, + right: ServiceTakeoverCompatibility, +): boolean { + return left.kind === "supported" && right.kind === "supported" && left.token === right.token; +} diff --git a/src/service/ownership-mutation-lease.d.mts b/src/service/ownership-mutation-lease.d.mts new file mode 100644 index 00000000000..48d293263cd --- /dev/null +++ b/src/service/ownership-mutation-lease.d.mts @@ -0,0 +1,32 @@ +export interface OwnershipMutationLeaseOptions { + readonly waitMs?: number; + readonly now?: () => number; + readonly sleep?: (ms: number) => void; + readonly processAlive?: (pid: number) => boolean; + readonly beforeRelease?: (lockPath: string) => void; + readonly joinToken?: string; +} + +export interface OwnershipMutationLease { readonly token: string; release(): void } + +export declare const OWNERSHIP_MUTATION_LEASE_TOKEN_ENV: "OCX_OWNERSHIP_MUTATION_LEASE_TOKEN"; + +export declare function ownershipMutationLeaseChildEnvironment( + environment: NodeJS.ProcessEnv, + token: string, +): NodeJS.ProcessEnv; + +export declare function unprivilegedOwnershipMutationEnvironment( + environment: NodeJS.ProcessEnv, +): NodeJS.ProcessEnv; + +export declare function acquireOwnershipMutationLease( + statePaths: readonly string[], + options?: OwnershipMutationLeaseOptions, +): OwnershipMutationLease; + +export declare function withOwnershipMutationLease( + statePaths: readonly string[], + run: () => T, + options?: OwnershipMutationLeaseOptions, +): T; diff --git a/src/service/ownership-mutation-lease.mjs b/src/service/ownership-mutation-lease.mjs new file mode 100644 index 00000000000..008d4e14f27 --- /dev/null +++ b/src/service/ownership-mutation-lease.mjs @@ -0,0 +1,211 @@ +import { randomUUID } from "node:crypto"; +import { + closeSync, + existsSync, + fsyncSync, + lstatSync, + mkdirSync, + openSync, + readFileSync, + readdirSync, + realpathSync, + rmdirSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { basename, dirname, join } from "node:path"; + +const WAIT_MS = 2_000; +const POLL_MS = 20; +const STALE_MS = 30_000; +const PROCESS_INSTANCE = randomUUID(); +const held = new Map(); +const delegatedTokens = new Map(); +const sleeper = new Int32Array(new SharedArrayBuffer(4)); +export const OWNERSHIP_MUTATION_LEASE_TOKEN_ENV = "OCX_OWNERSHIP_MUTATION_LEASE_TOKEN"; + +export function ownershipMutationLeaseChildEnvironment(environment, token) { + return { ...environment, [OWNERSHIP_MUTATION_LEASE_TOKEN_ENV]: token }; +} + +export function unprivilegedOwnershipMutationEnvironment(environment) { + const child = { ...environment }; + delete child[OWNERSHIP_MUTATION_LEASE_TOKEN_ENV]; + return child; +} + +function sleep(ms) { Atomics.wait(sleeper, 0, 0, ms); } +function processAlive(pid) { + try { process.kill(pid, 0); return true; } + catch (error) { return error?.code !== "ESRCH"; } +} + +function leasePath(statePaths) { + const authority = statePaths.at(-1); + if (!authority) throw new Error("cannot acquire ownership mutation lease without a service-state path"); + try { return `${realpathSync.native(authority)}.mutation.lock`; } + catch { + try { return join(realpathSync.native(dirname(authority)), `${basename(authority)}.mutation.lock`); } + catch { return `${authority}.mutation.lock`; } + } +} + +function ownerName(record) { + return `v1-${record.pid}-${record.processInstance}-${record.token}.json`; +} + +function parseOwnerName(name) { + const match = /^v1-([1-9][0-9]*)-[0-9a-f-]+-[0-9a-f-]+[.]json$/i.exec(name); + if (!match) return null; + const pid = Number(match[1]); + return Number.isSafeInteger(pid) && pid > 0 ? pid : null; +} + +function readOwner(path) { + try { + const lock = lstatSync(path); + if (!lock.isDirectory()) return null; + const entries = readdirSync(path); + if (entries.length !== 1) return null; + const ownerPath = join(path, entries[0]); + const owner = lstatSync(ownerPath); + if (!owner.isFile() || owner.size > 4096) return null; + const record = JSON.parse(readFileSync(ownerPath, "utf8")); + if (record?.version !== 1 || !Number.isSafeInteger(record.pid) || record.pid <= 0 + || typeof record.processInstance !== "string" || !record.processInstance + || typeof record.token !== "string" || !record.token + || !Number.isFinite(record.createdAt) || entries[0] !== ownerName(record)) return null; + const currentLock = lstatSync(path); + const currentOwner = lstatSync(ownerPath); + if (currentLock.dev !== lock.dev || currentLock.ino !== lock.ino + || currentOwner.dev !== owner.dev || currentOwner.ino !== owner.ino + || currentOwner.size !== owner.size) return null; + return { path, ownerPath, record, lockDev: lock.dev, lockIno: lock.ino, ownerDev: owner.dev, ownerIno: owner.ino, ownerSize: owner.size, mtimeMs: owner.mtimeMs }; + } catch { return null; } +} + +function sameOwner(left, right) { + return left.record.token === right.record.token + && left.record.pid === right.record.pid + && left.record.processInstance === right.record.processInstance + && left.lockDev === right.lockDev && left.lockIno === right.lockIno + && left.ownerDev === right.ownerDev && left.ownerIno === right.ownerIno + && left.ownerSize === right.ownerSize; +} + +function readIncompleteOwner(path) { + try { + const lock = lstatSync(path); + if (!lock.isDirectory()) return null; + const entries = readdirSync(path); + if (entries.length === 0) return { path, ownerPath: null, pid: null, mtimeMs: lock.mtimeMs }; + if (entries.length !== 1) return null; + const pid = parseOwnerName(entries[0]); + if (!pid) return null; + const ownerPath = join(path, entries[0]); + const owner = lstatSync(ownerPath); + return owner.isFile() ? { path, ownerPath, pid, mtimeMs: owner.mtimeMs } : null; + } catch { return null; } +} + +function reclaim(path, now, alive) { + const observed = readOwner(path); + const incomplete = observed ? null : readIncompleteOwner(path); + if (!observed && !incomplete) return false; + const createdAt = observed ? Math.max(observed.record.createdAt, observed.mtimeMs) : incomplete.mtimeMs; + const pid = observed?.record.pid ?? incomplete.pid; + if (now() - createdAt <= STALE_MS || (pid !== null && alive(pid))) return false; + if (observed) { + const current = readOwner(path); + if (!current || !sameOwner(observed, current)) return false; + } + try { + const ownerPath = observed?.ownerPath ?? incomplete.ownerPath; + if (ownerPath) unlinkSync(ownerPath); + rmdirSync(path); + return true; + } catch { return false; } +} + +export function acquireOwnershipMutationLease( + statePaths, + options = {}, +) { + const path = leasePath(statePaths); + const nested = held.get(path); + if (nested) { + nested.depth += 1; + return { token: nested.snapshot.record.token, release: () => release(path, options) }; + } + const now = options.now ?? Date.now; + const alive = options.processAlive ?? processAlive; + const explicitJoinToken = options.joinToken; + const envJoinToken = process.env[OWNERSHIP_MUTATION_LEASE_TOKEN_ENV]; + const joinToken = explicitJoinToken ?? envJoinToken ?? delegatedTokens.get(path); + if (joinToken) { + const owner = readOwner(path); + if (owner?.record.token === joinToken && alive(owner.record.pid)) { + delegatedTokens.set(path, joinToken); + if (envJoinToken === joinToken) delete process.env[OWNERSHIP_MUTATION_LEASE_TOKEN_ENV]; + held.set(path, { depth: 1, snapshot: owner, delegated: true }); + return { token: joinToken, release: () => release(path, options) }; + } + delegatedTokens.delete(path); + if (envJoinToken === joinToken) delete process.env[OWNERSHIP_MUTATION_LEASE_TOKEN_ENV]; + if (explicitJoinToken) throw new Error("ownership mutation lease delegation is invalid or no longer live"); + } + const wait = options.waitMs ?? WAIT_MS; + const deadline = now() + wait; + if (!existsSync(dirname(path))) mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + for (;;) { + const record = { version: 1, pid: process.pid, processInstance: PROCESS_INSTANCE, token: randomUUID(), createdAt: now() }; + const ownerPath = join(path, ownerName(record)); + let madeDirectory = false; + let descriptor = null; + try { + mkdirSync(path, { mode: 0o700 }); + madeDirectory = true; + descriptor = openSync(ownerPath, "wx", 0o600); + writeFileSync(descriptor, `${JSON.stringify(record)}\n`, "utf8"); + fsyncSync(descriptor); + closeSync(descriptor); + descriptor = null; + const snapshot = readOwner(path); + if (!snapshot || snapshot.record.token !== record.token) throw new Error("ownership mutation lease could not be verified"); + held.set(path, { depth: 1, snapshot, delegated: false }); + return { token: record.token, release: () => release(path, options) }; + } catch (error) { + if (descriptor !== null) { try { closeSync(descriptor); } catch { /* stale recovery owns uncertain cleanup */ } } + if (madeDirectory) { + try { unlinkSync(ownerPath); } catch { /* partial owner is recovered after dead-PID proof */ } + try { rmdirSync(path); } catch { /* owner entry or successor keeps the directory live */ } + } + if (error?.code !== "EEXIST") throw error; + if (reclaim(path, now, alive)) continue; + if (now() >= deadline) throw new Error(`another process owns the runtime mutation lease at ${path}`); + (options.sleep ?? sleep)(POLL_MS); + } + } +} + +function release(path, options) { + const currentHeld = held.get(path); + if (!currentHeld) return; + currentHeld.depth -= 1; + if (currentHeld.depth > 0) return; + held.delete(path); + if (currentHeld.delegated) return; + options.beforeRelease?.(path); + try { + const current = readOwner(path); + if (!current || !sameOwner(currentHeld.snapshot, current)) return; + unlinkSync(currentHeld.snapshot.ownerPath); + rmdirSync(path); + } catch { /* token-specific stale recovery handles an uncertain release */ } +} + +export function withOwnershipMutationLease(statePaths, run, options = {}) { + const lease = acquireOwnershipMutationLease(statePaths, options); + try { return run(); } + finally { lease.release(); } +} diff --git a/src/service/repair.ts b/src/service/repair.ts index ad58e93118a..34937498238 100644 --- a/src/service/repair.ts +++ b/src/service/repair.ts @@ -92,7 +92,7 @@ export function unknownServiceOwnerRefusal(reason: string, action = "repair"): s return `Background service ${action} stopped: ${reason}, so the runtime's recorded owner could ` + "not be determined.\n" + "The service registration was left exactly as it is — not re-enabled, not rewritten and not restarted.\n" - + "Run 'ocx service install' to re-register the service and take the runtime back."; + + "Repair the service-state file or its permissions, then run 'ocx service install' to take the runtime back."; } async function assertSchedulerSnapshotBeforeStart( diff --git a/src/service/state-lock.ts b/src/service/state-lock.ts new file mode 100644 index 00000000000..6bc2fabfb92 --- /dev/null +++ b/src/service/state-lock.ts @@ -0,0 +1,269 @@ +import { randomUUID } from "node:crypto"; +import { + closeSync, + existsSync, + fsyncSync, + lstatSync, + mkdirSync, + openSync, + readFileSync, + readdirSync, + realpathSync, + rmdirSync, + unlinkSync, + writeFileSync, + type Stats, +} from "node:fs"; +import { basename, dirname, join } from "node:path"; + +const SERVICE_STATE_LOCK_WAIT_MS = 2_000; +const SERVICE_STATE_LOCK_POLL_MS = 20; +const SERVICE_STATE_LOCK_STALE_MS = 30_000; +const PROCESS_INSTANCE = randomUUID(); + +interface ServiceStateLockRecord { + readonly version: 1; + readonly pid: number; + readonly processInstance: string; + readonly token: string; + readonly createdAt: number; +} + +interface ServiceStateLockSnapshot { + readonly record: ServiceStateLockRecord; + readonly ownerPath: string; + readonly lockIdentity: Pick; + readonly ownerIdentity: Pick; + readonly mtimeMs: number; +} + +export interface ServiceStateLockHooks { + readonly now?: () => number; + readonly sleep?: (ms: number) => void; + readonly processAlive?: (pid: number) => boolean; + readonly beforeStaleDelete?: (lockPath: string) => void; + readonly beforeRelease?: (lockPath: string) => void; +} + +interface HeldServiceStateLock { + depth: number; + readonly snapshot: ServiceStateLockSnapshot; +} + +const heldLocks = new Map(); + +function lockPathForStatePath(statePath: string): string { + try { return `${realpathSync.native(statePath)}.lock`; } + catch { + try { return join(realpathSync.native(dirname(statePath)), `${basename(statePath)}.lock`); } + catch { return `${statePath}.lock`; } + } +} + +function lockOwnerProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + // EPERM means the process exists but this account cannot signal it. Unknown failures + // also fail closed: only ESRCH proves the holder is gone. + return (error as NodeJS.ErrnoException).code !== "ESRCH"; + } +} + +function sameIdentity( + left: Pick, + right: Pick, +): boolean { + return left.dev === right.dev && left.ino === right.ino; +} + +function ownerFileName(record: ServiceStateLockRecord): string { + return `v1-${record.pid}-${record.processInstance}-${record.token}.json`; +} + +function parseOwnerFileName(name: string): { pid: number; pathToken: string } | null { + const match = /^v1-([1-9][0-9]*)-([0-9a-f-]+)-([0-9a-f-]+)[.]json$/i.exec(name); + if (!match) return null; + const pid = Number(match[1]); + return Number.isSafeInteger(pid) && pid > 0 ? { pid, pathToken: `${match[2]}-${match[3]}` } : null; +} + +function readLockSnapshot(lockPath: string): ServiceStateLockSnapshot | null { + let lockIdentity: Stats; + let entries: string[]; + try { + lockIdentity = lstatSync(lockPath); + if (!lockIdentity.isDirectory()) return null; + entries = readdirSync(lockPath); + } catch { + return null; + } + if (entries.length !== 1 || !parseOwnerFileName(entries[0]!)) return null; + const ownerPath = join(lockPath, entries[0]!); + try { + const ownerIdentity = lstatSync(ownerPath); + if (!ownerIdentity.isFile() || ownerIdentity.size > 4096) return null; + const value = JSON.parse(readFileSync(ownerPath, "utf8")) as Partial; + if (value.version !== 1 || !Number.isSafeInteger(value.pid) || (value.pid ?? 0) <= 0 + || typeof value.processInstance !== "string" || value.processInstance.length === 0 + || typeof value.token !== "string" || value.token.length === 0 + || typeof value.createdAt !== "number" || !Number.isFinite(value.createdAt)) return null; + const record = value as ServiceStateLockRecord; + if (entries[0] !== ownerFileName(record)) return null; + const currentLock = lstatSync(lockPath); + const currentOwner = lstatSync(ownerPath); + if (!currentLock.isDirectory() || !sameIdentity(lockIdentity, currentLock) + || !currentOwner.isFile() || !sameIdentity(ownerIdentity, currentOwner) + || currentOwner.size !== ownerIdentity.size) return null; + return { record, ownerPath, lockIdentity, ownerIdentity, mtimeMs: ownerIdentity.mtimeMs }; + } catch { + return null; + } +} + +function sameLock(left: ServiceStateLockSnapshot, right: ServiceStateLockSnapshot): boolean { + return left.record.token === right.record.token + && left.record.pid === right.record.pid + && left.record.processInstance === right.record.processInstance + && sameIdentity(left.lockIdentity, right.lockIdentity) + && sameIdentity(left.ownerIdentity, right.ownerIdentity) + && left.ownerIdentity.size === right.ownerIdentity.size; +} + +function parsedIncompleteOwner(lockPath: string): { ownerPath: string | null; pid: number | null; mtimeMs: number } | null { + try { + const lock = lstatSync(lockPath); + if (!lock.isDirectory()) return null; + const entries = readdirSync(lockPath); + if (entries.length === 0) return { ownerPath: null, pid: null, mtimeMs: lock.mtimeMs }; + if (entries.length !== 1) return null; + const parsed = parseOwnerFileName(entries[0]!); + if (!parsed) return null; + const ownerPath = join(lockPath, entries[0]!); + const owner = lstatSync(ownerPath); + return owner.isFile() ? { ownerPath, pid: parsed.pid, mtimeMs: owner.mtimeMs } : null; + } catch { + return null; + } +} + +function reclaimStaleLock(lockPath: string, hooks: ServiceStateLockHooks): boolean { + const now = hooks.now ?? Date.now; + const processAlive = hooks.processAlive ?? lockOwnerProcessAlive; + const snapshot = readLockSnapshot(lockPath); + const incomplete = snapshot ? null : parsedIncompleteOwner(lockPath); + if (!snapshot && !incomplete) return false; + const ownerPath = snapshot?.ownerPath ?? incomplete!.ownerPath; + const ownerPid = snapshot?.record.pid ?? incomplete!.pid; + const createdAt = snapshot ? Math.max(snapshot.record.createdAt, snapshot.mtimeMs) : incomplete!.mtimeMs; + if (now() - createdAt <= SERVICE_STATE_LOCK_STALE_MS || (ownerPid !== null && processAlive(ownerPid))) { + return false; + } + if (snapshot) { + const current = readLockSnapshot(lockPath); + if (!current || !sameLock(snapshot, current)) return false; + } + hooks.beforeStaleDelete?.(lockPath); + try { + // The owner filename contains the holder's PID, process-instance nonce and token. A + // successor has a different name, so this unlink cannot delete the successor's owner. + if (ownerPath) unlinkSync(ownerPath); + rmdirSync(lockPath); + return true; + } catch { + return false; + } +} + +function acquireOne(lockPath: string, hooks: ServiceStateLockHooks, waitMs: number): ServiceStateLockSnapshot { + const held = heldLocks.get(lockPath); + if (held) { held.depth += 1; return held.snapshot; } + const now = hooks.now ?? Date.now; + const sleep = hooks.sleep ?? (ms => Bun.sleepSync(ms)); + const deadline = now() + waitMs; + if (!existsSync(dirname(lockPath))) mkdirSync(dirname(lockPath), { recursive: true, mode: 0o700 }); + for (;;) { + const record: ServiceStateLockRecord = { + version: 1, + pid: process.pid, + processInstance: PROCESS_INSTANCE, + token: randomUUID(), + createdAt: now(), + }; + const ownerPath = join(lockPath, ownerFileName(record)); + let createdDirectory = false; + let descriptor: number | null = null; + try { + mkdirSync(lockPath, { mode: 0o700 }); + createdDirectory = true; + descriptor = openSync(ownerPath, "wx", 0o600); + writeFileSync(descriptor, `${JSON.stringify(record)}\n`, "utf8"); + fsyncSync(descriptor); + closeSync(descriptor); + descriptor = null; + const snapshot = readLockSnapshot(lockPath); + if (!snapshot || snapshot.record.token !== record.token) throw new Error("service state lock ownership could not be verified"); + heldLocks.set(lockPath, { depth: 1, snapshot }); + return snapshot; + } catch (error) { + if (descriptor !== null) { try { closeSync(descriptor); } catch { /* best-effort */ } } + if (createdDirectory) { + try { unlinkSync(ownerPath); } catch { /* incomplete owner may remain for dead-PID recovery */ } + try { rmdirSync(lockPath); } catch { /* another entry or uncertain owner remains */ } + } + const code = (error as NodeJS.ErrnoException).code; + if (code !== "EEXIST") throw error; + if (reclaimStaleLock(lockPath, hooks)) continue; + if (now() >= deadline) { + throw new Error(`another process owns the service state lock at ${lockPath}; nothing was written`); + } + sleep(SERVICE_STATE_LOCK_POLL_MS); + } + } +} + +function releaseOne(lockPath: string, hooks: ServiceStateLockHooks): void { + const held = heldLocks.get(lockPath); + if (!held) return; + held.depth -= 1; + if (held.depth > 0) return; + heldLocks.delete(lockPath); + hooks.beforeRelease?.(lockPath); + try { + const current = readLockSnapshot(lockPath); + if (!current || !sameLock(held.snapshot, current)) return; + unlinkSync(held.snapshot.ownerPath); + rmdirSync(lockPath); + } catch { /* a verified future holder or stale recovery owns cleanup */ } +} + +export function assertServiceStateLocksOwned(statePaths: readonly string[]): void { + for (const path of statePaths) { + const lockPath = lockPathForStatePath(path); + const held = heldLocks.get(lockPath); + const current = readLockSnapshot(lockPath); + if (!held || !current || !sameLock(held.snapshot, current)) { + throw new Error(`service state lock ownership changed before commit: ${lockPath}`); + } + } +} + +export function withServiceStateLocks( + statePaths: readonly string[], + run: () => T, + options: { readonly waitMs?: number; readonly hooks?: ServiceStateLockHooks } = {}, +): T { + const hooks = options.hooks ?? {}; + const lockPaths = [...new Set(statePaths.map(lockPathForStatePath))].sort(); + const acquired: string[] = []; + try { + for (const lockPath of lockPaths) { + acquireOne(lockPath, hooks, options.waitMs ?? SERVICE_STATE_LOCK_WAIT_MS); + acquired.push(lockPath); + } + return run(); + } finally { + for (const lockPath of acquired.reverse()) releaseOne(lockPath, hooks); + } +} diff --git a/src/service/state-record.d.mts b/src/service/state-record.d.mts new file mode 100644 index 00000000000..6fa0eb77efd --- /dev/null +++ b/src/service/state-record.d.mts @@ -0,0 +1,36 @@ +export interface ServiceOwnershipRecord { + readonly owner: "cli" | "desktop"; + readonly installId: string; + readonly consentGeneration: number; + readonly [key: string]: unknown; +} + +export declare const SERVICE_OWNERSHIP_PROTOCOL_VERSION: 1; +export declare const SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION: "2.61.0"; + +export interface ServiceInstallStateRecord { + readonly version: 1 | 2; + readonly codexHome: string; + readonly opencodexHome: string; + readonly revision?: number; + readonly ownership?: ServiceOwnershipRecord; + readonly [key: string]: unknown; +} + +export type ServiceStateRecordEvidence = + | { readonly path: string; readonly kind: "absent" } + | { readonly path: string; readonly kind: "unreadable"; readonly reason: string } + | { readonly path: string; readonly kind: "invalid" } + | { readonly path: string; readonly kind: "valid"; readonly state: ServiceInstallStateRecord }; + +export type AuthoritativeServiceStateRecord = + | { readonly kind: "none"; readonly revision: 0; readonly needsRepair: false } + | { readonly kind: "state"; readonly state: ServiceInstallStateRecord; readonly revision: number; readonly needsRepair: boolean } + | { readonly kind: "unknown"; readonly reason: string }; + +export declare function parseServiceOwnershipRecord(value: unknown): ServiceOwnershipRecord | null; +export declare function parseServiceInstallStateRecord(value: unknown): ServiceInstallStateRecord | null; +export declare function serviceStatePathsForHomes(opencodexHome: string, defaultOpenCodexHome: string, platform?: NodeJS.Platform): string[]; +export declare function inspectServiceStateRecords(paths: readonly string[], read?: (path: string) => string): readonly ServiceStateRecordEvidence[]; +export declare function serviceStateFingerprint(value: unknown): string; +export declare function selectAuthoritativeServiceState(evidence: readonly ServiceStateRecordEvidence[]): AuthoritativeServiceStateRecord; diff --git a/src/service/state-record.mjs b/src/service/state-record.mjs new file mode 100644 index 00000000000..dc0270cb469 --- /dev/null +++ b/src/service/state-record.mjs @@ -0,0 +1,138 @@ +import { readFileSync, realpathSync } from "node:fs"; +import { posix, win32 } from "node:path"; + +export const SERVICE_OWNERSHIP_PROTOCOL_VERSION = 1; +export const SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION = "2.61.0"; + +const isObject = value => Boolean(value) && typeof value === "object" && !Array.isArray(value); +const isNonNegativeSafeInteger = value => typeof value === "number" && Number.isSafeInteger(value) && value >= 0; +const nonEmptyString = value => typeof value === "string" && value.length > 0; + +/** Parse the ownership payload shared by the Bun service code and the Node launcher. */ +export function parseServiceOwnershipRecord(value) { + if (!isObject(value)) return null; + if (value.owner !== "cli" && value.owner !== "desktop") return null; + if (!nonEmptyString(value.installId) || !isNonNegativeSafeInteger(value.consentGeneration)) return null; + return value; +} + +/** Parse the COMPLETE install record; ownership alone is not enough to trust the file. */ +export function parseServiceInstallStateRecord(value) { + if (!isObject(value) || (value.version !== 1 && value.version !== 2)) return null; + if (!nonEmptyString(value.codexHome) || !nonEmptyString(value.opencodexHome)) return null; + for (const key of ["codexSqliteHome", "bunPath", "launcherPath", "winswVersion", "winswSha256"]) { + if (value[key] !== undefined && !nonEmptyString(value[key])) return null; + } + if (value.cliPath !== undefined && value.cliPath !== null && !nonEmptyString(value.cliPath)) return null; + if (value.revision !== undefined && !isNonNegativeSafeInteger(value.revision)) return null; + if (value.consentGenerationCeiling !== undefined && !isNonNegativeSafeInteger(value.consentGenerationCeiling)) return null; + if (value.ownershipProtocolVersion !== undefined && !isNonNegativeSafeInteger(value.ownershipProtocolVersion)) return null; + if (value.ownership !== undefined && parseServiceOwnershipRecord(value.ownership) === null) return null; + if (value.version === 1) { + if (value.backend !== undefined) return null; + } else if (value.backend !== "scheduler" && value.backend !== "native") return null; + return value; +} + +export function serviceStatePathsForHomes(opencodexHome, defaultOpenCodexHome, platform = process.platform) { + const tools = platform === "win32" ? win32 : posix; + const primary = tools.join(opencodexHome, "service-state.json"); + const legacy = tools.join(defaultOpenCodexHome, "service-state.json"); + const key = path => { + let canonical; + try { canonical = realpathSync.native(path); } + catch { + try { canonical = tools.join(realpathSync.native(tools.dirname(path)), tools.basename(path)); } + catch { canonical = tools.resolve(path); } + } + return platform === "win32" ? canonical.toLowerCase() : canonical; + }; + return key(primary) === key(legacy) ? [primary] : [primary, legacy]; +} + +function errorCode(error) { + return error && typeof error === "object" && "code" in error ? String(error.code ?? "") : ""; +} + +/** Read every supplied state path without collapsing absent, invalid and unreadable. */ +export function inspectServiceStateRecords(paths, read = path => readFileSync(path, "utf8")) { + return paths.map(path => { + let raw; + try { + raw = read(path); + } catch (error) { + const code = errorCode(error); + return code === "ENOENT" + ? { path, kind: "absent" } + : { path, kind: "unreadable", reason: code || String(error) }; + } + try { + const state = parseServiceInstallStateRecord(JSON.parse(raw)); + return state ? { path, kind: "valid", state } : { path, kind: "invalid" }; + } catch { + return { path, kind: "invalid" }; + } + }); +} + +function canonical(value) { + if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`; + if (!isObject(value)) return JSON.stringify(value); + return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}`; +} + +export function serviceStateFingerprint(value) { + return canonical(value); +} + +/** + * Select one authoritative generation from mirrored state. + * + * The final path is authoritative. A lower-revision mirror is repairable; a same-or-newer + * disagreement is unordered evidence and fails closed. Before the authority exists, one valid + * active-home record is imported exactly once as migration input. + */ +export function selectAuthoritativeServiceState(evidence) { + const authority = evidence.at(-1); + if (!authority) return { kind: "none", revision: 0, needsRepair: false }; + if (authority.kind === "unreadable") { + return { kind: "unknown", reason: `the authoritative service state could not be read (${authority.reason})` }; + } + if (authority.kind === "invalid") { + return { kind: "unknown", reason: "the authoritative service install record is present but not valid" }; + } + if (authority.kind === "valid") { + const fingerprint = canonical(authority.state); + const authorityRevision = authority.state.revision ?? 0; + const unorderedConflict = evidence.slice(0, -1).find(entry => entry.kind === "valid" + && (entry.state.revision ?? 0) >= authorityRevision + && canonical(entry.state) !== fingerprint); + if (unorderedConflict) { + return { kind: "unknown", reason: `a service state mirror conflicts with authority revision ${authorityRevision}` }; + } + return { + kind: "state", + state: authority.state, + revision: authorityRevision, + needsRepair: evidence.slice(0, -1).some(entry => entry.kind !== "valid" || canonical(entry.state) !== fingerprint), + }; + } + + // The authority has never been established. A single valid active-home mirror is the + // migration source; after the first write it can no longer vote against the authority. + const migration = evidence.slice(0, -1); + const unreadable = migration.find(entry => entry.kind === "unreadable"); + if (unreadable) return { kind: "unknown", reason: `a legacy service state path could not be read (${unreadable.reason})` }; + if (migration.some(entry => entry.kind === "invalid")) { + return { kind: "unknown", reason: "a legacy service install record is present but not valid" }; + } + const valid = migration.filter(entry => entry.kind === "valid"); + if (valid.length === 0) return { kind: "none", revision: 0, needsRepair: false }; + const revision = Math.max(...valid.map(entry => entry.state.revision ?? 0)); + const newest = valid.filter(entry => (entry.state.revision ?? 0) === revision); + const fingerprint = canonical(newest[0].state); + if (newest.some(entry => canonical(entry.state) !== fingerprint)) { + return { kind: "unknown", reason: `legacy service state mirrors disagree at revision ${revision}` }; + } + return { kind: "state", state: newest[0].state, revision, needsRepair: true }; +} diff --git a/src/service/state.ts b/src/service/state.ts index f4b75753636..dbd2520b07b 100644 --- a/src/service/state.ts +++ b/src/service/state.ts @@ -1,23 +1,33 @@ -import { accessSync, chmodSync, closeSync, constants as fsConstants, existsSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs"; -import { randomUUID } from "node:crypto"; +import { accessSync, constants as fsConstants, existsSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { delimiter, dirname, isAbsolute, join, posix, resolve, win32 } from "node:path"; import { expandUserPath, getConfigDir } from "../config"; +import { atomicWriteFileStreamed } from "../config/atomic-write"; import { resolveCodexHomeDir, type CodexHomeDeps } from "../codex/home"; import { resolveCodexSqliteHome } from "../codex/paths"; import { durableBunRuntime, type BunRuntimeSource, type DurableBunRuntime } from "../lib/bun-runtime"; import { WINSW_SHA256, WINSW_VERSION } from "../lib/winsw"; -import { hardenSecretPath } from "../lib/windows-secret-acl"; -import { recordOwnedConfigPath } from "../lib/config-ownership"; import { isProtectedHomeUnderTest, isTestHomeGuardArmed } from "../lib/test-home-guard"; import { isStandaloneBinary } from "../lib/standalone"; import { inspectInstallStateBytes, parseInstallStateRecord, parseOwnershipClaim, - resolveOwnershipFromEvidence, + SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION, + SERVICE_OWNERSHIP_PROTOCOL_VERSION, + selectAuthoritativeServiceState, + serviceStateFingerprint, serviceStateFilesFor, } from "./install-state-contract.mjs"; +import type { ServiceStateRecordEvidence } from "./state-record.mjs"; +import { assertServiceStateLocksOwned, withServiceStateLocks, type ServiceStateLockHooks } from "./state-lock"; +import { withOwnershipMutationLease, type OwnershipMutationLeaseOptions } from "./ownership-mutation-lease.mjs"; +import { + assessServiceTakeoverCompatibility, + sameServiceTakeoverCompatibility, + type ManagingCliObservation, + type ServiceTakeoverCompatibility, +} from "./ownership-compatibility"; /** * Written only by the launchd plist and the systemd unit. `OCX_SERVICE=1` cannot stand in @@ -28,6 +38,7 @@ export const SERVICE_MANAGED_ENV = "OCX_SERVICE_MANAGED"; export const LABEL = "com.opencodex.proxy"; export const TASK = "opencodex-proxy"; +export { SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION, SERVICE_OWNERSHIP_PROTOCOL_VERSION }; // This module lives one level below the original src/service.ts, so path-relative // lookups anchored at that file's directory go through this constant instead. @@ -143,8 +154,6 @@ function defaultOpenCodexHome(): string { } export function serviceStatePathsForOpenCodexHome(opencodexHome: string): string[] { - // Shared with the Node launcher, which has to consult the SAME list: reading only the - // anchor is how it missed a claim recorded on the legacy default-home path. return serviceStateFilesFor(opencodexHome, defaultOpenCodexHome()); } @@ -264,6 +273,8 @@ export interface ServiceInstallState { * one as its own prior consent. */ consentGenerationCeiling?: number; + /** Written only by CLIs whose start/repair/update paths honor a desktop claim. */ + ownershipProtocolVersion?: number; } /** @@ -341,6 +352,7 @@ function installProvenanceRecord(backend: ServiceBackend, launcherPath?: string codexSqliteHome: resolveCodexSqliteHome({ codexHome }), bunPath: bun, cliPath: cli, + ownershipProtocolVersion: SERVICE_OWNERSHIP_PROTOCOL_VERSION, ...(launcherPath ? { launcherPath } : {}), backend, ...(backend === "native" ? { winswVersion: WINSW_VERSION, winswSha256: WINSW_SHA256 } : {}), @@ -357,40 +369,22 @@ function installProvenanceRecord(backend: ServiceBackend, launcherPath?: string * runtime back to the npm launcher without saying anything. Preserving it here is what makes * the consent durable. */ -export function writeServiceInstallState(backend: ServiceBackend = "scheduler", launcherPath?: string | null): void { +export function writeServiceInstallState( + backend: ServiceBackend = "scheduler", + launcherPath?: string | null, + deps: ServiceStateSwapDeps = {}, +): void { swapServiceInstallState(current => ({ ...installProvenanceRecord(backend, launcherPath), - // Resolved INSIDE the swap, which runs while the anchor lock is held, and across every - // state path so a claim living only on the legacy mirror is carried onto the anchor. - // - // Resolving before the lock was a lost-update window of its own: a takeover recorded - // between the resolution and the swap's base read lands in `current`, passes the revision - // check untouched, and is then overwritten by the older claim this function captured. - // The compare-and-swap cannot see that, because the stale value never came from the base. - // - // This does NOT refuse on an unknown resolution. It runs at the END of a successful - // install or repair, where a throw would report a service that is registered and running - // as a failure. The fail-closed decision belongs in front of the mutation, where repair - // and the updaters make it; here the job is to preserve as much as can be read. - ...preservedConsent(current, resolveServiceOwnership()), - })); + ...preservedConsent(current), + }), deps); } /** The ownership half of a record: the claim itself plus the generation high-water mark. */ function preservedConsent( current: ServiceInstallState | null, - resolution: ServiceOwnershipResolution, ): Pick { - // Both inputs are read under the lock, and they can still disagree: `current` is the anchor - // alone, the resolution spans every path. Never let the older grant win, and on an equal - // generation keep `current` — the anchor is the record every reader resolves first, so - // preferring it is the fail-safe tie. - const resolved = resolution.kind === "owned" ? resolution.ownership : undefined; - const ownership = resolved === undefined - ? current?.ownership - : current?.ownership && current.ownership.consentGeneration >= resolved.consentGeneration - ? current.ownership - : resolved; + const ownership = current?.ownership; const ceiling = Math.max(current?.consentGenerationCeiling ?? 0, ownership?.consentGeneration ?? 0); return { ...(ownership ? { ownership } : {}), @@ -399,23 +393,16 @@ function preservedConsent( } export function readServiceInstallState(): ServiceInstallState | null { - for (const path of serviceStatePaths()) { - try { - const parsed = parseServiceInstallState(JSON.parse(readFileSync(path, "utf8"))); - if (parsed) return parsed; - } catch { - /* try the next known state path */ - } - } - return null; + const resolved = resolveServiceState(); + return resolved.kind === "state" ? resolved.state : null; } -/** Raised when a state write kept losing its compare-and-swap; NOTHING was written. */ +/** Raised when a non-cooperating writer prevents a stable authoritative commit. */ export class ServiceStateConflictError extends Error { constructor(readonly path: string, readonly attempts: number) { super( `service install state at ${path} was rewritten by another process during all ${attempts} ` - + "compare-and-swap attempts; nothing was written. Re-run the command.", + + "compare-and-swap attempts; a stable commit could not be verified. Re-run the command.", ); this.name = "ServiceStateConflictError"; } @@ -434,177 +421,43 @@ export interface ServiceStateSwapDeps { beforeCommit?: (attempt: number) => void; /** How long to wait for another process to release the anchor lock. */ lockWaitMs?: number; + /** Deterministic lock seams for failure-order tests. */ + lockHooks?: ServiceStateLockHooks; + /** Atomic publisher seam. The callback must run immediately before its commit point. */ + commitStateFile?: (path: string, serialized: string, validate: () => void) => void; + /** A mirror failure occurs after the authority committed and is therefore diagnostic. */ + onMirrorError?: (path: string, error: unknown) => void; + /** Allows consented mutations to preserve a machine-readable unknown-subject error. */ + unknownStateError?: (reason: string) => Error; + /** Shared with update/install/start so replacement and ownership mutation cannot overlap. */ + mutationLease?: OwnershipMutationLeaseOptions; } -const SERVICE_STATE_SWAP_ATTEMPTS = 5; -const SERVICE_STATE_LOCK_WAIT_MS = 2_000; -const SERVICE_STATE_LOCK_POLL_MS = 20; -/** - * How old a lock must be before it is treated as abandoned. - * - * It has to exceed the longest legitimate critical section, not the typical one. On Windows - * each committed path runs `hardenSecretPath` synchronously, whose own documentation records - * a worst case around ninety seconds for sequential calls under load; a thirty-second - * threshold would let a second writer evict a holder that is simply still working, and both - * would then compute the same base revision and write over each other. - */ -const SERVICE_STATE_LOCK_STALE_MS = 300_000; -const SERVICE_STATE_REPLACE_ATTEMPTS = 5; -const SERVICE_STATE_REPLACE_RETRY_MS = 40; -/** Lock paths this process holds, with the token written into each and a re-entrancy depth. */ -const heldStateLocks = new Map(); - -/** The token inside a lock file, or null when it cannot be read. */ -function readLockToken(lockPath: string): string | null { - try { return readFileSync(lockPath, "utf8").trim() || null; } catch { return null; } -} - -function isFileExistsError(error: unknown): boolean { - return Boolean(error) && typeof error === "object" && "code" in (error as object) - && (error as { code?: unknown }).code === "EEXIST"; +export interface ServiceStateMutationContext { + readonly revision: number; } -/** - * Hold an exclusive lock over the anchor record for one whole read-modify-write. - * - * The revision check alone cannot make the swap atomic: two processes can both pass it, - * both commit, and both verify their own bytes, after which the second silently drops the - * first's mutation and reports success. `O_EXCL` creation is the cheap cross-process - * exclusion that closes it for every writer that comes through here. - * - * The revision check stays anyway, because this lock binds only cooperating writers — an - * older `ocx` on the same machine does not take it. - * - * Re-entrant per process. A swap nested inside another one is a caller ordering its own - * writes, not a race, and blocking it would be a self-deadlock. - */ -function withServiceStateLock(anchor: string, run: () => T, waitMs = SERVICE_STATE_LOCK_WAIT_MS): T { - const lockPath = `${anchor}.lock`; - const held = heldStateLocks.get(lockPath); - if (held !== undefined) { - held.depth += 1; - try { return run(); } finally { releaseHeldLock(lockPath); } - } - const dir = dirname(lockPath); - if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 }); - const deadline = Date.now() + waitMs; - const token = randomUUID(); - let fd: number; - for (;;) { - try { - fd = openSync(lockPath, "wx", 0o600); - break; - } catch (error) { - // A lock we could not create for any reason OTHER than "it is held" is a filesystem - // failure, and writing the record anyway is the unprotected path this exists to close. - if (!isFileExistsError(error)) throw error; - if (Date.now() < deadline) { Bun.sleepSync(SERVICE_STATE_LOCK_POLL_MS); continue; } - // Break a lock whose holder is gone. Age comes from the lock file itself, so a holder - // that is merely slow keeps refusing us rather than being evicted mid-write. - // - // The token is re-read and compared before the unlink: without it, a holder that - // released and a NEW holder that took the lock in the same instant would be evicted as - // if it were the abandoned one, and two writers would proceed from one base revision. - const abandoned = readLockToken(lockPath); - let ageMs: number | null = null; - try { ageMs = Date.now() - statSync(lockPath).mtimeMs; } catch { ageMs = null; } - if (ageMs !== null && ageMs > SERVICE_STATE_LOCK_STALE_MS) { - if (readLockToken(lockPath) === abandoned) { - try { unlinkSync(lockPath); } catch { /* another process broke it first */ } - } - continue; - } - throw new Error( - `another process is writing the service install state at ${anchor} and did not release ` - + `it within ${waitMs}ms; nothing was written. Re-run the command.`, - ); - } - } - // Identify the holder inside the file so neither eviction nor release can remove a lock - // some other process has since taken. - try { writeFileSync(fd, `${token}\n`, { encoding: "utf8" }); } catch { /* best-effort */ } - heldStateLocks.set(lockPath, { depth: 1, token }); - try { - return run(); - } finally { - try { closeSync(fd); } catch { /* best-effort */ } - releaseHeldLock(lockPath); - } -} +const SERVICE_STATE_SWAP_ATTEMPTS = 5; -function releaseHeldLock(lockPath: string): void { - const held = heldStateLocks.get(lockPath); - if (held === undefined) return; - held.depth -= 1; - if (held.depth > 0) return; - heldStateLocks.delete(lockPath); - // Remove OUR lock instance only. If the file on disk carries a different token, this - // holder was evicted as stale and someone else owns the pathname now; unlinking it would - // hand a third writer the lock while the second is still inside its critical section. - if (readLockToken(lockPath) !== held.token) return; - try { unlinkSync(lockPath); } catch { /* best-effort */ } -} - -/** One state path's record, or null when it is absent or unparseable. Throws if unreadable. */ -function readServiceInstallStateAt(path: string): ServiceInstallState | null { - const evidence = inspectServiceStateEvidence([path])[0]!; - // Unreadable is not absent. Treating EACCES as "no record" would compute a swap from an - // empty base and erase an ownership claim we were merely not allowed to look at. - if (evidence.kind === "unreadable") { - throw new Error( - `service install state at ${path} could not be read (${evidence.reason}), so its recorded ` - + "owner cannot be preserved; nothing was written. Fix the file's permissions and retry.", - ); +function authoritativeState( + paths: readonly string[], + unknownStateError?: (reason: string) => Error, +): { current: ServiceInstallState | null; revision: number; fingerprint: string } { + const selected = selectAuthoritativeServiceState( + inspectServiceStateEvidence(paths) as readonly ServiceStateRecordEvidence[], + ); + if (selected.kind === "unknown") { + throw unknownStateError?.(selected.reason) ?? new Error(`${selected.reason}; nothing was written`); } - // Invalid IS overwritten: there is no claim in an unparseable record to preserve. - return evidence.kind === "valid" ? evidence.state : null; + if (selected.kind === "none") return { current: null, revision: 0, fingerprint: "none" }; + const current = selected.state as ServiceInstallState; + return { current, revision: selected.revision, fingerprint: serviceStateFingerprint(current) }; } -/** - * Publish one state file, replacing it as a unit. - * - * An in-place write truncates first, so a kill, a power loss or a failed write between the - * truncate and the last byte leaves the anchor empty or half-serialized. That used to read - * back as "no install state"; since the reader became fail-closed it reads as `unknown`, - * which blocks `service start`, repair, restart and every update until the operator runs a - * takeover install. Writing a sibling temporary file and renaming it means the previous valid - * record survives an interrupted commit. - * - * The temporary file is hardened BEFORE the rename, not after: between rename and chmod the - * record would otherwise be readable at the default mode. - * - * Windows can refuse the replace while a scanner or another reader holds the destination - * open. That is transient, so it is retried briefly and then falls back to the in-place - * write — a narrow torn-write window is a better failure than an install that cannot record - * what it just registered. - */ -function commitServiceStateFile(path: string, serialized: string): void { - const dir = dirname(path); - recordOwnedConfigPath(getConfigDir(), path); - if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 }); - const staged = `${path}.${process.pid}.${randomUUID().slice(0, 8)}.tmp`; - try { - writeFileSync(staged, serialized, { encoding: "utf8", mode: 0o600 }); - try { chmodSync(staged, 0o600); } catch { /* best-effort */ } - if (process.platform === "win32") hardenSecretPath(staged, { required: true }); - for (let attempt = 0; ; attempt += 1) { - try { - renameSync(staged, path); - return; - } catch { - if (attempt >= SERVICE_STATE_REPLACE_ATTEMPTS - 1) { - writeFileSync(path, serialized, { encoding: "utf8", mode: 0o600 }); - try { chmodSync(path, 0o600); } catch { /* best-effort */ } - if (process.platform === "win32") hardenSecretPath(path, { required: true }); - return; - } - Bun.sleepSync(SERVICE_STATE_REPLACE_RETRY_MS); - } - } - } finally { - // A rename that succeeded consumed the staged path; anything left is ours to clean up. - if (existsSync(staged)) { try { unlinkSync(staged); } catch { /* best-effort */ } } - } +function commitServiceStateFile(path: string, serialized: string, validate: () => void): void { + atomicWriteFileStreamed(path, descriptor => { + writeFileSync(descriptor, serialized, { encoding: "utf8" }); + }, { validateBeforeRename: validate }); } /** @@ -621,41 +474,79 @@ function commitServiceStateFile(path: string, serialized: string): void { * because two writers racing from one base both compute the same next revision — identical * bytes mean nothing was lost, and differing bytes mean something was. * - * WHAT THE LOCK IS. {@link withServiceStateLock} holds the anchor exclusively for the whole - * read-modify-write, because the revision check alone is not atomic: two processes can both - * pass it, both commit and both verify their own bytes, after which the second silently - * drops the first's mutation and reports success. The revision check remains the guard - * against a writer that does not take the lock, such as an older `ocx` on the same machine. + * The final path is the authority. With a custom home that is the legacy default-home path — + * the only path every writer can derive — and the active-home path is a compatibility mirror. + * The authority's atomic rename is the commit point. A mirror failure is reported but cannot + * roll back or reclassify the already committed mutation; the next writer repairs the mirror. */ export function swapServiceInstallState( - mutate: (current: ServiceInstallState | null) => ServiceInstallState | null, + mutate: (current: ServiceInstallState | null, context: ServiceStateMutationContext) => ServiceInstallState | null, deps: ServiceStateSwapDeps = {}, ): ServiceInstallState | null { const paths = deps.paths ?? serviceStateWritePaths(); - // The anchor is the first path, which is the state path for THIS OpenCodex home; - // `readServiceInstallState` reads the same list in the same order, so the record the - // swap compares against is the record every reader resolves. The remaining paths are - // legacy mirrors and receive a copy of whatever the anchor commits. - const anchor = paths[0]; - if (anchor === undefined) throw new Error("refusing to swap service install state with no state path"); + const authority = paths.at(-1); + if (authority === undefined) throw new Error("refusing to swap service install state with no state path"); + const mirrors = paths.filter(path => path !== authority); const attempts = deps.attempts ?? SERVICE_STATE_SWAP_ATTEMPTS; - return withServiceStateLock(anchor, () => { + const publish = deps.commitStateFile ?? commitServiceStateFile; + return withOwnershipMutationLease(paths, () => withServiceStateLocks(paths, () => { for (let attempt = 0; attempt < attempts; attempt += 1) { - const base = readServiceInstallStateAt(anchor); - const baseRevision = base?.revision ?? 0; - const candidate = mutate(base); - if (candidate === null) return base; - const next: ServiceInstallState = { ...candidate, revision: baseRevision + 1 }; + const base = authoritativeState(paths, deps.unknownStateError); + const candidate = mutate(base.current, { revision: base.revision }); + if (candidate === null) return base.current; + if (base.revision >= Number.MAX_SAFE_INTEGER) { + throw new Error("service state revision is exhausted; refusing to publish an unversioned mutation"); + } + const next: ServiceInstallState = { ...candidate, revision: base.revision + 1 }; const serialized = JSON.stringify(next, null, 2) + "\n"; deps.beforeCommit?.(attempt); - if ((readServiceInstallStateAt(anchor)?.revision ?? 0) !== baseRevision) continue; - for (const path of paths) commitServiceStateFile(path, serialized); - let committed: string | null = null; - try { committed = readFileSync(anchor, "utf8"); } catch { /* the comparison below decides */ } - if (committed === serialized) return next; + assertServiceStateLocksOwned(paths); + const fresh = authoritativeState(paths, deps.unknownStateError); + if (fresh.revision !== base.revision || fresh.fingerprint !== base.fingerprint) continue; + const validate = () => assertServiceStateLocksOwned(paths); + publish(authority, serialized, validate); + const committed = authoritativeState([authority]); + if (committed.revision !== next.revision || committed.fingerprint !== serviceStateFingerprint(next)) continue; + for (const mirror of mirrors) { + try { publish(mirror, serialized, validate); } + catch (error) { + (deps.onMirrorError ?? ((path, cause) => console.warn( + `service state committed, but compatibility mirror ${path} could not be refreshed: ${cause instanceof Error ? cause.message : String(cause)}`, + )))(mirror, error); + } + } + return next; } - throw new ServiceStateConflictError(anchor, attempts); - }, deps.lockWaitMs); + throw new ServiceStateConflictError(authority, attempts); + }, { waitMs: deps.lockWaitMs, hooks: deps.lockHooks }), deps.mutationLease); +} + +export interface RemoveServiceStateDeps { + readonly paths?: readonly string[]; + readonly unlink?: (path: string) => void; + readonly lockWaitMs?: number; + readonly lockHooks?: ServiceStateLockHooks; +} + +/** + * Delete mirrors first and the authority last under the same ownership locks. + * + * A crash or mirror error before the final unlink leaves the authority in place, so a stale + * mirror can never become a migration source and resurrect a released desktop claim. + */ +export function removeServiceInstallStateRecords(deps: RemoveServiceStateDeps = {}): void { + const paths = deps.paths ?? serviceStateWritePaths(); + const authority = paths.at(-1); + if (!authority) return; + const unlink = deps.unlink ?? unlinkSync; + withOwnershipMutationLease(paths, () => withServiceStateLocks(paths, () => { + for (const mirror of paths.slice(0, -1)) { + assertServiceStateLocksOwned(paths); + if (existsSync(mirror)) unlink(mirror); + } + assertServiceStateLocksOwned(paths); + if (existsSync(authority)) unlink(authority); + }, { waitMs: deps.lockWaitMs, hooks: deps.lockHooks }), { waitMs: deps.lockWaitMs }); } /** The recorded owner of ONE already-read record, or null. Prefer {@link resolveServiceOwnership}. */ @@ -672,18 +563,91 @@ export function serviceOwnership(state: ServiceInstallState | null = readService * exact demotion the record exists to prevent. Absence is the only thing that may mean no * claim. */ -export type ServiceOwnershipResolution = - | { readonly kind: "none" } - | { readonly kind: "owned"; readonly ownership: ServiceOwnership } +export type ServiceStateResolution = + | { readonly kind: "none"; readonly revision: 0; readonly needsRepair: false } + | { readonly kind: "state"; readonly state: ServiceInstallState; readonly revision: number; readonly needsRepair: boolean } | { readonly kind: "unknown"; readonly reason: string }; +export type ServiceOwnershipSubject = + | { readonly kind: "none"; readonly revision: number } + | { readonly kind: "owned"; readonly ownership: ServiceOwnership; readonly revision: number }; + +export type ServiceOwnershipResolution = ServiceOwnershipSubject + | { readonly kind: "unknown"; readonly reason: string }; + +export function resolveServiceState( + evidence: readonly ServiceStateEvidence[] = inspectServiceStateEvidence(), +): ServiceStateResolution { + const selected = selectAuthoritativeServiceState(evidence as readonly ServiceStateRecordEvidence[]); + if (selected.kind === "unknown") return selected; + if (selected.kind === "none") return selected; + return { + kind: "state", + state: selected.state as ServiceInstallState, + revision: selected.revision, + needsRepair: selected.needsRepair, + }; +} + export function resolveServiceOwnership( evidence: readonly ServiceStateEvidence[] = inspectServiceStateEvidence(), ): ServiceOwnershipResolution { - // The resolution rule is the shared contract's, for the same reason the record contract is: - // the Node launcher decides this question too, and a weaker copy there is an authorization - // gap rather than a style problem. - return resolveOwnershipFromEvidence(evidence) as unknown as ServiceOwnershipResolution; + const state = resolveServiceState(evidence); + if (state.kind === "unknown") return state; + if (state.kind === "none" || !state.state.ownership) return { kind: "none", revision: state.revision }; + return { kind: "owned", ownership: state.state.ownership, revision: state.revision }; +} + +export function sameServiceOwnershipSubject( + left: ServiceOwnershipSubject, + right: ServiceOwnershipSubject, +): boolean { + if (left.kind !== right.kind || left.revision !== right.revision) return false; + if (left.kind === "none" || right.kind === "none") return true; + return left.ownership.owner === right.ownership.owner + && left.ownership.installId === right.ownership.installId + && left.ownership.consentGeneration === right.ownership.consentGeneration; +} + +function sameServiceOwnershipIdentity(left: ServiceOwnershipSubject, right: ServiceOwnershipSubject): boolean { + if (left.kind !== right.kind) return false; + if (left.kind === "none" || right.kind === "none") return true; + return left.ownership.owner === right.ownership.owner + && left.ownership.installId === right.ownership.installId + && left.ownership.consentGeneration === right.ownership.consentGeneration; +} + +function serviceOwnershipSubject( + state: ServiceInstallState | null, + revision: number, +): ServiceOwnershipSubject { + return state?.ownership + ? { kind: "owned", ownership: state.ownership, revision } + : { kind: "none", revision }; +} + +export class ServiceOwnershipSubjectMismatchError extends Error { + readonly code = "service-ownership-subject-mismatch" as const; + constructor(readonly expected: ServiceOwnershipSubject, readonly actual: ServiceOwnershipSubject) { + super("service ownership changed after consent; resolve again and ask for fresh approval"); + this.name = "ServiceOwnershipSubjectMismatchError"; + } +} + +export class ServiceOwnershipSubjectUnknownError extends Error { + readonly code = "service-ownership-subject-unknown" as const; + constructor(readonly expected: ServiceOwnershipSubject, readonly reason: string) { + super(`service ownership could not be revalidated after consent (${reason}); nothing was written`); + this.name = "ServiceOwnershipSubjectUnknownError"; + } +} + +export class ServiceTakeoverCompatibilityChangedError extends Error { + readonly code = "service-takeover-compatibility-changed" as const; + constructor(readonly actual: ServiceTakeoverCompatibility) { + super("the managing CLI compatibility changed after consent; resolve again and ask for fresh approval"); + this.name = "ServiceTakeoverCompatibilityChangedError"; + } } /** @@ -742,21 +706,65 @@ function ownershipBaseRecord(current: ServiceInstallState | null): ServiceInstal * generation alone, so every relaunch of an app that already has consent is a no-op on the * number. A different owner or a different install id is a new grant and increments it once. */ +export interface RecordServiceOwnerRequest { + readonly owner: ServiceOwner; + readonly installId: string; + readonly expectedSubject: ServiceOwnershipSubject; + readonly expectedCompatibility: Extract; +} + +export interface RecordServiceOwnerDeps extends ServiceStateSwapDeps { + /** Re-observes BOTH the registered manager and the current PATH manager inside the lock. */ + readonly observeManagers: () => Readonly>; +} + export function recordServiceOwner( - claim: { owner: ServiceOwner; installId: string }, - deps: ServiceStateSwapDeps = {}, -): ServiceOwnership { - if (!claim.installId) throw new Error("refusing to record service ownership without an install id"); + request: RecordServiceOwnerRequest, + deps: RecordServiceOwnerDeps, +): Extract { + if (!request.installId) throw new Error("refusing to record service ownership without an install id"); + if (!request.expectedSubject || request.expectedCompatibility?.kind !== "supported") { + throw new Error("refusing to record service ownership without the exact approved subject and compatibility token"); + } + if (!deps || typeof deps.observeManagers !== "function") { + throw new Error("refusing to record service ownership without a managing-CLI revalidation callback"); + } + const { observeManagers, ...swapDeps } = deps; let recorded: ServiceOwnership | null = null; - swapServiceInstallState(current => { + const committed = swapServiceInstallState((current, context) => { + const actualSubject = serviceOwnershipSubject(current, context.revision); + if (!sameServiceOwnershipSubject(request.expectedSubject, actualSubject)) { + throw new ServiceOwnershipSubjectMismatchError(request.expectedSubject, actualSubject); + } + let managers: Readonly>; + try { managers = observeManagers(); } + catch (error) { + throw new ServiceTakeoverCompatibilityChangedError({ + kind: "blocked", + reason: "managing-cli-unknown", + detail: error instanceof Error ? error.message : String(error), + minimumCliVersion: SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION, + }); + } + const compatibility = assessServiceTakeoverCompatibility({ + state: current, + subject: actualSubject, + managers, + }); + if (!sameServiceTakeoverCompatibility(request.expectedCompatibility, compatibility)) { + throw new ServiceTakeoverCompatibilityChangedError(compatibility); + } const previous = current?.ownership ?? null; // The ceiling, not just the live claim: a grant that was released left its number // behind on purpose, so a later grant cannot reuse it. const floor = Math.max(previous?.consentGeneration ?? 0, current?.consentGenerationCeiling ?? 0); + if (floor >= Number.MAX_SAFE_INTEGER) { + throw new Error("service ownership consent generation is exhausted; nothing was written"); + } recorded = { - owner: claim.owner, - installId: claim.installId, - consentGeneration: previous && ownershipGrantedTo(previous, claim.owner, claim.installId) + owner: request.owner, + installId: request.installId, + consentGeneration: previous && ownershipGrantedTo(previous, request.owner, request.installId) ? previous.consentGeneration : floor + 1, }; @@ -765,9 +773,14 @@ export function recordServiceOwner( ownership: recorded, consentGenerationCeiling: Math.max(floor, recorded.consentGeneration), }; - }, deps); - if (recorded === null) throw new Error("service ownership was not recorded"); - return recorded; + }, { + ...swapDeps, + unknownStateError: reason => new ServiceOwnershipSubjectUnknownError(request.expectedSubject, reason), + }); + if (recorded === null || !committed?.ownership || committed.revision === undefined) { + throw new Error("service ownership was not recorded"); + } + return { kind: "owned", ownership: committed.ownership, revision: committed.revision }; } /** @@ -776,9 +789,23 @@ export function recordServiceOwner( * Writes nothing when there is no claim to release, so asking about an unowned runtime never * creates an install record describing a service nobody registered. */ -export function releaseServiceOwner(deps: ServiceStateSwapDeps = {}): ServiceOwnership | null { +export interface ReleaseServiceOwnerDeps extends ServiceStateSwapDeps { + /** Service install refreshes provenance first; that known write may advance only revision. */ + readonly allowRevisionAdvance?: boolean; +} + +export function releaseServiceOwner( + expectedSubject: ServiceOwnershipSubject, + deps: ReleaseServiceOwnerDeps = {}, +): ServiceOwnership | null { + const { allowRevisionAdvance = false, ...swapDeps } = deps; let released: ServiceOwnership | null = null; - swapServiceInstallState(current => { + swapServiceInstallState((current, context) => { + const actualSubject = serviceOwnershipSubject(current, context.revision); + const matches = allowRevisionAdvance + ? sameServiceOwnershipIdentity(expectedSubject, actualSubject) && actualSubject.revision >= expectedSubject.revision + : sameServiceOwnershipSubject(expectedSubject, actualSubject); + if (!matches) throw new ServiceOwnershipSubjectMismatchError(expectedSubject, actualSubject); released = current?.ownership ?? null; if (!current?.ownership) return null; const { ownership: _released, ...withoutOwnership } = current; @@ -792,7 +819,10 @@ export function releaseServiceOwner(deps: ServiceStateSwapDeps = {}): ServiceOwn current.ownership.consentGeneration, ), }; - }, deps); + }, { + ...swapDeps, + unknownStateError: reason => new ServiceOwnershipSubjectUnknownError(expectedSubject, reason), + }); return released; } @@ -806,20 +836,15 @@ export type ServiceStateEvidence = /** * Every state path, with what each one said. * - * `readServiceInstallState` returns the FIRST path that parsed and discards the - * rest, so a valid mirror beside a corrupt one reads as clean. That is the right - * behavior for callers that just need the install state; it is the wrong input - * for deciding ownership, where a disagreement between mirrors is exactly the - * evidence that matters. + * The final path is authoritative; earlier paths are compatibility mirrors and the + * migration source only while the authority is absent. Keeping the raw evidence separate + * lets the selector distinguish migration, degraded mirrors and unordered conflicts. */ export function inspectServiceStateEvidence( paths: readonly string[] = serviceStatePaths(), ): readonly ServiceStateEvidence[] { - // ENOENT is an answer. EACCES, ENOTDIR and the rest are a failure to ask, and collapsing - // them into absence is how a locked-down state file would become permission to write. - // The classification is the shared contract's, so the launcher makes the same call. return paths.map(path => ( - inspectInstallStateBytes(path, at => readFileSync(at, "utf8")) as unknown as ServiceStateEvidence + inspectInstallStateBytes(path, at => readFileSync(at, "utf8")) as ServiceStateEvidence )); } diff --git a/src/types/request.ts b/src/types/request.ts index 7a73eaa5987..975db448179 100644 --- a/src/types/request.ts +++ b/src/types/request.ts @@ -30,6 +30,8 @@ export interface OcxReasoningReplayIdentity { * the holder, so late tool-call cache writes see the active physical identity. */ export interface OcxReasoningReplayScopeRef { + /** Process-local caller principal; `loopback` denotes the trusted local-only admission lane. */ + readonly clientPrincipalId?: string; /** * Conversation namespace for replay state. Historically this was always the Codex parent-thread * id; headerless Responses callers use a raw sanitized thread/Cursor/session fallback, never the diff --git a/src/update/index.ts b/src/update/index.ts index 17ffa6dadaf..a8af29661dc 100644 --- a/src/update/index.ts +++ b/src/update/index.ts @@ -11,6 +11,7 @@ import { readPid, readRuntimePort } from "../config/process-state"; import { pendingTeardownOutstanding } from "../config/pending-teardown"; import type { ServiceOwnership } from "../service/state"; import { planUpdateRuntimeHandling } from "./runtime-ownership.mjs"; +import { acquireOwnershipMutationLease } from "../service/ownership-mutation-lease.mjs"; import { npmInvocation } from "./npm-invocation.mjs"; import { pnpmInvocation, pnpmInvocationForPath, resolvePnpmCommands } from "./pnpm-invocation.mjs"; import { detectInstallFromPath } from "./install-detection.mjs"; @@ -326,14 +327,29 @@ export function checkUpdatePackageIntegrity( * the runtime, and treating it as such is how an unreadable record reactivates the npm * launcher over a takeover the user consented to. */ -async function resolvedRuntimeOwnership(): Promise<{ ownership: ServiceOwnership | null; ownershipUnknown: boolean }> { +interface RuntimeOwnershipObservation { + readonly ownership: ServiceOwnership | null; + readonly ownershipUnknown: boolean; + readonly subjectToken: string; +} + +async function resolvedRuntimeOwnership(): Promise { try { const { resolveServiceOwnership } = await import("../service"); const resolution = resolveServiceOwnership(); - if (resolution.kind === "owned") return { ownership: resolution.ownership, ownershipUnknown: false }; - return { ownership: null, ownershipUnknown: resolution.kind === "unknown" }; + if (resolution.kind === "owned") return { + ownership: resolution.ownership, + ownershipUnknown: false, + subjectToken: JSON.stringify(["owned", resolution.revision, resolution.ownership]), + }; + if (resolution.kind === "none") return { + ownership: null, + ownershipUnknown: false, + subjectToken: JSON.stringify(["none", resolution.revision]), + }; + return { ownership: null, ownershipUnknown: true, subjectToken: "unknown" }; } catch { - return { ownership: null, ownershipUnknown: true }; + return { ownership: null, ownershipUnknown: true, subjectToken: "unknown" }; } } @@ -404,11 +420,16 @@ export async function runUpdate(): Promise { } catch { /* best-effort */ } // What this update may do to the runtime. A desktop takeover vetoes both the stop and the // service refresh below; see `planUpdateRuntimeHandling` for why each half is wrong. - let runtimePlan = planUpdateRuntimeHandling({ - ...(await resolvedRuntimeOwnership()), + const initialOwnership = await resolvedRuntimeOwnership(); + const runtimePlan = planUpdateRuntimeHandling({ + ...initialOwnership, serviceInstalled: serviceWasInstalled, }); if (runtimePlan.notice) console.log(runtimePlan.notice); + if (!runtimePlan.mayReplacePackage) { + console.error("⚠️ Update stopped before tray handoff, runtime stop, or package replacement because runtime ownership is unknown."); + process.exit(1); + } let trayWasInstalled = false; let trayWasRunning = false; if (process.platform === "win32") { @@ -456,20 +477,7 @@ export async function runUpdate(): Promise { // silently skips the recovery the receipt was written to trigger (#3008). // Full `ocx stop` semantics (drain, service stop, restore). let stopAttempted = false; - // Re-read at the point of action rather than trusting the plan formed above. Between the - // two the Windows tray handoff spawns children and the listen target is captured, so a - // takeover can land in between — and stopping a runtime that just changed hands is the - // failure this lane exists to prevent. Reassigning the one variable keeps the recovery - // branches and the restart hint reading the same decision as the stop. - { - const atStop = planUpdateRuntimeHandling({ - ...(await resolvedRuntimeOwnership()), - serviceInstalled: serviceWasInstalled, - }); - if (atStop.notice && atStop.notice !== runtimePlan.notice) console.log(atStop.notice); - runtimePlan = atStop; - } - if (runtimePlan.stopRuntime && (serviceWasInstalled || readPid() || readRuntimePort() || pendingTeardownOutstanding())) { + if (runtimePlan.mayStopRuntime && (serviceWasInstalled || readPid() || readRuntimePort() || pendingTeardownOutstanding())) { stopAttempted = true; console.log("⏹ Stopping the running proxy before updating..."); const stopStdio = updateChildStdio(); @@ -530,26 +538,45 @@ export async function runUpdate(): Promise { } } - console.log(`Updating${latest ? ` to v${latest}` : ""}…\n$ ${bin} ${cmdArgs.join(" ")}`); - const installStdio = updateChildStdio(); - // Every post-update action below receives this path. For pnpm it is replaced only - // by a path returned after tree+shim verification; on rollback, activePath is - // likewise returned only after the old group has been verified again. - let postUpdateLauncher = join(packageRoot(), "bin", "ocx.mjs"); - // The pnpm owner preflight has verified this package tree and global group. Keep that exact - // package path as the recovery starting point; the path returned by the update transaction - // replaces it only after post-update tree+shim verification succeeds. - if (installer === "pnpm" && owner) { - postUpdateLauncher = join(owner.packagePath, "bin", "ocx.mjs"); - } + let postUpdateLauncher = installer === "pnpm" && owner + ? join(owner.packagePath, "bin", "ocx.mjs") + : join(packageRoot(), "bin", "ocx.mjs"); let postUpdateLauncherUsable = true; let r: { status: number | null; signal?: NodeJS.Signals | null; stdout?: string | Buffer | null; stderr?: string | Buffer | null; - }; + } | null = null; + const { serviceStatePaths } = await import("../service"); + const replacementLease = acquireOwnershipMutationLease(serviceStatePaths()); + let replacementRefusal: string | null = null; + try { + // Ownership can change while registry and stop work is in flight. Unknown at this exact + // boundary blocks replacement; a confirmed desktop claim still permits updating the idle + // npm installation while leaving the bundled sidecar alone. + const replacementOwnership = await resolvedRuntimeOwnership(); + const replacementPlan = planUpdateRuntimeHandling({ + ...replacementOwnership, + serviceInstalled: serviceWasInstalled, + }); + const replacementLiveness = runtimePlan.mayStopRuntime + ? (await proxyIdentityAt(capturedListen.port, { hostname: capturedListen.hostname }) + ? "live" + : probeProxyLiveness(capturedListen.port, capturedListen.hostname)) + : "dead"; + if (replacementOwnership.subjectToken !== initialOwnership.subjectToken + || !replacementPlan.mayReplacePackage + || replacementLiveness !== "dead") { + replacementRefusal = replacementPlan.notice + ?? (replacementLiveness === "live" + ? "⚠️ Update stopped because a proxy became live after the stop decision; rerun from the beginning." + : "⚠️ Update stopped because runtime ownership or liveness changed after the stop decision; rerun from the beginning."); + } else { + console.log(`Updating${latest ? ` to v${latest}` : ""}…\n$ ${bin} ${cmdArgs.join(" ")}`); + + // Every post-update action below receives the verified active launcher. if (installer === "pnpm") { let update: ReturnType; try { @@ -597,7 +624,26 @@ export async function runUpdate(): Promise { ...target.options, }); } - if (installStdio === "pipe") logSpawnOutput("", r); + if (r && installStdio === "pipe") logSpawnOutput("", r); + } + } finally { + replacementLease.release(); + } + if (replacementRefusal) { + if (trayWasRunning) { + try { + const { startWindowsTray } = await import("../tray/windows"); + startWindowsTray(); + } catch { /* preserve the ownership refusal */ } + } + console.error(replacementRefusal); + process.exit(1); + } + if (!r) throw new Error("update replacement returned no result"); + const postInstallPlan = planUpdateRuntimeHandling({ + ...(await resolvedRuntimeOwnership()), + serviceInstalled: serviceWasInstalled, + }); if (r.status === 0) { console.log(`\n✅ Updated${latest ? ` to v${latest}` : ""}.`); // Re-enter through the verified active package launcher. This keeps the Codex @@ -628,7 +674,7 @@ export async function runUpdate(): Promise { // The stop above unloaded any managed service; repair it with the NEW files // (spawn the fresh cli.ts so updated code writes the baked paths) so a // launchd/schtasks/systemd user isn't left with the background proxy down. - if (runtimePlan.refreshService) { + if (postInstallPlan.mayRestoreService) { console.log("🔁 Refreshing the background service with the updated files..."); const { serviceReinstallArgs } = await import("../service"); const { reclaimListenPort } = await import("../server/port-reclaim"); @@ -685,7 +731,7 @@ export async function runUpdate(): Promise { ...(await resolvedRuntimeOwnership()), serviceInstalled: true, }); - if (!nowOwned.stopRuntime) { + if (!nowOwned.mayStopRuntime) { console.warn(nowOwned.notice ?? "⚠️ The background runtime is owned elsewhere; not starting a second proxy."); return; } @@ -716,20 +762,20 @@ export async function runUpdate(): Promise { if (prevBake === undefined) delete process.env.OCX_BAKE_PORT; else process.env.OCX_BAKE_PORT = prevBake; } - } else if (runtimePlan.stopRuntime) { + } else if (postInstallPlan.mayStopRuntime) { console.log(`Restart the proxy: ${launcherStartHint(postUpdateLauncher, capturedListen.port)}`); } } else { if (stopAttempted && trayWasRunning && postUpdateLauncherUsable) { spawnSync(process.execPath, [postUpdateLauncher, "tray", "start"], { stdio: "ignore", windowsHide: true }); } - if (stopAttempted && runtimePlan.refreshService && postUpdateLauncherUsable) { + if (stopAttempted && postInstallPlan.mayRestoreService && postUpdateLauncherUsable) { const service = spawnSync(process.execPath, [postUpdateLauncher, "service", "repair"], { stdio: "inherit", windowsHide: true, }); if (service.status !== 0) console.warn("⚠️ Previous background service could not be restored; run 'ocx service repair'."); - } else if (stopAttempted && postUpdateLauncherUsable) { + } else if (stopAttempted && postUpdateLauncherUsable && postInstallPlan.mayStopRuntime) { const env = { ...process.env }; delete env.OCX_SERVICE; const child = spawn(process.execPath, [postUpdateLauncher, "start", "--port", String(capturedListen.port)], { @@ -739,6 +785,8 @@ export async function runUpdate(): Promise { env: withProcessRuntimeProvenance(env), }); child.unref(); + } else if (stopAttempted && !postInstallPlan.mayStopRuntime) { + console.warn(postInstallPlan.notice ?? "⚠️ Runtime ownership changed during the update; not starting a second proxy."); } else if (stopAttempted) { console.error("opencodex: no verified active launcher remains for automatic recovery; reinstall opencodex manually."); } diff --git a/src/update/job.ts b/src/update/job.ts index d3ce29ec9be..dd8e47748e5 100644 --- a/src/update/job.ts +++ b/src/update/job.ts @@ -26,7 +26,7 @@ import { listListenPids, reclaimListenPort, scanListenPids, type ListenPidScan } import { dropWindowsTcpRowsForLocalPort } from "../server/windows-tcp-drop"; import { isOpencodexHealthz, probeHostname, proxyIdentityAt, type HealthzIdentity } from "../server/proxy-liveness"; import { isServiceInstalled, isServiceViable, readServiceBackend, stopWindows } from "../service"; -import { updateRestartVeto, type ServiceOwnershipResolution } from "./restart-ownership"; +import { runUpdateRestartWithOwnershipLease, type ServiceOwnershipResolution } from "./restart-ownership"; import { type Channel, type Installer, @@ -1970,15 +1970,12 @@ export async function runGuiUpdateWorker( } if (restart) { - // The package updater it just ran deliberately left a foreign-owned runtime alone, and - // restarting here would replace the app's sidecar with an npm proxy. - const veto = updateRestartVeto(io.resolveOwnershipFn); - if (veto) { updateJob(job, { status: "succeeded", restarted: false }, veto); return; } - job = updateJob(job, { status: "restarting" }, "Update installed. Restarting proxy..."); - if (!(await finishGuiUpdateRestart(job, captured, check.installer, { - ...io.restartIo, - packageLauncherPathFn: () => activeLauncher, - }))) return; + const outcome = await runUpdateRestartWithOwnershipLease(io.resolveOwnershipFn, async () => { + job = updateJob(job!, { status: "restarting" }, "Update installed. Restarting proxy..."); + return finishGuiUpdateRestart(job!, captured, check.installer, { ...io.restartIo, packageLauncherPathFn: () => activeLauncher }); + }); + if (outcome.kind === "veto") { updateJob(job, { status: "succeeded", restarted: false }, outcome.notice); return; } + if (!outcome.value) return; updateJob(job, { status: "succeeded", restarted: true }, "Restart requested and proxy is healthy."); return; } diff --git a/src/update/restart-ownership.ts b/src/update/restart-ownership.ts index 7e0471e379d..32cafee1e6e 100644 --- a/src/update/restart-ownership.ts +++ b/src/update/restart-ownership.ts @@ -1,5 +1,10 @@ import { resolveServiceOwnership } from "../service"; import type { ServiceOwnershipResolution } from "../service"; +import { serviceStatePaths } from "../service/state"; +import { + acquireOwnershipMutationLease, + OWNERSHIP_MUTATION_LEASE_TOKEN_ENV, +} from "../service/ownership-mutation-lease.mjs"; import { planUpdateRuntimeHandling } from "./runtime-ownership.mjs"; export type { ServiceOwnershipResolution }; @@ -27,6 +32,23 @@ export function updateRestartVeto( // The restart decision does not refresh the service; only the stop veto is read here. serviceInstalled: false, }); - if (plan.stopRuntime) return null; + if (plan.mayStopRuntime) return null; return plan.notice ?? "The background runtime is owned elsewhere; it was left running."; } + +export async function runUpdateRestartWithOwnershipLease( + resolve: (() => ServiceOwnershipResolution) | undefined, + restart: () => Promise, +): Promise<{ readonly kind: "veto"; readonly notice: string } | { readonly kind: "ran"; readonly value: T }> { + const lease = acquireOwnershipMutationLease(serviceStatePaths()); + const previous = process.env[OWNERSHIP_MUTATION_LEASE_TOKEN_ENV]; + process.env[OWNERSHIP_MUTATION_LEASE_TOKEN_ENV] = lease.token; + try { + const veto = updateRestartVeto(resolve); + return veto ? { kind: "veto", notice: veto } : { kind: "ran", value: await restart() }; + } finally { + if (previous === undefined) delete process.env[OWNERSHIP_MUTATION_LEASE_TOKEN_ENV]; + else process.env[OWNERSHIP_MUTATION_LEASE_TOKEN_ENV] = previous; + lease.release(); + } +} diff --git a/src/update/runtime-ownership.d.mts b/src/update/runtime-ownership.d.mts index cc9b74fd853..754c3499a4d 100644 --- a/src/update/runtime-ownership.d.mts +++ b/src/update/runtime-ownership.d.mts @@ -1,10 +1,40 @@ -/** Declaration for the plain-ESM runtime-ownership rule shared with `bin/ocx.mjs`. */ export declare function planUpdateRuntimeHandling(input: { ownership: { owner: string; installId: string; consentGeneration: number } | null; ownershipUnknown?: boolean; serviceInstalled: boolean; }): { - stopRuntime: boolean; - refreshService: boolean; + mayReplacePackage: boolean; + mayStopRuntime: boolean; + mayRestoreService: boolean; notice: string | null; }; + +export declare function planStoppedRuntimeRecovery(input: { + stopAttempted: boolean; + ownership: { owner: string; installId: string; consentGeneration: number } | null; + ownershipUnknown?: boolean; + sameOwner: boolean; + liveness: "live" | "dead" | "unknown"; + serviceInstalled: boolean; + launcherUsable: boolean; + hadRuntimeState: boolean; +}): { + action: "none" | "manual" | "service" | "direct"; + reason: string; +}; + +type RuntimeTarget = { port: number; hostname: string }; +type RuntimeLiveness = "live" | "dead" | "unknown"; + +export declare function inspectPackageRuntimeLiveness(input: { + capturedTarget: RuntimeTarget; + readCurrentTarget(): + | { kind: "target"; target: RuntimeTarget } + | { kind: "absent" } + | { kind: "unknown" }; + probe(target: RuntimeTarget): RuntimeLiveness; +}): { + current: RuntimeLiveness | "absent"; + captured: RuntimeLiveness; + overall: RuntimeLiveness; +}; diff --git a/src/update/runtime-ownership.mjs b/src/update/runtime-ownership.mjs index 0e7fba1341f..c9eb52e5df8 100644 --- a/src/update/runtime-ownership.mjs +++ b/src/update/runtime-ownership.mjs @@ -7,17 +7,6 @@ * situation separately is how a fix ships on one side only. */ -/* - * There is deliberately no ownership PARSER here any more. - * - * This module used to carry one so the Node launcher could read the record without importing - * TypeScript, kept "in step" with the authoritative reader by a test that drove the same - * shapes through both. It was not in step: it inspected one path and treated a record that - * failed the whole install-state contract as an unowned runtime whenever its `ownership` - * field was simply absent. Reading and resolving now live in - * `src/service/install-state-contract.mjs`, which both runtimes import, so there is one - * algorithm rather than two that a test has to keep aligned. - */ /** * Decide how an update treats a runtime it may not own. * @@ -38,25 +27,24 @@ * two-record ownership design accepted; `ocx service install` clears the marker and restores * the ordinary path. * - * Both returned flags are VETOES, not commands: each updater already has its own reasons to - * stop the proxy and to refresh the service, and this plan can only take them away. + * The three returned flags are separate authorities, not commands. In particular, leaving + * a runtime running is not permission to replace the package it may be executing from. * - * THE LIMIT OF THIS RULE. It reads the recorded claim, not the live process. If the app was - * deleted and the user then starts an npm proxy by hand, the stale claim still vetoes the - * stop and the update replaces package files under a live server. Proving WHICH runtime is - * answering needs the identity the bundled CLI's resolve contract will carry; until then the - * notice tells the user how to clear the marker. + * A recorded desktop claim does not prove which binary is live. Until the bundled resolver + * carries installation identity, it therefore blocks package replacement as well as stop and + * restoration; the notice tells a stale-marker user how to take ownership back explicitly. * * @param {{ ownership: { owner: string, installId: string, consentGeneration: number } | null, ownershipUnknown?: boolean, serviceInstalled: boolean }} input - * @returns {{ stopRuntime: boolean, refreshService: boolean, notice: string | null }} + * @returns {{ mayReplacePackage: boolean, mayStopRuntime: boolean, mayRestoreService: boolean, notice: string | null }} */ export function planUpdateRuntimeHandling({ ownership, ownershipUnknown = false, serviceInstalled }) { // Unreadable, malformed or contradictory is not "nobody owns it". Reading it that way is // how a permissions error reactivates the npm launcher over a consented takeover. if (ownershipUnknown) { return { - stopRuntime: false, - refreshService: false, + mayReplacePackage: false, + mayStopRuntime: false, + mayRestoreService: false, notice: "⚠️ The background runtime's recorded owner could not be determined, so it was " + "left running and the service registration was not touched. " + "Run 'ocx service install' to re-register the service and take the runtime back.", @@ -67,13 +55,68 @@ export function planUpdateRuntimeHandling({ ownership, ownershipUnknown = false, // registration. if (ownership && ownership.owner !== "cli") { return { - stopRuntime: false, - refreshService: false, + // A claim does not prove which binary is live. A stale desktop marker beside a + // manually started npm proxy would otherwise replace that proxy's executing files. + mayReplacePackage: false, + mayStopRuntime: false, + mayRestoreService: false, notice: `🖥️ The desktop app owns the background runtime (install ${ownership.installId}, ` - + `consent generation ${ownership.consentGeneration}). It was left running, and the ` + + `consent generation ${ownership.consentGeneration}). It and the npm package were left unchanged, and the ` + "service registration was neither re-enabled nor restarted. " + "If the desktop app is gone, run 'ocx service install' to take the runtime back.", }; } - return { stopRuntime: true, refreshService: serviceInstalled, notice: null }; + return { + mayReplacePackage: true, + mayStopRuntime: true, + mayRestoreService: serviceInstalled, + notice: null, + }; +} + +/** Decide recovery after this updater already stopped the prior CLI-owned runtime. */ +export function planStoppedRuntimeRecovery({ + stopAttempted, + ownership, + ownershipUnknown = false, + sameOwner, + liveness, + serviceInstalled, + launcherUsable, + hadRuntimeState, +}) { + if (!stopAttempted) return { action: "none", reason: "not-stopped" }; + if (ownershipUnknown) return { action: "manual", reason: "ownership-unknown" }; + if (!sameOwner || (ownership && ownership.owner !== "cli")) { + return { action: "none", reason: "ownership-transferred" }; + } + if (liveness !== "dead") return { action: "manual", reason: `runtime-${liveness}` }; + if (!launcherUsable) return { action: "manual", reason: "launcher-unavailable" }; + if (serviceInstalled) return { action: "service", reason: "same-cli-owner" }; + if (hadRuntimeState) return { action: "direct", reason: "same-cli-owner" }; + return { action: "none", reason: "nothing-to-restore" }; +} + +/** + * Re-read the current package runtime before probing. The result keeps an absent current + * record distinct from a dead captured endpoint while still projecting one fail-closed + * liveness verdict for replacement and recovery decisions. + */ +export function inspectPackageRuntimeLiveness({ capturedTarget, readCurrentTarget, probe }) { + const currentTarget = readCurrentTarget(); + const observations = new Map(); + const inspect = target => { + const key = `${target.hostname}:${target.port}`; + if (!observations.has(key)) observations.set(key, probe(target)); + return observations.get(key); + }; + // Probe the fresh record first. It is the address a replacement runtime may have + // published while the updater was waiting on the ownership lease. + const current = currentTarget.kind === "target" ? inspect(currentTarget.target) : currentTarget.kind; + const captured = inspect(capturedTarget); + const verdicts = current === "absent" ? [captured] : [current, captured]; + const overall = verdicts.includes("live") + ? "live" + : verdicts.includes("unknown") ? "unknown" : "dead"; + return { current, captured, overall }; } diff --git a/src/web-search/executor.ts b/src/web-search/executor.ts index 33b6a1eb6cc..e14288ebafa 100644 --- a/src/web-search/executor.ts +++ b/src/web-search/executor.ts @@ -49,10 +49,12 @@ export type SidecarOutcome = WebSearchResult & { error?: string }; * The forward backend throttles burst sidecar traffic, and without a replay the 429 becomes a * failed tool result that poisons the query for the whole turn (see failedQueries in loop.ts). * 1 initial send + 2 replays; Retry-After is honored as a lower bound and capped by - * RETRY_AFTER_CEILING_MS (an instruction past the ceiling ends with the 429 instead of - * parking the search). Each wait releases the unread 429 body first so sockets do not - * accumulate under a rate-limit storm. Abort or timeout ends the wait through the existing - * catch, exactly like an abort during the SSE parse. + * RETRY_AFTER_CEILING_MS and the remaining sidecar deadline (an instruction past either + * ends with the 429 instead of parking the search). Each wait releases the unread 429 body first so sockets do not + * accumulate under a rate-limit storm. The release itself may take up to a second, so a + * deadline landing during release or backoff ends with the 429 already in hand rather than + * a timeout; a caller abort still ends the wait through the shared catch, exactly like an + * abort during the SSE parse. */ const SIDECAR_429_MAX_ATTEMPTS = 3; const SIDECAR_429_BASE_DELAY_MS = 1_000; @@ -98,9 +100,10 @@ export async function runWebSearch( stream: true, }; const url = `${forwardProvider.baseUrl}/responses`; + // t0 precedes the deadline timer's start so the remaining-time check stays conservative. + const t0 = Date.now(); const linkedSignal = signalWithTimeout(settings.timeoutMs, abortSignal); const sidecarExit = sidecarEnter("web-search"); - const t0 = Date.now(); try { const sendOnce = () => fetchWithResetRetry( // Recovery nests INSIDE the version helper: applyUpstreamRecoveryInit then always receives a @@ -129,10 +132,19 @@ export async function runWebSearch( }); // A deadline, not a clamp: an instruction past the ceiling ends the search with the // 429 instead of parking it at a provider that already said it would refuse. - if (delay > RETRY_AFTER_CEILING_MS) break; + if (delay > RETRY_AFTER_CEILING_MS || delay >= settings.timeoutMs - (Date.now() - t0)) break; console.warn(`[web-search] sidecar HTTP 429 — retrying (${attempt + 2}/${SIDECAR_429_MAX_ATTEMPTS}) after ${delay}ms`); - await releaseResponseBodyBestEffort(res.body, linkedSignal.signal); - await sleepWithAbort(delay, linkedSignal.signal); + try { + await releaseResponseBodyBestEffort(res.body, linkedSignal.signal); + await sleepWithAbort(delay, linkedSignal.signal); + } catch (e) { + // The release above may consume up to 1s, so the sidecar deadline can land during + // cleanup or mid-backoff — before the replay is dispatched. The observed 429 is + // already in hand: end with it rather than laundering it into a timeout. A caller + // abort (or a non-deadline throw) still propagates to the shared catch below. + if (!linkedSignal.signal.aborted || linkedSignal.signal.reason === abortSignal?.reason) throw e; + break; + } res = await sendOnce(); } // Attach the body guard before ANY branch reads it. The success path guarded itself below, diff --git a/structure/decisions/ADR-0097-post-write-app-server-freshness.md b/structure/decisions/ADR-0097-post-write-app-server-freshness.md new file mode 100644 index 00000000000..070709ea12e --- /dev/null +++ b/structure/decisions/ADR-0097-post-write-app-server-freshness.md @@ -0,0 +1,12 @@ +# ADR-0097 — decision recorded under "CLI Codex restart scope" + +- Contract owner: [runtime.md](../runtime.md#cli-codex-restart-scope) + +## Decision record + +- 목적과 의도: Avoid telling an operator to interrupt a fresh Codex session after a successful sync. +- 기존 구현 및 제약 조건: Startup already classified catalog freshness, but the ordinary post-write CLI warning treated every running app-server as stale; explicit restart flags must keep their existing consent semantics. +- 검토한 주요 대안: Warn on every running process; suppress every warning; classify only the advisory non-restart path from one process observation. +- 선택한 방식: Retain command lines from the classifier's enumeration and warn only for the proven-stale subset. +- 다른 대안 대신 이 방식을 선택한 이유: Presence alone cannot prove stale state, while suppressing every warning would hide a real in-memory catalog mismatch. +- 장점, 단점 및 영향: Mixed fresh/stale sets name only stale PIDs and unknown observations stay quiet; explicit restart requests remain unchanged. diff --git a/structure/decisions/ADR-0098-schema-bound-exec-command-input-repair.md b/structure/decisions/ADR-0098-schema-bound-exec-command-input-repair.md new file mode 100644 index 00000000000..8640b675e42 --- /dev/null +++ b/structure/decisions/ADR-0098-schema-bound-exec-command-input-repair.md @@ -0,0 +1,12 @@ +# ADR-0098 — decision recorded under "Schema-bound flat shell repair" + +- Contract owner: [transports/responses.md](../transports/responses.md#schema-bound-flat-shell-repair) + +## Decision record + +- 목적과 의도: Let an unambiguous routed `exec_command` wrapper reach the Codex shell bridge without weakening tool identity or argument validation. +- 기존 구현 및 제약 조건: Freeform code-mode tools already unwrap `input`, but a genuinely declared flat function must keep its name and Codex validates it against required `cmd`. +- 검토한 주요 대안: Rename the call to code-mode `exec`; rewrite every function's `input`; add a schema-bound repair at authoritative function-call completion. +- 선택한 방식: Repair only the exact bare declaration and exact one-member string payload when the current-turn schema requires string `cmd`. +- 다른 대안 대신 이 방식을 선택한 이유: Tool names and arbitrary `input` fields are caller-owned; the original schema is the only authority that makes the representation change deterministic. +- 장점, 단점 및 영향: Buffered, streamed-completion, and replay paths converge on valid `cmd` arguments; previews and ambiguous or namespaced shapes stay untouched. diff --git a/structure/desktop-shell.md b/structure/desktop-shell.md index 29408205c78..86b32275c57 100644 --- a/structure/desktop-shell.md +++ b/structure/desktop-shell.md @@ -10,7 +10,11 @@ webview to the proxy's loopback dashboard (`/#/usage`) rather than bundling or s itself. The page renders what the shell tells it and probes nothing on its own; it asks `startup_phases` for the state list rather than restating it, takes the current state from `startup_snapshot` on load because the first states finish in milliseconds, and then follows the -`startup-phase` event. It uses no `alert`, `confirm` or `prompt`: the embedded webview implements +`startup-phase` event. `startup_snapshot` always answers with a state; it used to be able to +answer with nothing, and the page returns early on a falsy progress, so the one case it could not +render — a shell with no startup state — arrived as silence rather than as a diagnostic. A shell +that cannot find its own startup state now reports that as a failure the user can read and copy. +It uses no `alert`, `confirm` or `prompt`: the embedded webview implements none of the matching WKUIDelegate panel methods on macOS, so a platform dialog is declined without drawing anything. `withGlobalTauri` is on so that page can invoke without a bundler. Only the local app origin @@ -30,6 +34,20 @@ into that record instead of discarding it, which is what makes an immediate side distinguishable from a slow start. The page asks for the state list and the run's progress rather than reconstructing either, because the early states finish faster than a listener can attach. +The deadline is a promise that the screen stops changing, so something keeps it when the run does +not. The sequence publishes its first state before any lookup that can fail, and a guard bound to +that run reports a terminal state for it if the run returns without one or outlives the ceiling. +The guard is idempotent and generation-scoped: it will not overwrite a result the run reported, +and one left over from an earlier run will not fail the retry that replaced it. It waits a short +grace past the ceiling so the run's own failure, which names the endpoint, the home and how the +child ended, is the diagnostic on screen rather than the guard's thinner one. + +"Has not started" is a state of its own rather than the first phase. The sequence's state used to +be seeded with `registering`, so a shell that never began rendered exactly like one that had just +begun — on the surface whose whole job is to tell those apart. `not-started` is deliberately +absent from the phase list the page draws its checklist from: it is the absence of a run, so a row +for it would be a step that never completes. + The shell resolves nothing itself. Resolving runs the bundled `ocx resolve --json` and reads one `ocx-resolve/1` document: the configuration home, the effective port, and a liveness verdict with three answers rather than two. `live` means attach as a guest; `absent-proven` means every @@ -180,10 +198,13 @@ marker, which the GUI detects to identify the shell without using IPC. The release workflow packages the desktop shell as `OpenCodex--macos.dmg`, `OpenCodex--windows-x64.msi`, `OpenCodex--linux-x86_64.AppImage`, and `OpenCodex--linux-amd64.deb`. Each artifact is collected with a `.sha256` file; -signed updater artifacts also carry `.sig` files. A release attachment job combines the -standalone and desktop assets, verifies checksums, and writes `latest.json` only when the -updater key secret is configured; it then requires all four platforms to have updater -signatures. +signed updater artifacts also carry `.sig` files. A pre-publication verification job +combines the standalone and desktop assets, derives the expected file set from the +packaging matrices, verifies every checksum and every updater signature, and writes +`latest.json` only when the updater key secret is configured, requiring all four +platforms to have updater signatures. Publication waits for that verification, and the +attachment job uploads the verified bundle only after the verification receipt names +the same version and commit. On macOS, in-app updates download `OpenCodex--macos.app.tar.gz`; the DMG is for the first installation. @@ -199,3 +220,30 @@ The macOS desktop shell writes the WidgetKit snapshot to The schema version is `1`; the Rust writer refreshes it every five minutes after an immediate first write. The WidgetKit appex reads this privacy-safe file and performs no network access. + +## The tray icon opens a usage popup + +A left click on the tray icon opens a small always-on-top window anchored to the icon, not the +dashboard. Reading the current numbers is the reason to look at a tray icon at all, and the +dashboard is still one menu item away. The popup reuses the dashboard session and the same +management endpoints; it is given no additional IPC capability and no admin token. + +Two platform facts shape it. A Linux tray host may deliver no usable click to the application, +so the same surface is reachable from a menu item there. And before the startup sequence has +resolved a runtime there is nothing to report, so a click with no proxy falls back to showing +the main window rather than opening an empty popup. + +The popup uses the native translucent surface on macOS and Windows: macOS applies the active HUD +window material with a 12-point corner radius, and Windows applies Acrylic. Linux remains opaque +because its compositor owns blur and Tauri's window-effects path does not support it. The +`VIBRANT_SURFACE` constant in `desktop/src-tauri/src/popup.rs` is the single platform verdict for +both the transparent native builder and the page's `data-tray-vibrancy="on"` hook, so the page +cannot make an opaque Linux window transparent by mistake. + +Transparent Tauri windows on macOS require the `macos-private-api` Cargo feature and +`app.macOSPrivateApi` in `desktop/src-tauri/tauri.conf.json`. Enabling that API forecloses Mac App +Store submission; this shell ships as a Developer ID DMG, so its release channel accepts that +tradeoff. + +The tray title keeps its existing period. The popup answers the detailed question, so the title +does not change meaning as a side effect of adding it. diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 726af4a7023..1fda4c2ed76 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -19,6 +19,15 @@ The [Orca importer](codex-home.md#orca-source-owned-account-import) is a local C no management route. Imported accounts use existing quota validation; deferred warmups reread linked sources after the quota await to reject revoked or rotated captures. +## Compact desktop usage + +The standalone `/#/tray` GUI route presents local usage and account limits without the +full dashboard navigation. It reuses the existing API session and fetch wrapper; it +has no Tauri IPC capability. Companion settings control its sections and chart. Account +limits use account-level management reads rather than attributing aggregate provider +quotas to individual accounts. Missing usage is distinct from measured zero. The popup +shares the existing timeline renderer with the companion settings preview. + ## Dashboard serving Account refresh actions follow the [credential refresh-lock identity contract](catalog.md#accounts-namespaces-and-pool-rotation): a held unreadable lock is distinct from one this process may release, and path-probe errors preserve the callback outcome. Cooperating lock metadata changes serialize through the existing SQLite mutation transaction; release keeps the descriptor open through identity comparison and any unlink, then closes it. Failed metadata writes remove only a matching owned path after successful coordination; unknown identity, failed probes or unavailable coordination retain the path for stale recovery. Async refresh work holds no metadata transaction. The bundled React dashboard is built into `gui/dist` and served by the same Bun proxy. `ocx gui` starts diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 7dda9c214c6..1f66d59f083 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -172,17 +172,22 @@ searches run, their hosted cells complete, the held client calls are released fo execute, and the leg's own terminal closes the turn with no continuation sent upstream. The destination therefore does not receive that search result during the turn. It gets it on the next one: every search the bridge executes is recorded in `src/responses/bridge-search-replay-cache.ts` -under the hosted cell's proxy-minted id, scoped to the upstream destination and bounded by entry -count, total bytes, and a one-hour TTL. When the caller replays that cell, +under the hosted cell's proxy-minted id, scoped to the admitted caller principal, client +conversation, and exact provider, adapter, model, destination, and physical credential binding, and bounded by entry count, total +bytes, and a one-hour TTL. An unavailable scope fails closed. When the caller replays that cell, `restoreBridgedWebSearchCalls` in `src/adapters/openai-responses/tool-output-recovery.ts` puts the destination's own `function_call` and the executed `function_call_output` back in the cell's position before the next turn's first leg is dispatched, recording exactly the text `appendBridgeSearchTurn` would have sent on a continuation leg so a replayed turn and a continued turn show the destination one consistent conversation. The rewrite runs only for a provider with -`webSearchBridge.enabled`, and a miss — unknown id, expired entry, a different destination, or a -`call_id` the body already carries — leaves the replayed item untouched. Re-running the search or -synthesizing result text is not a permitted recovery. The bridge finalizes request-scoped OpenAI sidecar authority on completion, failure, and client cancellation — cancellation releases immediately rather than waiting on an abandoned upstream read — so a recovery probe lease no search consumed is always returned. +`webSearchBridge.enabled`, and a miss — unknown id, expired entry, a different conversation or +serving binding, or a `call_id` the body already carries — leaves the replayed item untouched. +Re-running the search or synthesizing result text is not a permitted recovery. The bridge finalizes +request-scoped OpenAI sidecar authority on completion, failure, and client cancellation — +cancellation releases immediately rather than waiting on an abandoned upstream read — so a +recovery probe lease no search consumed is always returned. `tests/web-search/web-search-bridge-replay.test.ts` pins the restore and each of those refusals. +A forward OpenAI search sidecar retries a 429 only when the requested delay fits both its retry ceiling and the remaining overall sidecar deadline. A delay that cannot fit returns and records the original 429 so pool routing retains quota evidence. A leg whose upstream terminal is `response.failed` or `response.incomplete` runs no search at all and closes any cell it opened rather than leaving it in progress. Assistant text is not treated as a search diff --git a/structure/runtime.md b/structure/runtime.md index 438e95547c6..ad62f7475bf 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -64,6 +64,14 @@ Catalog-derived reasoning-level diagnostics are escaped only at the human-output `ocx system codex-restart` requests a full Codex desktop-app restart and app-server restarts through the management endpoint. `src/cli/capabilities.ts` names that scope in its summary and `--yes` description; `src/cli/system-command.ts` explains the desktop interruption when confirmation is missing and sends no restart request. Human output says the restart was requested, while `--json` preserves the complete server result, including skipped or refused desktop outcomes. +After a CLI catalog/cache write, advisory restart guidance compares each running Codex app-server's +start time with the written catalog mtime. It reports only processes proven stale; a fresh or +unreadable observation does not claim that another restart is required. Explicit +`--restart-codex` and `--restart-app-server-only` retain their operator-consent semantics and act on +verified matching processes regardless of the advisory freshness result. + +> Decision record: [ADR-0097](decisions/ADR-0097-post-write-app-server-freshness.md) + ## Hub management dashboard address When hub management ingress is enabled, `src/cli/dispatch.ts` opens the dashboard on the literal IPv4 loopback address and configured ingress port, matching the listener in `src/server/index.ts`. Other dashboard address selection is unchanged. @@ -384,6 +392,8 @@ Automatic Codex pool selection and account status share the [plan exclusion cont ### Empty forced search answers `src/web-search/loop.ts` makes at most one extra answer attempt after a clean forced-answer terminal with no visible output or tool call. The recovery has no tools and reuses gathered search results. Malformed calls fail before refusal/truncation passthrough, and well-formed recognized refusal/truncation terminals pass through unchanged, including empty or partial answers. The extra generation may incur provider usage. + +OpenAI sidecar 429 replays run only when their backoff fits the remaining sidecar deadline; otherwise the original 429 remains the routing-health outcome rather than becoming a timeout. ## Scoped provider quota for Combo selection `src/providers/quota/report-cache.ts` publishes routing evidence only when a producer explicitly supplies its @@ -530,64 +540,54 @@ opaque `installId` naming the owning installation rather than the user or the ma `consentGeneration`. An absent claim means the CLI install that registered the service owns the runtime, which is what every record written before the field existed says. -`src/service/install-state-contract.mjs` holds the record shape, the path list and the -resolution rule, and both runtimes import it: `src/service/state.ts` and the Node launcher -`bin/ocx.mjs`, which cannot import TypeScript. The launcher previously kept its own reader, -and the divergence was an authorization gap rather than a style problem — it inspected only -the anchor path and answered "unowned" for any record whose `ownership` field was absent, -including one that failed the contract outright. - -Every write goes through `swapServiceInstallState`. It holds an `O_EXCL` lock beside the -anchor record for the whole read-modify-write, re-reads the anchor immediately before -committing and compares the committed bytes afterwards, and it runs the whole sequence again -when another writer landed inside that window; `revision` is the compare-and-swap token. The -lock excludes cooperating writers, and the revision check catches a writer that does not take -it, such as an older `ocx` on the same machine. The lock file carries a token identifying its -holder, so eviction and release each remove only the instance they own, and the stale -threshold exceeds the longest legitimate critical section rather than the typical one. Each -file is published by writing a sibling temporary file and renaming it, so an interrupted -commit leaves the previous valid record rather than a truncated one the fail-closed reader -would report as unknown. - -`writeServiceInstallState` rebuilds only the install provenance and carries the ownership -claim across unchanged, which is what keeps an install, a repair, an update or a stop from -dropping it. It resolves that claim INSIDE the swap, while the lock is held: a resolution -taken beforehand is a lost update the compare-and-swap cannot detect, because the stale value -never came from the base record. Where the anchor and the cross-path resolution still -disagree, the higher `consentGeneration` wins and an equal generation keeps the anchor. - -`resolveServiceOwnership` is how a claim is read for a decision. It reads every state path -and answers `none`, `owned` or `unknown`; absence is the only thing that means no claim, so -an unreadable path, a corrupt anchor record, or paths naming different owners all refuse -rather than reading as CLI-owned. `consentGenerationCeiling` survives a release, so granting, -releasing and granting again cannot reuse a number an app-local record may still hold. - -`recordServiceOwner` is idempotent on the same owner and install id, so a relaunch leaves the -generation alone and a grant moves it exactly once. -`ownershipGrantedTo(ownership, owner, installId)` is the comparison an installation applies -to its own locally stored 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, and a null claim means the CLI install still owns it. - -The verbs that ACTIVATE the npm registration refuse on a foreign or unknown owner: +Every write goes through `swapServiceInstallState`. With a custom home, the default-home +record is the authority every writer can derive and the active-home record is a compatibility +mirror; with one path, that path is authoritative. `src/service/state-lock.ts` holds +token/PID/process-instance locks for every path in canonical order. A live holder is never +evicted because of age, and release deletes only its token-named owner. The authoritative +file is fsynced and atomically renamed through `src/config/atomic-write.ts`; that rename is +the commit point. Mirrors receive the exact committed bytes afterwards. A mirror failure is +diagnostic rather than rollback, and the next writer repairs it. An absent authority imports +one valid legacy mirror once; same-or-newer mirror disagreement and unreadable authority are +`unknown`, never ownership votes. Uninstall removes mirrors before the authority, so a +partial deletion cannot turn a revoked mirror claim back into migration input. + +`resolveServiceOwnership` answers `none`, `owned` or `unknown` from that authoritative +generation. `consentGenerationCeiling` survives a release, so granting, releasing and +granting again cannot reuse a number an app-local record may still hold. +`recordServiceOwner` requires the exact `owner`/`installId`/`consentGeneration`/`revision` +subject shown on the consent surface. The comparison runs again inside the same lock and on +every internal retry; a mismatch or unknown subject writes nothing and requires fresh user +approval. `ownershipGrantedTo` remains the narrower relaunch test for an already-owned app. + +Permanent takeover also requires `assessServiceTakeoverCompatibility` to approve both the +preserved service launcher and the selected PATH launcher. Every observed manager must be +OpenCodex 2.61.0 or later, and a preserved registration must carry ownership protocol 1. +Missing, old, malformed or unknown manager evidence blocks takeover and leaves registration +and autostart untouched. The supported verdict carries an opaque token over the approved +subject and both manager identities; `recordServiceOwner` re-observes and compares it inside +the lock, so a mutable shim or downgrade cannot inherit earlier consent. An upgrade is a +separate user-authorized action; declining or failing it leaves the app a guest. + +The verbs that activate the npm registration refuse on a foreign or unknown owner: `src/service/repair.ts` stops before it asserts, writes, stops or starts anything, and `ocx service start` reports the same refusal. `stop` and `uninstall` are not gated, because they deactivate. `src/update/runtime-ownership.mjs` vetoes both the pre-update stop and the post-update service refresh for all three update lanes — `src/update/index.ts`, -`bin/ocx.mjs` and the dashboard worker in `src/update/job.ts` — and the two package updaters -re-read the claim immediately before each runtime action — the stop and the direct-start -fallback — rather than trusting a plan formed earlier in the run, because an app can take the -runtime while the tray handoff spawns children or an install runs for minutes. The -registration is never deleted; `ocx service install` is the one verb that releases the -marker, and it does so only after the registration succeeded. - -The veto reads the recorded claim, not the live process. An app removed without releasing -leaves a stale claim, and proving which runtime is answering needs the identity the bundled -CLI's resolve contract will carry; until then the refusals name `ocx service install` as the -way to clear it. - -Re-reading narrows the window between a decision and its action; it does not remove it. A -claim recorded after the last read and before the child process starts is still acted on with -stale information. Closing that needs an action-scoped ownership lease held across the child, -which the state lock deliberately is not — holding it across `ocx stop` or a service refresh -would deadlock against the child's own write. +`bin/ocx.mjs` and the dashboard worker in `src/update/job.ts`. The shared update decision has +three independent authorities: package replacement, runtime stop and service restoration. +Unknown and desktop ownership deny all three because a claim alone does not prove that the live +process is detached from the npm package; CLI ownership permits the ordinary stop-first +flow. Both package updaters use `src/service/install-state-contract.mjs`, backed by the single +`state-record.mjs` parser and authority selector. One mutation lease covers the fresh stop +authorization, the stop child, the current runtime-record re-read and package replacement. +The updater never treats the pre-stop address as proof that this installation is idle; +an unreadable current record is unknown, and a valid address is probed even when its recorded +PID is gone. Lease delegation is passed only to stop and recovery children, never package +manager children. A replacement refusal passes through owner-aware recovery: only the same CLI +owner revives the stopped runtime; foreign ownership stays transferred and unknown ownership +remains a reported recovery requirement. Dashboard restart delegates the lease token to its repair child. Direct +start holds the same lease through bind plus PID and runtime-address publication. If listener +rollback cannot prove the socket closed, the process retains its lease until exit. +The registration is never deleted; `ocx service install` releases the marker only after the +registration succeeds. diff --git a/structure/transports/responses.md b/structure/transports/responses.md index b1e28958fa1..a82e8f8cb08 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -125,6 +125,16 @@ Function-call wrappers around freeform bodies are restored by is recoverable because the wrapper is otherwise unusable; two alternate fields are ambiguous and therefore remain untouched. Foreign freeform grammars never receive that compatibility rewrite. +#### Schema-bound flat shell repair + +Completed Responses function calls have one separate schema-bound flat-shell repair. When the +exact bare `exec_command` declaration requires a string `cmd`, a provider result containing only +the string member `{ "input": "..." }` is rewritten to `{ "cmd": "..." }`; the call name is not +changed. Namespaced tools, additional or conflicting members, non-string values, malformed JSON, +partial streaming previews, and schemas that do not prove this exact contract remain byte-exact. + +> Decision record: [ADR-0098](../decisions/ADR-0098-schema-bound-exec-command-input-repair.md) + Progressive preview for those wrappers is decoded by `src/responses/progressive-freeform-input.ts` in both the adapter-event bridge and routed function-call restoration, over the classification in `src/responses/freeform-wrapper-scan.ts`. diff --git a/tests/ci-workflows/ci-workflows.test.ts b/tests/ci-workflows/ci-workflows.test.ts index 2b79f6b4cc8..fba748aec7d 100644 --- a/tests/ci-workflows/ci-workflows.test.ts +++ b/tests/ci-workflows/ci-workflows.test.ts @@ -843,9 +843,10 @@ describe("GitHub Actions hardening", () => { contents: "read", }); - // Publication is the irreversible public act, so it waits for both packaging jobs; - // the full ordering contract is in tests/ci-workflows/release-pipeline-contract.test.ts. - expect(release.jobs?.publish?.needs).toEqual(["validate-dispatch", "package-standalone", "package-desktop"]); + // Publication is the irreversible public act, so it waits for the pre-publication + // verification of everything it will publish; the full ordering contract is in + // tests/ci-workflows/release-pipeline-contract.test.ts. + expect(release.jobs?.publish?.needs).toEqual(["validate-dispatch", "verify-release"]); expect(release.jobs?.publish?.["runs-on"]).toBe("ubuntu-latest"); expect(release.jobs?.publish?.permissions).toEqual({ contents: "write", @@ -5583,8 +5584,10 @@ test.skipIf(process.platform === "win32")("release shell recovers only unverifie const script = prelude + (scenario.mode === "missing-receipt" ? "" : publish) + '\n' + (scenario.dry ? "" : `PUBLISHED=$(sed -n 's/^published=//p' "$GITHUB_OUTPUT")\n${smoke}`); const child = Bun.spawn(["bash", "--noprofile", "--norc", "-e", "-o", "pipefail", "-c", script], { + // RESUME mirrors the workflow, where the env always defines it; the + // non-resume branches are what every scenario here exercises. env: { ...process.env, SCENARIO: scenario.mode, DRY_RUN: String(scenario.dry), - NPM_DIST_TAG: "latest", RELEASE_VERSION: "9.8.7", GITHUB_OUTPUT: output, + NPM_DIST_TAG: "latest", RELEASE_VERSION: "9.8.7", RESUME: "false", GITHUB_OUTPUT: output, GITHUB_STEP_SUMMARY: summary, CALLS: calls, COUNTER: join(dir, "counter") }, stdin: "ignore", stdout: "pipe", stderr: "pipe", }); diff --git a/tests/ci-workflows/release-desktop-scripts.test.ts b/tests/ci-workflows/release-desktop-scripts.test.ts index 18b37d41793..60c51e2cd30 100644 --- a/tests/ci-workflows/release-desktop-scripts.test.ts +++ b/tests/ci-workflows/release-desktop-scripts.test.ts @@ -1,9 +1,25 @@ import { describe, expect, test } from "bun:test"; +import { createHash, generateKeyPairSync, sign as ed25519Sign } from "node:crypto"; import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { basename, join } from "node:path"; -import { collectReleaseAssets } from "../../desktop/scripts/collect-release-assets"; -import { buildUpdaterManifest, writeUpdaterManifest } from "../../desktop/scripts/updater-manifest"; +import { bundlesByTarget, collectReleaseAssets } from "../../desktop/scripts/collect-release-assets"; +import { + runBuildLocal, + summarizeAttempts, + type ArtifactEntry, + type BuildLocalDeps, +} from "../../desktop/scripts/build-local"; +import { buildUpdaterManifest, platformFiles, writeUpdaterManifest } from "../../desktop/scripts/updater-manifest"; +import { standaloneArchiveName, standaloneTargets } from "../../scripts/standalone-targets"; +import { + expectedReleaseAssets, + parseMinisignPublicKey, + releaseMatrixTargets, + verifyChecksums, + verifyReleaseAssets, + verifyUpdaterSignature, +} from "../../desktop/scripts/verify-release-assets"; import { repoPath } from "../helpers/repo-root"; function temporaryDirectory(): string { @@ -219,6 +235,127 @@ describe("desktop release scripts", () => { * an extension signed that way, so the app would have installed with no widget and nothing in * the build would have said so. */ +describe("local bundle builds", () => { + type Script = Record; + const depsFor = ( + scripted: Script, + opts: { platform?: string; initialArtifacts?: ArtifactEntry[]; argv?: string[] } = {}, + ) => { + const calls: string[][] = []; + const logs: string[] = []; + const errors: string[] = []; + const artifacts = (opts.initialArtifacts ?? []).map(entry => ({ ...entry })); + const deps: BuildLocalDeps = { + spawn: args => { + calls.push(args); + const format = args[args.indexOf("--bundles") + 1]!; + const verbose = args.includes("--verbose"); + const key = verbose ? `${format}#v` : format; + const status = Object.hasOwn(scripted, key) ? scripted[key]! : 0; + // A successful non-verbose build refreshes the artifact, like the real bundler. + if (status === 0 && !verbose) { + const existing = artifacts.find(entry => entry.path.includes(format)); + if (existing) existing.mtimeMs += 1; + else artifacts.push({ path: `/out/OpenCodex-test_${format}`, mtimeMs: 200 }); + } + return { status }; + }, + log: line => { logs.push(line); }, + error: line => { errors.push(line); }, + listArtifacts: () => artifacts.map(entry => ({ ...entry })), + argv: opts.argv ?? [], + platform: opts.platform ?? "linux", + }; + return { calls, logs, errors, deps }; + }; + + test("a failing format does not destroy the formats that build", () => { + // Observed on a real GNOME desktop (120_install_verification.md): one shared + // invocation died on the AppImage and the deb was never attempted. + const { calls, logs, deps } = depsFor({ appimage: 1, deb: 0 }); + expect(runBuildLocal(deps)).toBe(1); + const formats = calls.map(args => args[args.indexOf("--bundles") + 1]); + expect(formats).toContain("appimage"); + expect(formats).toContain("deb"); + expect(logs.some(line => line.includes("appimage: FAILED"))).toBe(true); + expect(logs.some(line => line.includes("deb: ok"))).toBe(true); + expect(logs.some(line => line.includes("/out/OpenCodex-test_deb"))).toBe(true); + expect(logs.some(line => line.includes("updater artifacts skipped"))).toBe(false); + }); + + test("a failing format is retried verbosely so the bundler's own stderr surfaces", () => { + const { calls, errors, deps } = depsFor({ appimage: 1, deb: 0 }); + runBuildLocal(deps); + expect(errors.some(line => line.includes("rerunning with --verbose"))).toBe(true); + const verboseCalls = calls.filter(args => args.includes("--verbose")); + expect(verboseCalls).toHaveLength(1); + expect(verboseCalls[0]?.slice(0, 3)).toEqual(["tauri", "--verbose", "build"]); + expect(verboseCalls[0]).toContain("appimage"); + expect(verboseCalls.some(args => args.includes("deb"))).toBe(false); + }); + + test("a verbose retry that succeeds does not change the recorded failure", () => { + const { logs, deps } = depsFor({ appimage: 1, "appimage#v": 0, deb: 0 }); + expect(runBuildLocal(deps)).toBe(1); + expect(logs.some(line => line.includes("appimage: FAILED"))).toBe(true); + }); + + test("stale bundle output is not reported as this run's artifact", () => { + const { logs, deps } = depsFor( + { appimage: 1, deb: 0 }, + { initialArtifacts: [{ path: "/out/OpenCodex-test_appimage", mtimeMs: 100 }] }, + ); + runBuildLocal(deps); + expect(logs.some(line => line.includes("/out/OpenCodex-test_appimage"))).toBe(false); + expect(logs.some(line => line.includes("/out/OpenCodex-test_deb"))).toBe(true); + }); + + test("a spawn that never started counts as a failure", () => { + const { logs, deps } = depsFor({ appimage: null, deb: 0 }); + expect(runBuildLocal(deps)).toBe(1); + expect(logs.some(line => line.includes("appimage: FAILED"))).toBe(true); + }); + + test("a spawn error reports the launch failure", () => { + const errors: string[] = []; + const deps = depsFor({ deb: 0 }).deps; + const originalSpawn = deps.spawn; + deps.error = line => { errors.push(line); }; + deps.spawn = args => (args.includes("appimage") ? { status: null, error: new Error("spawn bunx ENOENT") } : originalSpawn(args)); + expect(runBuildLocal(deps)).toBe(1); + expect(errors.some(line => line.includes("could not start tauri"))).toBe(true); + }); + + test("the invocation shape is one tauri build per format, extra argv forwarded everywhere", () => { + const { calls, deps } = depsFor({ appimage: 1, deb: 0 }, { argv: ["--target", "x86_64-unknown-linux-gnu"] }); + runBuildLocal(deps); + expect(calls[0]?.slice(0, 5)).toEqual(["tauri", "build", "--ci", "--bundles", "appimage"]); + expect(calls[1]?.slice(0, 5)).toEqual(["tauri", "--verbose", "build", "--ci", "--bundles"]); + for (const call of calls) { + expect(call.slice(-2)).toEqual(["--target", "x86_64-unknown-linux-gnu"]); + } + }); + + test("a fully successful build exits zero and keeps the updater note", () => { + const { logs, deps } = depsFor({}); + expect(runBuildLocal(deps)).toBe(0); + expect(logs.some(line => line.includes("updater artifacts skipped"))).toBe(true); + }); + + test("macOS hosts build app and dmg", () => { + const { calls, deps } = depsFor({}, { platform: "darwin" }); + expect(runBuildLocal(deps)).toBe(0); + const formats = calls.map(args => args[args.indexOf("--bundles") + 1]); + expect(formats).toEqual(["app", "dmg"]); + }); + + test("summarizeAttempts decides the exit code from the per-format outcomes", () => { + expect(summarizeAttempts([{ format: "appimage", status: 0 }, { format: "deb", status: 0 }]).exitCode).toBe(0); + expect(summarizeAttempts([{ format: "appimage", status: 1 }, { format: "deb", status: 0 }]).exitCode).toBe(1); + expect(summarizeAttempts([{ format: "appimage", status: 1 }, { format: "deb", status: 1 }]).lines[0]).toContain("FAILED"); + }); +}); + describe("the desktop build toolchain carries the bundle-type marker", () => { // updater.rs selects the deb updater target from tauri_utils::platform::bundle_type(), // which reads a marker the tauri-bundler patches into the binary at packaging time. @@ -313,3 +450,235 @@ describe("widget extension signing", () => { .toBeLessThan(script.indexOf("swift build")); }); }); + +/** + * The pre-publication verifier is the authority the verify-release job runs before + * anything may publish. Its expected set is derived from the real release matrices + * and the producer tables, its signatures are real Ed25519 fixtures in minisign + * shape, and the receipt it writes is the one attach-release requires. + */ +describe("release asset verification", () => { + const VERSION = "2.61.0"; + + function writeAsset(dir: string, name: string, payload: Buffer): void { + const digest = createHash("sha256").update(payload).digest("hex"); + writeFileSync(join(dir, name), payload); + writeFileSync(join(dir, `${name}.sha256`), `${digest} ${name}\n`); + } + + function makeMinisignKeypair(keyIdHex: string): { + pubkeyText: string; + keyId: Buffer; + signPayload: (payload: Buffer) => string; + } { + const { publicKey, privateKey } = generateKeyPairSync("ed25519"); + const raw = Buffer.from(publicKey.export({ format: "der", type: "spki" })).subarray(-32); + const keyId = Buffer.from(keyIdHex, "hex"); + const pubkeyText = `untrusted comment: test public key\n${Buffer.concat([Buffer.from("Ed"), keyId, raw]).toString("base64")}\n`; + const signPayload = (payload: Buffer): string => + `untrusted comment: test signature\n${Buffer.concat([Buffer.from("Ed"), keyId, ed25519Sign(null, payload, privateKey)]).toString("base64")}\n`; + return { pubkeyText, keyId, signPayload }; + } + + test("derives the expected set from the real release matrices and producer tables", () => { + const workflow = readFileSync(repoPath(".github", "workflows", "release.yml"), "utf8"); + const { standaloneTargets: workflowStandalone, desktopTargets } = releaseMatrixTargets(workflow); + // The workflow matrix and the builder's shared target set must agree exactly. + expect([...workflowStandalone].sort()).toEqual([...standaloneTargets].sort()); + expect(desktopTargets).toHaveLength(3); + + const expected = expectedReleaseAssets({ + version: VERSION, + desktopTargets, + requireSignatures: true, + }); + for (const name of [ + `ocx-${VERSION}-bun-windows-x64.zip`, + `ocx-${VERSION}-bun-linux-x64.tar.gz`, + `ocx-${VERSION}-bun-darwin-arm64.tar.gz.sha256`, + `OpenCodex-${VERSION}-macos.dmg`, + `OpenCodex-${VERSION}-macos.app.tar.gz.sig`, + `OpenCodex-${VERSION}-windows-x64.msi`, + `OpenCodex-${VERSION}-linux-x86_64.AppImage`, + `OpenCodex-${VERSION}-linux-amd64.deb`, + ]) { + expect(expected).toContain(name); + } + // Signature presence follows the updater table exactly: a bundle is signed + // precisely when platformFiles names it as an updater target, so a new updater + // target changes this contract by itself rather than needing a hand edit here. + const updaterSuffixes = new Set(Object.values(platformFiles)); + for (const bundle of desktopTargets.flatMap(target => bundlesByTarget[target]!)) { + expect(expected).toContain(`OpenCodex-${VERSION}-${bundle.name}`); + expect(expected.includes(`OpenCodex-${VERSION}-${bundle.name}.sig`)) + .toBe(updaterSuffixes.has(bundle.name)); + } + expect(expected.some(name => name.includes("/"))).toBe(false); + }); + + test("verifies every recorded checksum and refuses a directory-prefixed record", () => { + const dir = temporaryDirectory(); + try { + writeAsset(dir, "ocx-1.0.0-bun-linux-x64.tar.gz", Buffer.from("payload")); + expect(verifyChecksums(dir)).toBe(1); + + const digest = createHash("sha256").update(Buffer.from("payload")).digest("hex"); + writeFileSync(join(dir, "bad.sha256"), `${digest} ocx-1.0.0-bun-linux-x64.tar.gz\n`); + expect(() => verifyChecksums(dir)).toThrow(/must record its own payload/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("rejects a tampered payload and a missing payload", () => { + const dir = temporaryDirectory(); + try { + writeAsset(dir, "ocx-1.0.0-bun-linux-x64.tar.gz", Buffer.from("payload")); + writeFileSync(join(dir, "ocx-1.0.0-bun-linux-x64.tar.gz"), Buffer.from("tampered")); + expect(() => verifyChecksums(dir)).toThrow(/Checksum mismatch/); + + rmSync(join(dir, "ocx-1.0.0-bun-linux-x64.tar.gz")); + expect(() => verifyChecksums(dir)).toThrow(/which is missing/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("verifies updater signatures against the pinned key and refuses lookalikes", () => { + const dir = temporaryDirectory(); + try { + const { pubkeyText, signPayload } = makeMinisignKeypair("0123456789abcdef"); + const key = parseMinisignPublicKey(pubkeyText); + const payload = Buffer.from("signed payload bytes"); + const asset = join(dir, "OpenCodex-1.0.0-macos.app.tar.gz"); + writeFileSync(asset, payload); + writeFileSync(`${asset}.sig`, signPayload(payload)); + expect(() => verifyUpdaterSignature(asset, key)).not.toThrow(); + + writeFileSync(asset, Buffer.from("tampered payload")); + expect(() => verifyUpdaterSignature(asset, key)).toThrow(/Signature verification failed/); + writeFileSync(asset, payload); + + const other = makeMinisignKeypair("fedcba9876543210"); + writeFileSync(`${asset}.sig`, other.signPayload(payload)); + expect(() => verifyUpdaterSignature(asset, key)).toThrow(/not the pinned updater key/); + + const hashed = `untrusted comment: test\n${Buffer.concat([Buffer.from("ED"), other.keyId, Buffer.alloc(64)]).toString("base64")}\n`; + writeFileSync(`${asset}.sig`, hashed); + expect(() => verifyUpdaterSignature(asset, key)).toThrow(/Unsupported signature algorithm/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("runs the full pre-publication verification and writes the receipt", () => { + const root = temporaryDirectory(); + try { + const { pubkeyText, signPayload } = makeMinisignKeypair("0123456789abcdef"); + // The verifier reads the matrices and the pinned key from the repo root, so the + // scratch root gets the real workflow and a conf carrying the fixture key. + mkdirSync(join(root, ".github", "workflows"), { recursive: true }); + writeFileSync( + join(root, ".github", "workflows", "release.yml"), + readFileSync(repoPath(".github", "workflows", "release.yml"), "utf8"), + ); + mkdirSync(join(root, "desktop", "src-tauri"), { recursive: true }); + writeFileSync( + join(root, "desktop", "src-tauri", "tauri.conf.json"), + JSON.stringify({ plugins: { updater: { pubkey: Buffer.from(pubkeyText, "utf8").toString("base64") } } }), + ); + + const dir = join(root, "dist", "release"); + mkdirSync(dir, { recursive: true }); + // The fixture derives from the producer tables — the standalone target module, + // the bundle table, and the updater platform table — assembled independently + // of the function under test. Building it with expectedReleaseAssets would + // hide an omission in the expected set; hand-writing it would go stale the + // next time a target is added (which is exactly the union failure this test + // once carried: the deb became an updater target and this oracle missed its + // signature). + const desktopTargets = releaseMatrixTargets( + readFileSync(join(root, ".github", "workflows", "release.yml"), "utf8"), + ).desktopTargets; + const produced = [ + ...standaloneTargets.map(target => standaloneArchiveName(VERSION, target)), + ...desktopTargets.flatMap(target => + bundlesByTarget[target]!.map(bundle => `OpenCodex-${VERSION}-${bundle.name}`)), + ]; + const updaterSuffixes = new Set(Object.values(platformFiles)); + const signed = new Set( + produced.filter(name => updaterSuffixes.has(name.slice(`OpenCodex-${VERSION}-`.length))), + ); + for (const name of produced) { + writeAsset(dir, name, Buffer.from(`payload:${name}`)); + if (signed.has(name)) { + writeFileSync(join(dir, `${name}.sig`), signPayload(readFileSync(join(dir, name)))); + } + } + + // The derivation is checked against the oracle, not trusted: the expected set + // must be exactly the produced payloads plus their companions. + const expected = expectedReleaseAssets({ + version: VERSION, + desktopTargets, + requireSignatures: true, + }); + const oracle = produced.flatMap(name => + signed.has(name) ? [name, `${name}.sha256`, `${name}.sig`] : [name, `${name}.sha256`]); + expect([...expected].sort()).toEqual([...oracle].sort()); + + const receiptPath = join(root, "verification", "receipt.json"); + const manifestPath = join(dir, "latest.json"); + const receipt = verifyReleaseAssets({ + version: VERSION, + dir, + repo: "lidge-jun/opencodex", + sha: "0123456789abcdef0123456789abcdef01234567", + repoRoot: root, + manifestOut: manifestPath, + receiptOut: receiptPath, + requireSignatures: true, + }); + + expect(receipt.expectedFiles).toBe(expected.length); + expect(receipt.checksumsVerified) + .toBe(produced.length); + expect(receipt.signaturesVerified).toBe(signed.size); + // Same rule as the signed set: the platform list is the updater table's keys, + // not a copy of them. + expect(receipt.manifestPlatforms).toEqual(Object.keys(platformFiles).sort()); + expect(JSON.parse(readFileSync(receiptPath, "utf8"))).toEqual(receipt); + + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as { + platforms: Record; + }; + expect(manifest.platforms["linux-x86_64"]!.url) + .toBe(`https://github.com/lidge-jun/opencodex/releases/download/v${VERSION}/OpenCodex-${VERSION}-linux-x86_64.AppImage`); + + // Anything beyond the expected set is refused rather than published. + writeFileSync(join(dir, "stray.txt"), "stray"); + expect(() => verifyReleaseAssets({ + version: VERSION, + dir, + repo: "lidge-jun/opencodex", + sha: "0123456789abcdef0123456789abcdef01234567", + repoRoot: root, + manifestOut: manifestPath, + requireSignatures: true, + })).toThrow(/Unexpected files/); + rmSync(join(dir, "stray.txt")); + + rmSync(join(dir, `OpenCodex-${VERSION}-windows-x64.msi`)); + expect(() => verifyReleaseAssets({ + version: VERSION, + dir, + repo: "lidge-jun/opencodex", + sha: "0123456789abcdef0123456789abcdef01234567", + repoRoot: root, + requireSignatures: true, + })).toThrow(/Missing expected release assets/); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/ci-workflows/release-pipeline-contract.test.ts b/tests/ci-workflows/release-pipeline-contract.test.ts index 8f8ff47875a..208b1d962c0 100644 --- a/tests/ci-workflows/release-pipeline-contract.test.ts +++ b/tests/ci-workflows/release-pipeline-contract.test.ts @@ -7,11 +7,13 @@ type WorkflowStep = { if?: string; uses?: string; with?: Record; + env?: Record; run?: string; shell?: string; }; type WorkflowJob = { + if?: string; needs?: string[]; strategy?: { matrix?: { include?: Array<{ os?: string }> } }; steps?: WorkflowStep[]; @@ -95,37 +97,90 @@ describe("release pipeline contract", () => { // The bare names above only resolve end to end if the step checksums from the directory // the artifact lives in (it leaves the per-target build directory first), if the upload - // glob picks the checksum file up, and if the download flattens every artifact beside - // the verifier. Locking only the final shasum line would leave those joints unguarded. - // YAML block scalars are dedented on parse, so the script's own lines carry no - // indentation here. + // glob picks the checksum file up, and if the pre-publication verifier downloads every + // artifact flattened beside them. Locking only the final line would leave those joints + // unguarded. YAML block scalars are dedented on parse, so the script's own lines carry + // no indentation here. expect(archive!.run).toMatch(/^ *cd \.\.\/\.\.$/m); const upload = release.jobs?.["package-standalone"]?.steps ?.find(candidate => candidate.uses?.startsWith("actions/upload-artifact@")); expect(String(upload?.with?.path)).toContain("dist/ocx-*.sha256"); - const download = release.jobs?.["attach-release"]?.steps + const download = release.jobs?.["verify-release"]?.steps ?.find(candidate => candidate.uses?.startsWith("actions/download-artifact@") && candidate.with?.pattern === "standalone-*"); expect(download?.with?.["merge-multiple"]).toBe(true); expect(download?.with?.path).toBe("dist/release"); + }); - const verify = release.jobs?.["attach-release"]?.steps - ?.find(candidate => candidate.run?.includes("shasum")); + test("publication consumes the verified packaging result", () => { + const verify = release.jobs?.["verify-release"]; expect(verify).toBeDefined(); - expect(verify!.run).toContain("cd dist/release"); - expect(verify!.run).toContain("shasum -a 256 -c ./*.sha256"); - }); + expect(needsOf(verify).sort()) + .toEqual(["package-desktop", "package-standalone", "validate-dispatch"]); + // Verification is not a publication-mode step: a dry run must prove the same chain + // a real release relies on, so the job carries no dry-run exemption. + expect(verify!.if).toBeUndefined(); + expect(verify!.steps?.some(candidate => candidate.run?.includes("verify-release-assets.ts"))) + .toBe(true); - test("publication waits for both packaging jobs", () => { const publish = release.jobs?.publish; expect(publish).toBeDefined(); - expect(needsOf(publish).sort()) - .toEqual(["package-desktop", "package-standalone", "validate-dispatch"]); + expect(needsOf(publish).sort()).toEqual(["validate-dispatch", "verify-release"]); const attach = release.jobs?.["attach-release"]; expect(attach).toBeDefined(); - expect(needsOf(attach).sort()).toEqual(["package-desktop", "package-standalone", "publish"]); + expect(needsOf(attach).sort()).toEqual(["publish", "verify-release"]); + }); + + test("attach uploads only the verified bundle, and only after requiring its receipt", () => { + const steps = release.jobs?.["attach-release"]?.steps ?? []; + // Verification happens exactly once, before publication: attach must not re-verify + // checksums or regenerate the manifest from unverified parts. + expect(steps.some(candidate => candidate.run?.includes("shasum"))).toBe(false); + expect(steps.some(candidate => candidate.run?.includes("updater-manifest.ts"))).toBe(false); + + const bundle = steps.find(candidate => candidate.uses?.startsWith("actions/download-artifact@") + && candidate.with?.name === "verified-release"); + expect(bundle?.with?.path).toBe("dist/release"); + + const receiptCheck = steps.findIndex(candidate => candidate.run?.includes("verification/receipt.json")); + const upload = steps.findIndex(candidate => candidate.run?.includes("gh release upload")); + expect(receiptCheck).toBeGreaterThanOrEqual(0); + expect(upload).toBeGreaterThan(receiptCheck); + }); + + test("a partial publication has a recorded, explicit recovery path", () => { + const releaseText = readFileSync(repoPath(".github", "workflows", "release.yml"), "utf8"); + // The only way npm publish is ever skipped: an explicit recovery input, requiring + // the version to already be acknowledged on npm, refusing combination with dry-run. + expect(releaseText).toContain("resume-after-npm-publish:"); + + const publishSteps = release.jobs?.publish?.steps ?? []; + const preflight = publishSteps.find(candidate => candidate.name === "Preflight release metadata"); + expect(preflight?.run).toContain("no acknowledged publication to resume from"); + expect(preflight?.run).toContain("cannot combine with dry-run"); + + const publication = publishSteps.find(candidate => candidate.id === "publication"); + // The summary line references RELEASE_VERSION under set -u; the env must carry it. + expect(publication?.env?.RELEASE_VERSION).toBe("${{ inputs.version }}"); + expect(publication?.run).toContain('if [ "$RESUME" = "true" ]'); + expect(publication?.run).toContain('echo "published=true" >> "$GITHUB_OUTPUT"'); + // A successful publish records the recovery path at the moment it matters. + expect(publication?.run).toContain("never republish this version"); + + // The version-line gate must let the resume path past a tag it created itself. + const versionLine = publishSteps.find(candidate => candidate.run?.includes("assert-releasable")); + expect(versionLine?.env?.RESUME).toBe("${{ inputs.resume-after-npm-publish }}"); + expect(versionLine?.run).toContain('$RESUME'); + + // A run that failed after the release was created must be able to complete the + // attachment on resume; outside resume, an existing release stays a hard failure. + const create = publishSteps.find(candidate => candidate.name === "Create GitHub release"); + expect(create?.env?.RESUME).toBe("${{ inputs.resume-after-npm-publish }}"); + expect(create?.run).toContain('gh release view "$release_tag"'); + expect(create?.run).toContain("already exists; reusing it for attachment"); + expect(create?.run).toContain("refusing to reuse it outside the resume path"); }); }); diff --git a/tests/cli/cli-catalog-prewarm.test.ts b/tests/cli/cli-catalog-prewarm.test.ts index e2c1af3e1fa..2c9cdd3aa67 100644 --- a/tests/cli/cli-catalog-prewarm.test.ts +++ b/tests/cli/cli-catalog-prewarm.test.ts @@ -53,18 +53,18 @@ describe("catalog prewarm on handleStart bind", () => { } }); - test("handleStart schedules catalog prewarm immediately after a successful bind", async () => { + test("handleStart schedules catalog prewarm after ownership publication", async () => { const cli = (await readText("src/cli/index.ts")).replace(/\r\n/g, "\n"); - const bindIdx = cli.indexOf("server = startServer(port"); + const transactionIdx = cli.indexOf("boundStart = await bindAndPublishStartOwnership({"); + const publishedIdx = cli.indexOf("const { server, serverModule, port, readinessGate, config } = boundStart", transactionIdx); const prewarmIdx = cli.indexOf("scheduleCatalogPrewarm()"); - const breakIdx = cli.indexOf("\n break;", bindIdx); + const guardianIdx = cli.indexOf("const guardian = startTokenGuardian()", prewarmIdx); expect(cli).toContain('from "./catalog-prewarm"'); - expect(bindIdx).toBeGreaterThan(-1); - expect(prewarmIdx).toBeGreaterThan(bindIdx); - expect(breakIdx).toBeGreaterThan(prewarmIdx); - // Must stay inside the successful-bind try path, not only on a later sync. - expect(cli.slice(bindIdx, breakIdx)).toContain("scheduleCatalogPrewarm()"); + expect(transactionIdx).toBeGreaterThan(-1); + expect(publishedIdx).toBeGreaterThan(transactionIdx); + expect(prewarmIdx).toBeGreaterThan(publishedIdx); + expect(guardianIdx).toBeGreaterThan(prewarmIdx); expect(cli).not.toContain('void import("../codex/catalog").then(({ gatherRoutedModels })'); }); }); diff --git a/tests/cli/cli-dispatch.test.ts b/tests/cli/cli-dispatch.test.ts index 7cf4877feae..83502242139 100644 --- a/tests/cli/cli-dispatch.test.ts +++ b/tests/cli/cli-dispatch.test.ts @@ -407,13 +407,12 @@ describe("a busy preferred port never becomes a second proxy (#5004)", () => { // One 750ms probe is what produced the duplicate; the guard spends the larger budget. expect(fn).toContain("START_OWNERSHIP_LIVENESS"); - // Both refusals end the process, and the refusal a user sees is the one they already - // know from the owner path. - expect(fn).toMatch(/decision === "refuse-live-proxy"[\s\S]{0,400}?process\.exit\(1\)/); + // Both refusals preserve the exit code through the caller's lease-cleanup boundary. + expect(fn).toMatch(/decision === "refuse-live-proxy"[\s\S]{0,400}?StartCommandExit\(1\)/); expect(fn).toContain("Use 'ocx stop' first."); - expect(fn).toMatch(/decision === "refuse-unidentified-holder"[\s\S]{0,700}?process\.exit\(1\)/); + expect(fn).toMatch(/decision === "refuse-unidentified-holder"[\s\S]{0,700}?StartCommandExit\(1\)/); // The wrapper's `if %ERRORLEVEL% NEQ 0` loop still terminates on a served port. - expect(fn).toMatch(/decision === "service-stay-out"[\s\S]{0,500}?process\.exit\(0\)/); + expect(fn).toMatch(/decision === "service-stay-out"[\s\S]{0,500}?StartCommandExit\(0\)/); }); test("the pre-bind owner probe spends the same budget before it deletes state", () => { diff --git a/tests/cli/cli-ready.test.ts b/tests/cli/cli-ready.test.ts index 197c0ae276f..c214ecde156 100644 --- a/tests/cli/cli-ready.test.ts +++ b/tests/cli/cli-ready.test.ts @@ -850,19 +850,19 @@ describe("runReady production findLiveProxy deadline wiring (source-level)", () describe("handleStart OCX_SERVICE exit guard (source-level)", () => { const cliSource = readFileSync(repoPath("src/cli/index.ts"), "utf8"); - test("an already-live proxy exits 0 in OCX_SERVICE context", () => { + test("an already-live proxy preserves the service/refusal exit codes without bypassing cleanup", () => { // The `OCX_SERVICE === "1"` comparison moved into `decideStartWithLiveOwner` // (src/cli/dispatch.ts), where the sentinel semantics are asserted at runtime // across the whole matrix (tests/cli/cli-dispatch.test.ts). This oracle pins the - // exits that the decision routes to: stay-out exits 0, the conflict exits 1. + // typed exits that the decision routes to: stay-out returns 0, the conflict returns 1. expect(cliSource).toMatch(/decideStartWithLiveOwner\(\{/); - // Anchored at the owner branch. `chooseListenPort` carries its own stay-out/refusal pair - // for the busy-port guard (#5004) and it sits EARLIER in the file, so an unanchored match - // would quietly move to that one and stop asserting anything about this branch. - const ownerBranch = cliSource.slice(cliSource.indexOf("decideStartWithLiveOwner({")); - const stayOut = ownerBranch.match(/decision === "service-stay-out"[\s\S]{0,800}?process\.exit\(0\)/); - expect(stayOut, "the service stay-out decision must exit 0 when the port is already served").not.toBeNull(); - const nonService = ownerBranch.match(/Proxy already running[\s\S]{0,300}?process\.exit\(1\)/); + // Anchor after the lease transaction begins. The earlier preflight has the same decision + // pair but does not need a typed exit because it owns no lease yet. + const transaction = cliSource.slice(cliSource.indexOf("bindAndPublishStartOwnership({")); + const ownerBranch = transaction.slice(transaction.indexOf("decideStartWithLiveOwner({")); + const stayOut = ownerBranch.match(/decision === "service-stay-out"[\s\S]{0,800}?StartCommandExit\(0\)/); + expect(stayOut, "the service stay-out decision must return 0 when the port is already served").not.toBeNull(); + const nonService = ownerBranch.match(/decision === "refuse"[\s\S]{0,500}?StartCommandExit\(1\)/); expect(nonService, "non-service refusal keeps the exit 1 conflict error").not.toBeNull(); }); diff --git a/tests/cli/cli-resolve.test.ts b/tests/cli/cli-resolve.test.ts index 69fbac9764b..a7177ff241b 100644 --- a/tests/cli/cli-resolve.test.ts +++ b/tests/cli/cli-resolve.test.ts @@ -127,6 +127,21 @@ describe("runResolve", () => { expect(parsed.port.effective).toBe(RESOLVE_DEFAULT_PORT); }); + test("accepts async dead probes for every candidate endpoint", async () => { + const lines: string[] = []; + const code = await runResolve({ json: true }, { + configDir: () => "/h", + readDiagnostics: () => ({ config: {}, source: "default", error: null } as ConfigDiagnostics), + findLive: async () => null, + readRuntime: () => ({ port: 10110, hostname: "127.0.0.1" }), + probeEndpoint: async () => "dead", + cliVersion: () => "1.2.3", + stdout: { log: value => lines.push(value) }, + }); + expect(code).toBe(0); + expect((JSON.parse(lines[0]!) as { liveness: { status: string } }).liveness.status).toBe("absent-proven"); + }); + test("an undecidable probe is unknown, and unknown is never answered as absent", async () => { // The launch decision keys on this verdict: a timed-out probe or a listener that // withholds /healthz must exit 1 rather than let the caller start a second runtime. @@ -152,8 +167,8 @@ describe("runResolve", () => { test("absence requires every endpoint dead, not just the configured one", async () => { // The runtime record can point at a live port while the configured port refuses; // answering from the configured port alone would shadow-start over the record. - // everyEndpointProvenDown short-circuits on the first non-dead answer: an unknown - // runtime endpoint defeats the proof without the configured one being probed. + // Every candidate is probed: an unknown runtime endpoint defeats the proof even when the + // configured endpoint is dead. const seen: string[] = []; const code = await runResolve({ json: true }, { configDir: () => "/h", diff --git a/tests/cli/start-ownership-publication.test.ts b/tests/cli/start-ownership-publication.test.ts new file mode 100644 index 00000000000..23a3c825987 --- /dev/null +++ b/tests/cli/start-ownership-publication.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, test } from "bun:test"; +import { + bindAndPublishStartOwnership, + StartOwnershipRollbackUncertainError, +} from "../../src/cli/start-ownership-publication"; + +function fixture(options: { + failPid?: boolean; + failRuntime?: boolean; + failStop?: boolean; + failRemoveRuntime?: boolean; + failRemovePid?: boolean; +} = {}) { + const events: string[] = []; + const deps = { + acquireLease: () => ({ release: () => { events.push("release"); } }), + bind: async () => { events.push("bind"); return { id: 1 }; }, + writePid: () => { + events.push("pid"); + if (options.failPid) throw new Error("pid write failed"); + }, + writeRuntime: () => { + events.push("runtime"); + if (options.failRuntime) throw new Error("runtime write failed"); + }, + stopBound: async () => { + events.push("stop"); + if (options.failStop) throw new Error("stop failed"); + }, + removeRuntime: () => { + events.push("remove-runtime"); + if (options.failRemoveRuntime) throw new Error("runtime cleanup failed"); + }, + removePid: () => { + events.push("remove-pid"); + if (options.failRemovePid) throw new Error("pid cleanup failed"); + }, + }; + return { events, deps }; +} + +describe("start ownership publication", () => { + test("success releases only after bind and both records", async () => { + const { events, deps } = fixture(); + await bindAndPublishStartOwnership(deps); + expect(events).toEqual(["bind", "pid", "runtime", "release"]); + }); + + test("a bind refusal releases without publishing or rollback", async () => { + const { events, deps } = fixture(); + deps.bind = async () => { events.push("bind-refused"); throw new Error("refused"); }; + await expect(bindAndPublishStartOwnership(deps)).rejects.toThrow("refused"); + expect(events).toEqual(["bind-refused", "release"]); + }); + + for (const failure of ["pid", "runtime"] as const) { + test(`${failure} publication failure stops and cleans before release`, async () => { + const { events, deps } = fixture({ + failPid: failure === "pid", + failRuntime: failure === "runtime", + }); + await expect(bindAndPublishStartOwnership(deps)).rejects.toThrow(`${failure} write failed`); + expect(events).toEqual(failure === "pid" + ? ["bind", "pid", "stop", "remove-runtime", "remove-pid", "release"] + : ["bind", "pid", "runtime", "stop", "remove-runtime", "remove-pid", "release"]); + }); + } + + test("listener rollback uncertainty cleans records but retains the lease", async () => { + const { events, deps } = fixture({ + failRuntime: true, + failStop: true, + }); + await expect(bindAndPublishStartOwnership(deps)).rejects.toBeInstanceOf(StartOwnershipRollbackUncertainError); + expect(events).toEqual(["bind", "pid", "runtime", "stop", "remove-runtime", "remove-pid"]); + }); + + test("cleanup failures still attempt both records and release after the listener stopped", async () => { + const { events, deps } = fixture({ failRuntime: true, failRemoveRuntime: true, failRemovePid: true }); + await expect(bindAndPublishStartOwnership(deps)).rejects.toBeInstanceOf(AggregateError); + expect(events).toEqual(["bind", "pid", "runtime", "stop", "remove-runtime", "remove-pid", "release"]); + }); +}); diff --git a/tests/cli/uninstall.test.ts b/tests/cli/uninstall.test.ts index ea984f274fd..1b0e6f399b5 100644 --- a/tests/cli/uninstall.test.ts +++ b/tests/cli/uninstall.test.ts @@ -243,7 +243,7 @@ describe("uninstall gates shared teardown on a proven service stop", () => { }); }); test("proof covers every distinct endpoint, not just the preferred one", async () => { - const { endpointsToProve, everyEndpointProvenDown } = await import("../../src/cli/uninstall-plan"); + const { endpointsToProve, everyEndpointProvenDown, everyEndpointProvenDownAsync } = await import("../../src/cli/uninstall-plan"); // A stale runtime record pointing at a closed port, and the live proxy on the // configured one. Probing only the runtime candidate reports "dead" for a port nobody @@ -267,6 +267,8 @@ describe("uninstall gates shared teardown on a proven service stop", () => { expect(endpointsToProve(null, {})).toEqual([{ hostname: "127.0.0.1", port: 10100 }]); // An empty set is not proof of anything. expect(everyEndpointProvenDown([], () => "dead")).toBe(false); + expect(await everyEndpointProvenDownAsync(endpoints, async () => "dead")).toBe(true); + expect(await everyEndpointProvenDownAsync([], async () => "dead")).toBe(false); // A nonsense runtime port is skipped rather than probed. expect(endpointsToProve({ port: 0 }, { port: 10100 })).toEqual([{ hostname: "127.0.0.1", port: 10100 }]); }); @@ -284,7 +286,7 @@ describe("uninstall gates shared teardown on a proven service stop", () => { .toBeLessThan(windowStep.indexOf("observed.respawnWindowVerified = true;")); // And the proof itself asks every candidate. expect(fn).toContain("endpointsToProve(readRuntimePort(), loadConfig())"); - expect(fn).toContain("everyEndpointProvenDown(endpoints, e => probeProxyLiveness(e.port, e.hostname))"); + expect(fn).toContain("everyEndpointProvenDownAsync(endpoints, probeEndpointLiveness)"); }); const safeTeardown: UninstallObservation = { diff --git a/tests/clients/desktop-cli-contracts.test.ts b/tests/clients/desktop-cli-contracts.test.ts index d4ae062c866..9c84bd8da27 100644 --- a/tests/clients/desktop-cli-contracts.test.ts +++ b/tests/clients/desktop-cli-contracts.test.ts @@ -86,7 +86,13 @@ describe("desktop CLI contracts", () => { test("the startup sequence refuses to start on anything but a proven absence", () => { const startup = code(STARTUP); - const run = startup.slice(startup.indexOf("async fn run(app: &AppHandle)")); + // Anchor on the name, not the full signature: a parameter added to the sequence is not a + // change to the order this case is about, and `indexOf` returning -1 silently slices the + // last character instead of failing, so every index below reads -1 and the case passes + // vacuously. That is exactly what it did when `run` gained its start instant. + const at = startup.indexOf("async fn run(app: &AppHandle"); + expect(at).toBeGreaterThan(-1); + const run = startup.slice(at); const unknown = run.indexOf("let Some(answer) = resolution.resolved() else {"); const attach = run.indexOf("match resolve::live_verdict(&resolution) {"); const guard = run.indexOf("if !resolve::may_start(&resolution) {"); diff --git a/tests/clients/desktop-install-identity.test.ts b/tests/clients/desktop-install-identity.test.ts index 797b2192e1b..4772e1a6c95 100644 --- a/tests/clients/desktop-install-identity.test.ts +++ b/tests/clients/desktop-install-identity.test.ts @@ -1,12 +1,13 @@ import { describe, expect, test } from "bun:test"; import { readFileSync } from "node:fs"; +import { parseServiceOwnershipRecord } from "../../src/service/state-record.mjs"; import { repoPath } from "../helpers/repo-root"; /** * The desktop app's half of the runtime-ownership claim. * * The claim lives in the shared service install state, which core owns across two files: the - * validation that decides what a record may say is in `src/service/install-state-contract.mjs`, + * validation that decides what a record may say is in `src/service/state-record.mjs`, * and the types, the three answers a read can give and `ownershipGrantedTo` — the comparison an * installation applies to its own locally stored install id — are in `src/service/state.ts`. The * shell holds the other half, an id of its own to compare against, and mirrors the rule rather @@ -21,7 +22,6 @@ const IDENTITY = repoPath(`${SHELL}/identity.rs`); const OWNERSHIP = repoPath(`${SHELL}/ownership.rs`); const STARTUP = repoPath(`${SHELL}/startup.rs`); const STATE = repoPath("src/service/state.ts"); -const CONTRACT = repoPath("src/service/install-state-contract.mjs"); function code(path: string): string { return readFileSync(path, "utf8").replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/[^\n]*/g, ""); @@ -31,7 +31,6 @@ describe("desktop install identity", () => { const identity = code(IDENTITY); const ownership = code(OWNERSHIP); const state = code(STATE); - const contract = code(CONTRACT); test("the installation's id is minted once and never rewritten", () => { // Exclusive, because two launches racing to mint would answer to two ids, and the second one @@ -55,10 +54,13 @@ describe("desktop install identity", () => { }); test("the owner values are the ones the record accepts", () => { - // Both halves of core's answer are read. The runtime rejection is what a record on disk meets, - // and the exported type is what every caller is compiled against; a parse that accepted a - // third owner and a type that forbade it would disagree exactly where a takeover happens. - expect(contract).toContain('value.owner !== "cli" && value.owner !== "desktop"'); + // Exercise the parser a record on disk actually meets, while also pinning the exported type + // every caller compiles against. If runtime acceptance and the type diverge, this takeover + // boundary fails at review instead of after an installation has claimed the runtime. + const claim = { installId: "install-a", consentGeneration: 1 }; + expect(parseServiceOwnershipRecord({ ...claim, owner: "cli" })).toEqual({ ...claim, owner: "cli" }); + expect(parseServiceOwnershipRecord({ ...claim, owner: "desktop" })).toEqual({ ...claim, owner: "desktop" }); + expect(parseServiceOwnershipRecord({ ...claim, owner: "another-owner" })).toBeNull(); expect(state).toContain('export type ServiceOwner = "cli" | "desktop"'); expect(ownership).toContain('#[serde(rename_all = "lowercase")]'); expect(ownership).toContain(" Cli,"); diff --git a/tests/clients/desktop-startup-surface.test.ts b/tests/clients/desktop-startup-surface.test.ts index 781416398c5..ee7e5ac61f5 100644 --- a/tests/clients/desktop-startup-surface.test.ts +++ b/tests/clients/desktop-startup-surface.test.ts @@ -17,7 +17,10 @@ const LIB = repoPath(`${SRC}/lib.rs`); const SIDECAR = repoPath(`${SRC}/sidecar.rs`); const STARTUP = repoPath(`${SRC}/startup.rs`); const PROXY = repoPath(`${SRC}/proxy.rs`); -const PAGE = repoPath("desktop/ui/main.js"); +// The startup surface is one file: the page and its script ship together in index.html, +// because a script loaded from a second file is not named by the policy the webview is +// actually served and never runs on some platforms. Read the page as the oracle for both. +const PAGE = repoPath("desktop/ui/index.html"); const CONFIG = repoPath("desktop/src-tauri/tauri.conf.json"); function code(path: string): string { @@ -154,6 +157,51 @@ describe("desktop startup surface", () => { expect(page).toContain("progress.failedPhase"); }); + test("the snapshot answers with a state rather than with nothing", () => { + // The page returns early on a falsy progress, so an absent answer was not a neutral one: it + // was a window frozen on its own markup, with no diagnostic in it and no event coming. + expect(lib).toContain("fn startup_snapshot(app: tauri::AppHandle) -> startup::Progress"); + expect(lib).not.toContain("Option"); + expect(lib).toContain("unwrap_or_else(startup::unavailable)"); + expect(startup).toContain("pub fn unavailable() -> Progress"); + }); + + test("not having started is a state of its own, and not a checklist row", () => { + // Seeding the state with the first phase made "has not started" render exactly like "started, + // and registering". A row for it would instead be a step that never completes. + expect(startup).toContain('Self::NotStarted => "not-started"'); + const list = startup.indexOf("pub const PHASES"); + expect(list).toBeGreaterThan(-1); + expect(startup.slice(list, startup.indexOf("];", list))).not.toContain("NotStarted"); + expect(startup).not.toContain("Progress::new(Phase::Registering, 0)"); + }); + + test("the run publishes before anything it does can return", () => { + // The lookup below used to come first, so a run that returned there had said nothing at all + // and the page could not tell that from a run still going. + const at = startup.indexOf("async fn run(app: &AppHandle"); + expect(at).toBeGreaterThan(-1); + const body = startup.slice(at, startup.indexOf("async fn register(", at)); + const published = body.indexOf("report(app, started, Phase::Registering, None);"); + expect(published).toBeGreaterThan(-1); + expect(body.indexOf("try_state::()")).toBeGreaterThan(published); + }); + + test("a run that reports nothing is still a run that ends", () => { + // Every early return in the sequence, and every step that outlives the ceiling, used to leave + // the surface on its last state for as long as the process lived. + const begin = startup.slice(startup.indexOf("pub fn begin("), startup.indexOf("fn settle(")); + expect(begin).toContain("run(&app, started).await;"); + expect(begin.slice(begin.indexOf("run(&app, started).await;"))).toContain("settle("); + expect(begin).toContain("sleep_until(started + DEADLINE + SETTLE_GRACE)"); + // Idempotent, and bound to the run it was started for: it may not overwrite a real result, + // and a guard left over from an earlier run may not fail the retry that replaced it. + const settle = startup.slice(startup.indexOf("fn settle("), startup.indexOf("async fn run(")); + expect(settle).toContain("startup.settled()"); + expect(settle).toContain("generation.load(Ordering::Acquire) != generation"); + expect(settle).toContain("Progress::new(Phase::Failed, elapsed_ms)"); + }); + test("the retry, the snapshot and the phase list are reachable from the page", () => { const handler = lib.slice( lib.indexOf("generate_handler!["), @@ -197,3 +245,43 @@ describe("desktop startup surface", () => { expect(page).toContain("clipboard.writeText"); }); }); + +/** + * The surface has to be able to stay silent. + * + * Two defects made it speak when it had nothing to say and stay quiet when it did. An id rule + * with display: grid outranks the user-agent [hidden] { display: none } rule, so the failure + * block - the Retry button and the empty diagnostic box - was painted during every normal start. + * And an invoke whose command never answers returns a promise that neither settles nor rejects, + * so the page kept its initial markup for as long as the shell stayed silent. Together they are + * the screen a user reads as a dead application: a starting headline, no checklist, one Retry. + */ +describe("the bootstrap page reports only what it was told", () => { + const markup = readFileSync(repoPath("desktop/ui/index.html"), "utf8"); + const page = readFileSync(PAGE, "utf8"); + + test("the failure block honours its hidden attribute", () => { + expect(/#failure\[hidden\][^{]*\{[^}]*display:\s*none/.test(markup)).toBe(true); + }); + + test("the bootstrap script carries the nonce token the shell replaces", () => { + // The webview is served a policy the configuration file does not contain. Tauri appends its + // own hashes and nonces to script-src, and a hash or nonce in that directive makes + // 'unsafe-inline' inert, so nothing loads unless it is named. Its injector only tags + // script[src^='http'], and this page loads its script by relative path, so the page has to + // carry the token itself; the shell replaces it with a real nonce and adds that nonce to the + // directive. Without it the surface renders as static markup on the platforms where the + // asset origin does not satisfy 'self' — observed on Linux, where the page never ran a line. + expect(markup).not.toContain("./main.js"); + expect(markup).toMatch(/