diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c56fbf319..d79dd31d2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -135,6 +135,16 @@ jobs: - name: Test Effect Windows process spawn run: bun run --cwd apps/server test src/windowsProcessEffect.test.ts + - name: Test Windows private state initialization + run: | + bun run --cwd apps/server test src/serverPrivateDirectories.test.ts + bun run --cwd apps/desktop test src/desktopScientDataDirectories.test.ts + + - name: Test Windows backend lifecycle + run: | + bun run --cwd apps/desktop test src/desktopBackendSupervisor.test.ts src/backendProcessTree.test.ts + bun run --cwd apps/server test src/desktopParentShutdown.test.ts + - name: Exercise Windows release staging run: bun run release:smoke diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 72ffa4c6b..29362af71 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,7 +16,7 @@ on: default: false type: boolean allow_unsigned_release: - description: "Explicitly allow unsigned desktop artifacts for this manual early-access release" + description: "Allow an unsigned Windows installer for this manual early-access release (macOS always requires signing)" required: true default: false type: boolean @@ -201,7 +201,7 @@ jobs: name: Build ${{ matrix.label }} needs: preflight runs-on: ${{ matrix.runner }} - timeout-minutes: 30 + timeout-minutes: ${{ matrix.timeout_minutes }} strategy: fail-fast: false matrix: @@ -211,21 +211,25 @@ jobs: platform: mac target: dmg arch: arm64 + timeout_minutes: 120 - label: macOS x64 runner: macos-15-intel platform: mac target: dmg arch: x64 + timeout_minutes: 120 - label: Linux x64 runner: ubuntu-24.04 platform: linux target: AppImage arch: x64 + timeout_minutes: 30 - label: Windows x64 runner: windows-2022 platform: win target: nsis arch: x64 + timeout_minutes: 30 steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -253,7 +257,8 @@ jobs: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}" bun install --lockfile-only --ignore-scripts - - name: Build desktop artifact + - name: Build macOS desktop artifact + if: matrix.platform == 'mac' shell: bash env: CSC_LINK: ${{ secrets.CSC_LINK }} @@ -261,6 +266,43 @@ jobs: APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + SCIENT_DESKTOP_UPDATE_REPOSITORY: ${{ needs.preflight.outputs.update_repository }} + PUBLISH_RELEASE: ${{ needs.preflight.outputs.publish_release }} + ALLOW_UNSIGNED_RELEASE: ${{ needs.preflight.outputs.unsigned_release }} + run: | + bash scripts/build-release-desktop-artifact.sh \ + "${{ matrix.platform }}" \ + "${{ matrix.target }}" \ + "${{ matrix.arch }}" \ + "${{ needs.preflight.outputs.version }}" + + - name: Upload macOS notarization evidence + if: ${{ always() && matrix.platform == 'mac' }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: notarization-${{ matrix.arch }} + path: release/notarization-*.json + if-no-files-found: warn + retention-days: 30 + + - name: Build Linux desktop artifact + if: matrix.platform == 'linux' + shell: bash + env: + SCIENT_DESKTOP_UPDATE_REPOSITORY: ${{ needs.preflight.outputs.update_repository }} + PUBLISH_RELEASE: ${{ needs.preflight.outputs.publish_release }} + ALLOW_UNSIGNED_RELEASE: ${{ needs.preflight.outputs.unsigned_release }} + run: | + bash scripts/build-release-desktop-artifact.sh \ + "${{ matrix.platform }}" \ + "${{ matrix.target }}" \ + "${{ matrix.arch }}" \ + "${{ needs.preflight.outputs.version }}" + + - name: Build Windows desktop artifact + if: matrix.platform == 'win' + shell: bash + env: WIN_CSC_LINK: ${{ secrets.WIN_CSC_LINK }} WIN_CSC_KEY_PASSWORD: ${{ secrets.WIN_CSC_KEY_PASSWORD }} AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} @@ -274,119 +316,11 @@ jobs: PUBLISH_RELEASE: ${{ needs.preflight.outputs.publish_release }} ALLOW_UNSIGNED_RELEASE: ${{ needs.preflight.outputs.unsigned_release }} run: | - args=( - --platform "${{ matrix.platform }}" - --target "${{ matrix.target }}" - --arch "${{ matrix.arch }}" - --build-version "${{ needs.preflight.outputs.version }}" - --verbose - ) - - has_all() { - for value in "$@"; do - if [[ -z "$value" ]]; then - return 1 - fi - done - return 0 - } - - has_any() { - for value in "$@"; do - if [[ -n "$value" ]]; then - return 0 - fi - done - return 1 - } - - if [[ "${{ matrix.platform }}" == "mac" ]]; then - apple_values=( - "$CSC_LINK" - "$CSC_KEY_PASSWORD" - "$APPLE_API_KEY" - "$APPLE_API_KEY_ID" - "$APPLE_API_ISSUER" - ) - if has_all "${apple_values[@]}"; then - key_path="$RUNNER_TEMP/AuthKey_${APPLE_API_KEY_ID}.p8" - printf '%s' "$APPLE_API_KEY" > "$key_path" - export APPLE_API_KEY="$key_path" - echo "macOS signing enabled." - args+=(--signed) - elif has_any "${apple_values[@]}"; then - if [[ "$PUBLISH_RELEASE" == "true" ]]; then - echo "Public macOS releases cannot use a partial Apple signing configuration." >&2 - exit 1 - fi - echo "Build-only macOS signing disabled because the Apple signing configuration is incomplete." - elif [[ "$PUBLISH_RELEASE" == "true" && "$ALLOW_UNSIGNED_RELEASE" == "true" ]]; then - echo "Unsigned macOS early-access publication explicitly enabled." - elif [[ "$PUBLISH_RELEASE" == "true" ]]; then - echo "Public macOS releases require all Apple signing and notarization secrets." >&2 - exit 1 - else - echo "Build-only macOS signing disabled (missing one or more Apple signing secrets)." - fi - elif [[ "${{ matrix.platform }}" == "win" ]]; then - # Build-only validation may produce an unsigned installer. Public - # releases fail closed unless exactly one signing provider is complete. - certificate_values=("$WIN_CSC_LINK" "$WIN_CSC_KEY_PASSWORD") - azure_values=( - "$AZURE_TENANT_ID" - "$AZURE_CLIENT_ID" - "$AZURE_CLIENT_SECRET" - "$AZURE_TRUSTED_SIGNING_ENDPOINT" - "$AZURE_TRUSTED_SIGNING_ACCOUNT_NAME" - "$AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME" - "$AZURE_TRUSTED_SIGNING_PUBLISHER_NAME" - ) - - if has_all "${certificate_values[@]}" && ! has_any "${azure_values[@]}"; then - echo "Windows signing enabled (standard Authenticode certificate)." - args+=(--signed) - elif has_all "${azure_values[@]}" && ! has_any "${certificate_values[@]}"; then - echo "Windows signing enabled (Azure Trusted Signing)." - args+=(--signed) - elif has_any "${certificate_values[@]}" || has_any "${azure_values[@]}"; then - if [[ "$PUBLISH_RELEASE" == "true" ]]; then - echo "Public Windows releases require exactly one complete signing provider: WIN_CSC_LINK plus WIN_CSC_KEY_PASSWORD, or all Azure Trusted Signing secrets." >&2 - exit 1 - fi - echo "Build-only Windows signing disabled because the signing configuration is incomplete or conflicting." - elif [[ "$PUBLISH_RELEASE" == "true" && "$ALLOW_UNSIGNED_RELEASE" == "true" ]]; then - echo "Unsigned Windows early-access publication explicitly enabled." - elif [[ "$PUBLISH_RELEASE" == "true" ]]; then - echo "Public Windows releases require a standard Authenticode certificate or Azure Trusted Signing." >&2 - exit 1 - else - echo "Build-only Windows signing disabled because no signing provider is configured." - fi - else - echo "Signing disabled for ${{ matrix.platform }}." - fi - - if [[ "${{ matrix.platform }}" == "mac" && "${{ matrix.arch }}" == "x64" ]]; then - # electron-builder occasionally times out fetching x64 DMG helper bundles on GitHub-hosted macOS runners. - # Retry this narrow packaging path so transient upstream 504s do not block the whole release. - max_attempts=3 - for attempt in $(seq 1 "$max_attempts"); do - if bun run dist:desktop:artifact -- "${args[@]}"; then - break - fi - - if [[ "$attempt" -eq "$max_attempts" ]]; then - echo "macOS x64 desktop artifact build failed after $max_attempts attempts." - exit 1 - fi - - sleep_seconds=$((attempt * 15)) - echo "macOS x64 desktop artifact build failed on attempt $attempt/$max_attempts. Retrying in ${sleep_seconds}s..." - sleep "$sleep_seconds" - done - else - bun run dist:desktop:artifact -- "${args[@]}" - fi + bash scripts/build-release-desktop-artifact.sh \ + "${{ matrix.platform }}" \ + "${{ matrix.target }}" \ + "${{ matrix.arch }}" \ + "${{ needs.preflight.outputs.version }}" - name: Collect release assets shell: bash @@ -522,6 +456,8 @@ jobs: required_assets=( "Scient-${RELEASE_VERSION}-arm64.dmg" "Scient-${RELEASE_VERSION}-x64.dmg" + "Scient-${RELEASE_VERSION}-arm64.zip" + "Scient-${RELEASE_VERSION}-x64.zip" "Scient-${RELEASE_VERSION}-x64.exe" "Scient-${RELEASE_VERSION}-x86_64.AppImage" "latest-mac.yml" @@ -543,6 +479,8 @@ jobs: required_payloads=( "Scient-${RELEASE_VERSION}-arm64.dmg" "Scient-${RELEASE_VERSION}-x64.dmg" + "Scient-${RELEASE_VERSION}-arm64.zip" + "Scient-${RELEASE_VERSION}-x64.zip" "Scient-${RELEASE_VERSION}-x64.exe" "Scient-${RELEASE_VERSION}-x86_64.AppImage" ) @@ -637,7 +575,7 @@ jobs: if [[ "$UNSIGNED_RELEASE" == "true" ]]; then printf '%s\n' \ '> [!WARNING]' \ - '> This early-access release is unsigned. Windows may show an Unknown Publisher warning. On macOS, Scient downloads updates in-app and opens them in Finder for manual replacement.' \ + '> This early-access release includes an unsigned Windows installer, which may show an Unknown Publisher warning. macOS artifacts remain Developer ID signed, notarized, and stapled.' \ > release-notice.md else : > release-notice.md @@ -715,10 +653,71 @@ jobs: exit 1 fi + verify_published_macos: + name: Verify published macOS ${{ matrix.arch }} + if: ${{ needs.preflight.outputs.publish_release == 'true' }} + needs: [preflight, release] + runs-on: ${{ matrix.runner }} + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + include: + - arch: arm64 + runner: macos-14 + - arch: x64 + runner: macos-15-intel + steps: + - name: Checkout exact release source + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ needs.preflight.outputs.ref }} + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version-file: package.json + + - name: Download published macOS artifact + shell: bash + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ needs.preflight.outputs.tag }} + RELEASE_VERSION: ${{ needs.preflight.outputs.version }} + run: | + set -euo pipefail + mkdir -p published-macos + gh release download "$RELEASE_TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --pattern "Scient-${RELEASE_VERSION}-${{ matrix.arch }}.dmg" \ + --pattern "Scient-${RELEASE_VERSION}-${{ matrix.arch }}.zip" \ + --pattern SHA256SUMS.txt \ + --dir published-macos + + - name: Verify checksum and delivered macOS identity + shell: bash + env: + RELEASE_VERSION: ${{ needs.preflight.outputs.version }} + run: | + set -euo pipefail + for extension in dmg zip; do + asset="Scient-${RELEASE_VERSION}-${{ matrix.arch }}.${extension}" + expected_line="$(grep -F " ${asset}" published-macos/SHA256SUMS.txt | head -n 1 || true)" + if [[ -z "$expected_line" ]]; then + echo "Published checksum manifest is missing $asset." >&2 + exit 1 + fi + ( + cd published-macos + printf '%s\n' "$expected_line" | shasum -a 256 -c - + ) + done + bun scripts/verify-mac-release-artifact.ts published-macos + finalize: name: Finalize release if: ${{ needs.preflight.outputs.publish_release == 'true' && vars.SCIENT_FINALIZE_RELEASE == '1' }} - needs: [preflight, release] + needs: [preflight, verify_published_macos] runs-on: ubuntu-24.04 timeout-minutes: 10 steps: diff --git a/apps/desktop/resources/entitlements.appsnap.plist b/apps/desktop/resources/entitlements.appsnap.plist new file mode 100644 index 000000000..564c7a6bb --- /dev/null +++ b/apps/desktop/resources/entitlements.appsnap.plist @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/desktop/scripts/dev-electron.mjs b/apps/desktop/scripts/dev-electron.mjs index 861301371..acdeaf566 100644 --- a/apps/desktop/scripts/dev-electron.mjs +++ b/apps/desktop/scripts/dev-electron.mjs @@ -4,7 +4,7 @@ import { join } from "node:path"; import waitOn from "wait-on"; import { buildAppSnapHelper } from "./build-appsnap-helper.mjs"; -import { desktopDir, resolveElectronPath } from "./electron-launcher.mjs"; +import { desktopDir, resolveElectronLaunchCommand } from "./electron-launcher.mjs"; const port = Number(process.env.ELECTRON_RENDERER_PORT ?? 5733); const devServerUrl = `http://localhost:${port}`; @@ -140,18 +140,18 @@ function startApp() { return; } - const app = spawn( - resolveElectronPath(), + const electronCommand = resolveElectronLaunchCommand( [`--synara-dev-root=${desktopDir}`, "dist-electron/main.js"], - { - cwd: desktopDir, - env: { - ...childEnv, - VITE_DEV_SERVER_URL: devServerUrl, - }, - stdio: "inherit", - }, + { development: true }, ); + const app = spawn(electronCommand.electronPath, electronCommand.args, { + cwd: desktopDir, + env: { + ...childEnv, + VITE_DEV_SERVER_URL: devServerUrl, + }, + stdio: "inherit", + }); currentApp = app; diff --git a/apps/desktop/scripts/electron-launcher.mjs b/apps/desktop/scripts/electron-launcher.mjs index e06f504a2..3c3075ba2 100644 --- a/apps/desktop/scripts/electron-launcher.mjs +++ b/apps/desktop/scripts/electron-launcher.mjs @@ -5,6 +5,7 @@ import { copyFileSync, cpSync, existsSync, + lstatSync, mkdirSync, readFileSync, readdirSync, @@ -146,3 +147,87 @@ export function resolveElectronPath() { return buildMacLauncher(electronBinaryPath); } + +export function isLinuxSetuidSandboxConfigured( + electronBinaryPath, + { platform = process.platform, lstat = lstatSync } = {}, +) { + if (platform !== "linux") { + return true; + } + + const sandboxPath = join(dirname(electronBinaryPath), "chrome-sandbox"); + try { + const sandboxStat = lstat(sandboxPath); + return sandboxStat.isFile() && sandboxStat.uid === 0 && (sandboxStat.mode & 0o7777) === 0o4755; + } catch { + return false; + } +} + +export function isLinuxUserNamespaceSandboxAvailable({ + platform = process.platform, + runUnshare = spawnSync, +} = {}) { + if (platform !== "linux") { + return true; + } + + const result = runUnshare("unshare", ["-Ur", "true"], { + shell: false, + stdio: "ignore", + timeout: 5_000, + windowsHide: true, + }); + return !result.error && result.status === 0; +} + +export class LinuxSandboxConfigurationError extends Error { + constructor(sandboxPath) { + super( + `Electron needs either unprivileged user namespaces or a sandbox helper at ${sandboxPath} ` + + "that is a regular file owned by root with exact mode 4755. Enable one of those sandbox paths " + + "before launching Scient. For an isolated local development session only, " + + "set SCIENT_DEV_ALLOW_NO_SANDBOX=1 to accept the unsafe fallback explicitly.", + ); + this.name = "LinuxSandboxConfigurationError"; + this.sandboxPath = sandboxPath; + } +} + +export function resolveLinuxSandboxArgs( + electronBinaryPath, + { + platform = process.platform, + lstat = lstatSync, + runUnshare = spawnSync, + development = isDevelopment, + env = process.env, + warn = console.warn, + } = {}, +) { + if ( + isLinuxSetuidSandboxConfigured(electronBinaryPath, { platform, lstat }) || + isLinuxUserNamespaceSandboxAvailable({ platform, runUnshare }) + ) { + return []; + } + + const sandboxPath = join(dirname(electronBinaryPath), "chrome-sandbox"); + if (development && env.SCIENT_DEV_ALLOW_NO_SANDBOX === "1") { + warn( + "[desktop-launcher] Unsafe development override accepted: launching local Electron with --no-sandbox.", + ); + return ["--no-sandbox"]; + } + + throw new LinuxSandboxConfigurationError(sandboxPath); +} + +export function resolveElectronLaunchCommand(args = [], options = {}) { + const electronPath = resolveElectronPath(); + return { + electronPath, + args: [...resolveLinuxSandboxArgs(electronPath, options), ...args], + }; +} diff --git a/apps/desktop/scripts/electron-launcher.test.mjs b/apps/desktop/scripts/electron-launcher.test.mjs new file mode 100644 index 000000000..b5d9e6b7c --- /dev/null +++ b/apps/desktop/scripts/electron-launcher.test.mjs @@ -0,0 +1,113 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + isLinuxSetuidSandboxConfigured, + isLinuxUserNamespaceSandboxAvailable, + LinuxSandboxConfigurationError, + resolveLinuxSandboxArgs, +} from "./electron-launcher.mjs"; + +const ELECTRON_PATH = "/repo/node_modules/electron/dist/electron"; +const SANDBOX_PATH = "/repo/node_modules/electron/dist/chrome-sandbox"; + +describe("Linux Electron sandbox launch policy", () => { + it("leaves non-Linux launches unchanged without inspecting chrome-sandbox", () => { + const lstat = vi.fn(); + const runUnshare = vi.fn(); + + expect( + resolveLinuxSandboxArgs(ELECTRON_PATH, { platform: "darwin", lstat, runUnshare }), + ).toEqual([]); + expect(lstat).not.toHaveBeenCalled(); + expect(runUnshare).not.toHaveBeenCalled(); + }); + + it("keeps Chromium's sandbox when chrome-sandbox is root-owned setuid 4755", () => { + const lstat = vi.fn(() => ({ isFile: () => true, mode: 0o104755, uid: 0 })); + const runUnshare = vi.fn(); + + expect(isLinuxSetuidSandboxConfigured(ELECTRON_PATH, { platform: "linux", lstat })).toBe(true); + expect( + resolveLinuxSandboxArgs(ELECTRON_PATH, { platform: "linux", lstat, runUnshare }), + ).toEqual([]); + expect(lstat).toHaveBeenCalledWith(SANDBOX_PATH); + expect(runUnshare).not.toHaveBeenCalled(); + }); + + it("keeps Chromium's sandbox when unprivileged user namespaces work without a helper", () => { + const runUnshare = vi.fn(() => ({ status: 0 })); + + expect( + resolveLinuxSandboxArgs(ELECTRON_PATH, { + platform: "linux", + lstat: () => { + throw new Error("ENOENT"); + }, + runUnshare, + }), + ).toEqual([]); + expect(runUnshare).toHaveBeenCalledWith("unshare", ["-Ur", "true"], { + shell: false, + stdio: "ignore", + timeout: 5_000, + windowsHide: true, + }); + expect(isLinuxUserNamespaceSandboxAvailable({ platform: "linux", runUnshare })).toBe(true); + }); + + it.each([ + ["is not root-owned", { isFile: () => true, mode: 0o104755, uid: 1000 }], + ["is not setuid", { isFile: () => true, mode: 0o100755, uid: 0 }], + ["is group-writable", { isFile: () => true, mode: 0o104775, uid: 0 }], + ["has extra special bits", { isFile: () => true, mode: 0o106755, uid: 0 }], + ["is not a regular file", { isFile: () => false, mode: 0o104755, uid: 0 }], + ])("fails closed when chrome-sandbox %s", (_reason, metadata) => { + expect(() => + resolveLinuxSandboxArgs(ELECTRON_PATH, { + platform: "linux", + lstat: () => metadata, + runUnshare: () => ({ status: 1 }), + }), + ).toThrow(LinuxSandboxConfigurationError); + }); + + it("fails closed when both chrome-sandbox and unprivileged user namespaces are unavailable", () => { + expect(() => + resolveLinuxSandboxArgs(ELECTRON_PATH, { + platform: "linux", + lstat: () => { + throw new Error("ENOENT"); + }, + runUnshare: () => ({ status: 1 }), + }), + ).toThrow(expect.objectContaining({ sandboxPath: SANDBOX_PATH })); + }); + + it("allows --no-sandbox only through an explicit local-development override", () => { + const warn = vi.fn(); + + expect( + resolveLinuxSandboxArgs(ELECTRON_PATH, { + platform: "linux", + lstat: () => ({ isFile: () => false, mode: 0, uid: 1000 }), + runUnshare: () => ({ status: 1 }), + development: true, + env: { SCIENT_DEV_ALLOW_NO_SANDBOX: "1" }, + warn, + }), + ).toEqual(["--no-sandbox"]); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("Unsafe development override")); + }); + + it("refuses the unsafe override outside development", () => { + expect(() => + resolveLinuxSandboxArgs(ELECTRON_PATH, { + platform: "linux", + lstat: () => ({ isFile: () => false, mode: 0, uid: 1000 }), + runUnshare: () => ({ status: 1 }), + development: false, + env: { SCIENT_DEV_ALLOW_NO_SANDBOX: "1" }, + }), + ).toThrow(LinuxSandboxConfigurationError); + }); +}); diff --git a/apps/desktop/scripts/smoke-test.mjs b/apps/desktop/scripts/smoke-test.mjs index 677e33f2f..b864df9c5 100644 --- a/apps/desktop/scripts/smoke-test.mjs +++ b/apps/desktop/scripts/smoke-test.mjs @@ -2,14 +2,16 @@ import { spawn, spawnSync } from "node:child_process"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { resolveElectronLaunchCommand } from "./electron-launcher.mjs"; + const __dirname = dirname(fileURLToPath(import.meta.url)); const desktopDir = resolve(__dirname, ".."); -const electronBin = resolve(desktopDir, "node_modules/.bin/electron"); const mainJs = resolve(desktopDir, "dist-electron/main.js"); console.log("\nLaunching Electron smoke test..."); -const child = spawn(electronBin, [mainJs], { +const electronCommand = resolveElectronLaunchCommand([mainJs], { development: false }); +const child = spawn(electronCommand.electronPath, electronCommand.args, { stdio: ["pipe", "pipe", "pipe"], detached: process.platform !== "win32", env: { diff --git a/apps/desktop/scripts/start-electron.mjs b/apps/desktop/scripts/start-electron.mjs index 345c3843a..2822a18c3 100644 --- a/apps/desktop/scripts/start-electron.mjs +++ b/apps/desktop/scripts/start-electron.mjs @@ -1,7 +1,7 @@ import { spawn } from "node:child_process"; import { buildAppSnapHelper } from "./build-appsnap-helper.mjs"; -import { desktopDir, resolveElectronPath } from "./electron-launcher.mjs"; +import { desktopDir, resolveElectronLaunchCommand } from "./electron-launcher.mjs"; if (process.platform === "darwin") { buildAppSnapHelper({ arch: process.arch }); @@ -10,7 +10,10 @@ if (process.platform === "darwin") { const childEnv = { ...process.env }; delete childEnv.ELECTRON_RUN_AS_NODE; -const child = spawn(resolveElectronPath(), ["dist-electron/main.js"], { +const electronCommand = resolveElectronLaunchCommand(["dist-electron/main.js"], { + development: true, +}); +const child = spawn(electronCommand.electronPath, electronCommand.args, { stdio: "inherit", cwd: desktopDir, env: childEnv, diff --git a/apps/desktop/src/backendProcessTree.test.ts b/apps/desktop/src/backendProcessTree.test.ts new file mode 100644 index 000000000..c674f0cf8 --- /dev/null +++ b/apps/desktop/src/backendProcessTree.test.ts @@ -0,0 +1,89 @@ +import { EventEmitter } from "node:events"; + +import { describe, expect, it, vi } from "vitest"; + +import { + backendProcessContainmentOptions, + forceTerminateBackendProcessTree, +} from "./backendProcessTree"; + +describe("forceTerminateBackendProcessTree", () => { + it("always reserves an IPC channel and isolates POSIX process groups", () => { + expect(backendProcessContainmentOptions(true, "linux")).toEqual({ + detached: true, + stdio: ["ignore", "pipe", "pipe", "ipc"], + }); + expect(backendProcessContainmentOptions(false, "darwin")).toEqual({ + detached: true, + stdio: ["ignore", "inherit", "inherit", "ipc"], + }); + expect(backendProcessContainmentOptions(false, "win32")).toEqual({ + detached: false, + stdio: ["ignore", "inherit", "inherit", "ipc"], + }); + }); + + it("kills the detached POSIX process group", async () => { + const killProcessGroup = vi.fn(); + + await forceTerminateBackendProcessTree({ pid: 4321 }, { platform: "linux", killProcessGroup }); + + expect(killProcessGroup).toHaveBeenCalledWith(-4321, "SIGKILL"); + }); + + it("ignores a POSIX process group that already exited", async () => { + await expect( + forceTerminateBackendProcessTree( + { pid: 4321 }, + { + platform: "darwin", + killProcessGroup: () => { + const error = new Error("missing") as NodeJS.ErrnoException; + error.code = "ESRCH"; + throw error; + }, + }, + ), + ).resolves.toBeUndefined(); + }); + + it("uses the Windows taskkill executable without a shell", async () => { + const process = new EventEmitter(); + const spawnProcess = vi.fn(() => process); + + const terminating = forceTerminateBackendProcessTree( + { pid: 4321 }, + { + platform: "win32", + env: { SystemRoot: "D:\\Windows" }, + spawnProcess: spawnProcess as never, + }, + ); + process.emit("exit", 0, null); + await terminating; + + expect(spawnProcess).toHaveBeenCalledWith( + "D:\\Windows\\System32\\taskkill.exe", + ["/PID", "4321", "/T", "/F"], + { + env: { SystemRoot: "D:\\Windows" }, + shell: false, + stdio: "ignore", + windowsHide: true, + }, + ); + }); + + it("does not treat a missing Windows root as successful descendant cleanup", async () => { + const process = new EventEmitter(); + const spawnProcess = vi.fn(() => process); + const terminating = forceTerminateBackendProcessTree( + { pid: 4321 }, + { platform: "win32", spawnProcess: spawnProcess as never }, + ); + + process.emit("exit", 128, null); + + await expect(terminating).rejects.toThrow("taskkill exited with status 128"); + }); +}); diff --git a/apps/desktop/src/backendProcessTree.ts b/apps/desktop/src/backendProcessTree.ts new file mode 100644 index 000000000..73b062e7b --- /dev/null +++ b/apps/desktop/src/backendProcessTree.ts @@ -0,0 +1,73 @@ +import { spawn } from "node:child_process"; +import type { ChildProcess, SpawnOptions } from "node:child_process"; +import path from "node:path"; + +import { resolveWindowsSystemRoot } from "@synara/shared/windowsProcess"; + +export interface ForceTerminateBackendProcessTreeOptions { + readonly platform?: NodeJS.Platform; + readonly env?: NodeJS.ProcessEnv; + readonly killProcessGroup?: (pid: number, signal: NodeJS.Signals) => void; + readonly spawnProcess?: typeof spawn; +} + +export function backendProcessContainmentOptions( + captureLogs: boolean, + platform: NodeJS.Platform = process.platform, +): Pick { + return { + detached: platform !== "win32", + stdio: captureLogs + ? ["ignore", "pipe", "pipe", "ipc"] + : ["ignore", "inherit", "inherit", "ipc"], + }; +} + +function ignoreMissingProcess(error: unknown): void { + if ((error as NodeJS.ErrnoException)?.code !== "ESRCH") throw error; +} + +async function forceTerminateWindowsTree( + pid: number, + options: ForceTerminateBackendProcessTreeOptions, +): Promise { + const env = options.env ?? process.env; + const taskkill = path.win32.join(resolveWindowsSystemRoot(env), "System32", "taskkill.exe"); + const child = (options.spawnProcess ?? spawn)(taskkill, ["/PID", String(pid), "/T", "/F"], { + env, + shell: false, + stdio: "ignore", + windowsHide: true, + }); + + await new Promise((resolve, reject) => { + child.once("error", reject); + child.once("exit", (code) => { + if (code === 0) { + resolve(); + return; + } + reject(new Error(`taskkill exited with status ${code ?? "null"}`)); + }); + }); +} + +/** Force-kills the backend and all descendants after graceful IPC shutdown timed out. */ +export async function forceTerminateBackendProcessTree( + child: Pick, + options: ForceTerminateBackendProcessTreeOptions = {}, +): Promise { + const pid = child.pid; + if (!pid || pid <= 0) return; + + if ((options.platform ?? process.platform) === "win32") { + await forceTerminateWindowsTree(pid, options); + return; + } + + try { + (options.killProcessGroup ?? process.kill)(-pid, "SIGKILL"); + } catch (error) { + ignoreMissingProcess(error); + } +} diff --git a/apps/desktop/src/backendStartupReadiness.test.ts b/apps/desktop/src/backendStartupReadiness.test.ts index 6416f751c..952f5586d 100644 --- a/apps/desktop/src/backendStartupReadiness.test.ts +++ b/apps/desktop/src/backendStartupReadiness.test.ts @@ -5,37 +5,70 @@ import { waitForBackendStartupReady } from "./backendStartupReadiness"; describe("waitForBackendStartupReady", () => { it("resolves from http when no listening promise is provided", async () => { const waitForHttpReady = vi.fn<() => Promise>().mockResolvedValue(undefined); - const cancelHttpWait = vi.fn(); + const onHttpReady = vi.fn(); await expect( waitForBackendStartupReady({ waitForHttpReady, - cancelHttpWait, + onHttpReady, }), ).resolves.toBe("http"); expect(waitForHttpReady).toHaveBeenCalledTimes(1); - expect(cancelHttpWait).not.toHaveBeenCalled(); + expect(onHttpReady).toHaveBeenCalledTimes(1); }); - it("prefers the listening signal and cancels the http wait", async () => { + it("opens from the listening signal without declaring semantic readiness", async () => { let resolveListening!: () => void; + let resolveHttp!: () => void; const listeningPromise = new Promise((resolve) => { resolveListening = resolve; }); - const waitForHttpReady = vi.fn(() => new Promise(() => {})); - const cancelHttpWait = vi.fn(); + const waitForHttpReady = vi.fn( + () => + new Promise((resolve) => { + resolveHttp = resolve; + }), + ); + const onHttpReady = vi.fn(); const resultPromise = waitForBackendStartupReady({ listeningPromise, waitForHttpReady, - cancelHttpWait, + onHttpReady, }); resolveListening(); await expect(resultPromise).resolves.toBe("listening"); - expect(cancelHttpWait).toHaveBeenCalledTimes(1); + expect(onHttpReady).not.toHaveBeenCalled(); + + resolveHttp(); + await vi.waitFor(() => expect(onHttpReady).toHaveBeenCalledTimes(1)); + }); + + it("reports semantic readiness failure after the listening signal already opened the window", async () => { + let resolveListening!: () => void; + let rejectHttp!: (error: Error) => void; + const listeningPromise = new Promise((resolve) => { + resolveListening = resolve; + }); + const onHttpFailure = vi.fn(); + const resultPromise = waitForBackendStartupReady({ + listeningPromise, + waitForHttpReady: () => + new Promise((_resolve, reject) => { + rejectHttp = reject; + }), + onHttpFailure, + }); + + resolveListening(); + await expect(resultPromise).resolves.toBe("listening"); + + const error = new Error("startup readiness timed out"); + rejectHttp(error); + await vi.waitFor(() => expect(onHttpFailure).toHaveBeenCalledWith(error)); }); it("rejects when the listening promise fails before http is ready", async () => { @@ -45,7 +78,6 @@ describe("waitForBackendStartupReady", () => { waitForBackendStartupReady({ listeningPromise: Promise.reject(error), waitForHttpReady: () => new Promise(() => {}), - cancelHttpWait: vi.fn(), }), ).rejects.toThrow("backend exited"); }); diff --git a/apps/desktop/src/backendStartupReadiness.ts b/apps/desktop/src/backendStartupReadiness.ts index d32b5ab64..19af4aa6d 100644 --- a/apps/desktop/src/backendStartupReadiness.ts +++ b/apps/desktop/src/backendStartupReadiness.ts @@ -3,7 +3,8 @@ import { isBackendReadinessAborted } from "./backendReadiness"; export interface WaitForBackendStartupReadyOptions { readonly listeningPromise?: Promise | null; readonly waitForHttpReady: () => Promise; - readonly cancelHttpWait: () => void; + readonly onHttpReady?: () => void; + readonly onHttpFailure?: (error: unknown) => void; } export async function waitForBackendStartupReady( @@ -13,8 +14,14 @@ export async function waitForBackendStartupReady( const listeningPromise = options.listeningPromise; if (!listeningPromise) { - await httpReadyPromise; - return "http"; + try { + await httpReadyPromise; + options.onHttpReady?.(); + return "http"; + } catch (error) { + if (!isBackendReadinessAborted(error)) options.onHttpFailure?.(error); + throw error; + } } return await new Promise<"listening" | "http">((resolve, reject) => { @@ -25,9 +32,6 @@ export async function waitForBackendStartupReady( return; } settled = true; - if (source === "listening") { - options.cancelHttpWait(); - } resolve(source); }; @@ -44,11 +48,12 @@ export async function waitForBackendStartupReady( (error) => settleReject(error), ); httpReadyPromise.then( - () => settleResolve("http"), + () => { + options.onHttpReady?.(); + settleResolve("http"); + }, (error) => { - if (settled && isBackendReadinessAborted(error)) { - return; - } + if (!isBackendReadinessAborted(error)) options.onHttpFailure?.(error); settleReject(error); }, ); diff --git a/apps/desktop/src/desktopBackendSupervisor.test.ts b/apps/desktop/src/desktopBackendSupervisor.test.ts new file mode 100644 index 000000000..9a14ed07d --- /dev/null +++ b/apps/desktop/src/desktopBackendSupervisor.test.ts @@ -0,0 +1,389 @@ +import { EventEmitter } from "node:events"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + DesktopBackendTerminationError, + DesktopBackendSupervisor, + type DesktopBackendChild, + type DesktopBackendSupervisorOptions, +} from "./desktopBackendSupervisor"; + +class FakeBackendChild extends EventEmitter implements DesktopBackendChild { + pid: number | undefined; + connected = true; + sendReturnValue = true; + exitCode: number | null = null; + signalCode: NodeJS.Signals | null = null; + readonly sent: unknown[] = []; + + constructor(pid: number | undefined) { + super(); + this.pid = pid; + } + + send(message: unknown, callback?: (error: Error | null) => void): boolean { + this.sent.push(message); + callback?.(this.connected ? null : new Error("IPC disconnected")); + return this.connected && this.sendReturnValue; + } + + spawn(): void { + this.emit("spawn"); + } + + fail(error: Error): void { + this.emit("error", error); + } + + failSpawn(error: Error): void { + this.pid = undefined; + this.emit("error", error); + } + + exit(code: number | null = 0, signal: NodeJS.Signals | null = null): void { + this.exitCode = code; + this.signalCode = signal; + this.emit("exit", code, signal); + } +} + +function makeHarness(overrides: Partial = {}) { + const children: FakeBackendChild[] = []; + const prepared: number[] = []; + const exits: Array<{ generation: number; reason: string; expected: boolean }> = []; + const restarts: Array<{ attempt: number; delayMs: number; reason: string }> = []; + const forceTerminateTree = vi.fn(async (child: DesktopBackendChild) => { + (child as FakeBackendChild).exit(null, "SIGKILL"); + }); + const supervisor = new DesktopBackendSupervisor({ + prepareStart: async (generation) => { + prepared.push(generation); + }, + spawn: (generation) => { + const child = new FakeBackendChild(1_000 + generation); + children.push(child); + return child; + }, + requestGracefulShutdown: async (child, reason) => { + if (!child.send || child.connected === false) return false; + return await new Promise((resolve) => { + try { + child.send!({ type: "scient.backend.shutdown", reason }, (error) => + resolve(error === null), + ); + } catch { + resolve(false); + } + }); + }, + forceTerminateTree, + onGenerationExited: (event) => exits.push(event), + onRestartScheduled: (event) => restarts.push(event), + ...overrides, + }); + return { children, exits, forceTerminateTree, prepared, restarts, supervisor }; +} + +async function settleLifecycle(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + +beforeEach(() => { + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("DesktopBackendSupervisor", () => { + it("serializes duplicate starts into one backend generation", async () => { + const harness = makeHarness(); + + await Promise.all([harness.supervisor.start(), harness.supervisor.start()]); + + expect(harness.prepared).toEqual([1]); + expect(harness.children).toHaveLength(1); + expect(harness.supervisor.currentGeneration?.number).toBe(1); + }); + + it("keeps a running process active after a non-terminal child error", async () => { + const onError = vi.fn(); + const harness = makeHarness({ onError }); + await harness.supervisor.start(); + const child = harness.children[0]!; + + child.fail(new Error("IPC write failed")); + + expect(harness.supervisor.currentGeneration?.number).toBe(1); + expect(harness.exits).toHaveLength(0); + expect(harness.restarts).toHaveLength(0); + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ message: "IPC write failed" }), + "generation 1 process error", + ); + + child.exit(1); + await settleLifecycle(); + + expect(harness.exits).toEqual([ + { generation: 1, pid: 1001, reason: "code=1 signal=null", expected: false }, + ]); + expect(harness.restarts).toEqual([{ attempt: 0, delayMs: 500, reason: "code=1 signal=null" }]); + await vi.advanceTimersByTimeAsync(500); + expect(harness.children).toHaveLength(2); + }); + + it("closes a generation when spawning fails before a pid exists", async () => { + const harness = makeHarness(); + await harness.supervisor.start(); + + harness.children[0]!.failSpawn(new Error("executable missing")); + await settleLifecycle(); + + expect(harness.exits).toEqual([ + { + generation: 1, + pid: null, + reason: "spawn error=executable missing", + expected: false, + }, + ]); + expect(harness.restarts).toEqual([ + { attempt: 0, delayMs: 500, reason: "spawn error=executable missing" }, + ]); + }); + + it("backs off across unstable generations and resets only after readiness", async () => { + const harness = makeHarness(); + await harness.supervisor.start(); + + harness.children[0]!.exit(1); + await vi.advanceTimersByTimeAsync(500); + harness.children[1]!.exit(1); + await vi.advanceTimersByTimeAsync(1_000); + harness.supervisor.markReady(3); + harness.children[2]!.exit(1); + await settleLifecycle(); + + expect(harness.restarts.map(({ attempt, delayMs }) => ({ attempt, delayMs }))).toEqual([ + { attempt: 0, delayMs: 500 }, + { attempt: 1, delayMs: 1_000 }, + { attempt: 0, delayMs: 500 }, + ]); + }); + + it("fails closed when descendants of an exited generation cannot be cleaned up", async () => { + const cleanupError = new Error("descendant cleanup could not be proven"); + const onUnrecoverableGeneration = vi.fn(); + const harness = makeHarness({ + forceTerminateTree: vi.fn(async () => { + throw cleanupError; + }), + onUnrecoverableGeneration, + }); + await harness.supervisor.start(); + + harness.children[0]!.exit(1); + await settleLifecycle(); + + expect(harness.supervisor.desiredRunning).toBe(false); + expect(harness.supervisor.currentGeneration).toBeNull(); + expect(harness.restarts).toHaveLength(0); + expect(onUnrecoverableGeneration).toHaveBeenCalledWith({ + error: cleanupError, + generation: expect.objectContaining({ number: 1 }), + reason: "code=1 signal=null", + }); + }); + + it("ignores late events from a closed generation", async () => { + const harness = makeHarness(); + await harness.supervisor.start(); + const first = harness.children[0]!; + first.exit(1); + await vi.advanceTimersByTimeAsync(500); + + first.fail(new Error("late child error")); + + expect(harness.supervisor.currentGeneration?.number).toBe(2); + expect(harness.exits).toHaveLength(1); + expect(harness.restarts).toHaveLength(1); + }); + + it("replaces only the generation whose semantic readiness failed", async () => { + const harness = makeHarness(); + await harness.supervisor.start(); + const first = harness.children[0]!; + + const restarting = harness.supervisor.restartGeneration(1, "readiness check failed"); + await settleLifecycle(); + first.exit(0); + await restarting; + + expect(harness.restarts).toEqual([ + { attempt: 0, delayMs: 500, reason: "readiness check failed" }, + ]); + await vi.advanceTimersByTimeAsync(500); + expect(harness.supervisor.currentGeneration?.number).toBe(2); + + await harness.supervisor.restartGeneration(1, "stale readiness timeout"); + expect(harness.supervisor.currentGeneration?.number).toBe(2); + expect(harness.restarts).toHaveLength(1); + }); + + it("uses graceful IPC and does not force-kill a backend that exits", async () => { + const harness = makeHarness(); + await harness.supervisor.start(); + const child = harness.children[0]!; + + const stopping = harness.supervisor.stop("app quit"); + await Promise.resolve(); + child.exit(0); + await stopping; + + expect(child.sent).toEqual([{ type: "scient.backend.shutdown", reason: "app quit" }]); + expect(harness.forceTerminateTree).not.toHaveBeenCalled(); + expect(harness.exits[0]?.expected).toBe(true); + expect(harness.restarts).toHaveLength(0); + }); + + it("coalesces repeated stops and force-terminates the tree after timeout", async () => { + const harness = makeHarness({ + gracefulShutdownTimeoutMs: 100, + forcedExitTimeoutMs: 50, + }); + await harness.supervisor.start(); + const child = harness.children[0]!; + + const firstStop = harness.supervisor.stop("first quit"); + const secondStop = harness.supervisor.stop("second quit"); + await vi.advanceTimersByTimeAsync(100); + await Promise.all([firstStop, secondStop]); + + expect(child.sent).toEqual([{ type: "scient.backend.shutdown", reason: "first quit" }]); + expect(harness.forceTerminateTree).toHaveBeenCalledOnce(); + expect(harness.restarts).toHaveLength(0); + }); + + it("force-terminates immediately when the IPC channel is unavailable", async () => { + const harness = makeHarness({ + requestGracefulShutdown: () => false, + gracefulShutdownTimeoutMs: 10_000, + }); + await harness.supervisor.start(); + + await harness.supervisor.stop("lost IPC"); + + expect(harness.forceTerminateTree).toHaveBeenCalledOnce(); + expect(vi.getTimerCount()).toBe(0); + }); + + it("waits for an accepted IPC send even when send reports backpressure", async () => { + const harness = makeHarness(); + await harness.supervisor.start(); + const child = harness.children[0]!; + child.sendReturnValue = false; + + const stopping = harness.supervisor.stop("app quit"); + await settleLifecycle(); + child.exit(0); + await stopping; + + expect(child.sent).toEqual([{ type: "scient.backend.shutdown", reason: "app quit" }]); + expect(harness.forceTerminateTree).not.toHaveBeenCalled(); + }); + + it("rejects shutdown and retains ownership when force termination cannot stop the backend", async () => { + const harness = makeHarness({ + gracefulShutdownTimeoutMs: 10, + forcedExitTimeoutMs: 10, + forceTerminateTree: vi.fn(async () => undefined), + }); + await harness.supervisor.start(); + + const stopping = harness.supervisor.stop("app quit"); + await vi.advanceTimersByTimeAsync(20); + + await expect(stopping).rejects.toBeInstanceOf(DesktopBackendTerminationError); + expect(harness.supervisor.currentGeneration?.number).toBe(1); + expect(harness.supervisor.desiredRunning).toBe(false); + expect(harness.restarts).toHaveLength(0); + }); + + it("does not overlap a replacement with a backend that survived force termination", async () => { + const onError = vi.fn(); + const onUnrecoverableGeneration = vi.fn(); + const harness = makeHarness({ + gracefulShutdownTimeoutMs: 10, + forcedExitTimeoutMs: 10, + forceTerminateTree: vi.fn(async () => undefined), + onError, + onUnrecoverableGeneration, + }); + await harness.supervisor.start(); + const first = harness.children[0]!; + + const restarting = harness.supervisor.restartGeneration(1, "readiness timed out"); + await vi.advanceTimersByTimeAsync(20); + await restarting; + + expect(harness.supervisor.currentGeneration?.number).toBe(1); + expect(harness.children).toHaveLength(1); + expect(harness.restarts).toHaveLength(0); + expect(harness.supervisor.desiredRunning).toBe(false); + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Backend generation 1 remained alive after force termination.", + }), + "generation 1 restart blocked", + ); + expect(onUnrecoverableGeneration).toHaveBeenCalledWith({ + error: expect.objectContaining({ + message: "Backend generation 1 remained alive after force termination.", + }), + generation: { child: first, number: 1 }, + reason: "readiness timed out", + }); + + first.exit(1); + expect(harness.restarts).toHaveLength(0); + expect(harness.supervisor.currentGeneration).toBeNull(); + }); + + it("does not restart a start failure classified as fatal", async () => { + const fatalError = new Error("backend bundle missing"); + const onFatalStartFailure = vi.fn(); + const harness = makeHarness({ + prepareStart: async () => { + throw fatalError; + }, + classifyStartFailure: () => "fatal", + onFatalStartFailure, + }); + + await harness.supervisor.start(); + + expect(harness.supervisor.desiredRunning).toBe(false); + expect(harness.restarts).toHaveLength(0); + expect(onFatalStartFailure).toHaveBeenCalledWith(fatalError); + }); + + it("honors a start queued while graceful shutdown is still finishing", async () => { + const harness = makeHarness(); + await harness.supervisor.start(); + const first = harness.children[0]!; + + const stopping = harness.supervisor.stop("updater handoff"); + const restarting = harness.supervisor.start(); + await Promise.resolve(); + first.exit(0); + await Promise.all([stopping, restarting]); + + expect(harness.children).toHaveLength(2); + expect(harness.supervisor.currentGeneration?.number).toBe(2); + expect(harness.restarts).toHaveLength(0); + }); +}); diff --git a/apps/desktop/src/desktopBackendSupervisor.ts b/apps/desktop/src/desktopBackendSupervisor.ts new file mode 100644 index 000000000..c2c176557 --- /dev/null +++ b/apps/desktop/src/desktopBackendSupervisor.ts @@ -0,0 +1,355 @@ +import type { ScientBackendShutdownMessage } from "@synara/shared/backendControl"; + +export interface DesktopBackendChild { + readonly pid?: number | undefined; + readonly connected?: boolean | undefined; + readonly exitCode: number | null; + readonly signalCode: NodeJS.Signals | null; + once(event: "exit", listener: (code: number | null, signal: NodeJS.Signals | null) => void): this; + on(event: "error", listener: (error: Error) => void): this; + off(event: "exit", listener: (code: number | null, signal: NodeJS.Signals | null) => void): this; + send?(message: ScientBackendShutdownMessage, callback?: (error: Error | null) => void): boolean; +} + +export interface DesktopBackendGeneration { + readonly child: DesktopBackendChild; + readonly number: number; +} + +export interface DesktopBackendExit { + readonly generation: number; + readonly pid: number | null; + readonly reason: string; + readonly expected: boolean; +} + +export interface DesktopBackendSupervisorOptions { + readonly prepareStart: (generation: number) => Promise; + readonly spawn: (generation: number) => DesktopBackendChild; + readonly requestGracefulShutdown: ( + child: DesktopBackendChild, + reason: string, + ) => boolean | Promise; + readonly forceTerminateTree: (child: DesktopBackendChild) => Promise | void; + readonly onGenerationStarted?: (generation: DesktopBackendGeneration) => void; + readonly onGenerationExited?: (exit: DesktopBackendExit) => void; + readonly onRestartScheduled?: (input: { + readonly attempt: number; + readonly delayMs: number; + readonly reason: string; + }) => void; + readonly classifyStartFailure?: (error: unknown) => "fatal" | "retry"; + readonly onFatalStartFailure?: (error: unknown) => void; + readonly onUnrecoverableGeneration?: (input: { + readonly error: Error; + readonly generation: DesktopBackendGeneration; + readonly reason: string; + }) => void; + readonly onError?: (error: unknown, context: string) => void; + readonly setTimer?: typeof setTimeout; + readonly clearTimer?: typeof clearTimeout; + readonly restartBaseDelayMs?: number; + readonly restartMaxDelayMs?: number; + readonly gracefulShutdownTimeoutMs?: number; + readonly forcedExitTimeoutMs?: number; +} + +interface ActiveGeneration extends DesktopBackendGeneration { + closed: boolean; +} + +const DEFAULT_RESTART_BASE_DELAY_MS = 500; +const DEFAULT_RESTART_MAX_DELAY_MS = 10_000; +const DEFAULT_GRACEFUL_SHUTDOWN_TIMEOUT_MS = 8_000; +const DEFAULT_FORCED_EXIT_TIMEOUT_MS = 2_000; + +function childHasExited(child: DesktopBackendChild): boolean { + return child.exitCode !== null || child.signalCode !== null; +} + +function exitReason(code: number | null, signal: NodeJS.Signals | null): string { + return `code=${code ?? "null"} signal=${signal ?? "null"}`; +} + +export class DesktopBackendTerminationError extends Error { + readonly generation: number; + readonly pid: number | null; + readonly reason: string; + + constructor(active: DesktopBackendGeneration, reason: string) { + super(`Backend generation ${active.number} remained alive after force termination.`); + this.name = "DesktopBackendTerminationError"; + this.generation = active.number; + this.pid = active.child.pid ?? null; + this.reason = reason; + } +} + +/** + * Owns exactly one desired desktop backend process. Every mutation is serialized, + * and generation checks prevent late events from an old child changing current state. + */ +export class DesktopBackendSupervisor { + readonly #options: DesktopBackendSupervisorOptions; + readonly #setTimer: typeof setTimeout; + readonly #clearTimer: typeof clearTimeout; + + #desiredRunning = false; + #active: ActiveGeneration | null = null; + #generation = 0; + #restartAttempt = 0; + #restartTimer: ReturnType | null = null; + #transition: Promise = Promise.resolve(); + readonly #stoppingGenerations = new Set(); + + constructor(options: DesktopBackendSupervisorOptions) { + this.#options = options; + this.#setTimer = options.setTimer ?? setTimeout; + this.#clearTimer = options.clearTimer ?? clearTimeout; + } + + get desiredRunning(): boolean { + return this.#desiredRunning; + } + + get currentGeneration(): DesktopBackendGeneration | null { + return this.#active ? { child: this.#active.child, number: this.#active.number } : null; + } + + start(): Promise { + this.#desiredRunning = true; + return this.#enqueue(() => this.#ensureStarted()); + } + + stop(reason: string): Promise { + this.#desiredRunning = false; + if (this.#active) this.#stoppingGenerations.add(this.#active.number); + this.#clearRestartTimer(); + return this.#enqueue(async () => { + const active = this.#active; + if (await this.#stopActive(reason)) return; + throw new DesktopBackendTerminationError(active!, reason); + }); + } + + markReady(generation: number): void { + if ( + !this.#active || + this.#active.number !== generation || + this.#active.closed || + !this.#desiredRunning + ) { + return; + } + this.#restartAttempt = 0; + } + + restartGeneration(generation: number, reason: string): Promise { + return this.#enqueue(async () => { + if ( + !this.#desiredRunning || + !this.#active || + this.#active.number !== generation || + this.#active.closed + ) { + return; + } + const target = { child: this.#active.child, number: generation }; + const exited = await this.#stopActive(reason); + if (exited && this.#desiredRunning) { + this.#scheduleRestart(reason); + return; + } + if (exited) return; + + const error = new DesktopBackendTerminationError(target, reason); + this.#desiredRunning = false; + this.#clearRestartTimer(); + this.#options.onError?.(error, `generation ${generation} restart blocked`); + this.#options.onUnrecoverableGeneration?.({ + error, + generation: target, + reason, + }); + }); + } + + #enqueue(action: () => Promise): Promise { + const next = this.#transition.then(action, action); + this.#transition = next.catch((error: unknown) => { + this.#options.onError?.(error, "backend lifecycle transition"); + }); + return next; + } + + async #ensureStarted(): Promise { + if (!this.#desiredRunning || this.#active) return; + + const generation = ++this.#generation; + try { + await this.#options.prepareStart(generation); + if (!this.#desiredRunning || this.#active) return; + + const child = this.#options.spawn(generation); + const active: ActiveGeneration = { child, number: generation, closed: false }; + this.#active = active; + this.#bindChild(active); + this.#options.onGenerationStarted?.(active); + } catch (error) { + if (!this.#desiredRunning) return; + if (this.#options.classifyStartFailure?.(error) === "fatal") { + this.#desiredRunning = false; + this.#options.onFatalStartFailure?.(error); + return; + } + this.#scheduleRestart(error instanceof Error ? error.message : String(error)); + } + } + + #bindChild(active: ActiveGeneration): void { + active.child.on("error", (error) => { + if (active.child.pid === undefined) { + this.#handleGenerationClosed(active, `spawn error=${error.message}`); + return; + } + this.#options.onError?.(error, `generation ${active.number} process error`); + }); + active.child.once("exit", (code, signal) => { + this.#handleGenerationClosed(active, exitReason(code, signal)); + }); + } + + #handleGenerationClosed(active: ActiveGeneration, reason: string): void { + if (active.closed) return; + active.closed = true; + const wasCurrent = this.#active === active; + if (wasCurrent) this.#active = null; + const expected = this.#stoppingGenerations.delete(active.number) || !this.#desiredRunning; + this.#options.onGenerationExited?.({ + generation: active.number, + pid: active.child.pid ?? null, + reason, + expected, + }); + if (wasCurrent && !expected) { + void this.#enqueue(() => this.#cleanupExitedGenerationAndRestart(active, reason)); + } + } + + async #cleanupExitedGenerationAndRestart( + active: ActiveGeneration, + reason: string, + ): Promise { + try { + await this.#options.forceTerminateTree(active.child); + } catch (cause) { + const error = + cause instanceof Error + ? cause + : new Error("Failed to clean up the exited backend process tree.", { cause }); + this.#desiredRunning = false; + this.#clearRestartTimer(); + this.#options.onError?.(error, `generation ${active.number} descendant cleanup`); + this.#options.onUnrecoverableGeneration?.({ + error, + generation: active, + reason, + }); + return; + } + this.#scheduleRestart(reason); + } + + #scheduleRestart(reason: string): void { + if (!this.#desiredRunning || this.#restartTimer) return; + const baseDelay = this.#options.restartBaseDelayMs ?? DEFAULT_RESTART_BASE_DELAY_MS; + const maxDelay = this.#options.restartMaxDelayMs ?? DEFAULT_RESTART_MAX_DELAY_MS; + const attempt = this.#restartAttempt; + const delayMs = Math.min(baseDelay * 2 ** attempt, maxDelay); + this.#restartAttempt += 1; + this.#options.onRestartScheduled?.({ attempt, delayMs, reason }); + this.#restartTimer = this.#setTimer(() => { + this.#restartTimer = null; + void this.#enqueue(() => this.#ensureStarted()); + }, delayMs); + this.#restartTimer.unref?.(); + } + + #clearRestartTimer(): void { + if (!this.#restartTimer) return; + this.#clearTimer(this.#restartTimer); + this.#restartTimer = null; + } + + async #stopActive(reason: string): Promise { + const active = this.#active; + if (!active) return true; + this.#stoppingGenerations.add(active.number); + if (childHasExited(active.child)) { + this.#handleGenerationClosed(active, "already exited"); + return true; + } + + const gracefulTimeoutMs = + this.#options.gracefulShutdownTimeoutMs ?? DEFAULT_GRACEFUL_SHUTDOWN_TIMEOUT_MS; + const forcedExitTimeoutMs = this.#options.forcedExitTimeoutMs ?? DEFAULT_FORCED_EXIT_TIMEOUT_MS; + + const exitedGracefully = await this.#waitForExit(active, gracefulTimeoutMs, () => { + const sent = this.#options.requestGracefulShutdown(active.child, reason); + if (!sent) { + this.#options.onError?.( + new Error("Backend IPC shutdown request was unavailable."), + `generation ${active.number} graceful shutdown`, + ); + } + return sent; + }); + if (exitedGracefully) return true; + + try { + await this.#options.forceTerminateTree(active.child); + } catch (error) { + this.#options.onError?.(error, `generation ${active.number} force termination`); + } + const exitedAfterForce = await this.#waitForExit(active, forcedExitTimeoutMs); + return exitedAfterForce; + } + + async #waitForExit( + active: ActiveGeneration, + timeoutMs: number, + begin?: () => boolean | void | Promise, + ): Promise { + if (active.closed || childHasExited(active.child)) return true; + + return await new Promise((resolve) => { + let settled = false; + const settle = (exited: boolean) => { + if (settled) return; + settled = true; + active.child.off("exit", onExit); + this.#clearTimer(timeout); + resolve(exited); + }; + const onExit = () => settle(true); + active.child.once("exit", onExit); + const timeout = this.#setTimer(() => settle(false), Math.max(0, timeoutMs)); + timeout.unref?.(); + try { + void Promise.resolve(begin?.()).then( + (started) => { + if (started === false) settle(false); + }, + (error: unknown) => { + this.#options.onError?.(error, `generation ${active.number} graceful shutdown request`); + settle(false); + }, + ); + } catch (error) { + this.#options.onError?.(error, `generation ${active.number} graceful shutdown request`); + settle(false); + } + if (active.closed || childHasExited(active.child)) settle(true); + }); + } +} diff --git a/apps/desktop/src/desktopConnectionWake.ts b/apps/desktop/src/desktopConnectionWake.ts new file mode 100644 index 000000000..65d253354 --- /dev/null +++ b/apps/desktop/src/desktopConnectionWake.ts @@ -0,0 +1,6 @@ +// FILE: desktopConnectionWake.ts +// Purpose: Defines the main-to-renderer signal used to verify connections after native wake events. +// Layer: Desktop IPC +// Exports: DESKTOP_CONNECTION_WAKE_CHANNEL. + +export const DESKTOP_CONNECTION_WAKE_CHANNEL = "desktop:connection-wake"; diff --git a/apps/desktop/src/desktopDiagnostics.test.ts b/apps/desktop/src/desktopDiagnostics.test.ts new file mode 100644 index 000000000..3939b3b1b --- /dev/null +++ b/apps/desktop/src/desktopDiagnostics.test.ts @@ -0,0 +1,21 @@ +// FILE: desktopDiagnostics.test.ts +// Purpose: Verifies native log-folder opening and actionable OS errors. +// Layer: Desktop diagnostics tests + +import { describe, expect, it, vi } from "vitest"; + +import { openDesktopLogsDirectory } from "./desktopDiagnostics"; + +describe("openDesktopLogsDirectory", () => { + it("opens the exact Scient logs directory", async () => { + const openPath = vi.fn(async () => ""); + await expect(openDesktopLogsDirectory("/tmp/scient/logs", openPath)).resolves.toBeUndefined(); + expect(openPath).toHaveBeenCalledWith("/tmp/scient/logs"); + }); + + it("surfaces the native shell error", async () => { + await expect( + openDesktopLogsDirectory("/tmp/scient/logs", async () => "No application is registered"), + ).rejects.toThrow("No application is registered"); + }); +}); diff --git a/apps/desktop/src/desktopDiagnostics.ts b/apps/desktop/src/desktopDiagnostics.ts new file mode 100644 index 000000000..f3f89cfe9 --- /dev/null +++ b/apps/desktop/src/desktopDiagnostics.ts @@ -0,0 +1,17 @@ +// FILE: desktopDiagnostics.ts +// Purpose: Exposes narrow native diagnostics actions that remain available when the backend is down. +// Layer: Desktop main-process support + +export const DESKTOP_DIAGNOSTICS_IPC_CHANNELS = { + openLogsDirectory: "desktop:diagnostics-open-logs-directory", +} as const; + +export async function openDesktopLogsDirectory( + logsDirectory: string, + openPath: (path: string) => Promise, +): Promise { + const errorMessage = await openPath(logsDirectory); + if (errorMessage.trim().length > 0) { + throw new Error(errorMessage); + } +} diff --git a/apps/desktop/src/desktopScientDataDirectories.test.ts b/apps/desktop/src/desktopScientDataDirectories.test.ts new file mode 100644 index 000000000..9ba8f4673 --- /dev/null +++ b/apps/desktop/src/desktopScientDataDirectories.test.ts @@ -0,0 +1,78 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { PRIVATE_DIRECTORY_MODE } from "@synara/shared/privatePathPermissions"; +import { afterEach, describe, expect, it } from "vitest"; + +import { ensurePrivateDesktopScientDataDirectoriesSync } from "./desktopScientDataDirectories"; +import { seedScientHomeFromPapiLab } from "./legacyPapiLabHomeMigration"; + +const temporaryRoots: string[] = []; + +function makeRoot(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "scient-desktop-private-dirs-")); + temporaryRoots.push(root); + return root; +} + +afterEach(() => { + for (const root of temporaryRoots.splice(0)) { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +describe("ensurePrivateDesktopScientDataDirectoriesSync", () => { + it.runIf(process.platform !== "win32")( + "repairs migrated state before desktop logging can use it", + () => { + const container = makeRoot(); + const legacyHome = path.join(container, ".papilab"); + const scientHome = path.join(container, ".scient"); + const legacyLogsDir = path.join(legacyHome, "userdata", "logs"); + fs.mkdirSync(legacyLogsDir, { recursive: true }); + fs.writeFileSync(path.join(legacyLogsDir, "server.log"), "legacy"); + fs.chmodSync(path.join(legacyHome, "userdata"), 0o775); + fs.chmodSync(legacyLogsDir, 0o775); + + expect( + seedScientHomeFromPapiLab({ sourcePath: legacyHome, targetPath: scientHome }).status, + ).toBe("seeded"); + const paths = ensurePrivateDesktopScientDataDirectoriesSync(scientHome); + + expect(fs.readFileSync(path.join(paths.logsDir, "server.log"), "utf8")).toBe("legacy"); + for (const directoryPath of Object.values(paths)) { + expect(fs.statSync(directoryPath).mode & 0o777, directoryPath).toBe(PRIVATE_DIRECTORY_MODE); + } + }, + ); + + it.runIf(process.platform !== "win32")( + "creates a fresh desktop tree as owner-only under umask 002", + () => { + const scientHome = path.join(makeRoot(), ".scient"); + const previousUmask = process.umask(0o002); + let paths: ReturnType; + try { + paths = ensurePrivateDesktopScientDataDirectoriesSync(scientHome); + } finally { + process.umask(previousUmask); + } + + for (const directoryPath of Object.values(paths)) { + expect(fs.statSync(directoryPath).mode & 0o777, directoryPath).toBe(PRIVATE_DIRECTORY_MODE); + } + }, + ); + + it("creates every managed directory with Windows permission semantics", () => { + const paths = ensurePrivateDesktopScientDataDirectoriesSync( + path.join(makeRoot(), ".scient"), + "win32", + ); + + for (const directoryPath of Object.values(paths)) { + expect(fs.statSync(directoryPath).isDirectory(), directoryPath).toBe(true); + } + }); +}); diff --git a/apps/desktop/src/desktopScientDataDirectories.ts b/apps/desktop/src/desktopScientDataDirectories.ts new file mode 100644 index 000000000..e0cd389bc --- /dev/null +++ b/apps/desktop/src/desktopScientDataDirectories.ts @@ -0,0 +1,31 @@ +import path from "node:path"; + +import { + ensurePrivateScientDirectoriesSync, + type ScientDataDirectoryPaths, +} from "@synara/shared/scientDataDirectories"; + +export function deriveDesktopScientDataDirectories(baseDir: string): ScientDataDirectoryPaths { + const stateDir = path.join(baseDir, "userdata"); + const logsDir = path.join(stateDir, "logs"); + return { + baseDir, + stateDir, + secretsDir: path.join(stateDir, "secrets"), + worktreesDir: path.join(baseDir, "worktrees"), + attachmentsDir: path.join(stateDir, "attachments"), + logsDir, + providerLogsDir: path.join(logsDir, "provider"), + terminalLogsDir: path.join(logsDir, "terminals"), + }; +} + +/** Secures desktop-owned state before logging or the backend child can touch it. */ +export function ensurePrivateDesktopScientDataDirectoriesSync( + baseDir: string, + platform: NodeJS.Platform = process.platform, +): ScientDataDirectoryPaths { + const paths = deriveDesktopScientDataDirectories(baseDir); + ensurePrivateScientDirectoriesSync(paths, platform); + return paths; +} diff --git a/apps/desktop/src/initialBackendWindowOpen.test.ts b/apps/desktop/src/initialBackendWindowOpen.test.ts index c5c99c864..bb3c222d8 100644 --- a/apps/desktop/src/initialBackendWindowOpen.test.ts +++ b/apps/desktop/src/initialBackendWindowOpen.test.ts @@ -91,4 +91,21 @@ describe("openInitialBackendWindow", () => { expect(options.createWindow).not.toHaveBeenCalled(); expect(options.waitForBackendWindowReady).not.toHaveBeenCalled(); }); + + it("reports a non-abort readiness failure to the lifecycle owner", async () => { + const error = new Error("backend stayed unready"); + const onReadinessFailure = vi.fn(); + const options = createOptions({ + waitForBackendWindowReady: vi.fn(async () => { + throw error; + }), + onReadinessFailure, + }); + + openInitialBackendWindow(options); + const watchedPromise = vi.mocked(options.setReadinessInFlight).mock.calls[0]?.[0]; + await expect(watchedPromise).resolves.toBeUndefined(); + + expect(onReadinessFailure).toHaveBeenCalledWith(error); + }); }); diff --git a/apps/desktop/src/initialBackendWindowOpen.ts b/apps/desktop/src/initialBackendWindowOpen.ts index 6864f537d..0677bdd40 100644 --- a/apps/desktop/src/initialBackendWindowOpen.ts +++ b/apps/desktop/src/initialBackendWindowOpen.ts @@ -17,6 +17,7 @@ export interface InitialBackendWindowOpenOptions { readonly isReadinessAborted: (error: unknown) => boolean; readonly formatErrorMessage: (error: unknown) => string; readonly warn: (message: string, error: unknown) => void; + readonly onReadinessFailure?: (error: unknown) => void; } export function openInitialBackendWindow(options: InitialBackendWindowOpenOptions): void { @@ -46,6 +47,7 @@ export function openInitialBackendWindow(options: InitialBackendWindowOpenOption `bootstrap backend readiness warning message=${options.formatErrorMessage(error)}`, ); options.warn("[desktop] backend readiness check timed out during packaged bootstrap", error); + options.onReadinessFailure?.(error); }) .finally(() => { if (options.getReadinessInFlight() === nextOpen) { diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 774a99d76..a78450a61 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -22,6 +22,7 @@ import { Notification, nativeImage, nativeTheme, + powerMonitor, protocol, screen, session, @@ -36,6 +37,7 @@ import type { } from "electron"; import * as Effect from "effect/Effect"; import type { + DesktopConnectionWakeReason, DesktopTheme, DesktopUpdateActionResult, DesktopUpdateState, @@ -43,6 +45,7 @@ import type { import { autoUpdater, BaseUpdater, CancellationToken } from "electron-updater"; import type { ContextMenuItem } from "@synara/contracts"; +import { makeScientBackendShutdownMessage } from "@synara/shared/backendControl"; import { getMacTrafficLightPosition } from "@synara/shared/desktopChrome"; import { SCIENT_APP_NAME, @@ -57,6 +60,15 @@ import { RotatingFileSink } from "@synara/shared/logging"; import { ensureStaticSnapshot, findAsarArchivePath } from "@synara/shared/staticSnapshot"; import { isBackendReadinessAborted, waitForHttpReady } from "./backendReadiness"; import { resolveBackendNodeArgs } from "./backendNodeOptions"; +import { + backendProcessContainmentOptions, + forceTerminateBackendProcessTree, +} from "./backendProcessTree"; +import { + DesktopBackendSupervisor, + type DesktopBackendExit, + type DesktopBackendGeneration, +} from "./desktopBackendSupervisor"; import { bundleSignatureFromStats, isBundleStable, @@ -66,6 +78,8 @@ import { } from "./bundleSwapDetection"; import { waitForBackendStartupReady } from "./backendStartupReadiness"; import { showDesktopConfirmDialog } from "./confirmDialog"; +import { DESKTOP_CONNECTION_WAKE_CHANNEL } from "./desktopConnectionWake"; +import { DESKTOP_DIAGNOSTICS_IPC_CHANNELS, openDesktopLogsDirectory } from "./desktopDiagnostics"; import { LSREGISTER_PATH, parseLastLaunchVersion, @@ -155,6 +169,7 @@ import { repairBrowserProfileFromBridgeManifest, seedDesktopUserDataProfileFromPapiLab, } from "./desktopUserDataProfile"; +import { ensurePrivateDesktopScientDataDirectoriesSync } from "./desktopScientDataDirectories"; import { seedScientHomeFromPapiLab } from "./legacyPapiLabHomeMigration"; import { isBrokenPipeError } from "./desktopProcessErrors"; import { @@ -229,7 +244,11 @@ if (legacyPapiLabHome) { } } const BASE_DIR = resolvedScientHome; -const STATE_DIR = Path.join(BASE_DIR, "userdata"); +// Migration must run before this call because it atomically renames a staged +// legacy home into a non-existent target. From this point onward every desktop +// writer and the backend child see the same secured Scient-owned boundaries. +const SCIENT_DATA_DIRECTORIES = ensurePrivateDesktopScientDataDirectoriesSync(BASE_DIR); +const STATE_DIR = SCIENT_DATA_DIRECTORIES.stateDir; const DESKTOP_WINDOW_STATE_PATH = Path.join(STATE_DIR, "desktop-window-state.json"); const DESKTOP_SCHEME = SCIENT_DESKTOP_SCHEME; const LEGACY_PAPILAB_DESKTOP_SCHEME = "papilab"; @@ -240,7 +259,7 @@ const APP_DISPLAY_NAME = isDevelopment ? `${SCIENT_APP_NAME} (Dev)` : SCIENT_APP const APP_USER_MODEL_ID = scientBundleId(isDevelopment); const COMMIT_HASH_PATTERN = /^[0-9a-f]{7,40}$/i; const COMMIT_HASH_DISPLAY_LENGTH = 12; -const LOG_DIR = Path.join(STATE_DIR, "logs"); +const LOG_DIR = SCIENT_DATA_DIRECTORIES.logsDir; const LOG_FILE_MAX_BYTES = 10 * 1024 * 1024; const LOG_FILE_MAX_FILES = 10; const APP_RUN_ID = Crypto.randomBytes(6).toString("hex"); @@ -275,7 +294,7 @@ const browserPerfLoggingEnabled = process.env.SYNARA_BROWSER_PERF === "1"; type DesktopUpdateErrorContext = DesktopUpdateState["errorContext"]; let mainWindow: BrowserWindow | null = null; -let backendProcess: ChildProcess.ChildProcess | null = null; +let backendSupervisor: DesktopBackendSupervisor | null = null; let backendPort = 0; let backendAuthToken = ""; let backendHttpUrl = ""; @@ -283,8 +302,6 @@ let backendWsUrl = ""; let backendReadinessAbortController: AbortController | null = null; let backendInitialWindowOpenInFlight: Promise | null = null; let backendListeningDetector: ServerListeningDetector | null = null; -let restartAttempt = 0; -let restartTimer: ReturnType | null = null; let isQuitting = false; let isUpdaterInstallPreparing = false; let isUpdaterQuitAndInstallInFlight = false; @@ -454,6 +471,13 @@ function emitDesktopWindowState(window: BrowserWindow | null = mainWindow): void window.webContents.send(WINDOW_STATE_CHANNEL, getDesktopWindowState(window)); } +function emitDesktopConnectionWake(reason: DesktopConnectionWakeReason): void { + for (const window of BrowserWindow.getAllWindows()) { + if (window.isDestroyed() || window.webContents.isDestroyed()) continue; + window.webContents.send(DESKTOP_CONNECTION_WAKE_CHANNEL, reason); + } +} + function isSaveFileInput(input: unknown): input is { defaultFilename: string; contents: string; @@ -524,7 +548,8 @@ async function reserveBackendEndpoint(reason: string): Promise { } async function waitForBackendWindowReady(baseUrl: string): Promise<"listening" | "http"> { - return await waitForBackendStartupReady({ + const generation = backendSupervisor?.currentGeneration?.number ?? null; + const source = await waitForBackendStartupReady({ listeningPromise: backendListeningDetector?.promise ?? null, waitForHttpReady: () => waitForBackendHttpReady(baseUrl, { @@ -544,11 +569,29 @@ async function waitForBackendWindowReady(baseUrl: string): Promise<"listening" | } }, }), - cancelHttpWait: cancelBackendReadinessWait, + onHttpReady: () => { + if (generation !== null) backendSupervisor?.markReady(generation); + }, + onHttpFailure: (error) => { + if (generation === null) return; + const message = formatErrorMessage(error); + writeDesktopLogHeader( + `backend generation=${generation} semantic readiness failed message=${message}`, + ); + void backendSupervisor?.restartGeneration(generation, `readiness failed: ${message}`); + }, }); + return source; +} + +function restartBackendAfterReadinessFailure(generation: number): void { + writeDesktopLogHeader( + `backend generation=${generation} readiness failed; scheduling replacement`, + ); + void backendSupervisor?.restartGeneration(generation, "readiness check failed"); } -function ensureInitialBackendWindowOpen(baseUrl: string): void { +function ensureInitialBackendWindowOpen(baseUrl: string, generation?: number): void { openInitialBackendWindow({ isDevelopment, baseUrl, @@ -567,6 +610,11 @@ function ensureInitialBackendWindowOpen(baseUrl: string): void { warn: (message, error) => { console.warn(message, error); }, + ...(generation === undefined + ? {} + : { + onReadinessFailure: () => restartBackendAfterReadinessFailure(generation), + }), }); } @@ -1137,7 +1185,7 @@ function handleFatalStartupError(stage: string, error: unknown): void { `Stage: ${stage}\n${message}${detail}`, ); } - stopBackend(); + stopBackend(`fatal startup: ${stage}`); restoreStdIoCapture?.(); app.quit(); } @@ -2495,7 +2543,7 @@ async function installDownloadedUpdate(): Promise<{ isQuitting = true; isUpdaterInstallPreparing = true; clearUpdatePollTimer(); - await stopBackendAndWaitForExit(); + await stopBackendAndWaitForExit("updater install handoff"); await logMacUpdateDiagnostics("before install handoff"); isUpdaterQuitAndInstallInFlight = true; autoUpdater.quitAndInstall(); @@ -2734,45 +2782,24 @@ function backendEnv(): NodeJS.ProcessEnv { }; } -function scheduleBackendRestart(reason: string): void { - if (isQuitting || restartTimer) return; - - const delayMs = Math.min(500 * 2 ** restartAttempt, 10_000); - restartAttempt += 1; - safeConsoleError(`[desktop] backend exited unexpectedly (${reason}); restarting in ${delayMs}ms`); - - restartTimer = setTimeout(() => { - restartTimer = null; - void restartBackendAfterCrash(reason); - }, delayMs); +interface BackendGenerationRuntime { + readonly listeningDetector: ServerListeningDetector; + readonly closeSession: (details: string) => void; } -async function restartBackendAfterCrash(reason: string): Promise { - if (isQuitting || backendProcess) { - return; - } - - cancelBackendReadinessWait(); - try { - await reserveBackendEndpoint("backend restart"); - } catch (error) { - scheduleBackendRestart( - `failed to reserve restart port after ${reason}: ${formatErrorMessage(error)}`, - ); - return; +class MissingBackendEntryError extends Error { + constructor(readonly entryPath: string) { + super(`Missing packaged server entry at ${entryPath}`); + this.name = "MissingBackendEntryError"; } - - startBackend(); - ensureInitialBackendWindowOpen(backendHttpUrl); } -function startBackend(): void { - if (isQuitting || backendProcess) return; +const backendGenerationRuntimes = new Map(); +function spawnBackendGeneration(generation: number): ChildProcess.ChildProcess { const backendEntry = resolveBackendEntry(); if (!FS.existsSync(backendEntry)) { - scheduleBackendRestart(`missing server entry at ${backendEntry}`); - return; + throw new MissingBackendEntryError(backendEntry); } const captureBackendLogs = app.isPackaged && backendLogSink !== null; @@ -2784,11 +2811,12 @@ function startBackend(): void { ...backendEnv(), ELECTRON_RUN_AS_NODE: "1", }, - stdio: captureBackendLogs ? ["ignore", "pipe", "pipe"] : "inherit", + // POSIX force termination targets this dedicated process group. Windows + // uses taskkill /T after the same graceful IPC deadline. + ...backendProcessContainmentOptions(captureBackendLogs), }); const listeningDetector = new ServerListeningDetector(); backendListeningDetector = listeningDetector; - backendProcess = child; let backendSessionClosed = false; const closeBackendSession = (details: string) => { if (backendSessionClosed) return; @@ -2800,120 +2828,144 @@ function startBackend(): void { `pid=${child.pid ?? "unknown"} port=${backendPort} cwd=${resolveBackendCwd()}`, ); captureBackendOutput(child); - - child.once("spawn", () => { - restartAttempt = 0; - }); - - child.on("error", (error) => { - if (backendListeningDetector === listeningDetector) { - listeningDetector.fail(error); - backendListeningDetector = null; - } - if (backendProcess === child) { - backendProcess = null; - } - closeBackendSession(`pid=${child.pid ?? "unknown"} error=${error.message}`); - scheduleBackendRestart(error.message); - }); - - child.on("exit", (code, signal) => { - if (backendListeningDetector === listeningDetector) { - listeningDetector.fail( - new Error( - `backend exited before logging readiness (code=${code ?? "null"} signal=${signal ?? "null"})`, - ), - ); - backendListeningDetector = null; - } - if (backendProcess === child) { - backendProcess = null; - } - closeBackendSession( - `pid=${child.pid ?? "unknown"} code=${code ?? "null"} signal=${signal ?? "null"}`, - ); - if (isQuitting) return; - const reason = `code=${code ?? "null"} signal=${signal ?? "null"}`; - scheduleBackendRestart(reason); + backendGenerationRuntimes.set(generation, { + listeningDetector, + closeSession: closeBackendSession, }); + return child; } -function stopBackend(): void { - cancelBackendReadinessWait(); - backendListeningDetector = null; - if (restartTimer) { - clearTimeout(restartTimer); - restartTimer = null; +function handleBackendGenerationStarted(generation: DesktopBackendGeneration): void { + if (isDevelopment) { + void waitForBackendWindowReady(backendHttpUrl) + .then((source) => { + writeDesktopLogHeader(`backend generation=${generation.number} ready source=${source}`); + if (!mainWindow) { + mainWindow = createWindow(); + writeDesktopLogHeader("bootstrap main window created"); + } + }) + .catch((error) => { + if (isBackendReadinessAborted(error)) return; + writeDesktopLogHeader( + `backend generation=${generation.number} readiness warning message=${formatErrorMessage(error)}`, + ); + console.warn("[desktop] backend readiness check timed out", error); + restartBackendAfterReadinessFailure(generation.number); + if (!mainWindow) { + mainWindow = createWindow(); + writeDesktopLogHeader("bootstrap main window created after readiness warning"); + } + }); + return; } - const child = backendProcess; - backendProcess = null; - if (!child) return; - - if (child.exitCode === null && child.signalCode === null) { - child.kill("SIGTERM"); - setTimeout(() => { - if (child.exitCode === null && child.signalCode === null) { - child.kill("SIGKILL"); - } - }, BACKEND_FORCE_KILL_DELAY_MS).unref(); + const hadWindow = (mainWindow ?? BrowserWindow.getAllWindows()[0] ?? null) !== null; + ensureInitialBackendWindowOpen(backendHttpUrl, generation.number); + if (hadWindow && backendInitialWindowOpenInFlight === null) { + void waitForBackendWindowReady(backendHttpUrl) + .then((source) => { + writeDesktopLogHeader(`backend generation=${generation.number} ready source=${source}`); + }) + .catch((error) => { + if (isBackendReadinessAborted(error)) return; + console.warn("[desktop] restarted backend readiness check timed out", error); + restartBackendAfterReadinessFailure(generation.number); + }); } } -async function stopBackendAndWaitForExit(timeoutMs = BACKEND_SHUTDOWN_TIMEOUT_MS): Promise { +function handleBackendGenerationExited(exit: DesktopBackendExit): void { cancelBackendReadinessWait(); - backendListeningDetector = null; - if (restartTimer) { - clearTimeout(restartTimer); - restartTimer = null; - } - - const child = backendProcess; - backendProcess = null; - if (!child) return; - const backendChild = child; - if (backendChild.exitCode !== null || backendChild.signalCode !== null) return; - - await new Promise((resolve) => { - let settled = false; - let forceKillTimer: ReturnType | null = null; - let exitTimeoutTimer: ReturnType | null = null; - - function settle(): void { - if (settled) return; - settled = true; - backendChild.off("exit", onExit); - if (forceKillTimer) { - clearTimeout(forceKillTimer); - } - if (exitTimeoutTimer) { - clearTimeout(exitTimeoutTimer); - } - resolve(); - } - - function onExit(): void { - settle(); + const runtime = backendGenerationRuntimes.get(exit.generation); + backendGenerationRuntimes.delete(exit.generation); + if (runtime) { + if (backendListeningDetector === runtime.listeningDetector) { + runtime.listeningDetector.fail( + new Error(`backend generation ${exit.generation} closed (${exit.reason})`), + ); + backendListeningDetector = null; } + runtime.closeSession( + `pid=${exit.pid ?? "unknown"} generation=${exit.generation} ${exit.reason}`, + ); + } +} - backendChild.once("exit", onExit); - backendChild.kill("SIGTERM"); +function getBackendSupervisor(): DesktopBackendSupervisor { + if (backendSupervisor) return backendSupervisor; + backendSupervisor = new DesktopBackendSupervisor({ + prepareStart: async (generation) => { + cancelBackendReadinessWait(); + await reserveBackendEndpoint( + generation === 1 ? "bootstrap" : `backend generation ${generation}`, + ); + }, + spawn: spawnBackendGeneration, + requestGracefulShutdown: async (child, reason) => { + if (!child.send || child.connected === false) return false; + return await new Promise((resolve) => { + try { + child.send!(makeScientBackendShutdownMessage(reason), (error) => resolve(error === null)); + } catch { + resolve(false); + } + }); + }, + forceTerminateTree: (child) => forceTerminateBackendProcessTree(child), + onGenerationStarted: handleBackendGenerationStarted, + onGenerationExited: handleBackendGenerationExited, + onRestartScheduled: ({ delayMs, reason }) => { + safeConsoleError( + `[desktop] backend exited unexpectedly (${reason}); restarting in ${delayMs}ms`, + ); + }, + onError: (error, context) => { + safeConsoleError(`[desktop] ${context}: ${formatErrorMessage(error)}`); + }, + classifyStartFailure: (error) => + error instanceof MissingBackendEntryError ? "fatal" : "retry", + onFatalStartFailure: (error) => handleFatalStartupError("backend", error), + onUnrecoverableGeneration: ({ error, generation, reason }) => { + const message = formatErrorMessage(error); + writeDesktopLogHeader( + `backend generation=${generation.number} unrecoverable reason=${reason} message=${message}`, + ); + console.error(`[desktop] backend generation ${generation.number} could not recover`, error); + dialog.showErrorBox( + `${SCIENT_APP_NAME} backend needs attention`, + `Scient kept the desktop open because backend generation ${generation.number} could not be stopped safely.\n\n${message}`, + ); + }, + gracefulShutdownTimeoutMs: BACKEND_FORCE_KILL_DELAY_MS, + forcedExitTimeoutMs: BACKEND_SHUTDOWN_TIMEOUT_MS - BACKEND_FORCE_KILL_DELAY_MS, + }); + return backendSupervisor; +} - const forceKillDelayMs = Math.min(BACKEND_FORCE_KILL_DELAY_MS, Math.max(1, timeoutMs - 500)); - forceKillTimer = setTimeout(() => { - if (backendChild.exitCode === null && backendChild.signalCode === null) { - backendChild.kill("SIGKILL"); - } - }, forceKillDelayMs); - forceKillTimer.unref(); +function startBackend(): void { + if (isQuitting) return; + void getBackendSupervisor() + .start() + .catch((error: unknown) => { + safeConsoleError(`[desktop] backend start failed: ${formatErrorMessage(error)}`); + }); +} - exitTimeoutTimer = setTimeout(() => { - settle(); - }, timeoutMs); - exitTimeoutTimer.unref(); +function stopBackend(reason = "desktop stop"): void { + cancelBackendReadinessWait(); + if (!backendSupervisor) return; + void backendSupervisor.stop(reason).catch((error: unknown) => { + safeConsoleError(`[desktop] backend stop failed: ${formatErrorMessage(error)}`); }); } +async function stopBackendAndWaitForExit(reason = "desktop shutdown"): Promise { + cancelBackendReadinessWait(); + if (!backendSupervisor) return; + await backendSupervisor.stop(reason); +} + async function disposeBrowserUsePipeServerForShutdown(reason: string): Promise { const pipeServer = browserUsePipeServer; browserUsePipeServer = null; @@ -2935,26 +2987,30 @@ async function shutdownDesktopRuntime(reason: string): Promise { } isQuitting = true; - desktopShutdownPromise = (async () => { + const shutdown = (async () => { writeDesktopLogHeader(`${reason} shutdown start`); - try { - clearUpdateBackgroundBlurTimer(); - clearUpdateCheckTimeoutTimer(); - clearUpdatePollTimer(); - cancelBackendReadinessWait(); - appSnapManager?.dispose(); - appSnapManager = null; - await disposeBrowserUsePipeServerForShutdown(reason); - await stopBackendAndWaitForExit(); - browserManager.dispose(); - restoreStdIoCapture?.(); - writeDesktopLogHeader(`${reason} shutdown complete`); - } finally { - desktopShutdownComplete = true; - } + clearUpdateBackgroundBlurTimer(); + clearUpdateCheckTimeoutTimer(); + clearUpdatePollTimer(); + cancelBackendReadinessWait(); + appSnapManager?.dispose(); + appSnapManager = null; + await disposeBrowserUsePipeServerForShutdown(reason); + await stopBackendAndWaitForExit(reason); + browserManager.dispose(); + restoreStdIoCapture?.(); + writeDesktopLogHeader(`${reason} shutdown complete`); + desktopShutdownComplete = true; })(); + desktopShutdownPromise = shutdown; - return desktopShutdownPromise; + try { + await shutdown; + } catch (error) { + desktopShutdownPromise = null; + isQuitting = false; + throw error; + } } function requestGracefulAppQuit(reason: string): void { @@ -2964,13 +3020,17 @@ function requestGracefulAppQuit(reason: string): void { } void shutdownDesktopRuntime(reason) + .then(() => { + app.quit(); + }) .catch((error: unknown) => { const message = formatErrorMessage(error); writeDesktopLogHeader(`${reason} shutdown failed message=${message}`); console.warn(`[desktop] Shutdown failed during ${reason}: ${message}`); - }) - .finally(() => { - app.quit(); + dialog.showErrorBox( + `${SCIENT_APP_NAME} could not close safely`, + `Scient stayed open because its backend did not stop. Retry after checking running tasks.\n\n${message}`, + ); }); } @@ -3187,6 +3247,11 @@ function registerIpcHandlers(): void { shell.showItemInFolder(resolvedPath); }); + ipcMain.removeHandler(DESKTOP_DIAGNOSTICS_IPC_CHANNELS.openLogsDirectory); + ipcMain.handle(DESKTOP_DIAGNOSTICS_IPC_CHANNELS.openLogsDirectory, async () => { + await openDesktopLogsDirectory(LOG_DIR, (path) => shell.openPath(path)); + }); + ipcMain.removeHandler(WINDOW_MINIMIZE_CHANNEL); ipcMain.handle(WINDOW_MINIMIZE_CHANNEL, async (event) => { const window = BrowserWindow.fromWebContents(event.sender) ?? mainWindow; @@ -3448,6 +3513,19 @@ function createWindow(): BrowserWindow { window.on("unmaximize", () => emitDesktopWindowState(window)); window.on("enter-full-screen", () => emitDesktopWindowState(window)); window.on("leave-full-screen", () => emitDesktopWindowState(window)); + if (process.platform === "win32") { + window.on("query-session-end", (event) => { + if (desktopShutdownComplete) return; + event.preventDefault(); + writeDesktopLogHeader("Windows query-session-end received"); + requestGracefulAppQuit("Windows session end"); + }); + window.on("session-end", () => { + if (desktopShutdownPromise) return; + writeDesktopLogHeader("Windows session-end received"); + void shutdownDesktopRuntime("Windows session end"); + }); + } window.on("close", () => { try { writeDesktopWindowState(DESKTOP_WINDOW_STATE_PATH, { @@ -3539,39 +3617,11 @@ if (!hasSingleInstanceLock) { async function bootstrap(): Promise { writeDesktopLogHeader("bootstrap start"); backendAuthToken = Crypto.randomBytes(24).toString("hex"); - await reserveBackendEndpoint("bootstrap"); registerIpcHandlers(); writeDesktopLogHeader("bootstrap ipc handlers registered"); - startBackend(); + await getBackendSupervisor().start(); writeDesktopLogHeader("bootstrap backend start requested"); - - if (isDevelopment) { - void waitForBackendWindowReady(backendHttpUrl) - .then((source) => { - writeDesktopLogHeader(`bootstrap backend ready source=${source}`); - if (!mainWindow) { - mainWindow = createWindow(); - writeDesktopLogHeader("bootstrap main window created"); - } - }) - .catch((error) => { - if (isBackendReadinessAborted(error)) { - return; - } - writeDesktopLogHeader( - `bootstrap backend readiness warning message=${formatErrorMessage(error)}`, - ); - console.warn("[desktop] backend readiness check timed out during dev bootstrap", error); - if (!mainWindow) { - mainWindow = createWindow(); - writeDesktopLogHeader("bootstrap main window created after readiness warning"); - } - }); - return; - } - - ensureInitialBackendWindowOpen(backendHttpUrl); } app.on("before-quit", (event) => { @@ -3637,13 +3687,22 @@ if (hasSingleInstanceLock) { app.on("browser-window-focus", () => { handleDesktopAppForegrounded(); + emitDesktopConnectionWake("window-focus"); + }); + + powerMonitor.on("resume", () => { + emitDesktopConnectionWake("system-resume"); }); app.on("activate", () => { handleDesktopAppForegrounded(); + emitDesktopConnectionWake("app-activate"); if (BrowserWindow.getAllWindows().length === 0) { if (!isDevelopment) { - ensureInitialBackendWindowOpen(backendHttpUrl); + ensureInitialBackendWindowOpen( + backendHttpUrl, + backendSupervisor?.currentGeneration?.number, + ); return; } void waitForBackendWindowReady(backendHttpUrl) diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 0a7600a0c..dab31a3c9 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -9,6 +9,8 @@ import { import { SERVER_TRANSCRIBE_VOICE_CHANNEL } from "./voiceTranscription"; import { STORAGE_MIGRATION_IPC_CHANNELS } from "./desktopStorageMigration"; import { APPSNAP_IPC_CHANNELS } from "./appSnapIpc"; +import { DESKTOP_CONNECTION_WAKE_CHANNEL } from "./desktopConnectionWake"; +import { DESKTOP_DIAGNOSTICS_IPC_CHANNELS } from "./desktopDiagnostics"; const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; const SAVE_FILE_CHANNEL = "desktop:save-file"; @@ -45,6 +47,16 @@ function getDesktopWsUrl(): string | null { contextBridge.exposeInMainWorld("desktopBridge", { getWsUrl: getDesktopWsUrl, + onConnectionWake: (listener) => { + const wrappedListener = (_event: Electron.IpcRendererEvent, reason: unknown) => { + if (reason !== "app-activate" && reason !== "window-focus" && reason !== "system-resume") { + return; + } + listener(reason); + }; + ipcRenderer.on(DESKTOP_CONNECTION_WAKE_CHANNEL, wrappedListener); + return () => ipcRenderer.removeListener(DESKTOP_CONNECTION_WAKE_CHANNEL, wrappedListener); + }, // Absolute path for OS-dropped File objects (folders with spaces/parens, etc.). getPathForFile: (file: File) => { try { @@ -64,6 +76,9 @@ contextBridge.exposeInMainWorld("desktopBridge", { shell: { showInFolder: (path: string) => ipcRenderer.invoke(SHOW_IN_FOLDER_CHANNEL, path), }, + diagnostics: { + openLogsDirectory: () => ipcRenderer.invoke(DESKTOP_DIAGNOSTICS_IPC_CHANNELS.openLogsDirectory), + }, clipboard: { writeImagePngDataUrl: (dataUrl: string) => ipcRenderer.invoke(CLIPBOARD_WRITE_IMAGE_CHANNEL, dataUrl), diff --git a/apps/desktop/src/rotatingFileSink.test.ts b/apps/desktop/src/rotatingFileSink.test.ts index ebe0d9671..e9c9bd396 100644 --- a/apps/desktop/src/rotatingFileSink.test.ts +++ b/apps/desktop/src/rotatingFileSink.test.ts @@ -20,6 +20,22 @@ afterEach(() => { }); describe("RotatingFileSink", () => { + it("repairs the active log and rotated backups to private file modes", () => { + if (process.platform === "win32") return; + + const filePath = path.join(makeTempDir(), "private.log"); + fs.writeFileSync(filePath, "active"); + fs.writeFileSync(`${filePath}.1`, "backup"); + fs.chmodSync(filePath, 0o644); + fs.chmodSync(`${filePath}.1`, 0o664); + + const sink = new RotatingFileSink({ filePath, maxBytes: 1024, maxFiles: 2 }); + sink.write(" next"); + + expect(fs.statSync(filePath).mode & 0o777).toBe(0o600); + expect(fs.statSync(`${filePath}.1`).mode & 0o777).toBe(0o600); + }); + it("rotates when writes exceed max bytes", () => { const dir = makeTempDir(); const logPath = path.join(dir, "desktop-main.log"); diff --git a/apps/server/src/codexAuthenticationError.test.ts b/apps/server/src/codexAuthenticationError.test.ts new file mode 100644 index 000000000..46a09c8af --- /dev/null +++ b/apps/server/src/codexAuthenticationError.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; + +import { isCodexAuthenticationError } from "./codexAuthenticationError"; + +describe("isCodexAuthenticationError", () => { + it("uses Codex's structured unauthorized error as the authoritative signal", () => { + expect( + isCodexAuthenticationError({ + message: "Request failed", + detail: { error: { codexErrorInfo: "unauthorized" } }, + requiresProviderAccount: true, + }), + ).toBe(true); + }); + + it.each([ + "Authentication required", + "You are no longer logged in. Please log in again.", + "Not signed in. Run `codex login`.", + "invalid_grant", + "Refresh token was rejected because it is revoked", + "Unauthorized", + "Usage limit exceeded", + "Request failed with status 401 while contacting a custom proxy", + "Permission denied while reading a file", + "Response stream disconnected", + ])("does not infer account state from provider text: %s", (message) => { + expect(isCodexAuthenticationError({ message, requiresProviderAccount: true })).toBe(false); + }); + + it("fails closed when account ownership is omitted at runtime", () => { + expect( + isCodexAuthenticationError({ + message: "Request failed", + detail: { error: { codexErrorInfo: "unauthorized" } }, + } as Parameters[0]), + ).toBe(false); + }); + + it("does not reinterpret structured upstream authorization failures for custom providers", () => { + expect( + isCodexAuthenticationError({ + message: "Request failed", + detail: { error: { codexErrorInfo: "unauthorized" } }, + requiresProviderAccount: false, + }), + ).toBe(false); + }); + + it("does not treat a non-auth structured Codex error as authentication loss", () => { + expect( + isCodexAuthenticationError({ + message: "Server is busy", + detail: { error: { codexErrorInfo: "serverOverloaded" } }, + requiresProviderAccount: true, + }), + ).toBe(false); + }); +}); diff --git a/apps/server/src/codexAuthenticationError.ts b/apps/server/src/codexAuthenticationError.ts new file mode 100644 index 000000000..b534aa44e --- /dev/null +++ b/apps/server/src/codexAuthenticationError.ts @@ -0,0 +1,28 @@ +// Purpose: Conservatively identify Codex account-authentication failures. +// Structured app-server error data is authoritative. Generic provider text is +// never sufficient because custom OpenAI-compatible endpoints can also return +// an `Unauthorized` response without invalidating the user's Codex account. + +function asObject(value: unknown): Record | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return undefined; + } + return value as Record; +} + +function structuredCodexErrorInfo(detail: unknown): unknown { + const root = asObject(detail); + const error = asObject(root?.error); + return error?.codexErrorInfo ?? root?.codexErrorInfo; +} + +export function isCodexAuthenticationError(input: { + readonly message: string; + readonly detail?: unknown; + readonly requiresProviderAccount: boolean; +}): boolean { + return ( + input.requiresProviderAccount === true && + structuredCodexErrorInfo(input.detail) === "unauthorized" + ); +} diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index e950d87f5..48d380225 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -10,6 +10,10 @@ import { Effect, FileSystem, Layer, Path, ServiceMap } from "effect"; import OS from "node:os"; import pathPosix from "node:path/posix"; import pathWin32 from "node:path/win32"; +import { + ensurePrivateScientDirectoriesSync, + ensurePrivateScientStateDirectoriesSync, +} from "@synara/shared/scientDataDirectories"; import { realpathNearestExisting } from "./realpathNearestExisting"; @@ -160,9 +164,15 @@ export class ServerConfig extends ServiceMap.Service { + if (typeof baseDirOrPrefix === "string") { + // Explicit test fixtures commonly use shared roots such as /tmp. + // Secure Scient's children without changing or rejecting that root. + ensurePrivateScientStateDirectoriesSync(derivedPaths); + } else { + ensurePrivateScientDirectoriesSync({ baseDir, ...derivedPaths }); + } + }); const { homeDir, chatWorkspaceRoot, studioWorkspaceRoot } = yield* resolveCanonicalWorkspaceRoots({ homeDir: OS.homedir() }); diff --git a/apps/server/src/desktopParentShutdown.test.ts b/apps/server/src/desktopParentShutdown.test.ts new file mode 100644 index 000000000..da9153ce5 --- /dev/null +++ b/apps/server/src/desktopParentShutdown.test.ts @@ -0,0 +1,68 @@ +import { EventEmitter } from "node:events"; + +import { Effect, Fiber } from "effect"; +import { describe, expect, it } from "vitest"; + +import { waitForDesktopParentShutdown } from "./desktopParentShutdown"; + +describe("waitForDesktopParentShutdown", () => { + it("ignores unrelated messages and completes for the shutdown protocol", async () => { + const source = new EventEmitter(); + const fiber = Effect.runFork(waitForDesktopParentShutdown(source)); + await new Promise((resolve) => setImmediate(resolve)); + + source.emit("message", { type: "other" }); + expect(fiber.pollUnsafe()).toBeUndefined(); + source.emit("message", { type: "scient.backend.shutdown", reason: "app quit" }); + + await Effect.runPromise(Fiber.join(fiber)); + expect(fiber.pollUnsafe()).toBeDefined(); + expect(source.listenerCount("message")).toBe(0); + expect(source.listenerCount("disconnect")).toBe(0); + }); + + it("completes when the Electron IPC channel disconnects", async () => { + const source = new EventEmitter(); + const fiber = Effect.runFork(waitForDesktopParentShutdown(source)); + await new Promise((resolve) => setImmediate(resolve)); + + source.emit("disconnect"); + + await Effect.runPromise(Fiber.join(fiber)); + expect(source.listenerCount("message")).toBe(0); + expect(source.listenerCount("disconnect")).toBe(0); + }); + + it("completes when the parent disconnected before listeners were registered", async () => { + const source = Object.assign(new EventEmitter(), { connected: false }); + + await Effect.runPromise(waitForDesktopParentShutdown(source)); + + expect(source.listenerCount("message")).toBe(0); + expect(source.listenerCount("disconnect")).toBe(0); + }); + + it("settles only once when message and disconnect arrive together", async () => { + const source = new EventEmitter(); + const fiber = Effect.runFork(waitForDesktopParentShutdown(source)); + await new Promise((resolve) => setImmediate(resolve)); + + source.emit("message", { type: "scient.backend.shutdown", reason: "app quit" }); + source.emit("disconnect"); + + await Effect.runPromise(Fiber.join(fiber)); + expect(source.listenerCount("message")).toBe(0); + expect(source.listenerCount("disconnect")).toBe(0); + }); + + it("removes its listener when the server scope is interrupted", async () => { + const source = new EventEmitter(); + const fiber = Effect.runFork(waitForDesktopParentShutdown(source)); + await new Promise((resolve) => setImmediate(resolve)); + + await Effect.runPromise(Fiber.interrupt(fiber)); + + expect(source.listenerCount("message")).toBe(0); + expect(source.listenerCount("disconnect")).toBe(0); + }); +}); diff --git a/apps/server/src/desktopParentShutdown.ts b/apps/server/src/desktopParentShutdown.ts new file mode 100644 index 000000000..57e4baefc --- /dev/null +++ b/apps/server/src/desktopParentShutdown.ts @@ -0,0 +1,42 @@ +import { Effect } from "effect"; + +import { isScientBackendShutdownMessage } from "@synara/shared/backendControl"; + +export interface DesktopParentMessageSource { + readonly connected?: boolean; + on(event: "message", listener: (message: unknown) => void): unknown; + on(event: "disconnect", listener: () => void): unknown; + off(event: "message", listener: (message: unknown) => void): unknown; + off(event: "disconnect", listener: () => void): unknown; +} + +/** Completes when the Electron parent asks the scoped server runtime to shut down. */ +export function waitForDesktopParentShutdown( + source: DesktopParentMessageSource = process, +): Effect.Effect { + return Effect.callback((resume) => { + let settled = false; + const cleanup = () => { + source.off("message", onMessage); + source.off("disconnect", onDisconnect); + }; + const complete = () => { + if (settled) return; + settled = true; + cleanup(); + resume(Effect.void); + }; + const onMessage = (message: unknown) => { + if (!isScientBackendShutdownMessage(message)) return; + complete(); + }; + const onDisconnect = () => complete(); + source.on("message", onMessage); + source.on("disconnect", onDisconnect); + // `disconnect` is edge-triggered. Register first, then inspect the current + // channel state so a parent that disappeared during server startup cannot + // leave an orphaned backend behind. + if (source.connected === false) complete(); + return Effect.sync(cleanup); + }); +} diff --git a/apps/server/src/main.test.ts b/apps/server/src/main.test.ts index 9acf01a41..24548a698 100644 --- a/apps/server/src/main.test.ts +++ b/apps/server/src/main.test.ts @@ -16,6 +16,8 @@ import { NetService } from "@synara/shared/Net"; import { ServerConfig, type ServerConfigShape } from "./config"; import { Open, type OpenShape } from "./open"; import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; +import { PRIVATE_DIRECTORY_MODE } from "./privatePathPermissions"; +import { ServerSettingsService } from "./serverSettings"; import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; import { Server, type ServerShape } from "./effectServer"; @@ -70,6 +72,7 @@ const testLayer = Layer.mergeAll( openBrowser: (_target: string) => Effect.void, openInEditor: () => Effect.void, } satisfies OpenShape), + ServerSettingsService.layerTest(), AnalyticsService.layerTest, FetchHttpClient.layer, NodeServices.layer, @@ -141,6 +144,20 @@ it.layer(testLayer)("server CLI command", (it) => { assert.equal(resolvedConfig?.autoBootstrapProjectFromCwd, false); assert.equal(resolvedConfig?.logProviderEvents, false); assert.equal(resolvedConfig?.logWebSocketEvents, false); + if (process.platform !== "win32" && resolvedConfig) { + for (const directoryPath of [ + resolvedConfig.baseDir, + resolvedConfig.stateDir, + resolvedConfig.secretsDir, + resolvedConfig.worktreesDir, + resolvedConfig.attachmentsDir, + resolvedConfig.logsDir, + resolvedConfig.providerLogsDir, + resolvedConfig.terminalLogsDir, + ]) { + assert.equal(fs.statSync(directoryPath).mode & 0o777, PRIVATE_DIRECTORY_MODE); + } + } assert.equal(stop.mock.calls.length, 1); }), ); @@ -238,6 +255,64 @@ it.layer(testLayer)("server CLI command", (it) => { }), ); + it.effect("secures every Scient-owned state directory before server startup", () => + Effect.gen(function* () { + const preexistingLogsDir = path.join(defaultScientHome, "userdata", "logs"); + fs.mkdirSync(preexistingLogsDir, { recursive: true }); + fs.chmodSync(defaultScientHome, 0o775); + fs.chmodSync(path.join(defaultScientHome, "userdata"), 0o775); + fs.chmodSync(preexistingLogsDir, 0o775); + + const previousUmask = process.umask(0o002); + try { + yield* runCli([], { + SYNARA_MODE: "desktop", + SYNARA_NO_BROWSER: "true", + }); + } finally { + process.umask(previousUmask); + } + + assert.equal(start.mock.calls.length, 1); + const config = resolvedConfig; + if (!config) throw new Error("Expected server config to resolve before startup"); + const privateDirectories = [ + config.baseDir, + config.stateDir, + config.secretsDir, + config.worktreesDir, + config.attachmentsDir, + config.logsDir, + config.providerLogsDir, + config.terminalLogsDir, + ]; + for (const directoryPath of privateDirectories) { + assert.equal(fs.statSync(directoryPath).mode & 0o777, PRIVATE_DIRECTORY_MODE); + } + }), + ); + + it.effect("does not start when SCIENT_HOME is a symlink", () => + Effect.gen(function* () { + if (process.platform === "win32") return; + const container = makeTempHome("scient-main-symlink-"); + const target = path.join(container, "target"); + const symlink = path.join(container, "scient-home"); + fs.mkdirSync(target); + fs.chmodSync(target, 0o775); + fs.symlinkSync(target, symlink, "dir"); + + yield* runCli([], { + SCIENT_HOME: symlink, + SYNARA_MODE: "desktop", + SYNARA_NO_BROWSER: "true", + }).pipe(Effect.catch(() => Effect.void)); + + assert.equal(start.mock.calls.length, 0); + assert.equal(fs.statSync(target).mode & 0o777, 0o775); + }), + ); + it.effect("allows overriding desktop host with --host", () => Effect.gen(function* () { yield* runCli(["--host", "0.0.0.0"], { diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 80cd3a290..02e459937 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -10,6 +10,8 @@ import OS from "node:os"; import { Config, Data, Effect, FileSystem, Layer, Option, Path, Schema, ServiceMap } from "effect"; import { Command, Flag } from "effect/unstable/cli"; import { NetService } from "@synara/shared/Net"; +import { ensurePrivateScientDirectoriesSync } from "@synara/shared/scientDataDirectories"; +import { waitForDesktopParentShutdown } from "./desktopParentShutdown"; import { DEFAULT_PORT, deriveServerPaths, @@ -27,12 +29,13 @@ import { startServerMemoryDiagnostics } from "./memoryDiagnostics"; import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; import { makeProviderHealthLive } from "./provider/Layers/ProviderHealth"; import { ProviderConnectionLive } from "./provider/Layers/ProviderConnection"; +import { ProviderClientStatusProjectionLive } from "./provider/Layers/ProviderClientStatusProjection"; import { ProviderRuntimeManagerLive } from "./provider/Layers/ProviderRuntimeManager"; import { ProviderRuntimeManager } from "./provider/Services/ProviderRuntimeManager"; import { ProviderSessionReaperLive } from "./provider/Layers/ProviderSessionReaper"; import { Server } from "./effectServer"; import { ServerLoggerLive } from "./serverLogger"; -import { ServerSettingsService } from "./serverSettings"; +import { ServerSettingsLive, ServerSettingsService } from "./serverSettings"; import { formatHostForUrl, isWildcardHost } from "./startupAccess"; import { PtyAdapterLayerLive } from "./terminal/runtimeLayer"; import { AnalyticsServiceLayerLive } from "./telemetry/Layers/AnalyticsService"; @@ -174,6 +177,14 @@ const ServerConfigLive = (input: CliInput) => const baseDir = yield* resolveBaseDir(configuredHome); const userHomeDir = OS.homedir(); const derivedPaths = yield* deriveServerPaths(baseDir, devUrl); + yield* Effect.try({ + try: () => ensurePrivateScientDirectoriesSync({ baseDir, ...derivedPaths }), + catch: (cause) => + new StartupError({ + message: `Failed to secure Scient application data at ${baseDir}`, + cause, + }), + }); const noBrowser = resolveBooleanFlag(input.noBrowser, env.noBrowser ?? mode === "desktop"); const authToken = Option.getOrUndefined(input.authToken) ?? env.authToken; const autoBootstrapProjectFromCwd = resolveBooleanFlag( @@ -248,6 +259,11 @@ const LayerLive = (input: CliInput) => { Layer.provideMerge(providerLayer), Layer.provideMerge(PtyAdapterLayerLive), ); + const providerClientStatusProjectionLayer = ProviderClientStatusProjectionLive.pipe( + Layer.provideMerge(runtimeServicesLayer), + Layer.provideMerge(providerHealthLayer), + Layer.provideMerge(providerRuntimeLayer), + ); const providerSessionReaperLayer = ProviderSessionReaperLive.pipe( // The reaper coordinates orchestration state with live provider sessions, // so it belongs at the top level where both layers are available. @@ -255,16 +271,22 @@ const LayerLive = (input: CliInput) => { Layer.provideMerge(providerLayer), Layer.provideMerge(providerRuntimeLayer), ); + const analyticsLayer = AnalyticsServiceLayerLive.pipe( + // Analytics reads the same server-authoritative privacy setting exposed to + // the UI and the rest of the runtime. + Layer.provideMerge(ServerSettingsLive), + ); return Layer.empty.pipe( Layer.provideMerge(runtimeServicesLayer), Layer.provideMerge(providerLayer), Layer.provideMerge(providerHealthLayer), Layer.provideMerge(providerConnectionLayer), + Layer.provideMerge(providerClientStatusProjectionLayer), Layer.provideMerge(providerSessionReaperLayer), Layer.provideMerge(SqlitePersistence.layerConfig), Layer.provideMerge(ServerLoggerLive), - Layer.provideMerge(AnalyticsServiceLayerLive), + Layer.provideMerge(analyticsLayer), Layer.provideMerge(ServerConfigLive(input)), ); }; @@ -357,7 +379,9 @@ const makeServerProgram = (input: CliInput) => ); } - return yield* stopSignal; + return yield* config.mode === "desktop" + ? Effect.raceFirst(stopSignal, waitForDesktopParentShutdown()) + : stopSignal; }).pipe(Effect.provide(LayerLive(input))); /** diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index ae0756040..81381d591 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -188,6 +188,73 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { }), ); + it.effect("rebuilds durable session error correlation metadata from events", () => + Effect.gen(function* () { + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + const threadId = ThreadId.makeUnsafe("thread-session-error-rebuild"); + const occurredAt = "2026-07-21T10:00:00.000Z"; + + yield* eventStore.append({ + type: "thread.session-set", + eventId: EventId.makeUnsafe("event-session-error-rebuild"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt, + commandId: CommandId.makeUnsafe("command-session-error-rebuild"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("command-session-error-rebuild"), + metadata: {}, + payload: { + threadId, + session: { + threadId, + status: "error", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: "Authentication required", + lastErrorEventId: EventId.makeUnsafe("event-runtime-authentication-error"), + lastErrorClass: "authentication_error", + updatedAt: occurredAt, + }, + }, + }); + + const readPersistedMetadata = () => + sql<{ + readonly lastErrorEventId: string | null; + readonly lastErrorClass: string | null; + }>` + SELECT + last_error_event_id AS "lastErrorEventId", + last_error_class AS "lastErrorClass" + FROM projection_thread_sessions + WHERE thread_id = ${threadId} + `; + const expectedMetadata = [ + { + lastErrorEventId: "event-runtime-authentication-error", + lastErrorClass: "authentication_error", + }, + ]; + + yield* projectionPipeline.bootstrap; + assert.deepEqual(yield* readPersistedMetadata(), expectedMetadata); + + yield* sql`DELETE FROM projection_thread_sessions WHERE thread_id = ${threadId}`; + yield* sql` + DELETE FROM projection_state + WHERE projector = ${ORCHESTRATION_PROJECTOR_NAMES.threadSessions} + `; + assert.deepEqual(yield* readPersistedMetadata(), []); + + yield* projectionPipeline.bootstrap; + assert.deepEqual(yield* readPersistedMetadata(), expectedMetadata); + }), + ); + it.effect("persists turn-start thread settings into projection rows", () => Effect.gen(function* () { const projectionPipeline = yield* OrchestrationProjectionPipeline; diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 25c7e9a0d..6dfe46bfc 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -1393,6 +1393,8 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { runtimeMode: event.payload.session.runtimeMode, activeTurnId: event.payload.session.activeTurnId, lastError: event.payload.session.lastError, + lastErrorEventId: event.payload.session.lastErrorEventId ?? null, + lastErrorClass: event.payload.session.lastErrorClass ?? null, updatedAt: event.payload.session.updatedAt, }); }); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 1fc4e5fa6..d5bd8784c 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -191,17 +191,21 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { runtime_mode, active_turn_id, last_error, + last_error_event_id, + last_error_class, updated_at ) VALUES ( 'thread-1', - 'running', + 'error', 'codex', 'provider-session-1', 'provider-thread-1', 'approval-required', - 'turn-1', NULL, + 'Authentication required', + 'event-auth-error', + 'authentication_error', '2026-02-24T00:00:07.000Z' ) `; @@ -436,11 +440,13 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { ], session: { threadId: ThreadId.makeUnsafe("thread-1"), - status: "running", + status: "error", providerName: "codex", runtimeMode: "approval-required", - activeTurnId: asTurnId("turn-1"), - lastError: null, + activeTurnId: null, + lastError: "Authentication required", + lastErrorEventId: asEventId("event-auth-error"), + lastErrorClass: "authentication_error", updatedAt: "2026-02-24T00:00:07.000Z", }, }, diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index fd713e0b5..c45d6c395 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -422,6 +422,8 @@ function toProjectedSession(row: ProjectionThreadSessionDbRow): OrchestrationSes runtimeMode: row.runtimeMode, activeTurnId: row.activeTurnId, lastError: row.lastError, + ...(row.lastErrorEventId ? { lastErrorEventId: row.lastErrorEventId } : {}), + ...(row.lastErrorClass ? { lastErrorClass: row.lastErrorClass } : {}), updatedAt: row.updatedAt, }; } @@ -1006,6 +1008,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { runtime_mode AS "runtimeMode", active_turn_id AS "activeTurnId", last_error AS "lastError", + last_error_event_id AS "lastErrorEventId", + last_error_class AS "lastErrorClass", updated_at AS "updatedAt" FROM projection_thread_sessions ORDER BY thread_id ASC @@ -1408,6 +1412,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { runtime_mode AS "runtimeMode", active_turn_id AS "activeTurnId", last_error AS "lastError", + last_error_event_id AS "lastErrorEventId", + last_error_class AS "lastErrorClass", updated_at AS "updatedAt" FROM projection_thread_sessions WHERE thread_id = ${threadId} diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 92fc279f6..4c9a586c8 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -21,7 +21,7 @@ import { TurnId, } from "@synara/contracts"; import { Effect, Exit, Layer, ManagedRuntime, PubSub, Scope, Stream } from "effect"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts"; @@ -30,6 +30,7 @@ import { ProviderService, type ProviderServiceShape, } from "../../provider/Services/ProviderService.ts"; +import { ProviderSessionDirectoryPersistenceError } from "../../provider/Errors.ts"; import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; @@ -68,6 +69,9 @@ type LegacyProviderRuntimeEvent = { function createProviderServiceHarness() { const runtimeEventPubSub = Effect.runSync(PubSub.unbounded()); const runtimeSessions: ProviderSession[] = []; + const stopRuntimeSession = vi.fn>( + (_input) => Effect.void, + ); const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never; const service: ProviderServiceShape = { @@ -80,6 +84,7 @@ function createProviderServiceHarness() { respondToRequest: () => unsupported(), respondToUserInput: () => unsupported(), stopSession: () => unsupported(), + stopRuntimeSession, listSessions: () => Effect.succeed([...runtimeSessions]), getCapabilities: (provider) => Effect.succeed({ @@ -108,6 +113,7 @@ function createProviderServiceHarness() { service, emit, setSession, + stopRuntimeSession, }; } @@ -256,6 +262,7 @@ describe("ProviderRuntimeIngestion", () => { engine, emit: provider.emit, setProviderSession: provider.setSession, + stopRuntimeSession: provider.stopRuntimeSession, drain, }; } @@ -4077,6 +4084,352 @@ describe("ProviderRuntimeIngestion", () => { ); expect(thread.session?.status).toBe("error"); expect(thread.session?.lastError).toBe("runtime exploded"); + expect(thread.session?.lastErrorEventId).toBe("evt-runtime-error"); + expect(thread.session?.lastErrorClass).toBeNull(); + expect(harness.stopRuntimeSession).not.toHaveBeenCalled(); + + harness.emit({ + type: "session.exited", + eventId: asEventId("evt-runtime-error-exited"), + provider: "codex", + createdAt: new Date(Date.parse(now) + 1).toISOString(), + threadId: asThreadId("thread-1"), + payload: { reason: "Session stopped" }, + }); + + const stoppedThread = await waitForThread( + harness.engine, + (entry) => entry.session?.status === "stopped", + ); + expect(stoppedThread.session?.lastErrorEventId).toBeUndefined(); + expect(stoppedThread.session?.lastErrorClass).toBeUndefined(); + }); + + it("stops only the stale Codex runtime after a classified authentication error", async () => { + const harness = await createHarness(); + const now = "2026-07-21T10:00:00.000Z"; + harness.stopRuntimeSession.mockImplementation(({ threadId }) => + Effect.sync(() => { + harness.emit({ + type: "session.exited", + eventId: asEventId("evt-runtime-authentication-stop-exited"), + provider: "codex", + createdAt: "2026-07-21T10:00:00.001Z", + threadId, + payload: { reason: "Session stopped" }, + }); + }), + ); + + harness.emit({ + type: "runtime.error", + eventId: asEventId("evt-runtime-authentication-error"), + provider: "codex", + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-authentication-error"), + payload: { + message: "Authentication required", + class: "authentication_error", + }, + }); + + await waitForThread( + harness.engine, + (entry) => + entry.session?.status === "error" && + entry.session?.activeTurnId === null && + entry.session?.lastError === "Authentication required", + ); + await vi.waitFor(() => expect(harness.stopRuntimeSession).toHaveBeenCalledTimes(1)); + await new Promise((resolve) => setTimeout(resolve, 10)); + await harness.drain(); + const thread = await waitForThread( + harness.engine, + (entry) => + entry.session?.status === "error" && + entry.session?.activeTurnId === null && + entry.session?.lastError === "Authentication required", + ); + expect(harness.stopRuntimeSession).toHaveBeenCalledWith({ + threadId: asThreadId("thread-1"), + }); + expect(thread.session?.updatedAt).toBe(now); + expect(thread.session?.lastErrorEventId).toBe("evt-runtime-authentication-error"); + expect(thread.session?.lastErrorClass).toBe("authentication_error"); + + harness.emit({ + type: "session.exited", + eventId: asEventId("evt-later-unrelated-session-exit"), + provider: "codex", + createdAt: "2026-07-21T10:00:00.002Z", + threadId: asThreadId("thread-1"), + payload: { reason: "Later session stopped" }, + }); + const stoppedThread = await waitForThread( + harness.engine, + (entry) => entry.session?.status === "stopped", + ); + expect(stoppedThread.session?.lastErrorEventId).toBeUndefined(); + expect(stoppedThread.session?.lastErrorClass).toBeUndefined(); + }); + + it("preserves authentication metadata through its matching failed turn settlement", async () => { + const harness = await createHarness(); + const errorAt = "2026-07-21T10:00:30.000Z"; + const settledAt = "2026-07-21T10:00:30.001Z"; + harness.stopRuntimeSession.mockImplementation(({ threadId }) => + Effect.sync(() => { + harness.emit({ + type: "turn.completed", + eventId: asEventId("evt-authentication-turn-failed"), + provider: "codex", + createdAt: settledAt, + threadId, + turnId: asTurnId("turn-authentication-settlement"), + payload: { + state: "failed", + errorMessage: "Unauthorized", + }, + }); + harness.emit({ + type: "session.exited", + eventId: asEventId("evt-authentication-settlement-cleanup-exited"), + provider: "codex", + createdAt: "2026-07-21T10:00:30.002Z", + threadId, + payload: { reason: "Session stopped" }, + }); + }), + ); + + harness.emit({ + type: "runtime.error", + eventId: asEventId("evt-authentication-error-before-settlement"), + provider: "codex", + createdAt: errorAt, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-authentication-settlement"), + payload: { + message: "Authentication required", + class: "authentication_error", + }, + }); + + await vi.waitFor(() => expect(harness.stopRuntimeSession).toHaveBeenCalledTimes(1)); + await new Promise((resolve) => setTimeout(resolve, 10)); + await harness.drain(); + const authenticationErrorThread = await waitForThread( + harness.engine, + (entry) => + entry.session?.status === "error" && + entry.session.lastErrorEventId === "evt-authentication-error-before-settlement", + ); + expect(authenticationErrorThread.session?.lastError).toBe("Authentication required"); + expect(authenticationErrorThread.session?.lastErrorClass).toBe("authentication_error"); + expect(authenticationErrorThread.session?.updatedAt).toBe(settledAt); + + harness.emit({ + type: "session.exited", + eventId: asEventId("evt-after-authentication-settlement-unrelated-exit"), + provider: "codex", + createdAt: "2026-07-21T10:00:30.003Z", + threadId: asThreadId("thread-1"), + payload: { reason: "Later unrelated exit" }, + }); + + const stoppedThread = await waitForThread( + harness.engine, + (entry) => entry.session?.status === "stopped", + ); + expect(stoppedThread.session?.lastErrorEventId).toBeUndefined(); + expect(stoppedThread.session?.lastErrorClass).toBeUndefined(); + }); + + it("preserves matching authentication settlement when cleanup exit arrives first", async () => { + const harness = await createHarness(); + const settledAt = "2026-07-21T10:00:35.002Z"; + harness.stopRuntimeSession.mockImplementation(({ threadId }) => + Effect.sync(() => { + harness.emit({ + type: "session.exited", + eventId: asEventId("evt-authentication-reordered-cleanup-exited"), + provider: "codex", + createdAt: "2026-07-21T10:00:35.001Z", + threadId, + payload: { reason: "Session stopped" }, + }); + harness.emit({ + type: "turn.completed", + eventId: asEventId("evt-authentication-reordered-turn-failed"), + provider: "codex", + createdAt: settledAt, + threadId, + turnId: asTurnId("turn-authentication-reordered-settlement"), + payload: { + state: "failed", + errorMessage: "Unauthorized", + }, + }); + }), + ); + + harness.emit({ + type: "runtime.error", + eventId: asEventId("evt-authentication-error-before-reordered-settlement"), + provider: "codex", + createdAt: "2026-07-21T10:00:35.000Z", + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-authentication-reordered-settlement"), + payload: { + message: "Authentication required", + class: "authentication_error", + }, + }); + + await vi.waitFor(() => expect(harness.stopRuntimeSession).toHaveBeenCalledTimes(1)); + await new Promise((resolve) => setTimeout(resolve, 10)); + await harness.drain(); + const authenticationErrorThread = await waitForThread( + harness.engine, + (entry) => + entry.session?.status === "error" && + entry.session.lastErrorEventId === "evt-authentication-error-before-reordered-settlement", + ); + expect(authenticationErrorThread.session?.lastError).toBe("Authentication required"); + expect(authenticationErrorThread.session?.lastErrorClass).toBe("authentication_error"); + expect(authenticationErrorThread.session?.updatedAt).toBe(settledAt); + + harness.emit({ + type: "session.exited", + eventId: asEventId("evt-after-reordered-authentication-settlement-exit"), + provider: "codex", + createdAt: "2026-07-21T10:00:35.003Z", + threadId: asThreadId("thread-1"), + payload: { reason: "Later unrelated exit" }, + }); + + const stoppedThread = await waitForThread( + harness.engine, + (entry) => entry.session?.status === "stopped", + ); + expect(stoppedThread.session?.lastErrorEventId).toBeUndefined(); + expect(stoppedThread.session?.lastErrorClass).toBeUndefined(); + }); + + it("clears authentication metadata when the matching turn later succeeds", async () => { + const harness = await createHarness(); + harness.stopRuntimeSession.mockImplementation(({ threadId }) => + Effect.sync(() => { + harness.emit({ + type: "turn.completed", + eventId: asEventId("evt-authentication-turn-succeeded"), + provider: "codex", + createdAt: "2026-07-21T10:00:40.001Z", + threadId, + turnId: asTurnId("turn-authentication-success"), + payload: { state: "completed" }, + }); + }), + ); + + harness.emit({ + type: "runtime.error", + eventId: asEventId("evt-authentication-error-before-success"), + provider: "codex", + createdAt: "2026-07-21T10:00:40.000Z", + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-authentication-success"), + payload: { + message: "Authentication required", + class: "authentication_error", + }, + }); + + await vi.waitFor(() => expect(harness.stopRuntimeSession).toHaveBeenCalledTimes(1)); + await new Promise((resolve) => setTimeout(resolve, 10)); + await harness.drain(); + const readyThread = await waitForThread( + harness.engine, + (entry) => entry.session?.status === "ready", + ); + expect(readyThread.session?.lastError).toBeNull(); + expect(readyThread.session?.lastErrorEventId).toBeUndefined(); + expect(readyThread.session?.lastErrorClass).toBeUndefined(); + + harness.emit({ + type: "session.exited", + eventId: asEventId("evt-after-authentication-success-cleanup-exit"), + provider: "codex", + createdAt: "2026-07-21T10:00:40.002Z", + threadId: asThreadId("thread-1"), + payload: { reason: "Cleanup after successful settlement" }, + }); + await waitForThread(harness.engine, (entry) => entry.session?.status === "stopped"); + }); + + it("preserves authentication state when runtime cleanup emits before stop persistence fails", async () => { + const harness = await createHarness(); + const now = "2026-07-21T10:01:00.000Z"; + harness.stopRuntimeSession.mockImplementation(({ threadId }) => + Effect.gen(function* () { + harness.emit({ + type: "session.exited", + eventId: asEventId("evt-failed-stop-cleanup-exited"), + provider: "codex", + createdAt: "2026-07-21T10:01:00.001Z", + threadId, + payload: { reason: "Session stopped before directory persistence failed" }, + }); + return yield* Effect.fail( + new ProviderSessionDirectoryPersistenceError({ + operation: "ProviderService.stopRuntimeSession", + detail: "simulated directory persistence failure", + }), + ); + }), + ); + + harness.emit({ + type: "runtime.error", + eventId: asEventId("evt-authentication-error-before-failed-stop"), + provider: "codex", + createdAt: now, + threadId: asThreadId("thread-1"), + payload: { + message: "Authentication required", + class: "authentication_error", + }, + }); + + await vi.waitFor(() => expect(harness.stopRuntimeSession).toHaveBeenCalledTimes(1)); + await new Promise((resolve) => setTimeout(resolve, 10)); + await harness.drain(); + const authenticationErrorThread = await waitForThread( + harness.engine, + (entry) => + entry.session?.status === "error" && + entry.session.lastErrorEventId === "evt-authentication-error-before-failed-stop", + ); + expect(authenticationErrorThread.session?.lastError).toBe("Authentication required"); + expect(authenticationErrorThread.session?.lastErrorClass).toBe("authentication_error"); + expect(authenticationErrorThread.session?.updatedAt).toBe(now); + + harness.emit({ + type: "session.exited", + eventId: asEventId("evt-after-failed-stop-unrelated-exit"), + provider: "codex", + createdAt: "2026-07-21T10:01:00.002Z", + threadId: asThreadId("thread-1"), + payload: { reason: "Later unrelated exit" }, + }); + + const stoppedThread = await waitForThread( + harness.engine, + (entry) => entry.session?.status === "stopped", + ); + expect(stoppedThread.session?.lastErrorEventId).toBeUndefined(); + expect(stoppedThread.session?.lastErrorClass).toBeUndefined(); }); it("keeps the session running when a runtime.warning arrives during an active turn", async () => { diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index b3e316782..a537fa575 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -1672,6 +1672,82 @@ const make = Effect.gen(function* () { lookup: () => Effect.succeed(undefined), }); const providerDiffPlaceholdersRef = yield* Ref.make(new Map()); + const codexAuthenticationRecoveryStopsRef = yield* Ref.make< + ReadonlyMap< + ThreadId, + { + readonly eventId: EventId; + readonly turnId: TurnId | null; + readonly cleanupExitSuppressed: boolean; + readonly failedTurnSettlementObserved: boolean; + } + > + >(new Map()); + + const markCodexAuthenticationRecoveryStop = ( + threadId: ThreadId, + eventId: EventId, + turnId: TurnId | null, + ) => + Ref.update(codexAuthenticationRecoveryStopsRef, (pending) => { + const next = new Map(pending); + next.set(threadId, { + eventId, + turnId, + cleanupExitSuppressed: false, + failedTurnSettlementObserved: false, + }); + return next; + }); + + const clearCodexAuthenticationRecoveryStop = (threadId: ThreadId) => + Ref.update(codexAuthenticationRecoveryStopsRef, (pending) => { + if (!pending.has(threadId)) return pending; + const next = new Map(pending); + next.delete(threadId); + return next; + }); + + const suppressCodexAuthenticationRecoveryExit = (threadId: ThreadId, eventId: EventId) => + Ref.modify(codexAuthenticationRecoveryStopsRef, (pending) => { + const marker = pending.get(threadId); + if (!marker || !sameId(marker.eventId, eventId)) return [false, pending] as const; + const next = new Map(pending); + if (marker.cleanupExitSuppressed) { + next.delete(threadId); + return [false, next] as const; + } + if (marker.failedTurnSettlementObserved) { + next.delete(threadId); + } else { + next.set(threadId, { ...marker, cleanupExitSuppressed: true }); + } + return [true, next] as const; + }); + + const recordMatchingCodexAuthenticationTurnSettlement = ( + threadId: ThreadId, + eventId: EventId, + turnId: TurnId, + ) => + Ref.modify(codexAuthenticationRecoveryStopsRef, (pending) => { + const marker = pending.get(threadId); + if ( + !marker || + !sameId(marker.eventId, eventId) || + marker.turnId === null || + !sameId(marker.turnId, turnId) + ) { + return [false, pending] as const; + } + const next = new Map(pending); + if (marker.cleanupExitSuppressed) { + next.delete(threadId); + } else { + next.set(threadId, { ...marker, failedTurnSettlementObserved: true }); + } + return [true, next] as const; + }); const dispatchActivityUpdate = Effect.fnUntraced(function* ( event: ProviderRuntimeEvent, @@ -2741,15 +2817,55 @@ const make = Effect.gen(function* () { ) : { threadId: parentThread.id, thread: parentThread }; const thread = targetThreadResolution.thread; + const codexAuthenticationErrorEventId = + event.type === "session.exited" && + event.provider === "codex" && + thread.session?.status === "error" && + thread.session.lastErrorClass === "authentication_error" && + thread.session.lastErrorEventId !== undefined && + thread.session.lastErrorEventId !== null + ? thread.session.lastErrorEventId + : undefined; + const isCodexAuthenticationRecoveryExit = + codexAuthenticationErrorEventId !== undefined + ? yield* suppressCodexAuthenticationRecoveryExit( + thread.id, + codexAuthenticationErrorEventId, + ) + : false; + if ( + event.type === "session.started" || + event.type === "thread.started" || + event.type === "turn.started" + ) { + yield* clearCodexAuthenticationRecoveryStop(thread.id); + } const activeTurnId = thread.session?.activeTurnId ?? null; const eventTurnId = resolveTerminalTurnId(event, activeTurnId); const isTerminalTurnEvent = event.type === "turn.completed" || event.type === "turn.aborted"; + const preserveCodexAuthenticationErrorThroughTurnSettlement = + event.type === "turn.completed" && + runtimeTurnState(event) === "failed" && + eventTurnId !== undefined && + thread.session?.status === "error" && + thread.session.lastErrorClass === "authentication_error" && + thread.session.lastErrorEventId !== undefined && + thread.session.lastErrorEventId !== null + ? yield* recordMatchingCodexAuthenticationTurnSettlement( + thread.id, + thread.session.lastErrorEventId, + eventTurnId, + ) + : false; const conflictsWithActiveTurn = activeTurnId !== null && eventTurnId !== undefined && !sameId(activeTurnId, eventTurnId); const missingTurnForActiveTurn = activeTurnId !== null && eventTurnId === undefined; const shouldApplyThreadLifecycle = (() => { + if (isCodexAuthenticationRecoveryExit) { + return false; + } if (!STRICT_PROVIDER_LIFECYCLE_GUARD) { return true; } @@ -2820,8 +2936,9 @@ const make = Effect.gen(function* () { return activeTurnId !== null ? "running" : "ready"; } })(); - const lastError = - event.type === "session.state.changed" && event.payload.state === "error" + const lastError = preserveCodexAuthenticationErrorThroughTurnSettlement + ? (thread.session?.lastError ?? "Authentication required") + : event.type === "session.state.changed" && event.payload.state === "error" ? (event.payload.reason ?? thread.session?.lastError ?? "Provider session error") : event.type === "turn.completed" && runtimeTurnState(event) === "failed" ? (runtimeTurnErrorMessage(event) ?? thread.session?.lastError ?? "Turn failed") @@ -2830,6 +2947,9 @@ const make = Effect.gen(function* () { : (thread.session?.lastError ?? null); if (shouldApplyThreadLifecycle) { + if (!preserveCodexAuthenticationErrorThroughTurnSettlement) { + yield* clearCodexAuthenticationRecoveryStop(thread.id); + } if (event.type === "turn.started" && acceptedTurnStartedSourcePlan !== null) { yield* markSourceProposedPlanImplemented( acceptedTurnStartedSourcePlan.sourceThreadId, @@ -2861,6 +2981,12 @@ const make = Effect.gen(function* () { runtimeMode: thread.session?.runtimeMode ?? "full-access", activeTurnId: nextActiveTurnId, lastError, + ...(preserveCodexAuthenticationErrorThroughTurnSettlement + ? { + lastErrorEventId: thread.session?.lastErrorEventId, + lastErrorClass: thread.session?.lastErrorClass, + } + : {}), updatedAt: now, }, createdAt: now, @@ -3126,6 +3252,7 @@ const make = Effect.gen(function* () { if (event.type === "runtime.error") { const runtimeErrorMessage = runtimeErrorMessageFromEvent(event) ?? "Provider runtime error"; + const runtimeErrorClass = asString(runtimePayloadRecord(event)?.class); const erroredTurnId = eventTurnId ?? activeTurnId ?? undefined; if (erroredTurnId) { @@ -3151,6 +3278,11 @@ const make = Effect.gen(function* () { : activeTurnId === null || eventTurnId === undefined || sameId(activeTurnId, eventTurnId); if (shouldApplyRuntimeError) { + yield* clearCodexAuthenticationRecoveryStop(thread.id); + const shouldStopCodexAuthenticationRuntime = + event.provider === "codex" && + runtimeErrorClass === "authentication_error" && + providerService.stopRuntimeSession !== undefined; yield* orchestrationEngine.dispatch({ type: "thread.session.set", commandId: providerCommandId(event, "runtime-error-session-set"), @@ -3160,12 +3292,36 @@ const make = Effect.gen(function* () { status: "error", providerName: event.provider, runtimeMode: thread.session?.runtimeMode ?? "full-access", - activeTurnId: eventTurnId ?? null, + activeTurnId: shouldStopCodexAuthenticationRuntime ? null : (eventTurnId ?? null), lastError: runtimeErrorMessage, + lastErrorEventId: event.eventId, + lastErrorClass: runtimeErrorClass ?? null, updatedAt: now, }, createdAt: now, }); + if (shouldStopCodexAuthenticationRuntime && providerService.stopRuntimeSession) { + // Kill only the stale app-server process. The projected session's + // exact error-event correlation keeps its resulting session.exited + // notification from erasing the actionable authentication state. + yield* markCodexAuthenticationRecoveryStop( + thread.id, + event.eventId, + eventTurnId ?? null, + ); + yield* providerService.stopRuntimeSession({ threadId: thread.id }).pipe( + Effect.catch((error) => + // Keep the exact marker: adapter.stopSession may already have + // emitted session.exited before later directory/analytics work + // failed. The queued cleanup exit consumes this marker once; + // any new session/thread/turn also clears it as stale. + Effect.logWarning("Could not stop Codex runtime after authentication loss", { + threadId: thread.id, + error, + }), + ), + ); + } } } diff --git a/apps/server/src/persistence/Layers/ProjectionThreadSessions.ts b/apps/server/src/persistence/Layers/ProjectionThreadSessions.ts index 2499eba19..8eb0ad34e 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadSessions.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadSessions.ts @@ -26,6 +26,8 @@ const makeProjectionThreadSessionRepository = Effect.gen(function* () { runtime_mode, active_turn_id, last_error, + last_error_event_id, + last_error_class, updated_at ) VALUES ( @@ -35,6 +37,8 @@ const makeProjectionThreadSessionRepository = Effect.gen(function* () { ${row.runtimeMode}, ${row.activeTurnId}, ${row.lastError}, + ${row.lastErrorEventId}, + ${row.lastErrorClass}, ${row.updatedAt} ) ON CONFLICT (thread_id) @@ -44,6 +48,8 @@ const makeProjectionThreadSessionRepository = Effect.gen(function* () { runtime_mode = excluded.runtime_mode, active_turn_id = excluded.active_turn_id, last_error = excluded.last_error, + last_error_event_id = excluded.last_error_event_id, + last_error_class = excluded.last_error_class, updated_at = excluded.updated_at `, }); @@ -60,6 +66,8 @@ const makeProjectionThreadSessionRepository = Effect.gen(function* () { runtime_mode AS "runtimeMode", active_turn_id AS "activeTurnId", last_error AS "lastError", + last_error_event_id AS "lastErrorEventId", + last_error_class AS "lastErrorClass", updated_at AS "updatedAt" FROM projection_thread_sessions WHERE thread_id = ${threadId} diff --git a/apps/server/src/persistence/Layers/Sqlite.test.ts b/apps/server/src/persistence/Layers/Sqlite.test.ts index 275bb71bc..62f0c8bce 100644 --- a/apps/server/src/persistence/Layers/Sqlite.test.ts +++ b/apps/server/src/persistence/Layers/Sqlite.test.ts @@ -1,4 +1,5 @@ import { assert, it } from "@effect/vitest"; +import fs from "node:fs"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { Effect, FileSystem, Path } from "effect"; import * as SqlClient from "effect/unstable/sql/SqlClient"; @@ -21,6 +22,41 @@ it.effect("enables WAL for a file-backed database", () => }).pipe(Effect.provide(makeSqlitePersistenceLive(dbPath))); assert.strictEqual(rows[0]?.journal_mode.toLowerCase(), "wal"); + if (process.platform !== "win32") { + assert.strictEqual(fs.statSync(dbPath).mode & 0o777, 0o600); + for (const suffix of ["-wal", "-shm"]) { + const sidecarPath = `${dbPath}${suffix}`; + if (fs.existsSync(sidecarPath)) { + assert.strictEqual(fs.statSync(sidecarPath).mode & 0o777, 0o600); + } + } + } + }), + ).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect("rejects an existing symlinked sidecar before opening SQLite", () => + Effect.scoped( + Effect.gen(function* () { + if (process.platform === "win32") return; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "scient-sqlite-sidecar-", + }); + const dbPath = path.join(directory, "state.sqlite"); + const outsidePath = path.join(directory, "outside"); + fs.writeFileSync(outsidePath, "outside", { mode: 0o664 }); + fs.chmodSync(outsidePath, 0o664); + fs.symlinkSync(outsidePath, `${dbPath}-wal`, "file"); + + const exit = yield* Effect.gen(function* () { + yield* SqlClient.SqlClient; + }).pipe(Effect.provide(makeSqlitePersistenceLive(dbPath)), Effect.exit); + + assert.strictEqual(exit._tag, "Failure"); + assert.strictEqual(fs.readFileSync(outsidePath, "utf8"), "outside"); + assert.strictEqual(fs.statSync(outsidePath).mode & 0o777, 0o664); }), ).pipe(Effect.provide(NodeServices.layer)), ); diff --git a/apps/server/src/persistence/Layers/Sqlite.ts b/apps/server/src/persistence/Layers/Sqlite.ts index ad0bb671a..1720f6bc4 100644 --- a/apps/server/src/persistence/Layers/Sqlite.ts +++ b/apps/server/src/persistence/Layers/Sqlite.ts @@ -1,8 +1,11 @@ +import fs from "node:fs"; + import { Effect, Layer, FileSystem, Path } from "effect"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { runMigrations } from "../Migrations.ts"; import { ServerConfig } from "../../config.ts"; +import { ensurePrivateFileSync, repairPrivateFileSync } from "../../privatePathPermissions.ts"; type RuntimeSqliteLayerConfig = { readonly filename: string; @@ -26,6 +29,19 @@ const makeRuntimeSqliteLayer = ( return clientModule.layer(config); }).pipe(Layer.unwrap); +const repairExistingSidecars = (filename: string): void => { + for (const suffix of ["-wal", "-shm"]) { + const sidecarPath = `${filename}${suffix}`; + try { + fs.lstatSync(sidecarPath); + } catch (cause) { + if ((cause as NodeJS.ErrnoException).code === "ENOENT") continue; + throw cause; + } + repairPrivateFileSync(sidecarPath); + } +}; + const makeSetup = (filename: string) => Layer.effectDiscard( Effect.gen(function* () { @@ -43,6 +59,12 @@ const makeSetup = (filename: string) => } yield* sql`PRAGMA foreign_keys = ON;`; yield* runMigrations(); + if (filename !== ":memory:") { + yield* Effect.sync(() => { + ensurePrivateFileSync(filename); + repairExistingSidecars(filename); + }); + } }), ); @@ -51,6 +73,10 @@ export const makeSqlitePersistenceLive = (dbPath: string) => const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; yield* fs.makeDirectory(path.dirname(dbPath), { recursive: true }); + yield* Effect.sync(() => { + ensurePrivateFileSync(dbPath); + repairExistingSidecars(dbPath); + }); return Layer.provideMerge(makeSetup(dbPath), makeRuntimeSqliteLayer({ filename: dbPath })); }).pipe(Layer.unwrap); diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index cde27972f..8ef19dd50 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -70,6 +70,7 @@ import Migration0051 from "./Migrations/051_ProfileStatsDeletedTokensModel.ts"; import Migration0052 from "./Migrations/052_ProjectionThreadUserMessageSummaryIndex.ts"; import Migration0053 from "./Migrations/053_BackfillThreadActivitySequence.ts"; import Migration0054 from "./Migrations/054_ProjectPullRequestPins.ts"; +import Migration0055 from "./Migrations/055_ProjectionThreadSessionLastErrorEvent.ts"; /** * Migration loader with all migrations defined inline. @@ -136,6 +137,7 @@ export const migrationEntries = [ [52, "ProjectionThreadUserMessageSummaryIndex", Migration0052], [53, "BackfillThreadActivitySequence", Migration0053], [54, "ProjectPullRequestPins", Migration0054], + [55, "ProjectionThreadSessionLastErrorEvent", Migration0055], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/055_ProjectionThreadSessionLastErrorEvent.test.ts b/apps/server/src/persistence/Migrations/055_ProjectionThreadSessionLastErrorEvent.test.ts new file mode 100644 index 000000000..516646e6b --- /dev/null +++ b/apps/server/src/persistence/Migrations/055_ProjectionThreadSessionLastErrorEvent.test.ts @@ -0,0 +1,68 @@ +import { assert, it } from "@effect/vitest"; +import { Effect } from "effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import { describe } from "vitest"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +const projectionThreadSessionColumnNames = (sql: SqlClient.SqlClient) => + sql<{ readonly name: string }>` + SELECT name FROM pragma_table_info('projection_thread_sessions') + `.pipe(Effect.map((rows) => rows.map((row) => row.name))); + +describe("055_ProjectionThreadSessionLastErrorEvent", () => { + it.effect("adds and round-trips the last error metadata columns idempotently", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations({ toMigrationInclusive: 54 }); + assert.notInclude(yield* projectionThreadSessionColumnNames(sql), "last_error_event_id"); + assert.notInclude(yield* projectionThreadSessionColumnNames(sql), "last_error_class"); + + yield* runMigrations(); + yield* runMigrations(); + + assert.include(yield* projectionThreadSessionColumnNames(sql), "last_error_event_id"); + assert.include(yield* projectionThreadSessionColumnNames(sql), "last_error_class"); + + yield* sql` + INSERT INTO projection_thread_sessions ( + thread_id, + status, + runtime_mode, + last_error, + last_error_event_id, + last_error_class, + updated_at + ) + VALUES ( + 'thread-auth-error', + 'error', + 'full-access', + 'Authentication required', + 'event-auth-error', + 'authentication_error', + '2026-07-21T10:00:00.000Z' + ) + `; + + const rows = yield* sql<{ + readonly lastErrorEventId: string | null; + readonly lastErrorClass: string | null; + }>` + SELECT + last_error_event_id AS "lastErrorEventId", + last_error_class AS "lastErrorClass" + FROM projection_thread_sessions + WHERE thread_id = 'thread-auth-error' + `; + assert.deepEqual(rows, [ + { + lastErrorEventId: "event-auth-error", + lastErrorClass: "authentication_error", + }, + ]); + }).pipe(Effect.provide(NodeSqliteClient.layerMemory())), + ); +}); diff --git a/apps/server/src/persistence/Migrations/055_ProjectionThreadSessionLastErrorEvent.ts b/apps/server/src/persistence/Migrations/055_ProjectionThreadSessionLastErrorEvent.ts new file mode 100644 index 000000000..95e27ede7 --- /dev/null +++ b/apps/server/src/persistence/Migrations/055_ProjectionThreadSessionLastErrorEvent.ts @@ -0,0 +1,26 @@ +// FILE: 055_ProjectionThreadSessionLastErrorEvent.ts +// Purpose: Persist exact runtime-error correlation metadata for projected sessions. +// Layer: Server persistence migration + +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { columnExists } from "./schemaHelpers.ts"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + if (!(yield* columnExists(sql, "projection_thread_sessions", "last_error_event_id"))) { + yield* sql` + ALTER TABLE projection_thread_sessions + ADD COLUMN last_error_event_id TEXT + `; + } + + if (!(yield* columnExists(sql, "projection_thread_sessions", "last_error_class"))) { + yield* sql` + ALTER TABLE projection_thread_sessions + ADD COLUMN last_error_class TEXT + `; + } +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreadSessions.ts b/apps/server/src/persistence/Services/ProjectionThreadSessions.ts index 3e29d2bec..edf305f15 100644 --- a/apps/server/src/persistence/Services/ProjectionThreadSessions.ts +++ b/apps/server/src/persistence/Services/ProjectionThreadSessions.ts @@ -7,6 +7,7 @@ * @module ProjectionThreadSessionRepository */ import { + EventId, RuntimeMode, IsoDateTime, OrchestrationSessionStatus, @@ -25,6 +26,8 @@ export const ProjectionThreadSession = Schema.Struct({ runtimeMode: RuntimeMode, activeTurnId: Schema.NullOr(TurnId), lastError: Schema.NullOr(Schema.String), + lastErrorEventId: Schema.NullOr(EventId), + lastErrorClass: Schema.NullOr(Schema.String), updatedAt: IsoDateTime, }); export type ProjectionThreadSession = typeof ProjectionThreadSession.Type; diff --git a/apps/server/src/privatePathPermissions.ts b/apps/server/src/privatePathPermissions.ts index dc45e4782..77a4b5f5e 100644 --- a/apps/server/src/privatePathPermissions.ts +++ b/apps/server/src/privatePathPermissions.ts @@ -1,35 +1,31 @@ import fs from "node:fs"; import path from "node:path"; -export const PRIVATE_DIRECTORY_MODE = 0o700; -export const PRIVATE_FILE_MODE = 0o600; -export const PRIVATE_EXECUTABLE_FILE_MODE = 0o700; -const UNSUPPORTED_DIRECTORY_SYNC_CODES = new Set(["EINVAL", "ENOTSUP", "EBADF"]); - -export class PrivatePathPermissionError extends Error { - readonly path: string; - readonly operation: string; - - constructor(operation: string, targetPath: string, cause: unknown) { - super(`Failed to ${operation} private path ${targetPath}`, { cause }); - this.name = "PrivatePathPermissionError"; - this.path = targetPath; - this.operation = operation; - } -} +import { + PRIVATE_DIRECTORY_MODE, + PRIVATE_EXECUTABLE_FILE_MODE, + PRIVATE_FILE_MODE, + PrivatePathPermissionError, + ensurePrivateFileSync, + repairPrivateFileSync, + supportsPosixPermissions, + withPrivatePathContext, +} from "@synara/shared/privatePathPermissions"; + +export { + ensurePrivateDirectorySync, + ensurePrivateFileSync, + PRIVATE_DIRECTORY_MODE, + PRIVATE_EXECUTABLE_FILE_MODE, + PRIVATE_FILE_MODE, + PrivatePathPermissionError, + repairPrivateFileSync, + supportsPosixPermissions, +} from "@synara/shared/privatePathPermissions"; -function withPathContext(operation: string, targetPath: string, action: () => T): T { - try { - return action(); - } catch (cause) { - if (cause instanceof PrivatePathPermissionError) throw cause; - throw new PrivatePathPermissionError(operation, targetPath, cause); - } -} +const UNSUPPORTED_DIRECTORY_SYNC_CODES = new Set(["EINVAL", "ENOTSUP", "EBADF"]); -export function supportsPosixPermissions(platform: NodeJS.Platform = process.platform): boolean { - return platform !== "win32"; -} +const withPathContext = withPrivatePathContext; /** Flushes directory-entry changes where the platform exposes durable directory fsync. */ export async function syncDirectoryEntry( @@ -52,88 +48,6 @@ export async function syncDirectoryEntry( } } -export function ensurePrivateDirectorySync( - directoryPath: string, - platform: NodeJS.Platform = process.platform, -): void { - withPathContext("create", directoryPath, () => { - fs.mkdirSync(directoryPath, { recursive: true, mode: PRIVATE_DIRECTORY_MODE }); - }); - if (!supportsPosixPermissions(platform)) return; - - const directoryFlags = fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW; - const descriptor = withPathContext("open without following symlinks", directoryPath, () => - fs.openSync(directoryPath, directoryFlags), - ); - try { - withPathContext("set mode on", directoryPath, () => { - if (!fs.fstatSync(descriptor).isDirectory()) { - throw new Error("Path is not a directory"); - } - fs.fchmodSync(descriptor, PRIVATE_DIRECTORY_MODE); - }); - } finally { - fs.closeSync(descriptor); - } -} - -export function repairPrivateFileSync( - filePath: string, - options: { - readonly executable?: boolean; - readonly platform?: NodeJS.Platform; - } = {}, -): void { - if (!supportsPosixPermissions(options.platform)) return; - const targetMode = options.executable ? PRIVATE_EXECUTABLE_FILE_MODE : PRIVATE_FILE_MODE; - const descriptor = withPathContext("open without following symlinks", filePath, () => - fs.openSync(filePath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW), - ); - try { - withPathContext("set mode on", filePath, () => { - if (!fs.fstatSync(descriptor).isFile()) { - throw new Error("Path is not a regular file"); - } - fs.fchmodSync(descriptor, targetMode); - }); - } finally { - fs.closeSync(descriptor); - } -} - -export function ensurePrivateFileSync( - filePath: string, - options: { - readonly executable?: boolean; - readonly platform?: NodeJS.Platform; - } = {}, -): void { - if (!supportsPosixPermissions(options.platform)) { - withPathContext("create", filePath, () => { - const descriptor = fs.openSync(filePath, fs.constants.O_WRONLY | fs.constants.O_CREAT, 0o600); - fs.closeSync(descriptor); - }); - return; - } - - const targetMode = options.executable ? PRIVATE_EXECUTABLE_FILE_MODE : PRIVATE_FILE_MODE; - const flags = - fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_APPEND | fs.constants.O_NOFOLLOW; - const descriptor = withPathContext("open without following symlinks", filePath, () => - fs.openSync(filePath, flags, targetMode), - ); - try { - withPathContext("set mode on", filePath, () => { - if (!fs.fstatSync(descriptor).isFile()) { - throw new Error("Path is not a regular file"); - } - fs.fchmodSync(descriptor, targetMode); - }); - } finally { - fs.closeSync(descriptor); - } -} - export async function repairPrivateFile( filePath: string, options: { @@ -145,7 +59,10 @@ export async function repairPrivateFile( const targetMode = options.executable ? PRIVATE_EXECUTABLE_FILE_MODE : PRIVATE_FILE_MODE; let handle: fs.promises.FileHandle; try { - handle = await fs.promises.open(filePath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW); + handle = await fs.promises.open( + filePath, + fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK, + ); } catch (cause) { throw new PrivatePathPermissionError("open without following symlinks", filePath, cause); } diff --git a/apps/server/src/provider/Layers/AntigravityAdapter.test.ts b/apps/server/src/provider/Layers/AntigravityAdapter.test.ts index 0d2d1eb56..bb37d1eeb 100644 --- a/apps/server/src/provider/Layers/AntigravityAdapter.test.ts +++ b/apps/server/src/provider/Layers/AntigravityAdapter.test.ts @@ -95,6 +95,62 @@ GPT-OSS 120B (Medium) }); }); + it("parses Antigravity 1.1.5 machine model slugs and preserves them for selection", () => { + expect( + parseAntigravityModelLines(` +gemini-3.5-flash-medium +gemini-3.5-flash-high +gemini-3.5-flash-low +gemini-3.1-pro-low +gemini-3.1-pro-high +claude-sonnet-4-6 +claude-opus-4-6-thinking +gpt-oss-120b-medium +`), + ).toEqual([ + { + slug: "gemini-3.5-flash", + name: "Gemini 3.5 Flash", + supportedReasoningEfforts: [ + { value: "low", label: "Low" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High" }, + ], + defaultReasoningEffort: "medium", + }, + { + slug: "gemini-3.1-pro", + name: "Gemini 3.1 Pro", + supportedReasoningEfforts: [ + { value: "low", label: "Low" }, + { value: "high", label: "High" }, + ], + defaultReasoningEffort: "low", + }, + { slug: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, + { + slug: "claude-opus-4-6", + name: "Claude Opus 4.6", + supportedReasoningEfforts: [{ value: "thinking", label: "Thinking" }], + defaultReasoningEffort: "thinking", + }, + { + slug: "gpt-oss-120b", + name: "GPT OSS 120B", + supportedReasoningEfforts: [{ value: "medium", label: "Medium" }], + defaultReasoningEffort: "medium", + }, + ]); + + expect(resolveAntigravityCliModelLabel("gemini-3.5-flash", undefined, "medium")).toBe( + "gemini-3.5-flash-medium", + ); + expect(resolveAntigravityCliModelLabel("gemini-3.5-flash", { reasoningEffort: "high" })).toBe( + "gemini-3.5-flash-high", + ); + expect(resolveAntigravityCliModelLabel("claude-sonnet-4-6")).toBe("claude-sonnet-4-6"); + }); + it("discovers future CLI models without requiring a static catalog update", () => { expect( parseAntigravityModelLines(` diff --git a/apps/server/src/provider/Layers/AntigravityAdapter.ts b/apps/server/src/provider/Layers/AntigravityAdapter.ts index 0c7c8d63c..fc29c15f4 100644 --- a/apps/server/src/provider/Layers/AntigravityAdapter.ts +++ b/apps/server/src/provider/Layers/AntigravityAdapter.ts @@ -297,9 +297,14 @@ const DEFAULT_EFFORT_BY_MODEL: Readonly> = { "Claude Sonnet 4.6": "thinking", "Claude Opus 4.6": "thinking", "GPT-OSS 120B": "medium", + "gemini-3.5-flash": "medium", + "gemini-3.1-pro": "low", + "claude-opus-4-6": "thinking", + "gpt-oss-120b": "medium", }; -const EFFORT_ORDER = ["low", "medium", "high", "thinking"] as const; +const EFFORT_ORDER = ["low", "medium", "high", "thinking", "ultra"] as const; +const MACHINE_MODEL_EFFORTS = new Set(EFFORT_ORDER); function effortLabel(value: string): string { return value @@ -309,6 +314,23 @@ function effortLabel(value: string): string { .join(" "); } +function isAntigravityMachineModelSlug(value: string): boolean { + return /^(?:claude|deepseek|gemini|gpt|grok|llama|mistral|qwen)(?:-[a-z0-9.]+)+$/u.test(value); +} + +function antigravityModelDisplayName(model: string): string { + if (!isAntigravityMachineModelSlug(model)) return model; + return model + .split("-") + .map((part) => { + if (part === "gpt" || part === "oss") return part.toUpperCase(); + if (/^\d+b$/u.test(part)) return part.toUpperCase(); + return part.charAt(0).toUpperCase() + part.slice(1); + }) + .join(" ") + .replace(/\b(\d+) (\d+)\b/gu, "$1.$2"); +} + export function parseAntigravityCliModelLabel( value: string, ): { model: string; effort?: string } | null { @@ -318,11 +340,23 @@ export function parseAntigravityCliModelLabel( .replace(/^(?:[*•-]\s+)+/u, ""); if (!trimmed) return null; const match = trimmed.match(/^(.*?)\s+\(([^()]+)\)$/u); - if (!match?.[1] || !match[2]) return { model: trimmed }; - return { - model: match[1].trim(), - effort: match[2].trim().toLowerCase(), - }; + if (match?.[1] && match[2]) { + return { + model: match[1].trim(), + effort: match[2].trim().toLowerCase(), + }; + } + if (isAntigravityMachineModelSlug(trimmed)) { + const separatorIndex = trimmed.lastIndexOf("-"); + const possibleEffort = trimmed.slice(separatorIndex + 1); + if (MACHINE_MODEL_EFFORTS.has(possibleEffort as (typeof EFFORT_ORDER)[number])) { + return { + model: trimmed.slice(0, separatorIndex), + effort: possibleEffort, + }; + } + } + return { model: trimmed }; } export function antigravityPromptCommandLineIssue( @@ -340,13 +374,14 @@ export function parseAntigravityModelLines(output: string): ProviderListModelsRe for (const line of output.split(/\r?\n/g)) { const parsed = parseAntigravityCliModelLabel(line); if ( - !parsed?.effort || - !/^(?:claude|deepseek|gemini|gpt|grok|llama|mistral|qwen)\b/iu.test(parsed.model) + !parsed || + !/^(?:claude|deepseek|gemini|gpt|grok|llama|mistral|qwen)\b/iu.test(parsed.model) || + (!parsed.effort && !isAntigravityMachineModelSlug(parsed.model)) ) { continue; } const efforts = groups.get(parsed.model) ?? []; - if (!efforts.includes(parsed.effort)) efforts.push(parsed.effort); + if (parsed.effort && !efforts.includes(parsed.effort)) efforts.push(parsed.effort); groups.set(parsed.model, efforts); } return [...groups.entries()].map(([model, discoveredEfforts]) => { @@ -361,7 +396,7 @@ export function parseAntigravityModelLines(output: string): ProviderListModelsRe const defaultEffort = DEFAULT_EFFORT_BY_MODEL[model] ?? efforts[0]; return { slug: model, - name: model, + name: antigravityModelDisplayName(model), ...(efforts.length > 0 ? { supportedReasoningEfforts: efforts.map((effort) => ({ @@ -387,7 +422,10 @@ export function resolveAntigravityCliModelLabel( options?.reasoningEffort?.trim().toLowerCase() ?? discoveredDefaultEffort?.trim().toLowerCase() ?? DEFAULT_EFFORT_BY_MODEL[parsed.model]; - return effort ? `${parsed.model} (${effortLabel(effort)})` : parsed.model; + if (!effort) return parsed.model; + return isAntigravityMachineModelSlug(parsed.model) + ? `${parsed.model}-${effort}` + : `${parsed.model} (${effortLabel(effort)})`; } function parseModelLines(output: string): ProviderListModelsResult["models"] { diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 5ede91755..cdf71db49 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -14,7 +14,7 @@ import { import * as NodeServices from "@effect/platform-node/NodeServices"; import { afterAll, it, vi } from "@effect/vitest"; -import { Effect, Fiber, Layer, Option, Stream } from "effect"; +import { Effect, Fiber, FileSystem, Layer, Option, Stream } from "effect"; import { CodexAppServerManager, @@ -788,6 +788,187 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { }), ); + it.effect("classifies structured Codex unauthorized errors for guided recovery", () => + Effect.gen(function* () { + const adapter = yield* CodexAdapter; + const fileSystem = yield* FileSystem.FileSystem; + const codexHome = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "scient-codex-account-provider-", + }); + const threadId = asThreadId("thread-account-provider"); + yield* adapter.startSession({ + provider: "codex", + threadId, + providerOptions: { codex: { homePath: codexHome } }, + runtimeMode: "full-access", + }); + const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); + + lifecycleManager.emit("event", { + id: asEventId("evt-authentication-error"), + kind: "notification", + provider: "codex", + threadId, + createdAt: new Date().toISOString(), + method: "error", + turnId: asTurnId("turn-1"), + payload: { + error: { + message: "Request failed", + codexErrorInfo: "unauthorized", + }, + willRetry: false, + }, + } satisfies ProviderEvent); + + const firstEvent = yield* Fiber.join(firstEventFiber); + assert.equal(firstEvent._tag, "Some"); + if (firstEvent._tag !== "Some" || firstEvent.value.type !== "runtime.error") return; + assert.equal(firstEvent.value.payload.class, "authentication_error"); + }), + ); + + it.effect("treats a mixed-case OpenAI provider as custom for unauthorized errors", () => + Effect.gen(function* () { + const adapter = yield* CodexAdapter; + const fileSystem = yield* FileSystem.FileSystem; + const codexHome = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "scient-codex-custom-provider-", + }); + yield* fileSystem.writeFileString(`${codexHome}/config.toml`, 'model_provider = "OpenAI"\n'); + const threadId = asThreadId("thread-custom-provider"); + yield* adapter.startSession({ + provider: "codex", + threadId, + providerOptions: { codex: { homePath: codexHome } }, + runtimeMode: "full-access", + }); + const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); + + lifecycleManager.emit("event", { + id: asEventId("evt-custom-provider-unauthorized"), + kind: "notification", + provider: "codex", + threadId, + createdAt: new Date().toISOString(), + method: "error", + turnId: asTurnId("turn-custom-provider"), + payload: { + error: { + message: "Unauthorized", + codexErrorInfo: "unauthorized", + }, + willRetry: false, + }, + } satisfies ProviderEvent); + + const firstEvent = yield* Fiber.join(firstEventFiber); + assert.equal(firstEvent._tag, "Some"); + if (firstEvent._tag !== "Some" || firstEvent.value.type !== "runtime.error") return; + assert.equal(firstEvent.value.payload.class, "provider_error"); + }), + ); + + it.effect("fails closed when the Codex provider config cannot be read", () => + Effect.gen(function* () { + const adapter = yield* CodexAdapter; + const fileSystem = yield* FileSystem.FileSystem; + const codexHome = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "scient-codex-unreadable-provider-", + }); + yield* fileSystem.makeDirectory(`${codexHome}/config.toml`); + const threadId = asThreadId("thread-unreadable-provider"); + yield* adapter.startSession({ + provider: "codex", + threadId, + providerOptions: { codex: { homePath: codexHome } }, + runtimeMode: "full-access", + }); + const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); + + lifecycleManager.emit("event", { + id: asEventId("evt-unreadable-provider-unauthorized"), + kind: "notification", + provider: "codex", + threadId, + createdAt: new Date().toISOString(), + method: "error", + turnId: asTurnId("turn-unreadable-provider"), + payload: { + error: { + message: "Unauthorized", + codexErrorInfo: "unauthorized", + }, + willRetry: false, + }, + } satisfies ProviderEvent); + + const firstEvent = yield* Fiber.join(firstEventFiber); + assert.equal(firstEvent._tag, "Some"); + if (firstEvent._tag !== "Some" || firstEvent.value.type !== "runtime.error") return; + assert.equal(firstEvent.value.payload.class, "provider_error"); + }), + ); + + it.effect("fails closed for unauthorized errors from an untracked Codex thread", () => + Effect.gen(function* () { + const adapter = yield* CodexAdapter; + const threadId = asThreadId("thread-untracked-provider"); + const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); + + lifecycleManager.emit("event", { + id: asEventId("evt-untracked-provider-unauthorized"), + kind: "notification", + provider: "codex", + threadId, + createdAt: new Date().toISOString(), + method: "error", + turnId: asTurnId("turn-untracked-provider"), + payload: { + error: { + message: "Unauthorized", + codexErrorInfo: "unauthorized", + }, + willRetry: false, + }, + } satisfies ProviderEvent); + + const firstEvent = yield* Fiber.join(firstEventFiber); + assert.equal(firstEvent._tag, "Some"); + if (firstEvent._tag !== "Some" || firstEvent.value.type !== "runtime.error") return; + assert.equal(firstEvent.value.payload.class, "provider_error"); + }), + ); + + it.effect("keeps unrelated Codex runtime failures classified as provider errors", () => + Effect.gen(function* () { + const adapter = yield* CodexAdapter; + const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); + + lifecycleManager.emit("event", { + id: asEventId("evt-provider-error"), + kind: "notification", + provider: "codex", + threadId: asThreadId("thread-1"), + createdAt: new Date().toISOString(), + method: "error", + turnId: asTurnId("turn-1"), + payload: { + error: { + message: "Server is busy", + codexErrorInfo: "serverOverloaded", + }, + willRetry: false, + }, + } satisfies ProviderEvent); + + const firstEvent = yield* Fiber.join(firstEventFiber); + assert.equal(firstEvent._tag, "Some"); + if (firstEvent._tag !== "Some" || firstEvent.value.type !== "runtime.error") return; + assert.equal(firstEvent.value.payload.class, "provider_error"); + }), + ); + it.effect("maps non-fatal Codex error notifications to runtime.warning", () => Effect.gen(function* () { const adapter = yield* CodexAdapter; diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index c04d5dcb8..36667675e 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -28,7 +28,12 @@ import { ThreadId, TurnId, } from "@synara/contracts"; +import { + codexModelProviderRequiresOpenAIAccount, + parseCodexConfigModelProvider, +} from "@synara/shared/codexConfig"; import { Effect, FileSystem, Layer, Queue, Schema, ServiceMap, Stream } from "effect"; +import { join } from "node:path"; import { ProviderAdapterProcessError, @@ -52,6 +57,7 @@ import { sanitizeNestedCodexGeneratedImagePayloads, } from "../../codexGeneratedImages.ts"; import { isNonFatalCodexErrorMessage } from "../../codexErrorClassification.ts"; +import { isCodexAuthenticationError } from "../../codexAuthenticationError.ts"; import { ServerConfig } from "../../config.ts"; import { makeRuntimeTaskListItem } from "../runtimeTaskList.ts"; import { extractProposedPlanMarkdown } from "../planMode.ts"; @@ -166,6 +172,16 @@ function providerErrorMapsToWarning(event: ProviderEvent): boolean { ); } +function codexRuntimeErrorClass( + message: string, + detail: unknown, + requiresProviderAccount: boolean, +) { + return isCodexAuthenticationError({ message, detail, requiresProviderAccount }) + ? ("authentication_error" as const) + : ("provider_error" as const); +} + function normalizeCodexTokenUsage(value: unknown): ThreadTokenUsageSnapshot | undefined { const usage = asObject(value); const totalUsage = asObject(usage?.total_token_usage ?? usage?.total); @@ -823,6 +839,7 @@ function mapItemLifecycle( function mapToRuntimeEvents( event: ProviderEvent, canonicalThreadId: ThreadId, + requiresProviderAccount: boolean, ): ReadonlyArray { const payload = asObject(event.payload); const turn = asObject(payload?.turn); @@ -842,7 +859,15 @@ function mapToRuntimeEvents( type: treatAsWarning ? "runtime.warning" : "runtime.error", payload: { message: event.message, - ...(!treatAsWarning ? { class: "provider_error" as const } : {}), + ...(!treatAsWarning + ? { + class: codexRuntimeErrorClass( + event.message, + event.payload, + requiresProviderAccount, + ), + } + : {}), ...(event.payload !== undefined ? { detail: event.payload } : {}), }, }, @@ -1535,7 +1560,11 @@ function mapToRuntimeEvents( ...runtimeEventBase(event, canonicalThreadId), payload: { message, - ...(!treatAsWarning ? { class: "provider_error" as const } : {}), + ...(!treatAsWarning + ? { + class: codexRuntimeErrorClass(message, event.payload, requiresProviderAccount), + } + : {}), ...(event.payload !== undefined ? { detail: event.payload } : {}), }, }, @@ -1593,6 +1622,31 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const serverConfig = yield* Effect.service(ServerConfig); + const requiresProviderAccountByThread = new Map(); + + const readRequiresProviderAccount = (homePath?: string) => + Effect.gen(function* () { + const configuredHome = + homePath?.trim() || + process.env.CODEX_HOME?.trim() || + join(serverConfig.homeDir, ".codex"); + const configPath = join(configuredHome, "config.toml"); + const existence = yield* Effect.option(fileSystem.exists(configPath)); + if (existence._tag === "None") { + return false; + } + if (!existence.value) { + return true; + } + + const content = yield* Effect.option(fileSystem.readFileString(configPath)); + if (content._tag === "None") { + return false; + } + return codexModelProviderRequiresOpenAIAccount( + parseCodexConfigModelProvider(content.value), + ); + }); const nativeEventLogger = options?.nativeEventLogger ?? (options?.nativeEventLogPath !== undefined @@ -1655,16 +1709,29 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => : {}), }; - return Effect.tryPromise({ - try: () => manager.startSession(managerInput), - catch: (cause) => - new ProviderAdapterProcessError({ - provider: PROVIDER, - threadId: input.threadId, - detail: toMessage(cause, "Failed to start Codex adapter session."), - cause, - }), - }).pipe(Effect.map((session) => session)); + return Effect.gen(function* () { + const requiresProviderAccount = yield* readRequiresProviderAccount( + input.providerOptions?.codex?.homePath, + ); + requiresProviderAccountByThread.set(input.threadId, requiresProviderAccount); + + return yield* Effect.tryPromise({ + try: () => manager.startSession(managerInput), + catch: (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: toMessage(cause, "Failed to start Codex adapter session."), + cause, + }), + }).pipe( + Effect.tapError(() => + Effect.sync(() => { + requiresProviderAccountByThread.delete(input.threadId); + }), + ), + ); + }); }; const sendTurn: CodexAdapterShape["sendTurn"] = (input) => @@ -1913,9 +1980,22 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => }); const forkThread: CodexAdapterShape["forkThread"] = (input) => - Effect.tryPromise({ - try: () => manager.forkThread(input), - catch: (cause) => toRequestError(input.sourceThreadId, "thread/fork", cause), + Effect.gen(function* () { + const requiresProviderAccount = yield* readRequiresProviderAccount( + input.providerOptions?.codex?.homePath, + ); + requiresProviderAccountByThread.set(input.threadId, requiresProviderAccount); + + return yield* Effect.tryPromise({ + try: () => manager.forkThread(input), + catch: (cause) => toRequestError(input.sourceThreadId, "thread/fork", cause), + }).pipe( + Effect.tapError(() => + Effect.sync(() => { + requiresProviderAccountByThread.delete(input.threadId); + }), + ), + ); }); const respondToRequest: CodexAdapterShape["respondToRequest"] = ( @@ -1941,6 +2021,7 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => const stopSession: CodexAdapterShape["stopSession"] = (threadId) => Effect.sync(() => { manager.stopSession(threadId); + requiresProviderAccountByThread.delete(threadId); }); const listSessions: CodexAdapterShape["listSessions"] = () => @@ -1952,6 +2033,7 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => const stopAll: CodexAdapterShape["stopAll"] = () => Effect.sync(() => { manager.stopAll(); + requiresProviderAccountByThread.clear(); }); const getComposerCapabilities: NonNullable = () => @@ -2054,7 +2136,11 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => const listener = (event: ProviderEvent) => Effect.gen(function* () { yield* writeNativeEvent(event); - const runtimeEvents = mapToRuntimeEvents(event, event.threadId); + const runtimeEvents = mapToRuntimeEvents( + event, + event.threadId, + requiresProviderAccountByThread.get(event.threadId) === true, + ); if (runtimeEvents.length === 0) { yield* Effect.logDebug("ignoring unhandled Codex provider event", { method: event.method, diff --git a/apps/server/src/provider/Layers/ProviderClientStatusProjection.test.ts b/apps/server/src/provider/Layers/ProviderClientStatusProjection.test.ts new file mode 100644 index 000000000..510926bb5 --- /dev/null +++ b/apps/server/src/provider/Layers/ProviderClientStatusProjection.test.ts @@ -0,0 +1,208 @@ +import type { + ProviderKind, + ServerProviderRuntimeState, + ServerProviderStatus, +} from "@synara/contracts"; +import { DEFAULT_SERVER_SETTINGS } from "@synara/contracts"; +import { Effect, Stream } from "effect"; +import { describe, expect, it } from "vitest"; + +import { + makeProviderClientStatusProjection, + projectProviderClientStatus, +} from "./ProviderClientStatusProjection"; + +const PROVIDERS: ReadonlyArray = [ + "codex", + "claudeAgent", + "cursor", + "antigravity", + "grok", + "droid", + "kilo", + "opencode", + "pi", +]; + +const MISSING_RUNTIME: ServerProviderRuntimeState = { + source: "missing", + managedVersion: null, + canInstall: true, + canRepair: false, + canRollback: false, + canRemove: false, + message: "No usable provider runtime was found.", +}; + +function healthStatus(provider: ProviderKind): ServerProviderStatus { + return { + provider, + status: "error", + available: false, + authStatus: "unknown", + checkedAt: "2026-07-20T16:00:00.000Z", + }; +} + +describe("ProviderClientStatusProjection", () => { + it("enriches both cached and refreshed health snapshots through the same projection", async () => { + const statuses = PROVIDERS.map(healthStatus); + let refreshCount = 0; + const projection = makeProviderClientStatusProjection({ + getSettings: Effect.succeed(DEFAULT_SERVER_SETTINGS), + getHealthStatuses: Effect.succeed(statuses), + refreshHealthStatuses: Effect.sync(() => { + refreshCount += 1; + return statuses; + }), + healthChanges: Stream.empty, + resolveRuntime: (provider) => + Effect.succeed({ + source: provider === "pi" ? "bundled" : "missing", + executable: null, + managedVersion: null, + canInstall: provider !== "pi", + canRepair: false, + canRollback: false, + canRemove: false, + message: + provider === "pi" ? "Built into Scient." : "No usable provider runtime was found.", + }), + getRuntimeSnapshot: (provider) => + Effect.succeed({ + provider, + managedExecutablePath: null, + managedVersion: null, + previousReleaseAvailable: false, + bundled: provider === "pi", + canInstall: provider !== "pi", + installationState: null, + }), + runtimeChanges: Stream.empty, + }); + + const [cached, refreshed] = await Promise.all([ + Effect.runPromise(projection.getStatuses), + Effect.runPromise(projection.refreshStatuses), + ]); + + expect(refreshCount).toBe(1); + expect(cached).toHaveLength(PROVIDERS.length); + expect(refreshed).toHaveLength(PROVIDERS.length); + expect(refreshed.every((status) => status.runtime !== undefined)).toBe(true); + expect(refreshed.find((status) => status.provider === "antigravity")?.runtime.canInstall).toBe( + true, + ); + }); + + it.each(["health", "runtime"] as const)( + "projects complete statuses for %s change streams", + async (changeSource) => { + const statuses = [healthStatus("antigravity")]; + const projection = makeProviderClientStatusProjection({ + getSettings: Effect.succeed(DEFAULT_SERVER_SETTINGS), + getHealthStatuses: Effect.succeed(statuses), + refreshHealthStatuses: Effect.succeed(statuses), + healthChanges: changeSource === "health" ? Stream.make(statuses) : Stream.empty, + resolveRuntime: () => + Effect.succeed({ + ...MISSING_RUNTIME, + executable: null, + }), + getRuntimeSnapshot: () => + Effect.succeed({ + provider: "antigravity", + managedExecutablePath: null, + managedVersion: null, + previousReleaseAvailable: false, + bundled: false, + canInstall: true, + installationState: null, + }), + runtimeChanges: changeSource === "runtime" ? Stream.make(null) : Stream.empty, + }); + + const events = await Effect.runPromise( + projection.streamChanges.pipe(Stream.take(1), Stream.runCollect), + ); + const event = Array.from(events)[0]; + + expect(event).toHaveLength(1); + expect(event?.[0]?.runtime.canInstall).toBe(true); + }, + ); + + it.each(PROVIDERS)("always projects required runtime state for %s", (provider) => { + const projected = projectProviderClientStatus({ + status: healthStatus(provider), + runtime: + provider === "pi" + ? { + ...MISSING_RUNTIME, + source: "bundled", + canInstall: false, + message: "Built into Scient.", + } + : MISSING_RUNTIME, + installationState: null, + }); + + expect(projected.provider).toBe(provider); + expect(projected.runtime).toBeDefined(); + expect(projected.runtime.canInstall).toBe(provider !== "pi"); + }); + + it("replaces legacy runtime fields and clears stale installation state", () => { + const projected = projectProviderClientStatus({ + status: { + ...healthStatus("antigravity"), + runtime: { ...MISSING_RUNTIME, canInstall: false }, + installationState: { + operationId: "stale-install", + operation: "install", + status: "failed", + startedAt: "2026-07-20T15:00:00.000Z", + finishedAt: "2026-07-20T15:01:00.000Z", + message: "Stale failure.", + }, + }, + runtime: MISSING_RUNTIME, + installationState: null, + }); + + expect(projected.runtime.canInstall).toBe(true); + expect(projected.installationState).toBeUndefined(); + }); + + it("suppresses external updater actions for Scient-managed runtimes", () => { + const projected = projectProviderClientStatus({ + status: { + ...healthStatus("antigravity"), + versionAdvisory: { + status: "behind_latest", + currentVersion: "1.1.4", + latestVersion: "1.1.5", + updateCommand: "agy update", + canUpdate: true, + checkedAt: "2026-07-20T16:00:00.000Z", + message: "Update available.", + }, + }, + runtime: { + ...MISSING_RUNTIME, + source: "managed", + managedVersion: "1.1.4", + canInstall: false, + canRemove: true, + message: null, + }, + installationState: null, + }); + + expect(projected.versionAdvisory).toMatchObject({ + canUpdate: false, + updateCommand: null, + message: "Updates for this runtime are managed by Scient.", + }); + }); +}); diff --git a/apps/server/src/provider/Layers/ProviderClientStatusProjection.ts b/apps/server/src/provider/Layers/ProviderClientStatusProjection.ts new file mode 100644 index 000000000..f305f6c0b --- /dev/null +++ b/apps/server/src/provider/Layers/ProviderClientStatusProjection.ts @@ -0,0 +1,114 @@ +import type { + ProviderKind, + ServerProviderClientStatus, + ServerProviderRuntimeState, + ServerProviderStatus, + ServerSettings, +} from "@synara/contracts"; +import { Effect, Layer, Stream } from "effect"; + +import type { ProviderRuntimeSnapshot } from "../providerRuntimeTypes"; +import { ServerSettingsService } from "../../serverSettings"; +import { ProviderClientStatusProjection } from "../Services/ProviderClientStatusProjection"; +import { ProviderHealth } from "../Services/ProviderHealth"; +import { + ProviderRuntimeManager, + type ResolvedProviderRuntime, +} from "../Services/ProviderRuntimeManager"; + +export function projectProviderClientStatus(input: { + readonly status: ServerProviderStatus; + readonly runtime: ServerProviderRuntimeState; + readonly installationState: ServerProviderClientStatus["installationState"] | null; +}): ServerProviderClientStatus { + const { + runtime: _legacyRuntime, + installationState: _legacyInstallationState, + ...healthStatus + } = input.status; + const appManaged = input.runtime.source === "managed" || input.runtime.source === "bundled"; + return { + ...healthStatus, + ...(appManaged && healthStatus.versionAdvisory + ? { + versionAdvisory: { + ...healthStatus.versionAdvisory, + canUpdate: false, + updateCommand: null, + message: "Updates for this runtime are managed by Scient.", + }, + } + : {}), + runtime: input.runtime, + ...(input.installationState ? { installationState: input.installationState } : {}), + }; +} + +export function makeProviderClientStatusProjection(input: { + readonly getSettings: Effect.Effect; + readonly getHealthStatuses: Effect.Effect>; + readonly refreshHealthStatuses: Effect.Effect>; + readonly healthChanges: Stream.Stream>; + readonly resolveRuntime: ( + provider: ProviderKind, + configuredExecutable?: string | null, + ) => Effect.Effect; + readonly getRuntimeSnapshot: (provider: ProviderKind) => Effect.Effect; + readonly runtimeChanges: Stream.Stream; +}) { + const project = Effect.fn("ProviderClientStatusProjection.project")(function* ( + statuses: ReadonlyArray, + ) { + const settings = yield* input.getSettings.pipe(Effect.catch(() => Effect.succeed(null))); + return yield* Effect.forEach( + statuses, + (status) => + input.resolveRuntime(status.provider, settings?.providers[status.provider].binaryPath).pipe( + Effect.zip(input.getRuntimeSnapshot(status.provider)), + Effect.map(([runtime, snapshot]) => + projectProviderClientStatus({ + status, + runtime: { + source: runtime.source, + managedVersion: runtime.managedVersion, + canInstall: runtime.canInstall, + canRepair: runtime.canRepair, + canRollback: runtime.canRollback, + canRemove: runtime.canRemove, + message: runtime.message, + }, + installationState: snapshot.installationState, + }), + ), + ), + { concurrency: "unbounded" }, + ); + }); + + const getStatuses = input.getHealthStatuses.pipe(Effect.flatMap(project)); + const refreshStatuses = input.refreshHealthStatuses.pipe(Effect.flatMap(project)); + const streamChanges = Stream.merge( + input.healthChanges, + input.runtimeChanges.pipe(Stream.mapEffect(() => input.getHealthStatuses)), + ).pipe(Stream.mapEffect(project)); + + return { project, getStatuses, refreshStatuses, streamChanges }; +} + +export const ProviderClientStatusProjectionLive = Layer.effect( + ProviderClientStatusProjection, + Effect.gen(function* () { + const providerHealth = yield* ProviderHealth; + const providerRuntimeManager = yield* ProviderRuntimeManager; + const serverSettings = yield* ServerSettingsService; + return makeProviderClientStatusProjection({ + getSettings: serverSettings.getSettings, + getHealthStatuses: providerHealth.getStatuses, + refreshHealthStatuses: providerHealth.refresh, + healthChanges: providerHealth.streamChanges, + resolveRuntime: providerRuntimeManager.resolve, + getRuntimeSnapshot: providerRuntimeManager.getSnapshot, + runtimeChanges: providerRuntimeManager.streamChanges, + }); + }), +); diff --git a/apps/server/src/provider/Layers/ProviderConnection.test.ts b/apps/server/src/provider/Layers/ProviderConnection.test.ts index 033bc7590..580bd9968 100644 --- a/apps/server/src/provider/Layers/ProviderConnection.test.ts +++ b/apps/server/src/provider/Layers/ProviderConnection.test.ts @@ -4,13 +4,18 @@ import type { ServerProviderRuntimeSource, ServerProviderStatus, } from "@synara/contracts"; -import { Duration, Effect, Layer, Sink, Stream } from "effect"; +import { Duration, Effect, Fiber, Layer, Sink, Stream } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import { describe, expect, it, vi } from "vitest"; import { ServerConfig, type ServerConfigShape } from "../../config"; import { ServerSettingsService } from "../../serverSettings"; -import { PtyAdapter, type PtyAdapterShape } from "../../terminal/Services/PTY"; +import { + PtyAdapter, + type PtyAdapterShape, + type PtyProcess, + type PtySpawnInput, +} from "../../terminal/Services/PTY"; import { probeDroidAcpAuthentication } from "../acp/DroidAcpSupport"; import { ProviderConnection } from "../Services/ProviderConnection"; import { @@ -24,13 +29,26 @@ import { } from "../Services/ProviderRuntimeManager"; import { + antigravityAuthenticationCommandArgs, expectedMethodForProvider, makeProviderConnectionLive, + parseAntigravityOAuthAuthorizationUrl, + parseGrokOAuthAuthorizationUrl, providerConnectionCommandArgs, } from "./ProviderConnection"; +import { resolveProviderProbeCwd } from "./ProviderHealth"; const encoder = new TextEncoder(); +interface CapturedCommand { + readonly command: string; + readonly args: ReadonlyArray; + readonly options: { + readonly cwd?: string; + readonly stdin?: unknown; + }; +} + const TEST_CONFIG: ServerConfigShape = { mode: "web", port: 0, @@ -63,24 +81,37 @@ const TEST_CONFIG: ServerConfigShape = { anonymousIdPath: "/tmp/scient-test/anonymous-id", environmentIdPath: "/tmp/scient-test/environment-id", }; +const TEST_PROVIDER_PROBE_CWD = resolveProviderProbeCwd(TEST_CONFIG.stateDir); function makeHandle(input: { readonly code?: number; + readonly delayMs?: number; readonly hanging?: boolean; readonly stdout?: string; onKill?: () => void; }) { + const delay = input.delayMs + ? (effect: Effect.Effect) => + Effect.sleep(Duration.millis(input.delayMs!)).pipe(Effect.andThen(effect)) + : (effect: Effect.Effect) => effect; + const stdout = Stream.make(encoder.encode(input.stdout ?? "browser opened")); return ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(41), exitCode: input.hanging ? Effect.never - : Effect.succeed(ChildProcessSpawner.ExitCode(input.code ?? 0)), + : delay(Effect.succeed(ChildProcessSpawner.ExitCode(input.code ?? 0))), isRunning: Effect.succeed(Boolean(input.hanging)), kill: () => Effect.sync(() => input.onKill?.()), stdin: Sink.drain, stdout: input.hanging - ? Stream.never - : Stream.make(encoder.encode(input.stdout ?? "browser opened")), + ? input.stdout + ? Stream.concat(Stream.make(encoder.encode(input.stdout)), Stream.never) + : Stream.never + : input.delayMs + ? Stream.fromEffect(Effect.sleep(Duration.millis(input.delayMs))).pipe( + Stream.flatMap(() => stdout), + ) + : stdout, stderr: input.hanging ? Stream.never : Stream.empty, all: Stream.empty, getInputFd: () => Sink.drain, @@ -91,41 +122,78 @@ function makeHandle(input: { function makeConnectionTestLayer(input?: { readonly available?: boolean; readonly hanging?: boolean; + readonly processExitCode?: number; readonly processStdout?: string; readonly provider?: ProviderKind; readonly runtimeSource?: ServerProviderRuntimeSource; readonly timeout?: Duration.Duration; - readonly onSpawn?: (command: { command: string; args: ReadonlyArray }) => void; + readonly antigravityCodeWindowTimeout?: Duration.Duration; + readonly antigravityCodeWindowCloseSignal?: Effect.Effect; + readonly antigravityTimeout?: Duration.Duration; + readonly antigravityAuthenticationProbeInterval?: Duration.Duration; + readonly antigravityAuthenticationSettleTimeout?: Duration.Duration; + readonly beforeAntigravityOutputPublication?: Effect.Effect; + readonly afterAntigravityCodeWindowInputClosed?: Effect.Effect; + readonly onSpawn?: (command: CapturedCommand) => void; + readonly onStdinChunk?: (command: CapturedCommand, chunk: Uint8Array) => void; + readonly ptyOutputChunks?: ReadonlyArray; + readonly ptyOutputDelayMs?: number; + readonly onPtyReady?: (emitData: (data: string) => void) => void; + readonly processForCommand?: (command: CapturedCommand) => { + readonly code?: number; + readonly delayMs?: number; + readonly exitOnWrite?: boolean; + readonly hanging?: boolean; + readonly stdout?: string; + readonly onKill?: () => void; + }; readonly onKill?: () => void; - readonly onPtySpawn?: (input: unknown) => void; - readonly onPtyKill?: () => void; readonly droidAuthenticationProbe?: typeof probeDroidAcpAuthentication; readonly modelsAvailable?: boolean; + readonly listModelsHanging?: boolean; + readonly initiallyAuthenticated?: boolean; + readonly requiresProviderAccount?: boolean | null; readonly onListModels?: (input: { readonly provider: ProviderKind; readonly binaryPath?: string; + readonly cwd?: string; }) => void; }) { let connectionState: ServerProviderConnectionState | undefined; - let authenticated = false; + const connectionStateWaiters = new Set< + (state: ServerProviderConnectionState | undefined) => void + >(); + let authenticated = input?.initiallyAuthenticated ?? false; + let refreshCalls = 0; const status = (): ServerProviderStatus => ({ provider: input?.provider ?? "claudeAgent", status: authenticated ? "ready" : "error", available: input?.available ?? true, authStatus: authenticated ? "authenticated" : "unauthenticated", + ...(input?.requiresProviderAccount === null + ? {} + : input?.requiresProviderAccount !== undefined + ? { requiresProviderAccount: input.requiresProviderAccount } + : input?.provider === "codex" + ? { requiresProviderAccount: true } + : {}), checkedAt: new Date().toISOString(), ...(connectionState ? { connectionState } : {}), }); const providerHealthLayer = Layer.succeed(ProviderHealth, { getStatuses: Effect.sync(() => [status()]), refresh: Effect.sync(() => { - authenticated = true; + refreshCalls += 1; + // The first refresh is the preflight. A completed sign-in is verified by + // the following refresh, unless the fixture began authenticated. + if (refreshCalls > 1 && input?.hanging !== true) authenticated = true; return [status()]; }), updateProvider: () => Effect.die("unused"), setConnectionState: (_provider, state) => Effect.sync(() => { connectionState = state ?? undefined; + for (const waiter of [...connectionStateWaiters]) waiter(connectionState); return [status()]; }), streamChanges: Stream.empty, @@ -136,34 +204,124 @@ function makeConnectionTestLayer(input?: { listSkills: () => Effect.die("unused"), listPlugins: () => Effect.die("unused"), readPlugin: () => Effect.die("unused"), - listModels: ({ provider, binaryPath }) => - Effect.sync(() => { - input?.onListModels?.({ provider, ...(binaryPath ? { binaryPath } : {}) }); - return { - models: - input?.modelsAvailable === false - ? [] - : [{ slug: `${provider}-test-model`, name: `${provider} test model` }], - source: "test", - cached: false, - }; - }), + listModels: ({ provider, binaryPath, cwd }) => + input?.listModelsHanging + ? Effect.never + : Effect.sync(() => { + input?.onListModels?.({ + provider, + ...(binaryPath ? { binaryPath } : {}), + ...(cwd ? { cwd } : {}), + }); + return { + models: + input?.modelsAvailable === false + ? [] + : [{ slug: `${provider}-test-model`, name: `${provider} test model` }], + source: "test", + cached: false, + }; + }), listAgents: () => Effect.die("unused"), } satisfies ProviderDiscoveryServiceShape); const spawnerLayer = Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, ChildProcessSpawner.make((command) => { - const captured = command as unknown as { command: string; args: ReadonlyArray }; + const captured = command as unknown as CapturedCommand; input?.onSpawn?.(captured); - return Effect.succeed( - makeHandle({ - ...(input?.hanging !== undefined ? { hanging: input.hanging } : {}), - ...(input?.onKill ? { onKill: input.onKill } : {}), - ...(input?.processStdout ? { stdout: input.processStdout } : {}), - }), - ); + const process = input?.processForCommand?.(captured); + const handle = makeHandle({ + ...(process?.delayMs !== undefined ? { delayMs: process.delayMs } : {}), + ...(process?.code !== undefined + ? { code: process.code } + : input?.processExitCode !== undefined + ? { code: input.processExitCode } + : {}), + ...(process?.hanging !== undefined + ? { hanging: process.hanging } + : input?.hanging !== undefined + ? { hanging: input.hanging } + : {}), + ...(process?.onKill + ? { onKill: process.onKill } + : input?.onKill + ? { onKill: input.onKill } + : {}), + ...(process?.stdout + ? { stdout: process.stdout } + : input?.processStdout + ? { stdout: input.processStdout } + : {}), + }); + return Effect.succeed(handle); }), ); + const ptyLayer = Layer.succeed(PtyAdapter, { + spawn: (ptyInput: PtySpawnInput) => + Effect.sync(() => { + const captured: CapturedCommand = { + command: ptyInput.shell, + args: ptyInput.args ?? [], + options: { cwd: ptyInput.cwd }, + }; + input?.onSpawn?.(captured); + const configured = input?.processForCommand?.(captured); + const hanging = configured?.hanging ?? input?.hanging ?? false; + const stdout = configured?.stdout ?? input?.processStdout ?? "browser opened"; + const outputChunks = input?.ptyOutputChunks ?? (stdout ? [stdout] : []); + const exitCode = configured?.code ?? input?.processExitCode ?? 0; + const onKill = configured?.onKill ?? input?.onKill; + let exited = false; + const dataListeners = new Set<(data: string) => void>(); + const exitListeners = new Set< + (event: { exitCode: number; signal: number | null }) => void + >(); + input?.onPtyReady?.((data) => { + for (const listener of dataListeners) listener(data); + }); + const emitExit = () => { + if (exited) return; + exited = true; + for (const listener of exitListeners) listener({ exitCode, signal: null }); + }; + const process: PtyProcess = { + pid: 42, + write: (data) => { + input?.onStdinChunk?.(captured, encoder.encode(data)); + if (configured?.exitOnWrite) emitExit(); + }, + resize: () => undefined, + kill: () => { + onKill?.(); + emitExit(); + }, + pause: () => undefined, + resume: () => undefined, + onData: (listener) => { + dataListeners.add(listener); + if (outputChunks.length > 0) { + const emit = () => { + if (!dataListeners.has(listener)) return; + for (const chunk of outputChunks) listener(chunk); + }; + if (input?.ptyOutputDelayMs) setTimeout(emit, input.ptyOutputDelayMs); + else queueMicrotask(emit); + } + return () => dataListeners.delete(listener); + }, + onExit: (listener) => { + exitListeners.add(listener); + if (!hanging) { + const emit = () => exitListeners.has(listener) && emitExit(); + if (configured?.delayMs) setTimeout(emit, configured.delayMs); + else queueMicrotask(emit); + } + return () => exitListeners.delete(listener); + }, + }; + return process; + }), + } satisfies PtyAdapterShape); const providerRuntimeLayer = Layer.succeed(ProviderRuntimeManager, { prepareInstall: () => Effect.die("unused"), install: () => Effect.die("unused"), @@ -202,25 +360,31 @@ function makeConnectionTestLayer(input?: { }), streamChanges: Stream.empty, } satisfies ProviderRuntimeManagerShape); - const ptyLayer = Layer.succeed(PtyAdapter, { - spawn: (spawnInput) => - Effect.sync(() => { - input?.onPtySpawn?.(spawnInput); - return { - pid: 42, - write: () => undefined, - resize: () => undefined, - kill: () => input?.onPtyKill?.(), - pause: () => undefined, - resume: () => undefined, - onData: () => () => undefined, - onExit: () => () => undefined, - }; - }), - } satisfies PtyAdapterShape); - const layer = makeProviderConnectionLive({ ...(input?.timeout ? { timeout: input.timeout } : {}), + ...(input?.antigravityCodeWindowTimeout + ? { antigravityCodeWindowTimeout: input.antigravityCodeWindowTimeout } + : {}), + ...(input?.antigravityCodeWindowCloseSignal + ? { antigravityCodeWindowCloseSignal: input.antigravityCodeWindowCloseSignal } + : {}), + ...(input?.antigravityTimeout ? { antigravityTimeout: input.antigravityTimeout } : {}), + ...(input?.antigravityAuthenticationProbeInterval + ? { + antigravityAuthenticationProbeInterval: input.antigravityAuthenticationProbeInterval, + } + : {}), + ...(input?.antigravityAuthenticationSettleTimeout + ? { + antigravityAuthenticationSettleTimeout: input.antigravityAuthenticationSettleTimeout, + } + : {}), + ...(input?.beforeAntigravityOutputPublication + ? { beforeAntigravityOutputPublication: input.beforeAntigravityOutputPublication } + : {}), + ...(input?.afterAntigravityCodeWindowInputClosed + ? { afterAntigravityCodeWindowInputClosed: input.afterAntigravityCodeWindowInputClosed } + : {}), ...(input?.droidAuthenticationProbe ? { droidAuthenticationProbe: input.droidAuthenticationProbe } : {}), @@ -233,7 +397,20 @@ function makeConnectionTestLayer(input?: { Layer.provideMerge(spawnerLayer), Layer.provideMerge(ptyLayer), ); - return { layer, getConnectionState: () => connectionState }; + const waitForConnectionState = ( + predicate: (state: ServerProviderConnectionState | undefined) => boolean, + ) => { + if (predicate(connectionState)) return Promise.resolve(connectionState); + return new Promise((resolve) => { + const waiter = (state: ServerProviderConnectionState | undefined) => { + if (!predicate(state)) return; + connectionStateWaiters.delete(waiter); + resolve(state); + }; + connectionStateWaiters.add(waiter); + }); + }; + return { layer, getConnectionState: () => connectionState, waitForConnectionState }; } describe("provider connection command allowlist", () => { @@ -242,8 +419,17 @@ describe("provider connection command allowlist", () => { expect(providerConnectionCommandArgs("codex", "codex_browser")).toEqual(["login"]); }); - it("uses Claude Console login with fixed argv", () => { - expect(expectedMethodForProvider("claudeAgent")).toBe("claude_console"); + it("uses normal Claude account login by default and keeps explicit alternatives", () => { + expect(expectedMethodForProvider("claudeAgent")).toBe("claude_account"); + expect(providerConnectionCommandArgs("claudeAgent", "claude_account")).toEqual([ + "auth", + "login", + ]); + expect(providerConnectionCommandArgs("claudeAgent", "claude_sso")).toEqual([ + "auth", + "login", + "--sso", + ]); expect(providerConnectionCommandArgs("claudeAgent", "claude_console")).toEqual([ "auth", "login", @@ -256,14 +442,14 @@ describe("provider connection command allowlist", () => { expect(providerConnectionCommandArgs("cursor", "cursor_browser")).toEqual(["login"]); }); - it("launches Antigravity's provider-owned TTY login", () => { + it("selects Antigravity's provider-owned browser login strategy", () => { expect(expectedMethodForProvider("antigravity")).toBe("antigravity_browser"); expect(providerConnectionCommandArgs("antigravity", "antigravity_browser")).toEqual([]); }); it("uses Grok's provider-owned browser login", () => { expect(expectedMethodForProvider("grok")).toBe("grok_browser"); - expect(providerConnectionCommandArgs("grok", "grok_browser")).toEqual(["login"]); + expect(providerConnectionCommandArgs("grok", "grok_browser")).toEqual(["login", "--oauth"]); }); it("uses Droid's ACP device-pairing authentication", () => { @@ -283,16 +469,97 @@ describe("provider connection command allowlist", () => { }); }); +describe("Grok OAuth authorization URL parsing", () => { + const authorizationUrl = + "https://auth.x.ai/oauth2/authorize?response_type=code&redirect_uri=http%3A%2F%2F127.0.0.1%3A50418%2Fcallback&state=test-state&code_challenge=test-challenge"; + + it("accepts the exact xAI authorization route with a loopback callback", () => { + expect(parseGrokOAuthAuthorizationUrl(`Open this URL:\n${authorizationUrl}\n`)).toBe( + authorizationUrl, + ); + }); + + it("rejects lookalike hosts and callbacks that are not local", () => { + expect( + parseGrokOAuthAuthorizationUrl( + authorizationUrl.replace("auth.x.ai", "auth.x.ai.example.com"), + ), + ).toBeNull(); + expect( + parseGrokOAuthAuthorizationUrl( + authorizationUrl.replace("127.0.0.1%3A50418", "example.com%3A50418"), + ), + ).toBeNull(); + expect(parseGrokOAuthAuthorizationUrl(`${authorizationUrl}#unexpected-fragment`)).toBeNull(); + }); +}); + +describe("Antigravity OAuth authorization URL parsing", () => { + const authorizationUrl = + "https://accounts.google.com/o/oauth2/auth?response_type=code&redirect_uri=https%3A%2F%2Fantigravity.google%2Foauth-callback&client_id=test-client&state=test-state&code_challenge=test-challenge&code_challenge_method=S256"; + + it("accepts Google's authorization route only for Antigravity's callback", () => { + expect(parseAntigravityOAuthAuthorizationUrl(`Open this URL:\n${authorizationUrl}\n`)).toBe( + authorizationUrl, + ); + }); + + it("rejects lookalike Google hosts, foreign callbacks, and incomplete PKCE", () => { + expect( + parseAntigravityOAuthAuthorizationUrl( + authorizationUrl.replace("accounts.google.com", "accounts.google.com.example.com"), + ), + ).toBeNull(); + expect( + parseAntigravityOAuthAuthorizationUrl( + authorizationUrl.replace( + "antigravity.google%2Foauth-callback", + "example.com%2Foauth-callback", + ), + ), + ).toBeNull(); + expect( + parseAntigravityOAuthAuthorizationUrl( + authorizationUrl.replace("code_challenge_method=S256", "code_challenge_method=plain"), + ), + ).toBeNull(); + }); + + it("uses a sandboxed no-task print bootstrap with an impossible per-operation model", () => { + expect(antigravityAuthenticationCommandArgs("operation-1")).toEqual([ + "--sandbox", + "--mode", + "plan", + "--model", + "__scient_auth_only_operation-1", + "--print-timeout", + "600s", + "--print", + "Authenticate this Antigravity CLI only. Do not inspect or modify files and do not perform a task.", + ]); + }); +}); + describe("ProviderConnectionLive", () => { - it("runs managed Antigravity login in a PTY and verifies models before connecting", async () => { - const onPtySpawn = vi.fn(); - const onPtyKill = vi.fn(); + it("runs managed Antigravity's browser-auth bootstrap and verifies models", async () => { + const onSpawn = vi.fn(); + const onAuthenticationKill = vi.fn(); + const onListModels = vi.fn(); + let modelProbeCount = 0; const fixture = makeConnectionTestLayer({ provider: "antigravity", runtimeSource: "managed", - processStdout: "Gemini 3.5 Flash (High)\n", - onPtySpawn, - onPtyKill, + onSpawn, + onListModels, + processForCommand: ({ args }) => { + if (args.includes("--print")) { + return { hanging: true, onKill: onAuthenticationKill }; + } + modelProbeCount += 1; + return modelProbeCount === 1 + ? { code: 1, stdout: "Error: Please sign in to view available models.\n" } + : { code: 0, stdout: "Gemini 3.5 Flash (High)\n" }; + }, }); await Effect.runPromise( @@ -302,181 +569,980 @@ describe("ProviderConnectionLive", () => { provider: "antigravity", method: "antigravity_browser", }); - yield* Effect.sleep(Duration.millis(30)); + yield* Effect.sleep(Duration.millis(650)); expect(fixture.getConnectionState()?.status).toBe("connected"); }).pipe(Effect.provide(fixture.layer)), ); - expect(onPtySpawn).toHaveBeenCalledWith( - expect.objectContaining({ - shell: "agy", - args: [], - env: expect.objectContaining({ AGY_CLI_DISABLE_AUTO_UPDATE: "true" }), - }), + const authenticationSpawn = onSpawn.mock.calls.find(([spawn]) => + (spawn as { args: ReadonlyArray }).args.includes("--print"), + )?.[0] as CapturedCommand | undefined; + expect(authenticationSpawn).toMatchObject({ + command: "agy", + options: { cwd: TEST_PROVIDER_PROBE_CWD }, + }); + expect(authenticationSpawn?.args).toEqual( + expect.arrayContaining(["--sandbox", "--mode", "plan", "--model", "--print"]), + ); + expect(authenticationSpawn?.args[4]).toMatch(/^__scient_auth_only_[0-9a-f-]+$/u); + expect( + onSpawn.mock.calls + .map(([spawn]) => spawn as CapturedCommand) + .filter((spawn) => spawn.args.includes("models")), + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + options: expect.objectContaining({ cwd: TEST_PROVIDER_PROBE_CWD }), + }), + ]), ); - expect(onPtyKill).toHaveBeenCalledTimes(1); + expect(onAuthenticationKill).toHaveBeenCalledTimes(1); + expect(onListModels).toHaveBeenCalledWith({ + provider: "antigravity", + binaryPath: "agy", + cwd: TEST_PROVIDER_PROBE_CWD, + }); }); - it("starts Grok's browser login with the resolved executable", async () => { - const onSpawn = vi.fn(); + it("publishes only a validated transient Google OAuth URL for Antigravity", async () => { + const authorizationUrl = + "https://accounts.google.com/o/oauth2/auth?response_type=code&redirect_uri=https%3A%2F%2Fantigravity.google%2Foauth-callback&client_id=test-client&state=test-state&code_challenge=test-challenge&code_challenge_method=S256"; const fixture = makeConnectionTestLayer({ - provider: "grok", - runtimeSource: "managed", - onSpawn, + provider: "antigravity", + hanging: true, + processStdout: `Authentication required:\n${authorizationUrl}\n`, }); await Effect.runPromise( Effect.gen(function* () { const connection = yield* ProviderConnection; - yield* connection.start({ provider: "grok", method: "grok_browser" }); - yield* Effect.sleep(Duration.millis(20)); - expect(fixture.getConnectionState()?.status).toBe("connected"); + const authorizationUrlPublished = fixture.waitForConnectionState( + (state) => state?.authorizationUrl === authorizationUrl, + ); + const started = yield* connection.start({ + provider: "antigravity", + method: "antigravity_browser", + }); + const operationId = started.providers[0]?.connectionState?.operationId; + yield* Effect.promise(() => authorizationUrlPublished); + expect(fixture.getConnectionState()?.authorizationUrl).toBe(authorizationUrl); + yield* connection.cancel({ provider: "antigravity", operationId: operationId! }); + expect(fixture.getConnectionState()?.authorizationUrl).toBeUndefined(); }).pipe(Effect.provide(fixture.layer)), ); - - expect(onSpawn).toHaveBeenCalledWith( - expect.objectContaining({ command: "grok", args: ["--no-auto-update", "login"] }), - ); }); - it("runs Droid's authentication-only ACP handshake before verification", async () => { - const droidAuthenticationProbe = vi.fn(() => Effect.succeed({ methodId: "device-pairing" })); + it("coalesces flooded PTY output while preserving a fragmented OAuth URL", async () => { + const authorizationUrl = + "https://accounts.google.com/o/oauth2/auth?response_type=code&redirect_uri=https%3A%2F%2Fantigravity.google%2Foauth-callback&client_id=test-client&state=test-state&code_challenge=test-challenge&code_challenge_method=S256"; + const splitAt = Math.floor(authorizationUrl.length / 2); const fixture = makeConnectionTestLayer({ - provider: "droid", - droidAuthenticationProbe, + provider: "antigravity", + hanging: true, + ptyOutputChunks: [ + ...Array.from({ length: 2_000 }, (_, index) => `noise-${index}\n`), + authorizationUrl.slice(0, splitAt), + authorizationUrl.slice(splitAt), + ], }); await Effect.runPromise( Effect.gen(function* () { const connection = yield* ProviderConnection; - yield* connection.start({ provider: "droid", method: "droid_device_pairing" }); - yield* Effect.sleep(Duration.millis(20)); - expect(fixture.getConnectionState()?.status).toBe("connected"); + const authorizationUrlPublished = fixture.waitForConnectionState( + (state) => state?.authorizationUrl === authorizationUrl, + ); + const started = yield* connection.start({ + provider: "antigravity", + method: "antigravity_browser", + }); + const operationId = started.providers[0]?.connectionState?.operationId; + yield* Effect.promise(() => authorizationUrlPublished); + expect(fixture.getConnectionState()?.authorizationUrl).toBe(authorizationUrl); + yield* connection.cancel({ provider: "antigravity", operationId: operationId! }); + yield* Effect.sleep(Duration.millis(10)); + expect(fixture.getConnectionState()?.status).toBe("cancelled"); + expect(fixture.getConnectionState()?.authorizationUrl).toBeUndefined(); }).pipe(Effect.provide(fixture.layer)), ); - - expect(droidAuthenticationProbe).toHaveBeenCalledWith( - expect.objectContaining({ binaryPath: "droid", cwd: "/tmp" }), - ); }); - it("starts Claude login with fixed argv and verifies before connecting", async () => { - const onSpawn = vi.fn(); - const onListModels = vi.fn(); - const fixture = makeConnectionTestLayer({ onSpawn, onListModels }); + it("settles a claimed URL publication before the code deadline publishes verifying", async () => { + const authorizationUrl = + "https://accounts.google.com/o/oauth2/auth?response_type=code&redirect_uri=https%3A%2F%2Fantigravity.google%2Foauth-callback&client_id=test-client&state=test-state&code_challenge=test-challenge&code_challenge_method=S256"; + let closeCodeWindow!: () => void; + let releasePublication!: () => void; + let markPublicationClaimed!: () => void; + let markInputClosed!: () => void; + const codeWindowCloseSignal = new Promise((resolve) => { + closeCodeWindow = resolve; + }); + const publicationRelease = new Promise((resolve) => { + releasePublication = resolve; + }); + const publicationClaimed = new Promise((resolve) => { + markPublicationClaimed = resolve; + }); + const inputClosed = new Promise((resolve) => { + markInputClosed = resolve; + }); + const fixture = makeConnectionTestLayer({ + provider: "antigravity", + hanging: true, + processStdout: `Authentication required:\n${authorizationUrl}\n`, + antigravityCodeWindowCloseSignal: Effect.promise(() => codeWindowCloseSignal), + beforeAntigravityOutputPublication: Effect.sync(markPublicationClaimed).pipe( + Effect.andThen(Effect.promise(() => publicationRelease)), + ), + afterAntigravityCodeWindowInputClosed: Effect.sync(markInputClosed), + }); await Effect.runPromise( Effect.gen(function* () { const connection = yield* ProviderConnection; + const verifyingPublished = fixture.waitForConnectionState( + (state) => state?.status === "verifying", + ); + const cancelledPublished = fixture.waitForConnectionState( + (state) => state?.status === "cancelled", + ); const started = yield* connection.start({ - provider: "claudeAgent", - method: "claude_console", + provider: "antigravity", + method: "antigravity_browser", }); - expect(started.providers[0]?.connectionState?.operationId).toBeTruthy(); - yield* Effect.sleep(Duration.millis(20)); - expect(fixture.getConnectionState()?.status).toBe("connected"); + const operationId = started.providers[0]?.connectionState?.operationId; + yield* Effect.promise(() => publicationClaimed); + yield* Effect.sync(closeCodeWindow); + yield* Effect.promise(() => inputClosed); + const lateSubmission = yield* Effect.result( + connection.submitAuthorizationCode({ + provider: "antigravity", + operationId: operationId!, + authorizationCode: "4/test-code-after-deadline", + }), + ); + expect(lateSubmission._tag).toBe("Failure"); + if (lateSubmission._tag === "Failure") { + expect(lateSubmission.failure.reason).toBe("authorization_code_not_accepted"); + } + yield* Effect.sync(releasePublication); + yield* Effect.promise(() => verifyingPublished); + expect(fixture.getConnectionState()?.status).toBe("verifying"); + expect(fixture.getConnectionState()?.authorizationUrl).toBeUndefined(); + yield* connection.cancel({ provider: "antigravity", operationId: operationId! }); + yield* Effect.promise(() => cancelledPublished); + expect(fixture.getConnectionState()?.status).toBe("cancelled"); + expect(fixture.getConnectionState()?.authorizationUrl).toBeUndefined(); }).pipe(Effect.provide(fixture.layer)), ); + }); - expect(onSpawn).toHaveBeenCalledTimes(1); - expect(onSpawn.mock.calls[0]?.[0]).toMatchObject({ - command: "claude", - args: ["auth", "login", "--console"], + it("cancels an already-claimed URL publication without allowing stale state", async () => { + const authorizationUrl = + "https://accounts.google.com/o/oauth2/auth?response_type=code&redirect_uri=https%3A%2F%2Fantigravity.google%2Foauth-callback&client_id=test-client&state=test-state&code_challenge=test-challenge&code_challenge_method=S256"; + let releasePublication!: () => void; + let markPublicationClaimed!: () => void; + let emitPtyData!: (data: string) => void; + const publicationRelease = new Promise((resolve) => { + releasePublication = resolve; }); - expect(onListModels).toHaveBeenCalledWith({ - provider: "claudeAgent", - binaryPath: "claude", + const publicationClaimed = new Promise((resolve) => { + markPublicationClaimed = resolve; + }); + const fixture = makeConnectionTestLayer({ + provider: "antigravity", + hanging: true, + processStdout: `Authentication required:\n${authorizationUrl}\n`, + beforeAntigravityOutputPublication: Effect.sync(markPublicationClaimed).pipe( + Effect.andThen(Effect.promise(() => publicationRelease)), + ), + onPtyReady: (emitData) => { + emitPtyData = emitData; + }, }); - }); - - it("does not report connected when authenticated model discovery is empty", async () => { - const fixture = makeConnectionTestLayer({ modelsAvailable: false }); await Effect.runPromise( Effect.gen(function* () { const connection = yield* ProviderConnection; - yield* connection.start({ - provider: "claudeAgent", - method: "claude_console", + const cancelledPublished = fixture.waitForConnectionState( + (state) => state?.status === "cancelled", + ); + const started = yield* connection.start({ + provider: "antigravity", + method: "antigravity_browser", }); - yield* Effect.sleep(Duration.millis(20)); - expect(fixture.getConnectionState()?.status).toBe("failed"); - expect(fixture.getConnectionState()?.message).toContain("model catalog"); + const operationId = started.providers[0]?.connectionState?.operationId; + yield* Effect.promise(() => publicationClaimed); + const cancellation = yield* connection + .cancel({ provider: "antigravity", operationId: operationId! }) + .pipe(Effect.forkChild); + yield* Fiber.join(cancellation); + yield* Effect.promise(() => cancelledPublished); + expect(fixture.getConnectionState()?.status).toBe("cancelled"); + expect(fixture.getConnectionState()?.authorizationUrl).toBeUndefined(); + + yield* Effect.sync(() => { + releasePublication(); + emitPtyData(`late output:\n${authorizationUrl}\n`); + }); + yield* Effect.promise(() => Promise.resolve()); + expect(fixture.getConnectionState()?.status).toBe("cancelled"); + expect(fixture.getConnectionState()?.authorizationUrl).toBeUndefined(); }).pipe(Effect.provide(fixture.layer)), ); }); - it("cancels an active sign-in and kills its process", async () => { + it("closes Antigravity code entry on schedule even when the PTY hangs", async () => { const onKill = vi.fn(); - const fixture = makeConnectionTestLayer({ hanging: true, onKill }); + const fixture = makeConnectionTestLayer({ + provider: "antigravity", + antigravityCodeWindowTimeout: Duration.millis(20), + antigravityTimeout: Duration.millis(50), + processForCommand: ({ args }) => + args.includes("--print") + ? { hanging: true, onKill } + : { + code: 1, + hanging: false, + stdout: "Please sign in to view available models.\n", + }, + }); await Effect.runPromise( Effect.gen(function* () { const connection = yield* ProviderConnection; + const verifyingPublished = fixture.waitForConnectionState( + (state) => state?.status === "verifying", + ); + const failedPublished = fixture.waitForConnectionState( + (state) => state?.status === "failed", + ); const started = yield* connection.start({ - provider: "claudeAgent", - method: "claude_console", + provider: "antigravity", + method: "antigravity_browser", }); const operationId = started.providers[0]?.connectionState?.operationId; - expect(operationId).toBeTruthy(); - yield* Effect.sleep(Duration.millis(5)); - yield* connection.cancel({ provider: "claudeAgent", operationId: operationId! }); - expect(fixture.getConnectionState()?.status).toBe("cancelled"); + yield* Effect.promise(() => verifyingPublished); + expect(fixture.getConnectionState()?.status).toBe("verifying"); + const lateSubmission = yield* Effect.result( + connection.submitAuthorizationCode({ + provider: "antigravity", + operationId: operationId!, + authorizationCode: "4/test-code-after-deadline", + }), + ); + expect(lateSubmission._tag).toBe("Failure"); + if (lateSubmission._tag === "Failure") { + expect(lateSubmission.failure.reason).toBe("authorization_code_not_accepted"); + } + yield* Effect.promise(() => failedPublished); + expect(fixture.getConnectionState()?.status).toBe("failed"); }).pipe(Effect.provide(fixture.layer)), ); expect(onKill).toHaveBeenCalledTimes(1); }); - it("rejects sign-in before spawning when the provider is not installed", async () => { - const onSpawn = vi.fn(); - const fixture = makeConnectionTestLayer({ available: false, onSpawn }); + it("preserves a pre-deadline code through Antigravity's verification grace", async () => { + let authenticationVisible = false; + const submittedChunks: Uint8Array[] = []; + const fixture = makeConnectionTestLayer({ + provider: "antigravity", + antigravityCodeWindowTimeout: Duration.millis(200), + antigravityTimeout: Duration.millis(300), + antigravityAuthenticationProbeInterval: Duration.millis(5), + processForCommand: ({ args }) => + args.includes("--print") + ? { hanging: true } + : authenticationVisible + ? { code: 0, hanging: false, stdout: "Gemini 3.5 Flash (High)\n" } + : { + code: 1, + hanging: false, + stdout: "Please sign in to view available models.\n", + }, + onStdinChunk: (command, chunk) => { + if (command.args.includes("--print")) submittedChunks.push(chunk); + }, + }); - const result = await Effect.runPromise( + await Effect.runPromise( Effect.gen(function* () { const connection = yield* ProviderConnection; - return yield* Effect.result( - connection.start({ - provider: "claudeAgent", - method: "claude_console", - }), + const verifyingPublished = fixture.waitForConnectionState( + (state) => state?.status === "verifying", + ); + const connectedPublished = fixture.waitForConnectionState( + (state) => state?.status === "connected", ); + const started = yield* connection.start({ + provider: "antigravity", + method: "antigravity_browser", + }); + const operationId = started.providers[0]?.connectionState?.operationId; + const submissionFiber = yield* connection + .submitAuthorizationCode({ + provider: "antigravity", + operationId: operationId!, + authorizationCode: "4/test-code-before-deadline", + }) + .pipe(Effect.forkChild); + yield* Effect.promise(() => verifyingPublished); + expect(fixture.getConnectionState()?.status).toBe("verifying"); + authenticationVisible = true; + yield* Fiber.join(submissionFiber); + yield* Effect.promise(() => connectedPublished); + expect(fixture.getConnectionState()?.status).toBe("connected"); }).pipe(Effect.provide(fixture.layer)), ); - expect(result._tag).toBe("Failure"); - if (result._tag === "Failure") { - expect(result.failure.reason).toBe("provider_not_installed"); - } - expect(onSpawn).not.toHaveBeenCalled(); + expect(Buffer.concat(submittedChunks.map((chunk) => Buffer.from(chunk))).toString("utf8")).toBe( + "4/test-code-before-deadline\n", + ); }); - it("rejects a duplicate operation for the same provider", async () => { - const fixture = makeConnectionTestLayer({ hanging: true }); + it("accepts authentication that settles just after Antigravity's browser process exits", async () => { + const authorizationUrl = + "https://accounts.google.com/o/oauth2/auth?response_type=code&redirect_uri=https%3A%2F%2Fantigravity.google%2Foauth-callback&client_id=test-client&state=test-state&code_challenge=test-challenge&code_challenge_method=S256"; + let authenticationVisible = false; + const fixture = makeConnectionTestLayer({ + provider: "antigravity", + antigravityTimeout: Duration.millis(250), + antigravityAuthenticationProbeInterval: Duration.millis(5), + antigravityAuthenticationSettleTimeout: Duration.millis(150), + processForCommand: ({ args }) => + args.includes("--print") + ? { + code: 1, + delayMs: 20, + stdout: `Authentication required:\n${authorizationUrl}\n`, + } + : authenticationVisible + ? { code: 0, stdout: "Gemini 3.5 Flash (High)\n" } + : { + code: 1, + stdout: "Please sign in to view available models.\n", + }, + }); + + await Effect.runPromise( + Effect.gen(function* () { + const connection = yield* ProviderConnection; + const connectedPublished = fixture.waitForConnectionState( + (state) => state?.status === "connected", + ); + yield* connection.start({ + provider: "antigravity", + method: "antigravity_browser", + }); + yield* Effect.sleep(Duration.millis(45)); + authenticationVisible = true; + yield* Effect.promise(() => connectedPublished); + expect(fixture.getConnectionState()?.status).toBe("connected"); + }).pipe(Effect.provide(fixture.layer)), + ); + }); + + it("delivers one transient authorization code only to the active Antigravity PTY", async () => { + const authorizationUrl = + "https://accounts.google.com/o/oauth2/auth?response_type=code&redirect_uri=https%3A%2F%2Fantigravity.google%2Foauth-callback&client_id=test-client&state=test-state&code_challenge=test-challenge&code_challenge_method=S256"; + const submittedChunks: Uint8Array[] = []; + let codeSubmitted = false; + const fixture = makeConnectionTestLayer({ + provider: "antigravity", + listModelsHanging: true, + processForCommand: ({ args }) => + args.includes("--print") + ? { + hanging: true, + stdout: `Authentication required:\n${authorizationUrl}\n`, + } + : codeSubmitted + ? { code: 0, stdout: "Gemini 3.5 Flash (High)\n" } + : { code: 1, stdout: "Please sign in to view available models.\n" }, + onStdinChunk: (command, chunk) => { + if (command.args.includes("--print")) { + submittedChunks.push(chunk); + codeSubmitted = true; + } + }, + }); await Effect.runPromise( Effect.gen(function* () { const connection = yield* ProviderConnection; const started = yield* connection.start({ - provider: "claudeAgent", - method: "claude_console", + provider: "antigravity", + method: "antigravity_browser", + }); + const operationId = started.providers[0]?.connectionState?.operationId; + yield* Effect.sleep(Duration.millis(10)); + + const stale = yield* Effect.result( + connection.submitAuthorizationCode({ + provider: "antigravity", + operationId: "stale-operation", + authorizationCode: "4/test-code-stale", + }), + ); + expect(stale._tag).toBe("Failure"); + if (stale._tag === "Failure") expect(stale.failure.reason).toBe("operation_not_found"); + + yield* connection.submitAuthorizationCode({ + provider: "antigravity", + operationId: operationId!, + authorizationCode: "4/test-code-123", }); + const submitted = Buffer.concat( + submittedChunks.map((chunk) => Buffer.from(chunk)), + ).toString("utf8"); + expect(submitted).toBe("4/test-code-123\n"); + const duplicate = yield* Effect.result( - connection.start({ - provider: "claudeAgent", - method: "claude_console", + connection.submitAuthorizationCode({ + provider: "antigravity", + operationId: operationId!, + authorizationCode: "4/test-code-456", }), ); expect(duplicate._tag).toBe("Failure"); if (duplicate._tag === "Failure") { - expect(duplicate.failure.reason).toBe("already_running"); + expect(duplicate.failure.reason).toBe("authorization_code_already_submitted"); } - const operationId = started.providers[0]?.connectionState?.operationId; - yield* connection.cancel({ provider: "claudeAgent", operationId: operationId! }); + expect(fixture.getConnectionState()).not.toHaveProperty("authorizationCode"); + yield* connection.cancel({ provider: "antigravity", operationId: operationId! }); }).pipe(Effect.provide(fixture.layer)), ); }); + it("rejects a code when Antigravity exits before confirming authentication", async () => { + const authorizationUrl = + "https://accounts.google.com/o/oauth2/auth?response_type=code&redirect_uri=https%3A%2F%2Fantigravity.google%2Foauth-callback&client_id=test-client&state=test-state&code_challenge=test-challenge&code_challenge_method=S256"; + const fixture = makeConnectionTestLayer({ + provider: "antigravity", + processForCommand: ({ args }) => + args.includes("--print") + ? { + hanging: true, + exitOnWrite: true, + stdout: `Authentication required:\n${authorizationUrl}\n`, + } + : { code: 1, stdout: "Please sign in to view available models.\n" }, + }); + + await Effect.runPromise( + Effect.gen(function* () { + const connection = yield* ProviderConnection; + const started = yield* connection.start({ + provider: "antigravity", + method: "antigravity_browser", + }); + const operationId = started.providers[0]?.connectionState?.operationId; + yield* Effect.sleep(Duration.millis(10)); + const submission = yield* Effect.result( + connection.submitAuthorizationCode({ + provider: "antigravity", + operationId: operationId!, + authorizationCode: "4/test-code-123", + }), + ); + expect(submission._tag).toBe("Failure"); + if (submission._tag === "Failure") { + expect(submission.failure.reason).toBe("authorization_code_not_accepted"); + } + }).pipe(Effect.provide(fixture.layer)), + ); + }); + + it("ignores a delayed PTY flood after an Antigravity attempt is cancelled", async () => { + const authorizationUrl = + "https://accounts.google.com/o/oauth2/auth?response_type=code&redirect_uri=https%3A%2F%2Fantigravity.google%2Foauth-callback&client_id=test-client&state=test-state&code_challenge=test-challenge&code_challenge_method=S256"; + const fixture = makeConnectionTestLayer({ + provider: "antigravity", + hanging: true, + ptyOutputDelayMs: 30, + ptyOutputChunks: [ + ...Array.from({ length: 20_000 }, (_, index) => `late-noise-${index}\n`), + `Authentication required:\n${authorizationUrl}\n`, + ], + }); + + await Effect.runPromise( + Effect.gen(function* () { + const connection = yield* ProviderConnection; + const started = yield* connection.start({ + provider: "antigravity", + method: "antigravity_browser", + }); + const operationId = started.providers[0]?.connectionState?.operationId; + yield* Effect.sleep(Duration.millis(10)); + yield* connection.cancel({ provider: "antigravity", operationId: operationId! }); + yield* Effect.sleep(Duration.millis(50)); + expect(fixture.getConnectionState()?.status).toBe("cancelled"); + expect(fixture.getConnectionState()?.authorizationUrl).toBeUndefined(); + }).pipe(Effect.provide(fixture.layer)), + ); + }); + + it("rejects a code after the Antigravity stdin has closed during verification", async () => { + const fixture = makeConnectionTestLayer({ + provider: "antigravity", + listModelsHanging: true, + processForCommand: ({ args }) => + args.includes("models") + ? { code: 0, stdout: "Gemini 3.5 Flash (High)\n" } + : { code: 0, stdout: "Authentication complete.\n" }, + }); + + await Effect.runPromise( + Effect.gen(function* () { + const connection = yield* ProviderConnection; + const started = yield* connection.start({ + provider: "antigravity", + method: "antigravity_browser", + }); + const operationId = started.providers[0]?.connectionState?.operationId; + yield* Effect.sleep(Duration.millis(20)); + expect(fixture.getConnectionState()?.status).toBe("verifying"); + const late = yield* Effect.result( + connection.submitAuthorizationCode({ + provider: "antigravity", + operationId: operationId!, + authorizationCode: "4/test-code-late", + }), + ); + expect(late._tag).toBe("Failure"); + if (late._tag === "Failure") { + expect(late.failure.reason).toBe("authorization_code_not_accepted"); + } + yield* connection.cancel({ provider: "antigravity", operationId: operationId! }); + }).pipe(Effect.provide(fixture.layer)), + ); + }); + + it("starts Grok's browser login with the resolved executable", async () => { + const onSpawn = vi.fn(); + const fixture = makeConnectionTestLayer({ + provider: "grok", + runtimeSource: "managed", + onSpawn, + }); + + await Effect.runPromise( + Effect.gen(function* () { + const connection = yield* ProviderConnection; + yield* connection.start({ provider: "grok", method: "grok_browser" }); + yield* Effect.sleep(Duration.millis(20)); + expect(fixture.getConnectionState()?.status).toBe("connected"); + }).pipe(Effect.provide(fixture.layer)), + ); + + expect(onSpawn).toHaveBeenCalledWith( + expect.objectContaining({ + command: "grok", + args: ["--no-auto-update", "login", "--oauth"], + }), + ); + }); + + it("uses direct OAuth with a system Grok runtime too", async () => { + const onSpawn = vi.fn(); + const fixture = makeConnectionTestLayer({ provider: "grok", onSpawn }); + + await Effect.runPromise( + Effect.gen(function* () { + const connection = yield* ProviderConnection; + yield* connection.start({ provider: "grok", method: "grok_browser" }); + yield* Effect.sleep(Duration.millis(20)); + expect(fixture.getConnectionState()?.status).toBe("connected"); + }).pipe(Effect.provide(fixture.layer)), + ); + + expect(onSpawn).toHaveBeenCalledWith( + expect.objectContaining({ command: "grok", args: ["login", "--oauth"] }), + ); + }); + + it("publishes only a validated transient Grok OAuth URL while sign-in is active", async () => { + const authorizationUrl = + "https://auth.x.ai/oauth2/authorize?response_type=code&redirect_uri=http%3A%2F%2F127.0.0.1%3A50418%2Fcallback&state=test-state&code_challenge=test-challenge"; + const fixture = makeConnectionTestLayer({ + provider: "grok", + hanging: true, + processStdout: `Complete sign-in at:\n${authorizationUrl}\n`, + }); + + await Effect.runPromise( + Effect.gen(function* () { + const connection = yield* ProviderConnection; + const started = yield* connection.start({ provider: "grok", method: "grok_browser" }); + const operationId = started.providers[0]?.connectionState?.operationId; + yield* Effect.sleep(Duration.millis(10)); + expect(fixture.getConnectionState()?.authorizationUrl).toBe(authorizationUrl); + yield* connection.cancel({ provider: "grok", operationId: operationId! }); + expect(fixture.getConnectionState()?.authorizationUrl).toBeUndefined(); + }).pipe(Effect.provide(fixture.layer)), + ); + }); + + it("ends a rejected Grok OAuth flow with actionable retry guidance", async () => { + const fixture = makeConnectionTestLayer({ + provider: "grok", + processExitCode: 1, + }); + + await Effect.runPromise( + Effect.gen(function* () { + const connection = yield* ProviderConnection; + yield* connection.start({ provider: "grok", method: "grok_browser" }); + yield* Effect.sleep(Duration.millis(20)); + expect(fixture.getConnectionState()?.status).toBe("failed"); + expect(fixture.getConnectionState()?.message).toContain("fresh secure browser sign-in"); + }).pipe(Effect.provide(fixture.layer)), + ); + }); + + it("cancels Grok OAuth and kills the waiting callback process", async () => { + const onKill = vi.fn(); + const fixture = makeConnectionTestLayer({ + provider: "grok", + hanging: true, + onKill, + }); + + await Effect.runPromise( + Effect.gen(function* () { + const connection = yield* ProviderConnection; + const started = yield* connection.start({ + provider: "grok", + method: "grok_browser", + }); + const operationId = started.providers[0]?.connectionState?.operationId; + expect(operationId).toBeTruthy(); + yield* Effect.sleep(Duration.millis(5)); + yield* connection.cancel({ provider: "grok", operationId: operationId! }); + expect(fixture.getConnectionState()?.status).toBe("cancelled"); + }).pipe(Effect.provide(fixture.layer)), + ); + + expect(onKill).toHaveBeenCalledTimes(1); + }); + + it("times out Grok OAuth and kills the waiting callback process", async () => { + const onKill = vi.fn(); + const fixture = makeConnectionTestLayer({ + provider: "grok", + hanging: true, + timeout: Duration.millis(5), + onKill, + }); + + await Effect.runPromise( + Effect.gen(function* () { + const connection = yield* ProviderConnection; + yield* connection.start({ provider: "grok", method: "grok_browser" }); + yield* Effect.sleep(Duration.millis(20)); + expect(fixture.getConnectionState()?.status).toBe("failed"); + expect(fixture.getConnectionState()?.message).toContain("timed out"); + }).pipe(Effect.provide(fixture.layer)), + ); + + expect(onKill).toHaveBeenCalledTimes(1); + }); + + it("runs Droid's authentication-only ACP handshake before verification", async () => { + const droidAuthenticationProbe = vi.fn(() => Effect.succeed({ methodId: "device-pairing" })); + const fixture = makeConnectionTestLayer({ + provider: "droid", + droidAuthenticationProbe, + }); + + await Effect.runPromise( + Effect.gen(function* () { + const connection = yield* ProviderConnection; + yield* connection.start({ provider: "droid", method: "droid_device_pairing" }); + yield* Effect.sleep(Duration.millis(20)); + expect(fixture.getConnectionState()?.status).toBe("connected"); + }).pipe(Effect.provide(fixture.layer)), + ); + + expect(droidAuthenticationProbe).toHaveBeenCalledWith( + expect.objectContaining({ binaryPath: "droid", cwd: TEST_PROVIDER_PROBE_CWD }), + ); + }); + + it("starts terminal-equivalent Claude login and verifies before connecting", async () => { + const onSpawn = vi.fn(); + const onListModels = vi.fn(); + const fixture = makeConnectionTestLayer({ onSpawn, onListModels }); + + await Effect.runPromise( + Effect.gen(function* () { + const connection = yield* ProviderConnection; + const started = yield* connection.start({ + provider: "claudeAgent", + method: "claude_account", + }); + expect(started.providers[0]?.connectionState?.operationId).toBeTruthy(); + yield* Effect.sleep(Duration.millis(20)); + expect(fixture.getConnectionState()?.status).toBe("connected"); + }).pipe(Effect.provide(fixture.layer)), + ); + + expect(onSpawn).toHaveBeenCalledTimes(1); + expect(onSpawn.mock.calls[0]?.[0]).toMatchObject({ + command: "claude", + args: ["auth", "login"], + }); + expect(onListModels).toHaveBeenCalledWith({ + provider: "claudeAgent", + binaryPath: "claude", + cwd: TEST_PROVIDER_PROBE_CWD, + }); + }); + + it("does not report connected when authenticated model discovery is empty", async () => { + const fixture = makeConnectionTestLayer({ modelsAvailable: false }); + + await Effect.runPromise( + Effect.gen(function* () { + const connection = yield* ProviderConnection; + yield* connection.start({ + provider: "claudeAgent", + method: "claude_console", + }); + yield* Effect.sleep(Duration.millis(20)); + expect(fixture.getConnectionState()?.status).toBe("failed"); + expect(fixture.getConnectionState()?.message).toContain("model catalog"); + }).pipe(Effect.provide(fixture.layer)), + ); + }); + + it("cancels an active sign-in and kills its process", async () => { + const onKill = vi.fn(); + const fixture = makeConnectionTestLayer({ hanging: true, onKill }); + + await Effect.runPromise( + Effect.gen(function* () { + const connection = yield* ProviderConnection; + const started = yield* connection.start({ + provider: "claudeAgent", + method: "claude_console", + }); + const operationId = started.providers[0]?.connectionState?.operationId; + expect(operationId).toBeTruthy(); + yield* Effect.sleep(Duration.millis(5)); + yield* connection.cancel({ provider: "claudeAgent", operationId: operationId! }); + expect(fixture.getConnectionState()?.status).toBe("cancelled"); + }).pipe(Effect.provide(fixture.layer)), + ); + + expect(onKill).toHaveBeenCalledTimes(1); + }); + + it("rejects sign-in before spawning when the provider is not installed", async () => { + const onSpawn = vi.fn(); + const fixture = makeConnectionTestLayer({ available: false, onSpawn }); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const connection = yield* ProviderConnection; + return yield* Effect.result( + connection.start({ + provider: "claudeAgent", + method: "claude_console", + }), + ); + }).pipe(Effect.provide(fixture.layer)), + ); + + expect(result._tag).toBe("Failure"); + if (result._tag === "Failure") { + expect(result.failure.reason).toBe("provider_not_installed"); + } + expect(onSpawn).not.toHaveBeenCalled(); + }); + + it("returns the existing operation for a duplicate start", async () => { + const fixture = makeConnectionTestLayer({ hanging: true }); + + await Effect.runPromise( + Effect.gen(function* () { + const connection = yield* ProviderConnection; + const started = yield* connection.start({ + provider: "claudeAgent", + method: "claude_console", + }); + const duplicate = yield* connection.start({ + provider: "claudeAgent", + method: "claude_account", + }); + const operationId = started.providers[0]?.connectionState?.operationId; + expect(duplicate.providers[0]?.connectionState?.operationId).toBe(operationId); + yield* connection.cancel({ provider: "claudeAgent", operationId: operationId! }); + }).pipe(Effect.provide(fixture.layer)), + ); + }); + + it("does not spawn sign-in when a fresh preflight finds an existing account", async () => { + const onSpawn = vi.fn(); + const fixture = makeConnectionTestLayer({ initiallyAuthenticated: true, onSpawn }); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const connection = yield* ProviderConnection; + return yield* connection.start({ provider: "claudeAgent", method: "claude_account" }); + }).pipe(Effect.provide(fixture.layer)), + ); + + expect(result.providers[0]?.authStatus).toBe("authenticated"); + expect(result.providers[0]?.connectionState).toBeUndefined(); + expect(onSpawn).not.toHaveBeenCalled(); + }); + + it("never applies the Codex-only reauthentication override to another provider", async () => { + const onSpawn = vi.fn(); + const fixture = makeConnectionTestLayer({ initiallyAuthenticated: true, onSpawn }); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const connection = yield* ProviderConnection; + return yield* connection.start({ + provider: "claudeAgent", + method: "claude_account", + mode: "reauthenticate", + }); + }).pipe(Effect.provide(fixture.layer)), + ); + + expect(result.providers[0]?.authStatus).toBe("authenticated"); + expect(result.providers[0]?.connectionState).toBeUndefined(); + expect(onSpawn).not.toHaveBeenCalled(); + }); + + it("starts a fresh Codex login when runtime recovery explicitly requests reauthentication", async () => { + const onSpawn = vi.fn(); + const fixture = makeConnectionTestLayer({ + provider: "codex", + initiallyAuthenticated: true, + onSpawn, + }); + + await Effect.runPromise( + Effect.gen(function* () { + const connection = yield* ProviderConnection; + yield* connection.start({ + provider: "codex", + method: "codex_browser", + mode: "reauthenticate", + }); + yield* Effect.sleep(Duration.millis(20)); + expect(fixture.getConnectionState()?.status).toBe("connected"); + }).pipe(Effect.provide(fixture.layer)), + ); + + expect(onSpawn).toHaveBeenCalledWith( + expect.objectContaining({ command: "codex", args: ["login"] }), + ); + }); + + it("refuses official Codex reauthentication for a custom Codex provider", async () => { + const onSpawn = vi.fn(); + const fixture = makeConnectionTestLayer({ + provider: "codex", + initiallyAuthenticated: true, + requiresProviderAccount: false, + onSpawn, + }); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const connection = yield* ProviderConnection; + return yield* Effect.result( + connection.start({ + provider: "codex", + method: "codex_browser", + mode: "reauthenticate", + }), + ); + }).pipe(Effect.provide(fixture.layer)), + ); + + expect(result._tag).toBe("Failure"); + if (result._tag === "Failure") { + expect(result.failure.reason).toBe("invalid_method"); + } + expect(onSpawn).not.toHaveBeenCalled(); + }); + + it("fails closed when Codex account ownership is unknown", async () => { + const onSpawn = vi.fn(); + const fixture = makeConnectionTestLayer({ + provider: "codex", + initiallyAuthenticated: true, + requiresProviderAccount: null, + onSpawn, + }); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const connection = yield* ProviderConnection; + return yield* Effect.result( + connection.start({ + provider: "codex", + method: "codex_browser", + mode: "reauthenticate", + }), + ); + }).pipe(Effect.provide(fixture.layer)), + ); + + expect(result._tag).toBe("Failure"); + if (result._tag === "Failure") { + expect(result.failure.reason).toBe("invalid_method"); + } + expect(onSpawn).not.toHaveBeenCalled(); + }); + + it("can start a fresh operation after cancellation fully releases the provider", async () => { + const onSpawn = vi.fn(); + const fixture = makeConnectionTestLayer({ hanging: true, onSpawn }); + + await Effect.runPromise( + Effect.gen(function* () { + const connection = yield* ProviderConnection; + const first = yield* connection.start({ + provider: "claudeAgent", + method: "claude_account", + }); + const firstOperationId = first.providers[0]?.connectionState?.operationId; + yield* Effect.sleep(Duration.millis(5)); + yield* connection.cancel({ + provider: "claudeAgent", + operationId: firstOperationId!, + }); + + const second = yield* connection.start({ + provider: "claudeAgent", + method: "claude_sso", + }); + const secondOperationId = second.providers[0]?.connectionState?.operationId; + expect(secondOperationId).toBeTruthy(); + expect(secondOperationId).not.toBe(firstOperationId); + yield* Effect.sleep(Duration.millis(5)); + yield* connection.cancel({ + provider: "claudeAgent", + operationId: secondOperationId!, + }); + }).pipe(Effect.provide(fixture.layer)), + ); + + expect(onSpawn).toHaveBeenCalledTimes(2); + expect(onSpawn.mock.calls[1]?.[0]).toMatchObject({ + args: ["auth", "login", "--sso"], + }); + }); + it("times out and kills a sign-in that never finishes", async () => { const onKill = vi.fn(); const fixture = makeConnectionTestLayer({ diff --git a/apps/server/src/provider/Layers/ProviderConnection.ts b/apps/server/src/provider/Layers/ProviderConnection.ts index 1be9c561d..e1aac571a 100644 --- a/apps/server/src/provider/Layers/ProviderConnection.ts +++ b/apps/server/src/provider/Layers/ProviderConnection.ts @@ -16,15 +16,29 @@ import type { } from "@synara/contracts"; import { ServerProviderConnectionError } from "@synara/contracts"; import { prepareWindowsSafeProcess } from "@synara/shared/windowsProcess"; -import { Duration, Deferred, Effect, Exit, Fiber, Layer, Option, Ref, Result, Scope } from "effect"; +import { + Duration, + Deferred, + Effect, + Exit, + Fiber, + Layer, + Option, + Queue, + Ref, + Result, + Scope, + Stream, +} from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { ServerConfig } from "../../config"; import { buildCodexProcessEnv } from "../../codexProcessEnv"; import { resolveBaseCodexHomePath } from "../../codexHomePaths"; +import { ensurePrivateDirectorySync } from "../../privatePathPermissions"; import { ServerSettingsService } from "../../serverSettings"; import { collectUint8StreamText } from "../../stream/collectUint8StreamText"; -import { acquireClaudeAuthStatusLock } from "../claudeAuthStatusLock"; +import { PtyAdapter } from "../../terminal/Services/PTY"; import { buildClaudeProcessEnv } from "../claudeProcessEnv"; import { buildCursorAgentCommand } from "../acp/CursorAcpCommand"; import { probeDroidAcpAuthentication } from "../acp/DroidAcpSupport"; @@ -32,24 +46,162 @@ import { ProviderConnection, type ProviderConnectionShape } from "../Services/Pr import { ProviderDiscoveryService } from "../Services/ProviderDiscoveryService"; import { ProviderHealth } from "../Services/ProviderHealth"; import { ProviderRuntimeManager } from "../Services/ProviderRuntimeManager"; -import { PtyAdapter } from "../../terminal/Services/PTY"; -import { parseAntigravityModelsAuthStatus } from "./ProviderHealth"; +import { parseAntigravityModelsAuthStatus, resolveProviderProbeCwd } from "./ProviderHealth"; const CONNECTION_TIMEOUT = Duration.minutes(10); +const ANTIGRAVITY_AUTHORIZATION_WINDOW_SECONDS = 10 * 60; +const ANTIGRAVITY_CODE_WINDOW_TIMEOUT = Duration.seconds(ANTIGRAVITY_AUTHORIZATION_WINDOW_SECONDS); +const ANTIGRAVITY_AUTHENTICATION_PROBE_INTERVAL = Duration.millis(500); +const ANTIGRAVITY_AUTHENTICATION_SETTLE_TIMEOUT = Duration.seconds(30); +// Keep the provider-owned browser/code window aligned with the CLI print +// timeout, then allow a short hidden grace period for credentials written by +// the browser callback to become visible to `agy models`. +const ANTIGRAVITY_CONNECTION_TIMEOUT = Duration.seconds( + ANTIGRAVITY_AUTHORIZATION_WINDOW_SECONDS + 30, +); const CONNECTION_OUTPUT_MAX_BYTES = 64 * 1024; interface ActiveConnection { readonly operationId: string; readonly fiber: Fiber.Fiber; + readonly authorizationCodeInput: Deferred.Deferred; + readonly authorizationCodeAccepted: Deferred.Deferred; + readonly authorizationCodeSubmissionState: Ref.Ref<"open" | "submitted" | "closed">; + readonly authorizationCodeLifecycleClosed: Deferred.Deferred; } interface ConnectionCommand { readonly executable: string; readonly args: ReadonlyArray; readonly env: NodeJS.ProcessEnv; + readonly cwd?: string; readonly waitingMessage: string; - readonly lock?: "claude-auth"; - readonly strategy?: "antigravity-pty"; + readonly strategy?: "antigravity-browser"; +} + +interface ConnectionOutputObserver { + readonly onOutputChunk?: (chunk: Uint8Array) => Effect.Effect | undefined; +} + +const GROK_OAUTH_AUTHORIZATION_ORIGIN = "https://auth.x.ai"; +const GROK_OAUTH_AUTHORIZATION_PATH = "/oauth2/authorize"; +const GOOGLE_OAUTH_AUTHORIZATION_ORIGIN = "https://accounts.google.com"; +const GOOGLE_OAUTH_AUTHORIZATION_PATHS = new Set(["/o/oauth2/auth", "/o/oauth2/v2/auth"]); +const ANTIGRAVITY_OAUTH_CALLBACK_ORIGIN = "https://antigravity.google"; +const ANTIGRAVITY_OAUTH_CALLBACK_PATH = "/oauth-callback"; +const OAUTH_OUTPUT_BUFFER_MAX_CHARS = 16 * 1024; +const ANTIGRAVITY_AUTH_PROMPT = + "Authenticate this Antigravity CLI only. Do not inspect or modify files and do not perform a task."; +const authorizationCodeEncoder = new TextEncoder(); + +function outputUrlCandidates(output: string): ReadonlyArray { + return (output.match(/https:\/\/[^\s<>"']+/gu) ?? []).filter( + (candidate) => + !Array.from(candidate).some((character) => { + const codePoint = character.codePointAt(0) ?? 0; + return codePoint < 0x20 || codePoint === 0x7f; + }), + ); +} + +export function parseGrokOAuthAuthorizationUrl(output: string): string | null { + for (const candidate of outputUrlCandidates(output)) { + if (candidate.length > 8_192) continue; + try { + const url = new URL(candidate); + if ( + url.origin !== GROK_OAUTH_AUTHORIZATION_ORIGIN || + url.pathname !== GROK_OAUTH_AUTHORIZATION_PATH || + url.hash || + url.username || + url.password || + url.searchParams.get("response_type") !== "code" || + !url.searchParams.get("state") || + !url.searchParams.get("code_challenge") + ) { + continue; + } + const redirectValue = url.searchParams.get("redirect_uri"); + if (!redirectValue) continue; + const redirectUrl = new URL(redirectValue); + if ( + redirectUrl.protocol !== "http:" || + redirectUrl.hostname !== "127.0.0.1" || + !redirectUrl.port || + redirectUrl.pathname !== "/callback" || + redirectUrl.search || + redirectUrl.hash || + redirectUrl.username || + redirectUrl.password + ) { + continue; + } + return url.toString(); + } catch { + // Ignore malformed or incomplete output while the CLI is still streaming. + } + } + return null; +} + +export function parseAntigravityOAuthAuthorizationUrl(output: string): string | null { + for (const candidate of outputUrlCandidates(output)) { + if (candidate.length > 8_192) continue; + try { + const url = new URL(candidate); + if ( + url.origin !== GOOGLE_OAUTH_AUTHORIZATION_ORIGIN || + !GOOGLE_OAUTH_AUTHORIZATION_PATHS.has(url.pathname) || + url.hash || + url.username || + url.password || + url.searchParams.get("response_type") !== "code" || + url.searchParams.get("code_challenge_method") !== "S256" || + !url.searchParams.get("client_id") || + !url.searchParams.get("state") || + !url.searchParams.get("code_challenge") + ) { + continue; + } + const redirectValue = url.searchParams.get("redirect_uri"); + if (!redirectValue) continue; + const redirectUrl = new URL(redirectValue); + if ( + redirectUrl.origin !== ANTIGRAVITY_OAUTH_CALLBACK_ORIGIN || + redirectUrl.pathname !== ANTIGRAVITY_OAUTH_CALLBACK_PATH || + redirectUrl.search || + redirectUrl.hash || + redirectUrl.username || + redirectUrl.password + ) { + continue; + } + return url.toString(); + } catch { + // Ignore malformed or incomplete output while the CLI is still streaming. + } + } + return null; +} + +/** + * Antigravity 1.1.4 and newer have no login subcommand, and the hidden bare TUI does not + * advance to authentication. Print mode reaches provider-owned OAuth before + * model selection. A per-operation impossible model plus sandboxed plan mode + * prevents a real turn; the models health probe stops the process after auth. + */ +export function antigravityAuthenticationCommandArgs(operationId: string): ReadonlyArray { + return [ + "--sandbox", + "--mode", + "plan", + "--model", + `__scient_auth_only_${operationId}`, + "--print-timeout", + `${ANTIGRAVITY_AUTHORIZATION_WINDOW_SECONDS}s`, + "--print", + ANTIGRAVITY_AUTH_PROMPT, + ]; } export function expectedMethodForProvider( @@ -59,7 +211,7 @@ export function expectedMethodForProvider( case "codex": return "codex_browser"; case "claudeAgent": - return "claude_console"; + return "claude_account"; case "cursor": return "cursor_browser"; case "antigravity": @@ -78,12 +230,18 @@ export function providerConnectionCommandArgs( method: ServerProviderConnectionMethod, ): ReadonlyArray | null { if (provider === "codex" && method === "codex_browser") return ["login"]; + if (provider === "claudeAgent" && method === "claude_account") { + return ["auth", "login"]; + } + if (provider === "claudeAgent" && method === "claude_sso") { + return ["auth", "login", "--sso"]; + } if (provider === "claudeAgent" && method === "claude_console") { return ["auth", "login", "--console"]; } if (provider === "cursor" && method === "cursor_browser") return ["login"]; if (provider === "antigravity" && method === "antigravity_browser") return []; - if (provider === "grok" && method === "grok_browser") return ["login"]; + if (provider === "grok" && method === "grok_browser") return ["login", "--oauth"]; if (provider === "droid" && method === "droid_device_pairing") { return ["exec", "--output-format", "acp"]; } @@ -100,15 +258,31 @@ function makeConnectionError(input: { export function makeProviderConnectionLive(options?: { readonly timeout?: Duration.Duration; + readonly antigravityCodeWindowTimeout?: Duration.Duration; + readonly antigravityCodeWindowCloseSignal?: Effect.Effect; + readonly antigravityTimeout?: Duration.Duration; + readonly antigravityAuthenticationProbeInterval?: Duration.Duration; + readonly antigravityAuthenticationSettleTimeout?: Duration.Duration; + readonly beforeAntigravityOutputPublication?: Effect.Effect; + readonly afterAntigravityCodeWindowInputClosed?: Effect.Effect; readonly droidAuthenticationProbe?: typeof probeDroidAcpAuthentication; }) { const timeout = options?.timeout ?? CONNECTION_TIMEOUT; + const antigravityCodeWindowTimeout = + options?.antigravityCodeWindowTimeout ?? ANTIGRAVITY_CODE_WINDOW_TIMEOUT; + const antigravityTimeout = options?.antigravityTimeout ?? ANTIGRAVITY_CONNECTION_TIMEOUT; + const antigravityAuthenticationProbeInterval = + options?.antigravityAuthenticationProbeInterval ?? ANTIGRAVITY_AUTHENTICATION_PROBE_INTERVAL; + const antigravityAuthenticationSettleTimeout = + options?.antigravityAuthenticationSettleTimeout ?? ANTIGRAVITY_AUTHENTICATION_SETTLE_TIMEOUT; return Layer.effect( ProviderConnection, Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const serverConfig = yield* ServerConfig; + const providerConnectionCwd = resolveProviderProbeCwd(serverConfig.stateDir); + yield* Effect.sync(() => ensurePrivateDirectorySync(providerConnectionCwd)); const serverSettings = yield* ServerSettingsService; const providerHealth = yield* ProviderHealth; const providerDiscovery = yield* ProviderDiscoveryService; @@ -171,13 +345,6 @@ export function makeProviderConnectionLive(options?: { message: "This provider does not yet support in-app sign in.", }); } - if (method !== expectedMethod) { - return yield* makeConnectionError({ - provider, - reason: "invalid_method", - message: "The selected sign-in method is not valid for this provider.", - }); - } const args = providerConnectionCommandArgs(provider, method); if (!args) { return yield* makeConnectionError({ @@ -270,8 +437,9 @@ export function makeProviderConnectionLive(options?: { runtime.source === "managed" ? { ...process.env, AGY_CLI_DISABLE_AUTO_UPDATE: "true" } : process.env, - waitingMessage: "Finish signing in to Google in the browser window.", - strategy: "antigravity-pty", + cwd: serverConfig.stateDir, + waitingMessage: "Finish signing in to Google, then paste the code here.", + strategy: "antigravity-browser", } satisfies ConnectionCommand; } @@ -290,7 +458,8 @@ export function makeProviderConnectionLive(options?: { executable: runtime.executable ?? "grok", args: runtime.source === "managed" ? ["--no-auto-update", ...args] : args, env: process.env, - waitingMessage: "Finish signing in to xAI in the browser window.", + waitingMessage: + "Finish authorizing Grok in the xAI browser window. No terminal code is required.", } satisfies ConnectionCommand; } @@ -331,34 +500,45 @@ export function makeProviderConnectionLive(options?: { ...buildClaudeProcessEnv({ homeDir: serverConfig.homeDir }), ...(runtime.source === "managed" ? { DISABLE_AUTOUPDATER: "1" } : {}), }, - waitingMessage: "Finish signing in to Claude in the browser window.", - lock: "claude-auth", + waitingMessage: + method === "claude_sso" + ? "Finish signing in with your Claude organization in the browser window." + : method === "claude_console" + ? "Finish signing in to Anthropic Console in the browser window." + : "Finish signing in to your Claude account in the browser window.", } satisfies ConnectionCommand; }); const runCommandResult = Effect.fn("ProviderConnection.runCommandResult")(function* ( command: ConnectionCommand, + observer?: ConnectionOutputObserver, ) { const prepared = prepareWindowsSafeProcess(command.executable, command.args, { env: command.env, + ...(command.cwd ? { cwd: command.cwd } : {}), }); const child = yield* spawner.spawn( ChildProcess.make(prepared.command, prepared.args, { shell: prepared.shell, ...(prepared.windowsVerbatimArguments ? { windowsVerbatimArguments: true } : {}), env: command.env, + ...(command.cwd ? { cwd: command.cwd } : {}), stdin: "ignore", }), ); yield* Effect.addFinalizer(() => child.kill().pipe(Effect.ignore)); + const observe = (stream: Stream.Stream) => + observer?.onOutputChunk + ? stream.pipe(Stream.tap((chunk) => observer.onOutputChunk?.(chunk) ?? Effect.void)) + : stream; const [stdout, stderr, exitCode] = yield* Effect.all( [ collectUint8StreamText({ - stream: child.stdout, + stream: observe(child.stdout), maxBytes: CONNECTION_OUTPUT_MAX_BYTES, }), collectUint8StreamText({ - stream: child.stderr, + stream: observe(child.stderr), maxBytes: CONNECTION_OUTPUT_MAX_BYTES, }), child.exitCode.pipe(Effect.map(Number)), @@ -368,74 +548,178 @@ export function makeProviderConnectionLive(options?: { return { stdout: stdout.text, stderr: stderr.text, code: exitCode }; }); - const runCommand = (command: ConnectionCommand) => - runCommandResult(command).pipe(Effect.map((result) => result.code)); + const runCommand = (command: ConnectionCommand, observer?: ConnectionOutputObserver) => + runCommandResult(command, observer).pipe(Effect.map((result) => result.code)); - const runAntigravityConnection = (command: ConnectionCommand) => + const runAntigravityConnection = ( + command: ConnectionCommand, + operationId: string, + authorizationCodeInput: Deferred.Deferred, + authorizationCodeAccepted: Deferred.Deferred, + authorizationCodeSubmissionState: Ref.Ref<"open" | "submitted" | "closed">, + authorizationCodeLifecycleClosed: Deferred.Deferred, + onCodeWindowClosed: Effect.Effect, + observer?: ConnectionOutputObserver, + ) => Effect.gen(function* () { - const pty = yield* ptyAdapter.spawn({ - shell: command.executable, - args: [], - cwd: serverConfig.stateDir, - cols: 100, - rows: 30, - env: command.env, - }); - let exited = false; - const removeDataListener = pty.onData(() => undefined); - const removeExitListener = pty.onExit(() => { - exited = true; - }); - yield* Effect.addFinalizer(() => - Effect.sync(() => { - removeDataListener(); - removeExitListener(); - try { - pty.kill(); - } catch { - // The provider may already have exited after completing sign-in. - } - }), - ); - - while (true) { - if (exited) throw new Error("Antigravity sign-in exited before verification."); + const authenticationProbe = Effect.gen(function* () { const probe = yield* runCommandResult({ ...command, args: ["models"] }).pipe( Effect.scoped, Effect.result, ); - if ( + return ( Result.isSuccess(probe) && parseAntigravityModelsAuthStatus(probe.success) === "authenticated" - ) { - return 0; + ); + }); + const waitForAuthentication = Effect.gen(function* () { + while (true) { + if (yield* authenticationProbe) { + yield* Deferred.succeed(authorizationCodeAccepted, undefined); + return 0; + } + yield* Effect.sleep(antigravityAuthenticationProbeInterval); } - yield* Effect.sleep(Duration.seconds(1)); + }); + const prepared = prepareWindowsSafeProcess( + command.executable, + antigravityAuthenticationCommandArgs(operationId), + { + env: command.env, + ...(command.cwd ? { cwd: command.cwd } : {}), + }, + ); + const process = yield* ptyAdapter.spawn({ + shell: prepared.command, + args: [...prepared.args], + cwd: command.cwd ?? serverConfig.stateDir, + cols: 120, + rows: 40, + env: command.env, + }); + const processExit = yield* Deferred.make(); + const outputPublicationSettled = yield* Deferred.make(); + let outputOpen = true; + let outputPublicationClaimed = false; + // The PTY callback performs only bounded synchronous parsing. It + // enqueues at most one validated publication effect, never raw CLI + // output, so noisy output cannot accumulate buffers or waiting fibers. + const outputEffectQueue = yield* Queue.sliding>(1); + yield* Effect.addFinalizer(() => Queue.shutdown(outputEffectQueue).pipe(Effect.asVoid)); + if (observer?.onOutputChunk) { + yield* Stream.fromQueue(outputEffectQueue).pipe( + Stream.runForEach((effect) => effect), + Effect.forkScoped, + ); } - }).pipe(Effect.scoped); - - const runWithOptionalLock = ( - command: ConnectionCommand, - run: Effect.Effect, - ) => { - if (command.lock !== "claude-auth") return run; - return Effect.acquireUseRelease( - Effect.promise(() => acquireClaudeAuthStatusLock()), - () => run, - (release) => Effect.sync(release), + const removeDataListener = process.onData((data) => { + if (!outputOpen) return; + const publication = observer?.onOutputChunk?.(authorizationCodeEncoder.encode(data)); + if (publication) { + outputPublicationClaimed = true; + const orderedPublication = ( + options?.beforeAntigravityOutputPublication ?? Effect.void + ).pipe( + Effect.andThen(publication), + Effect.ensuring( + Deferred.succeed(outputPublicationSettled, undefined).pipe(Effect.asVoid), + ), + ); + Effect.runSync(Queue.offer(outputEffectQueue, orderedPublication)); + } + }); + const removeExitListener = process.onExit((event) => { + Effect.runFork(Deferred.succeed(processExit, event.exitCode).pipe(Effect.asVoid)); + }); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + outputOpen = false; + removeDataListener(); + removeExitListener(); + process.kill(); + }).pipe(Effect.ignore), + ); + const codeWindowClosed = yield* Deferred.make(); + const closeCodeWindow = Deferred.succeed(codeWindowClosed, undefined).pipe( + Effect.flatMap((closedNow) => + closedNow + ? Effect.gen(function* () { + const publicationClaimed = yield* Effect.sync(() => { + outputOpen = false; + return outputPublicationClaimed; + }); + yield* Ref.update(authorizationCodeSubmissionState, (submissionState) => + submissionState === "open" ? "closed" : submissionState, + ); + yield* options?.afterAntigravityCodeWindowInputClosed ?? Effect.void; + if (publicationClaimed) yield* Deferred.await(outputPublicationSettled); + yield* onCodeWindowClosed; + }) + : Effect.void, + ), + ); + yield* ( + options?.antigravityCodeWindowCloseSignal ?? Effect.sleep(antigravityCodeWindowTimeout) + ).pipe(Effect.andThen(closeCodeWindow), Effect.forkScoped); + const deliverAuthorizationCode = Deferred.await(authorizationCodeInput).pipe( + Effect.flatMap((code) => + Effect.try({ + try: () => process.write(code), + catch: (cause) => cause, + }), + ), + Effect.flatMap(() => Effect.never), + ); + const processCompletion = Effect.raceFirst( + Deferred.await(processExit), + deliverAuthorizationCode, + ).pipe( + Effect.flatMap((code) => + Effect.gen(function* () { + yield* closeCodeWindow; + if (yield* authenticationProbe) { + yield* Deferred.succeed(authorizationCodeAccepted, undefined); + return 0; + } + // A validated OAuth URL means the provider-owned browser flow + // may still be committing credentials when the bootstrap PTY + // exits. Keep probing briefly instead of publishing a false + // failure while that callback finishes. + const authorizationStarted = yield* Effect.sync(() => outputPublicationClaimed); + if (authorizationStarted) { + const settledAuthentication = yield* waitForAuthentication.pipe( + Effect.timeoutOption(antigravityAuthenticationSettleTimeout), + ); + if (Option.isSome(settledAuthentication)) { + return settledAuthentication.value; + } + } + return code || 1; + }), + ), + ); + return yield* Effect.raceFirst(waitForAuthentication, processCompletion); + }).pipe( + Effect.scoped, + Effect.ensuring( + Ref.update(authorizationCodeSubmissionState, (submissionState) => + submissionState === "open" ? "closed" : submissionState, + ).pipe( + Effect.andThen(Deferred.succeed(authorizationCodeLifecycleClosed, undefined)), + Effect.asVoid, + ), + ), ); - }; const start: ProviderConnectionShape["start"] = Effect.fn("ProviderConnection.start")( function* (input) { const { provider, method } = input; const reserved = yield* reserveProvider(provider); if (!reserved) { - return yield* makeConnectionError({ - provider, - reason: "already_running", - message: "A connection attempt is already running for this provider.", - }); + // Starting the same provider is idempotent. Returning the current + // operation lets a reopened dialog resume or cancel it without + // spawning a competing credential process. + return { providers: yield* providerHealth.getStatuses }; } const commandResult = yield* Effect.result(resolveCommand(provider, method)); @@ -443,13 +727,45 @@ export function makeProviderConnectionLive(options?: { yield* releaseProvider(provider, ""); return yield* commandResult.failure; } - const command = commandResult.success; + // Provider-owned sign-in and verification never need a project cwd. + // Keep them in Scient's private probe directory so authentication + // cannot trigger macOS access to whichever project launched the app. + const command = { ...commandResult.success, cwd: providerConnectionCwd }; + const refreshedBeforeStart = yield* providerHealth.refresh; + const currentStatus = refreshedBeforeStart.find((status) => status.provider === provider); + const requestsCodexReauthentication = + provider === "codex" && input.mode === "reauthenticate"; + if (requestsCodexReauthentication && currentStatus?.requiresProviderAccount !== true) { + yield* releaseProvider(provider, ""); + return yield* makeConnectionError({ + provider, + reason: "invalid_method", + message: + "OpenAI account reauthentication is unavailable for this Codex provider configuration.", + }); + } + const forceCodexReauthentication = requestsCodexReauthentication; + if ( + !forceCodexReauthentication && + currentStatus?.available && + currentStatus.authStatus === "authenticated" + ) { + yield* releaseProvider(provider, ""); + return { providers: refreshedBeforeStart }; + } const operationId = randomUUID(); + const authorizationCodeInput = yield* Deferred.make(); + const authorizationCodeAccepted = yield* Deferred.make(); + const authorizationCodeSubmissionState = yield* Ref.make<"open" | "submitted" | "closed">( + "open", + ); + const authorizationCodeLifecycleClosed = yield* Deferred.make(); const startedAt = new Date().toISOString(); const state = (input: { readonly status: ServerProviderConnectionState["status"]; readonly message: string; readonly finished?: boolean; + readonly authorizationUrl?: string; }): ServerProviderConnectionState => ({ operationId, method, @@ -457,6 +773,7 @@ export function makeProviderConnectionLive(options?: { startedAt, finishedAt: input.finished ? new Date().toISOString() : null, message: input.message, + ...(input.authorizationUrl ? { authorizationUrl: input.authorizationUrl } : {}), }); yield* publishState( @@ -469,18 +786,65 @@ export function makeProviderConnectionLive(options?: { provider, state({ status: "waiting_for_browser", message: command.waitingMessage }), ); - const connectionProcess = + let oauthOutputBuffer = ""; + let publishedAuthorizationUrl: string | null = null; + const oauthOutputObserver: ConnectionOutputObserver | undefined = + provider === "grok" || provider === "antigravity" + ? { + onOutputChunk: (chunk) => { + if (publishedAuthorizationUrl) return undefined; + oauthOutputBuffer = + `${oauthOutputBuffer}${Buffer.from(chunk).toString("utf8")}`.slice( + -OAUTH_OUTPUT_BUFFER_MAX_CHARS, + ); + const authorizationUrl = + provider === "grok" + ? parseGrokOAuthAuthorizationUrl(oauthOutputBuffer) + : parseAntigravityOAuthAuthorizationUrl(oauthOutputBuffer); + if (!authorizationUrl) return undefined; + publishedAuthorizationUrl = authorizationUrl; + return publishState( + provider, + state({ + status: "waiting_for_browser", + message: command.waitingMessage, + authorizationUrl, + }), + ).pipe(Effect.asVoid); + }, + } + : undefined; + const connectionProcess: Effect.Effect = provider === "droid" ? (options?.droidAuthenticationProbe ?? probeDroidAcpAuthentication)({ binaryPath: command.executable, childProcessSpawner: spawner, - cwd: serverConfig.cwd, + cwd: providerConnectionCwd, }).pipe(Effect.as(0)) - : command.strategy === "antigravity-pty" - ? runAntigravityConnection(command) - : runWithOptionalLock(command, runCommand(command).pipe(Effect.scoped)); + : command.strategy === "antigravity-browser" + ? runAntigravityConnection( + command, + operationId, + authorizationCodeInput, + authorizationCodeAccepted, + authorizationCodeSubmissionState, + authorizationCodeLifecycleClosed, + publishState( + provider, + state({ + status: "verifying", + message: "Verifying the connection.", + }), + ).pipe(Effect.asVoid), + oauthOutputObserver, + ) + : runCommand(command, oauthOutputObserver).pipe(Effect.scoped); + const operationTimeout = + provider === "antigravity" && options?.timeout === undefined + ? antigravityTimeout + : timeout; const exitCodeResult = yield* connectionProcess.pipe( - Effect.timeoutOption(timeout), + Effect.timeoutOption(operationTimeout), Effect.result, ); @@ -511,7 +875,10 @@ export function makeProviderConnectionLive(options?: { provider, state({ status: "failed", - message: "Sign in was not completed. No credentials were saved by Scient.", + message: + provider === "grok" + ? "Grok authorization was not completed. Close any old xAI page, update Grok if an update is available, then try again to start a fresh secure browser sign-in." + : "Sign in was not completed. No credentials were saved by Scient.", finished: true, }), ); @@ -544,7 +911,7 @@ export function makeProviderConnectionLive(options?: { .listModels({ provider, binaryPath: command.executable, - cwd: serverConfig.cwd, + cwd: providerConnectionCwd, }) .pipe(Effect.timeoutOption(Duration.seconds(30)), Effect.result); if ( @@ -592,7 +959,6 @@ export function makeProviderConnectionLive(options?: { }), ).pipe(Effect.asVoid), ), - Effect.ensuring(releaseProvider(provider, operationId)), ); // The gate prevents a very fast CLI exit from completing and releasing @@ -600,11 +966,21 @@ export function makeProviderConnectionLive(options?: { const startGate = yield* Deferred.make(); const fiber = yield* Deferred.await(startGate).pipe( Effect.andThen(operation), + // Own the reservation at the outermost fiber boundary. A caller can + // cancel immediately after start returns, before `operation` begins. + Effect.ensuring(releaseProvider(provider, operationId)), Effect.forkIn(operationScope), ); yield* Ref.update(activeConnectionsRef, (active) => { const next = new Map(active); - next.set(provider, { operationId, fiber }); + next.set(provider, { + operationId, + fiber, + authorizationCodeInput, + authorizationCodeAccepted, + authorizationCodeSubmissionState, + authorizationCodeLifecycleClosed, + }); return next; }); yield* Deferred.succeed(startGate, undefined); @@ -624,12 +1000,82 @@ export function makeProviderConnectionLive(options?: { }); } yield* Fiber.interrupt(active.fiber); + // Restart callers must not race the interrupted operation's process + // cleanup or reservation finalizer. Do not return until both finish. + yield* Fiber.await(active.fiber); + yield* releaseProvider(input.provider, input.operationId); const providers = yield* providerHealth.getStatuses; return { providers }; }, ); - return { start, cancel } satisfies ProviderConnectionShape; + const submitAuthorizationCode: ProviderConnectionShape["submitAuthorizationCode"] = Effect.fn( + "ProviderConnection.submitAuthorizationCode", + )(function* (input) { + if (input.provider !== "antigravity") { + return yield* makeConnectionError({ + provider: input.provider, + reason: "authorization_code_not_supported", + message: "This provider does not accept a pasted authorization code.", + }); + } + const active = (yield* Ref.get(activeConnectionsRef)).get(input.provider); + if (!active || active.operationId !== input.operationId) { + return yield* makeConnectionError({ + provider: input.provider, + reason: "operation_not_found", + message: "This connection attempt is no longer running.", + }); + } + const submissionClaim = yield* Ref.modify( + active.authorizationCodeSubmissionState, + (submissionState) => { + if (submissionState === "open") return ["accepted" as const, "submitted" as const]; + return [submissionState, submissionState] as const; + }, + ); + if (submissionClaim === "closed") { + return yield* makeConnectionError({ + provider: input.provider, + reason: "authorization_code_not_accepted", + message: "This connection attempt is no longer waiting for a code.", + }); + } + if (submissionClaim === "submitted") { + return yield* makeConnectionError({ + provider: input.provider, + reason: "authorization_code_already_submitted", + message: "A code was already submitted for this connection attempt.", + }); + } + const accepted = yield* Deferred.succeed( + active.authorizationCodeInput, + `${input.authorizationCode.trim()}\n`, + ); + if (!accepted) { + return yield* makeConnectionError({ + provider: input.provider, + reason: "authorization_code_already_submitted", + message: "A code was already submitted for this connection attempt.", + }); + } + const acceptedByProvider = (yield* Deferred.isDone(active.authorizationCodeAccepted)) + ? true + : yield* Effect.raceFirst( + Deferred.await(active.authorizationCodeAccepted).pipe(Effect.as(true)), + Deferred.await(active.authorizationCodeLifecycleClosed).pipe(Effect.as(false)), + ); + if (!acceptedByProvider) { + return yield* makeConnectionError({ + provider: input.provider, + reason: "authorization_code_not_accepted", + message: "This connection attempt stopped before it could accept the code.", + }); + } + return { providers: yield* providerHealth.getStatuses }; + }); + + return { start, cancel, submitAuthorizationCode } satisfies ProviderConnectionShape; }), ); } diff --git a/apps/server/src/provider/Layers/ProviderHealth.test.ts b/apps/server/src/provider/Layers/ProviderHealth.test.ts index ab373a04a..4ba933da1 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.test.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.test.ts @@ -1,8 +1,9 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as NodePath from "node:path"; import type { ServerProviderStatus } from "@synara/contracts"; import { DEFAULT_SERVER_SETTINGS, ServerProviderUpdateError } from "@synara/contracts"; import { describe, it, assert } from "@effect/vitest"; -import { Effect, FileSystem, Layer, Path, Sink, Stream } from "effect"; +import { Duration, Effect, Fiber, FileSystem, Layer, Path, Sink, Stream } from "effect"; import { TestClock } from "effect/testing"; import * as PlatformError from "effect/PlatformError"; import { ChildProcessSpawner } from "effect/unstable/process"; @@ -42,6 +43,7 @@ import { ProviderHealthLive, projectProviderStatusesForSettings, readCodexConfigModelProvider, + resolveProviderProbeCwd, stabilizeProviderStatusesAgainstTransientTimeouts, } from "./ProviderHealth"; import { resolvePackageManagedProviderMaintenance } from "../providerMaintenance"; @@ -50,6 +52,16 @@ import { resolvePackageManagedProviderMaintenance } from "../providerMaintenance const encoder = new TextEncoder(); +describe("provider health probe cwd", () => { + it("isolates background provider runtimes under Scient private state", () => { + const stateDir = NodePath.join("root", "scient-state"); + assert.strictEqual( + resolveProviderProbeCwd(stateDir), + NodePath.join(stateDir, "provider-health-probe"), + ); + }); +}); + function mockHandle(result: { stdout: string; stderr: string; code: number }) { return ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(1), @@ -73,6 +85,7 @@ function mockSpawnerLayer( options: | { readonly env?: NodeJS.ProcessEnv; + readonly cwd?: string; readonly windowsVerbatimArguments?: boolean; } | undefined, @@ -90,6 +103,7 @@ function mockSpawnerLayer( args: ReadonlyArray; options?: { env?: NodeJS.ProcessEnv; + cwd?: string; windowsVerbatimArguments?: boolean; }; }; @@ -259,8 +273,8 @@ it.layer(NodeServices.layer)("ProviderHealth", (it) => { }); assert.deepStrictEqual(capabilities.update, { - command: "agy update", - executable: "agy", + command: "/Users/test/.local/bin/agy update", + executable: "/Users/test/.local/bin/agy", args: ["update"], lockKey: "antigravity-native", pathPrepend: "/Users/test/.local/bin", @@ -684,6 +698,37 @@ it.layer(NodeServices.layer)("ProviderHealth", (it) => { [authTimeoutWarning], ); }); + + it.each([false, undefined] as const)( + "does not restore stale Codex account ownership when the current value is %s", + (requiresProviderAccount) => { + const previousReadyCodex = { + provider: "codex", + status: "ready", + available: true, + authStatus: "authenticated", + requiresProviderAccount: true, + checkedAt: "2026-06-04T17:00:00.000Z", + } satisfies ServerProviderStatus; + const currentTransientCodex = { + provider: "codex", + status: "warning", + available: true, + authStatus: "unknown", + ...(requiresProviderAccount !== undefined ? { requiresProviderAccount } : {}), + checkedAt: "2026-06-04T17:01:00.000Z", + message: "Could not verify Codex authentication status. Timed out while running command.", + } satisfies ServerProviderStatus; + + const [stabilized] = stabilizeProviderStatusesAgainstTransientTimeouts( + [previousReadyCodex], + [currentTransientCodex], + ); + + assert.notStrictEqual(stabilized?.requiresProviderAccount, true); + assert.strictEqual(stabilized?.requiresProviderAccount, requiresProviderAccount); + }, + ); }); describe("providerStatusesEqual", () => { @@ -744,6 +789,27 @@ it.layer(NodeServices.layer)("ProviderHealth", (it) => { false, ); }); + + it("detects a change in Codex account ownership", () => { + const readyCodex = { + ...readyCursor, + provider: "codex", + requiresProviderAccount: true, + } satisfies ServerProviderStatus; + + assert.strictEqual( + providerStatusesEqual( + [readyCodex], + [ + { + ...readyCodex, + requiresProviderAccount: false, + }, + ], + ), + false, + ); + }); }); // ── checkCodexProviderStatus tests ──────────────────────────────── @@ -775,6 +841,58 @@ it.layer(NodeServices.layer)("ProviderHealth", (it) => { ), ); + it.effect("fails closed without an auth probe when provider config cannot be read", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { runtimeDir } = yield* withTempCodexHome(); + const activeHome = path.join(runtimeDir, SYNARA_CODEX_HOME_OVERLAY_DIR); + yield* fileSystem.makeDirectory(activeHome, { recursive: true }); + yield* fileSystem.makeDirectory(path.join(activeHome, "config.toml")); + + const status = yield* makeCheckCodexProviderStatus("codex", activeHome); + + assert.strictEqual(status.status, "warning"); + assert.strictEqual(status.available, true); + assert.strictEqual(status.authStatus, "unknown"); + assert.strictEqual(status.requiresProviderAccount, undefined); + assert.match(status.message ?? "", /config\.toml could not be read/u); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; + throw new Error(`Auth probe must not run after config read failure: ${joined}`); + }), + ), + ), + ); + + it.effect("fails closed when the source config prevents environment preparation", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { tmpDir } = yield* withTempCodexHome(); + yield* fileSystem.makeDirectory(path.join(tmpDir, "config.toml")); + + const status = yield* checkCodexProviderStatus; + + assert.strictEqual(status.status, "warning"); + assert.strictEqual(status.available, false); + assert.strictEqual(status.authStatus, "unknown"); + assert.strictEqual(status.requiresProviderAccount, undefined); + assert.match(status.message ?? "", /configuration could not be read/u); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + throw new Error( + `No probe may run after environment preparation fails: ${args.join(" ")}`, + ); + }), + ), + ), + ); + it.effect("uses configured codex binary for version and auth probes", () => Effect.gen(function* () { yield* withTempCodexHome(); @@ -979,7 +1097,7 @@ it.layer(NodeServices.layer)("ProviderHealth", (it) => { Effect.gen(function* () { yield* withTempCodexHome( [ - 'model_provider = "portkey"', + '"model_provider" = "portkey"', "", "[model_providers.portkey]", 'base_url = "https://api.portkey.ai/v1"', @@ -991,6 +1109,7 @@ it.layer(NodeServices.layer)("ProviderHealth", (it) => { assert.strictEqual(status.status, "ready"); assert.strictEqual(status.available, true); assert.strictEqual(status.authStatus, "unknown"); + assert.strictEqual(status.requiresProviderAccount, false); assert.strictEqual( status.message, "Using a custom Codex model provider; OpenAI login check skipped.", @@ -1088,6 +1207,18 @@ it.layer(NodeServices.layer)("ProviderHealth", (it) => { }), ); + it.effect("preserves an unreadable config failure instead of treating it as missing", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { tmpDir } = yield* withTempCodexHome(); + yield* fileSystem.makeDirectory(path.join(tmpDir, "config.toml")); + + const result = yield* Effect.result(readCodexConfigModelProvider); + assert.strictEqual(result._tag, "Failure"); + }), + ); + it.effect("returns undefined when config has no model_provider key", () => Effect.gen(function* () { yield* withTempCodexHome('model = "gpt-5-codex"\n'); @@ -1173,6 +1304,13 @@ it.layer(NodeServices.layer)("ProviderHealth", (it) => { }), ); + it.effect("returns true when model_provider is mixed-case OpenAI", () => + Effect.gen(function* () { + yield* withTempCodexHome('model_provider = "OpenAI"\n'); + assert.strictEqual(yield* hasCustomModelProvider, true); + }), + ); + it.effect("returns true when model_provider is portkey", () => Effect.gen(function* () { yield* withTempCodexHome('model_provider = "portkey"\n'); @@ -1251,12 +1389,13 @@ it.layer(NodeServices.layer)("ProviderHealth", (it) => { ), ); - it.effect("rejects Claude.ai subscription authentication for third-party use", () => + it.effect("accepts Claude.ai subscription authentication", () => Effect.gen(function* () { const status = yield* checkClaudeProviderStatus; - assert.strictEqual(status.status, "warning"); - assert.strictEqual(status.authStatus, "unauthenticated"); - assert.match(status.message ?? "", /Anthropic Console/iu); + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.authStatus, "authenticated"); + assert.strictEqual(status.authType, "max"); + assert.strictEqual(status.authLabel, "Claude Max Subscription"); }).pipe( Effect.provide( mockSpawnerLayer((args) => @@ -1272,7 +1411,28 @@ it.layer(NodeServices.layer)("ProviderHealth", (it) => { ), ); - it.effect("preserves Console credentials and removes direct subscription OAuth", () => + it.effect("normalizes already-expanded Claude subscription labels", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus; + assert.strictEqual(status.authType, "Claude Max Subscription"); + assert.strictEqual(status.authLabel, "Claude Max Subscription"); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => + args.join(" ") === "--version" + ? { stdout: "2.1.215\n", stderr: "", code: 0 } + : { + stdout: + '{"loggedIn":true,"authMethod":"claude.ai","subscriptionType":"Claude Max Subscription"}\n', + stderr: "", + code: 0, + }, + ), + ), + ), + ); + + it.effect("preserves Console and subscription credential sources", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -1330,7 +1490,7 @@ it.layer(NodeServices.layer)("ProviderHealth", (it) => { assert.strictEqual(command, "claude"); assert.strictEqual(env?.ANTHROPIC_API_KEY, "stale-api-key"); assert.strictEqual(env?.ANTHROPIC_AUTH_TOKEN, "stale-auth-token"); - assert.strictEqual(env?.CLAUDE_CODE_OAUTH_TOKEN, undefined); + assert.strictEqual(env?.CLAUDE_CODE_OAUTH_TOKEN, "stale-oauth-token"); const joined = args.join(" "); if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; @@ -1351,7 +1511,7 @@ it.layer(NodeServices.layer)("ProviderHealth", (it) => { }), ); - it.effect("does not rescue Claude subscription OAuth through an SDK metadata probe", () => + it.effect("rescues a Claude subscription false negative through an SDK metadata probe", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -1376,7 +1536,12 @@ it.layer(NodeServices.layer)("ProviderHealth", (it) => { const status = yield* makeCheckClaudeProviderStatus( Effect.sync(() => { sdkProbeCalls += 1; - return "max"; + return { + email: "scientist@example.test", + subscriptionType: "max", + tokenSource: "claude.ai", + apiProvider: "firstParty", + }; }), "claude", homeDir, @@ -1398,12 +1563,12 @@ it.layer(NodeServices.layer)("ProviderHealth", (it) => { ), ); - assert.strictEqual(sdkProbeCalls, 0); + assert.strictEqual(sdkProbeCalls, 1); assert.strictEqual(status.provider, "claudeAgent"); - assert.strictEqual(status.status, "error"); - assert.strictEqual(status.authStatus, "unauthenticated"); - assert.strictEqual(status.authType, undefined); - assert.strictEqual(status.authLabel, undefined); + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.authStatus, "authenticated"); + assert.strictEqual(status.authType, "max"); + assert.strictEqual(status.authLabel, "Claude Max Subscription"); }), ); @@ -1693,6 +1858,32 @@ it.layer(NodeServices.layer)("ProviderHealth", (it) => { ), ), ); + + it.effect("uses SDK account initialization when the auth status command is unavailable", () => + Effect.gen(function* () { + const status = yield* makeCheckClaudeProviderStatus( + Effect.succeed({ + email: "scientist@example.test", + organization: "Research Lab", + tokenSource: "oauth", + }), + ); + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.authStatus, "authenticated"); + assert.strictEqual(status.authLabel, "Claude organization account"); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + if (joined === "auth status") { + return { stdout: "", stderr: "error: unknown command 'auth'", code: 2 }; + } + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); }); describe("checkOpenCodeProviderStatus", () => { @@ -1823,22 +2014,68 @@ it.layer(NodeServices.layer)("ProviderHealth", (it) => { }); describe("checkAntigravityProviderStatus", () => { - it.effect("rejects versions that predate --new-project support", () => + it.effect("rejects an unparseable Antigravity version", () => + Effect.gen(function* () { + const status = yield* checkAntigravityProviderStatus(); + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.available, false); + assert.strictEqual(status.authStatus, "unknown"); + assert.strictEqual(status.version, undefined); + assert.strictEqual( + status.message, + "Scient could not verify Antigravity CLI 1.1.4 or newer from the version command output.", + ); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + assert.strictEqual(args.join(" "), "--version"); + return { stdout: "Antigravity development build\n", stderr: "", code: 0 }; + }), + ), + ), + ); + + it.effect("rejects an Antigravity version probe timeout", () => { + return Effect.gen(function* () { + const statusFiber = yield* checkAntigravityProviderStatus().pipe(Effect.forkChild); + yield* Effect.yieldNow; + yield* TestClock.adjust(Duration.millis(4_001)); + yield* Effect.yieldNow; + const status = yield* Fiber.join(statusFiber); + + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.available, false); + assert.strictEqual(status.authStatus, "unknown"); + assert.strictEqual( + status.message, + "Scient could not verify Antigravity CLI 1.1.4 or newer because the version check timed out.", + ); + }).pipe( + Effect.provide( + hangingSpawnerLayer({ + onKill: () => undefined, + shouldHang: (args, command) => command === "agy" && args.join(" ") === "--version", + }), + ), + ); + }); + + it.effect("rejects versions that predate Scient's browser-auth flow", () => Effect.gen(function* () { const status = yield* checkAntigravityProviderStatus(); assert.strictEqual(status.status, "error"); assert.strictEqual(status.available, false); - assert.strictEqual(status.version, "1.0.11"); + assert.strictEqual(status.version, "1.1.3"); assert.strictEqual( status.message, - "Antigravity CLI 1.0.11 is too old for Scient. Upgrade to 1.0.12 or newer.", + "Antigravity CLI 1.1.3 is too old for Scient. Upgrade to 1.1.4 or newer.", ); }).pipe( Effect.provide( mockSpawnerLayer((args) => { const joined = args.join(" "); if (joined === "--version") { - return { stdout: "Antigravity CLI 1.0.11\n", stderr: "", code: 0 }; + return { stdout: "Antigravity CLI 1.1.3\n", stderr: "", code: 0 }; } throw new Error(`Unexpected args: ${joined}`); }), @@ -1853,14 +2090,14 @@ it.layer(NodeServices.layer)("ProviderHealth", (it) => { assert.strictEqual(status.status, "ready"); assert.strictEqual(status.available, true); assert.strictEqual(status.authStatus, "authenticated"); - assert.strictEqual(status.version, "1.1.2"); + assert.strictEqual(status.version, "1.1.4"); }).pipe( Effect.provide( mockSpawnerLayer((args, command) => { assert.strictEqual(command, "agy"); const joined = args.join(" "); if (joined === "--version") { - return { stdout: "Antigravity CLI 1.1.2\n", stderr: "", code: 0 }; + return { stdout: "Antigravity CLI 1.1.4\n", stderr: "", code: 0 }; } if (joined === "models") { return { @@ -1875,6 +2112,35 @@ it.layer(NodeServices.layer)("ProviderHealth", (it) => { ), ); + it.effect("returns ready for Antigravity 1.1.5 machine model output", () => + Effect.gen(function* () { + const status = yield* checkAntigravityProviderStatus(); + assert.strictEqual(status.provider, "antigravity"); + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.available, true); + assert.strictEqual(status.authStatus, "authenticated"); + assert.strictEqual(status.version, "1.1.5"); + }).pipe( + Effect.provide( + mockSpawnerLayer((args, command) => { + assert.strictEqual(command, "agy"); + const joined = args.join(" "); + if (joined === "--version") { + return { stdout: "1.1.5\n", stderr: "", code: 0 }; + } + if (joined === "models") { + return { + stdout: "gemini-3.5-flash-medium\ngemini-3.5-flash-high\nclaude-sonnet-4-6\n", + stderr: "", + code: 0, + }; + } + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + it.effect("maps Antigravity's clean-profile response to unauthenticated", () => Effect.gen(function* () { const status = yield* checkAntigravityProviderStatus(); @@ -1935,14 +2201,18 @@ it.layer(NodeServices.layer)("ProviderHealth", (it) => { it.effect("uses the configured Antigravity binary", () => Effect.gen(function* () { - const status = yield* checkAntigravityProviderStatus("/custom/bin/agy"); + const status = yield* checkAntigravityProviderStatus( + "/custom/bin/agy", + "/tmp/scient-state", + ); assert.strictEqual(status.status, "ready"); }).pipe( Effect.provide( - mockSpawnerLayer((args, command) => { + mockSpawnerLayer((args, command, _env, options) => { assert.strictEqual(command, "/custom/bin/agy"); + assert.strictEqual(options?.cwd, "/tmp/scient-state"); return args.join(" ") === "--version" - ? { stdout: "1.1.2\n", stderr: "", code: 0 } + ? { stdout: "1.1.4\n", stderr: "", code: 0 } : { stdout: "GPT-OSS 120B (Medium)\n", stderr: "", code: 0 }; }), ), diff --git a/apps/server/src/provider/Layers/ProviderHealth.ts b/apps/server/src/provider/Layers/ProviderHealth.ts index 8c4d24674..50423b9e8 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.ts @@ -9,6 +9,7 @@ * @module ProviderHealthLive */ import * as OS from "node:os"; +import * as NodePath from "node:path"; import type { ProviderKind, ServerSettings, @@ -19,7 +20,10 @@ import type { ServerProviderUpdateState, } from "@synara/contracts"; import { ServerProviderUpdateError } from "@synara/contracts"; -import { parseCodexConfigModelProvider } from "@synara/shared/codexConfig"; +import { + codexModelProviderRequiresOpenAIAccount, + parseCodexConfigModelProvider, +} from "@synara/shared/codexConfig"; import { decodeJsonResult } from "@synara/shared/schemaJson"; import { prepareWindowsSafeProcess } from "@synara/shared/windowsProcess"; import { @@ -65,7 +69,12 @@ import { } from "../acp/DroidAcpSupport"; import { parseClaudeAuthStatusFromOutput } from "../claudeAuthStatus"; import { acquireClaudeAuthStatusLock } from "../claudeAuthStatusLock"; +import { + probeClaudeAccountCapabilities, + type ClaudeAccountCapabilities, +} from "../claudeCapabilities"; import { buildClaudeProcessEnv } from "../claudeProcessEnv"; +import { MINIMUM_ANTIGRAVITY_CLI_VERSION } from "../antigravityReleaseChannel"; import { detailFromResult, extractAuthBoolean, @@ -85,6 +94,7 @@ import { writeProviderStatusCache, } from "../providerStatusCache"; import { makeProviderMaintenanceCommandCoordinator } from "../providerMaintenanceCommandCoordinator"; +import { providerExternalUpdateBlockReason } from "../providerUpdateRuntimePolicy"; import { enrichProviderStatusWithVersionAdvisory, compareSemverVersions, @@ -95,6 +105,7 @@ import { type PackageManagedProviderMaintenanceDefinition, } from "../providerMaintenance"; import { collectUint8StreamText } from "../../stream/collectUint8StreamText"; +import { ensurePrivateDirectorySync } from "../../privatePathPermissions"; import { buildCodexProcessEnv } from "../../codexProcessEnv.ts"; import { parseAntigravityModelLines } from "./AntigravityAdapter"; import { parseGrokCliModelList } from "./GrokAdapter"; @@ -102,6 +113,10 @@ import { parseGrokCliModelList } from "./GrokAdapter"; export { parseClaudeAuthStatusFromOutput } from "../claudeAuthStatus"; export type { CommandResult } from "../providerCliOutput"; +export function resolveProviderProbeCwd(stateDir: string): string { + return NodePath.join(stateDir, "provider-health-probe"); +} + const DEFAULT_TIMEOUT_MS = 4_000; const CLAUDE_HEALTH_TIMEOUT_MS = 20_000; const OPENCODE_HEALTH_TIMEOUT_MS = 20_000; @@ -116,8 +131,6 @@ const OPENCODE_PROVIDER = "opencode" as const; const PI_PROVIDER = "pi" as const; type ProviderStatuses = ReadonlyArray; const DISABLED_PROVIDER_STATUS_MESSAGE = "Provider is disabled in Scient settings."; -const MINIMUM_ANTIGRAVITY_CLI_VERSION = "1.0.12"; - const PROVIDERS = [ CODEX_PROVIDER, CLAUDE_AGENT_PROVIDER, @@ -531,15 +544,6 @@ export function parseAuthStatusFromOutput(result: CommandResult): { // ── Codex CLI config detection ────────────────────────────────────── -/** - * Providers that use OpenAI-native authentication via `codex login`. - * When the configured `model_provider` is one of these, the `codex login - * status` probe still runs. For any other provider value the auth probe - * is skipped because authentication is handled externally (e.g. via - * environment variables like `PORTKEY_API_KEY` or `AZURE_API_KEY`). - */ -const OPENAI_AUTH_PROVIDERS = new Set(["openai"]); - /** * Read the `model_provider` value from the Codex CLI config file. * @@ -548,7 +552,8 @@ const OPENAI_AUTH_PROVIDERS = new Set(["openai"]); * a full TOML parser to avoid adding a dependency for a single key. * * Returns `undefined` when the file does not exist or does not set - * `model_provider`. + * `model_provider`. Other filesystem failures remain failures so callers cannot + * mistake an unreadable configuration for the default OpenAI provider. */ export const readCodexConfigModelProvider = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -556,13 +561,10 @@ export const readCodexConfigModelProvider = Effect.gen(function* () { const codexHome = process.env.CODEX_HOME || path.join(OS.homedir(), ".codex"); const configPath = path.join(codexHome, "config.toml"); - const content = yield* fileSystem - .readFileString(configPath) - .pipe(Effect.orElseSucceed(() => undefined)); - if (content === undefined) { + if (!(yield* fileSystem.exists(configPath))) { return undefined; } - + const content = yield* fileSystem.readFileString(configPath); return parseCodexConfigModelProvider(content); }); @@ -574,7 +576,7 @@ export const readCodexConfigModelProvider = Effect.gen(function* () { */ export const hasCustomModelProvider = Effect.map( readCodexConfigModelProvider, - (provider) => provider !== undefined && !OPENAI_AUTH_PROVIDERS.has(provider), + (provider) => !codexModelProviderRequiresOpenAIAccount(provider), ); // ── Effect-native command execution ───────────────────────────────── @@ -590,14 +592,19 @@ const runProviderCommand = ( executable: string, args: ReadonlyArray, env: NodeJS.ProcessEnv = process.env, + cwd?: string, ) => Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const prepared = prepareWindowsSafeProcess(executable, args, { env }); + const prepared = prepareWindowsSafeProcess(executable, args, { + env, + ...(cwd ? { cwd } : {}), + }); const command = ChildProcess.make(prepared.command, prepared.args, { shell: prepared.shell, ...(prepared.windowsVerbatimArguments ? { windowsVerbatimArguments: true } : {}), env, + ...(cwd ? { cwd } : {}), // Health probes are non-interactive. Leaving stdin as a pipe can keep CLIs // such as Antigravity waiting even after a read-only subcommand has finished. stdin: "ignore", @@ -756,11 +763,16 @@ function cursorModelsOutputHasNoModels(output: string): boolean { return output.toLowerCase().includes("no models available"); } -const runAntigravityCommand = (args: ReadonlyArray, executable = "agy") => - runProviderCommand(executable, args, { - ...process.env, - AGY_CLI_DISABLE_AUTO_UPDATE: "true", - }).pipe( +const runAntigravityCommand = (args: ReadonlyArray, executable = "agy", cwd?: string) => + runProviderCommand( + executable, + args, + { + ...process.env, + AGY_CLI_DISABLE_AUTO_UPDATE: "true", + }, + cwd, + ).pipe( Effect.flatMap((result) => isWindowsShellCommandMissingResult({ code: result.code, stderr: result.stderr }) ? Effect.fail(new Error(`spawn ${executable} ENOENT`)) @@ -784,22 +796,13 @@ const readCodexConfigModelProviderForEnv = (env: NodeJS.ProcessEnv) => const codexHome = env.CODEX_HOME?.trim() || path.join(OS.homedir(), ".codex"); const configPath = path.join(codexHome, "config.toml"); - const content = yield* fileSystem - .readFileString(configPath) - .pipe(Effect.orElseSucceed(() => undefined)); - if (content === undefined) { + if (!(yield* fileSystem.exists(configPath))) { return undefined; } - + const content = yield* fileSystem.readFileString(configPath); return parseCodexConfigModelProvider(content); }); -const hasCustomModelProviderForEnv = (env: NodeJS.ProcessEnv) => - Effect.map( - readCodexConfigModelProviderForEnv(env), - (provider) => provider !== undefined && !OPENAI_AUTH_PROVIDERS.has(provider), - ); - export const makeCheckCodexProviderStatus = ( binaryPath?: string, homePath?: string, @@ -811,7 +814,22 @@ export const makeCheckCodexProviderStatus = ( Effect.gen(function* () { const checkedAt = new Date().toISOString(); const executable = nonEmptyTrimmed(binaryPath) ?? "codex"; - const probeEnv = yield* Effect.promise(() => makeCodexProbeEnv(homePath)); + const probeEnvResult = yield* Effect.tryPromise({ + try: () => makeCodexProbeEnv(homePath), + catch: (cause) => cause, + }).pipe(Effect.result); + if (Result.isFailure(probeEnvResult)) { + return { + provider: CODEX_PROVIDER, + status: "warning" as const, + available: false, + authStatus: "unknown" as const, + checkedAt, + message: + "Could not prepare the Codex provider environment because its configuration could not be read.", + } satisfies ServerProviderStatus; + } + const probeEnv = probeEnvResult.success; // Probe 1: `codex --version` — is the CLI reachable? const versionProbe = yield* runCodexCommand(["--version"], executable, probeEnv).pipe( @@ -877,12 +895,29 @@ export const makeCheckCodexProviderStatus = ( // authentication through their own environment variables, so `codex // login status` will report "not logged in" even when the CLI works // fine. Skip the auth probe entirely for non-OpenAI providers. - if (yield* hasCustomModelProviderForEnv(probeEnv)) { + const accountRequirement = yield* readCodexConfigModelProviderForEnv(probeEnv).pipe( + Effect.map(codexModelProviderRequiresOpenAIAccount), + Effect.result, + ); + if (Result.isFailure(accountRequirement)) { + return { + provider: CODEX_PROVIDER, + status: "warning" as const, + available: true, + authStatus: "unknown" as const, + version: parsedVersion, + checkedAt, + message: + "Could not determine whether this Codex provider uses an OpenAI account because config.toml could not be read.", + } satisfies ServerProviderStatus; + } + if (!accountRequirement.success) { return { provider: CODEX_PROVIDER, status: "ready" as const, available: true, authStatus: "unknown" as const, + requiresProviderAccount: false, version: parsedVersion, checkedAt, message: "Using a custom Codex model provider; OpenAI login check skipped.", @@ -942,6 +977,7 @@ export const makeCheckCodexProviderStatus = ( status: parsed.status, available: true, authStatus: parsed.authStatus, + requiresProviderAccount: true, version: parsedVersion, ...(codexAuthType ? { authType: codexAuthType } : {}), ...(codexLabel ? { authLabel: codexLabel } : {}), @@ -957,8 +993,45 @@ export const checkCodexProviderStatus = makeCheckCodexProviderStatus(); // ── Claude Agent health check ─────────────────────────────────────── +function claudeSubscriptionLabel(subscriptionType: string): string { + const normalized = toTitleCaseWords(subscriptionType) + .replace(/^Claude\s+/iu, "") + .replace(/\s+Subscription$/iu, "") + .replace(/\s+Plan$/iu, "") + .replace(/^(Max|Pro)plan$/iu, "$1"); + return `Claude ${normalized} Subscription`; +} + +function claudeAccountMetadata(input: { + readonly authMethod?: string; + readonly subscriptionType?: string; + readonly capabilities?: ClaudeAccountCapabilities; +}): { readonly authType?: string; readonly authLabel: string } { + const authMethod = input.authMethod ?? input.capabilities?.tokenSource; + const subscriptionType = input.subscriptionType ?? input.capabilities?.subscriptionType; + const normalizedAuthMethod = authMethod?.toLowerCase().replace(/[\s_.-]+/gu, ""); + const apiProvider = input.capabilities?.apiProvider; + const apiCredential = + normalizedAuthMethod === "apikey" || input.capabilities?.apiKeySource !== undefined; + if (apiCredential) return { authType: "apiKey", authLabel: "Anthropic Console" }; + if (subscriptionType) { + return { + authType: subscriptionType, + authLabel: claudeSubscriptionLabel(subscriptionType), + }; + } + if (input.capabilities?.organization) { + return { authType: authMethod ?? "organization", authLabel: "Claude organization account" }; + } + if (apiProvider && apiProvider !== "firstParty") { + return { authType: apiProvider, authLabel: `Claude via ${toTitleCaseWords(apiProvider)}` }; + } + const authType = authMethod ?? apiProvider; + return authType ? { authType, authLabel: "Claude account" } : { authLabel: "Claude account" }; +} + export const makeCheckClaudeProviderStatus = ( - _resolveSubscriptionType?: Effect.Effect, + resolveCapabilities?: Effect.Effect, binaryPath?: string, homeDir?: string, _options?: { readonly falseNegativeRetryDelayMs?: number }, @@ -969,6 +1042,10 @@ export const makeCheckClaudeProviderStatus = ( const claudeEnv = buildClaudeProcessEnv( homeDir ? { env: process.env, homeDir } : { env: process.env }, ); + const resolveSdkCapabilities = () => + resolveCapabilities + ? resolveCapabilities.pipe(Effect.catch(() => Effect.succeed(undefined))) + : Effect.succeed(undefined); // Probe 1: `claude --version` — is the CLI reachable? const versionProbe = yield* runClaudeCommand(["--version"], executable, claudeEnv).pipe( @@ -1034,6 +1111,19 @@ export const makeCheckClaudeProviderStatus = ( if (Result.isFailure(authProbe)) { const error = authProbe.failure; + const capabilities = yield* resolveSdkCapabilities(); + if (capabilities) { + const metadata = claudeAccountMetadata({ capabilities }); + return { + provider: CLAUDE_AGENT_PROVIDER, + status: "ready" as const, + available: true, + authStatus: "authenticated" as const, + version: parsedVersion, + ...metadata, + checkedAt, + } satisfies ServerProviderStatus; + } return { provider: CLAUDE_AGENT_PROVIDER, status: "warning" as const, @@ -1049,6 +1139,19 @@ export const makeCheckClaudeProviderStatus = ( } if (Option.isNone(authProbe.success)) { + const capabilities = yield* resolveSdkCapabilities(); + if (capabilities) { + const metadata = claudeAccountMetadata({ capabilities }); + return { + provider: CLAUDE_AGENT_PROVIDER, + status: "ready" as const, + available: true, + authStatus: "authenticated" as const, + version: parsedVersion, + ...metadata, + checkedAt, + } satisfies ServerProviderStatus; + } return { provider: CLAUDE_AGENT_PROVIDER, status: "warning" as const, @@ -1062,27 +1165,25 @@ export const makeCheckClaudeProviderStatus = ( const authOutput = authProbe.success.value; const parsed = parseClaudeAuthStatusFromOutput(authOutput); - const authMethod = extractClaudeAuthMethodFromOutput(authOutput); - const normalizedAuthMethod = authMethod?.toLowerCase().replace(/[\s_-]+/gu, ""); - const approvedConsoleCredential = normalizedAuthMethod === "apikey"; - const unsupportedSubscription = - parsed.authStatus === "authenticated" && normalizedAuthMethod === "claude.ai"; - const effectiveParsed: ReturnType = - unsupportedSubscription - ? { - status: "warning", - authStatus: "unauthenticated", - message: - "This Claude account type cannot be used by Scient. Connect through Anthropic Console instead.", - } - : parsed.authStatus === "authenticated" && !approvedConsoleCredential - ? { - status: "warning", - authStatus: "unknown", - message: - "Claude is signed in, but Scient could not verify an Anthropic Console credential.", - } - : parsed; + const capabilities = + parsed.authStatus === "authenticated" || !resolveCapabilities + ? undefined + : yield* resolveSdkCapabilities(); + const sdkVerified = capabilities !== undefined; + const effectiveParsed: ReturnType = sdkVerified + ? { status: "ready", authStatus: "authenticated" } + : parsed; + const authMethod = extractClaudeAuthMethodFromOutput(authOutput) ?? capabilities?.tokenSource; + const subscriptionType = + extractSubscriptionTypeFromOutput(authOutput) ?? capabilities?.subscriptionType; + const metadata = + effectiveParsed.authStatus === "authenticated" + ? claudeAccountMetadata({ + ...(authMethod ? { authMethod } : {}), + ...(subscriptionType ? { subscriptionType } : {}), + ...(capabilities ? { capabilities } : {}), + }) + : undefined; return { provider: CLAUDE_AGENT_PROVIDER, @@ -1090,7 +1191,7 @@ export const makeCheckClaudeProviderStatus = ( available: true, authStatus: effectiveParsed.authStatus, version: parsedVersion, - ...(approvedConsoleCredential ? { authType: "apiKey", authLabel: "Anthropic Console" } : {}), + ...(metadata ?? {}), checkedAt, ...(effectiveParsed.message ? { message: effectiveParsed.message } : {}), } satisfies ServerProviderStatus; @@ -1219,7 +1320,7 @@ const runDroidCommand = (args: ReadonlyArray, executable = "droid") => export const makeCheckDroidProviderStatus = ( binaryPath?: string, - homeDir: string = OS.homedir(), + probeCwd: string = OS.homedir(), authenticationProbe: typeof verifyDroidAcpAuthentication = verifyDroidAcpAuthentication, modelProbe: (input: { readonly binaryPath: string; @@ -1293,7 +1394,7 @@ export const makeCheckDroidProviderStatus = ( const authentication = yield* authenticationProbe({ binaryPath: executable, childProcessSpawner, - cwd: homeDir, + cwd: probeCwd, }).pipe(Effect.timeoutOption(CLAUDE_HEALTH_TIMEOUT_MS)); const authStatus = Option.getOrElse(authentication, () => "unknown" as const); const modelReadiness = @@ -1301,7 +1402,7 @@ export const makeCheckDroidProviderStatus = ( ? yield* modelProbe({ binaryPath: executable, childProcessSpawner, - cwd: homeDir, + cwd: probeCwd, }).pipe(Effect.timeoutOption(CLAUDE_HEALTH_TIMEOUT_MS)) : Option.none(); const verified = @@ -1503,11 +1604,12 @@ export function parseAntigravityModelsAuthStatus(result: CommandResult): ServerP export const checkAntigravityProviderStatus = ( binaryPath?: string, + cwd?: string, ): Effect.Effect => Effect.gen(function* () { const checkedAt = new Date().toISOString(); const executable = nonEmptyTrimmed(binaryPath) ?? "agy"; - const versionProbe = yield* runAntigravityCommand(["--version"], executable).pipe( + const versionProbe = yield* runAntigravityCommand(["--version"], executable, cwd).pipe( Effect.timeoutOption(DEFAULT_TIMEOUT_MS), Effect.result, ); @@ -1526,11 +1628,12 @@ export const checkAntigravityProviderStatus = ( if (Option.isNone(versionProbe.success)) { return { provider: ANTIGRAVITY_PROVIDER, - status: "warning", - available: true, + status: "error", + available: false, authStatus: "unknown", checkedAt, - message: "Antigravity CLI version check timed out.", + message: + "Scient could not verify Antigravity CLI 1.1.4 or newer because the version check timed out.", } satisfies ServerProviderStatus; } const version = versionProbe.success.value; @@ -1545,10 +1648,18 @@ export const checkAntigravityProviderStatus = ( } satisfies ServerProviderStatus; } const parsedVersion = parseGenericCliVersion(`${version.stdout}\n${version.stderr}`); - if ( - parsedVersion !== null && - compareSemverVersions(parsedVersion, MINIMUM_ANTIGRAVITY_CLI_VERSION) < 0 - ) { + if (parsedVersion === null) { + return { + provider: ANTIGRAVITY_PROVIDER, + status: "error", + available: false, + authStatus: "unknown", + checkedAt, + message: + "Scient could not verify Antigravity CLI 1.1.4 or newer from the version command output.", + } satisfies ServerProviderStatus; + } + if (compareSemverVersions(parsedVersion, MINIMUM_ANTIGRAVITY_CLI_VERSION) < 0) { return { provider: ANTIGRAVITY_PROVIDER, status: "error", @@ -1559,7 +1670,7 @@ export const checkAntigravityProviderStatus = ( message: `Antigravity CLI ${parsedVersion} is too old for Scient. Upgrade to ${MINIMUM_ANTIGRAVITY_CLI_VERSION} or newer.`, } satisfies ServerProviderStatus; } - const models = yield* runAntigravityCommand(["models"], executable).pipe( + const models = yield* runAntigravityCommand(["models"], executable, cwd).pipe( Effect.timeoutOption(CLAUDE_HEALTH_TIMEOUT_MS), Effect.result, ); @@ -1821,6 +1932,7 @@ export function providerStatusesEqual( status.status === next.status && status.available === next.available && status.authStatus === next.authStatus && + status.requiresProviderAccount === next.requiresProviderAccount && (status.authType ?? null) === (next.authType ?? null) && (status.authLabel ?? null) === (next.authLabel ?? null) && status.voiceTranscriptionAvailable === next.voiceTranscriptionAvailable && @@ -1868,11 +1980,24 @@ export function stabilizeProviderStatusesAgainstTransientTimeouts( } // A single slow CLI probe should not make an already usable provider look broken. - return { + const stabilized = { ...previous, checkedAt: status.checkedAt, ...(status.updateState !== undefined ? { updateState: status.updateState } : {}), }; + if (status.provider !== CODEX_PROVIDER) { + return stabilized; + } + + // Never restore stale account ownership from the cache. Reauthentication is + // authorized only from the current config read, including false or unknown. + const { requiresProviderAccount: _staleOwnership, ...withoutStaleOwnership } = stabilized; + return { + ...withoutStaleOwnership, + ...(status.requiresProviderAccount !== undefined + ? { requiresProviderAccount: status.requiresProviderAccount } + : {}), + }; }); } @@ -1990,6 +2115,8 @@ export function makeProviderHealthLive(options?: { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const serverConfig = yield* ServerConfig; const serverSettings = yield* ServerSettingsService; + const providerHealthProbeCwd = resolveProviderProbeCwd(serverConfig.stateDir); + yield* Effect.sync(() => ensurePrivateDirectorySync(providerHealthProbeCwd)); const changesPubSub = yield* Effect.acquireRelease( PubSub.unbounded>(), PubSub.shutdown, @@ -2112,8 +2239,22 @@ export function makeProviderHealthLive(options?: { updateLockKey: null, }); } + const configuredExecutable = getProviderBinaryPath(provider, settings); + const runtime = options?.resolveProviderRuntime + ? yield* options.resolveProviderRuntime(provider, configuredExecutable) + : null; + if (runtime && !runtime.executable) { + return makeProviderMaintenanceCapabilities({ + provider, + packageName: definition.npmPackageName, + latestVersionSource: definition.latestVersionSource ?? null, + updateExecutable: null, + updateArgs: [], + updateLockKey: null, + }); + } return yield* resolveProviderMaintenanceCapabilitiesEffect(definition, { - binaryPath: getProviderBinaryPath(provider, settings), + binaryPath: runtime?.executable ?? configuredExecutable, env: process.env, platform: process.platform, }).pipe(Effect.provideService(FileSystem.FileSystem, fileSystem)); @@ -2270,9 +2411,24 @@ export function makeProviderHealthLive(options?: { CLAUDE_AGENT_PROVIDER, settings.providers.claudeAgent.binaryPath, ).pipe( - Effect.flatMap((binaryPath) => - makeCheckClaudeProviderStatus(undefined, binaryPath, serverConfig.homeDir), - ), + Effect.flatMap((binaryPath) => { + const executable = nonEmptyTrimmed(binaryPath) ?? "claude"; + const env = buildClaudeProcessEnv({ + env: process.env, + homeDir: serverConfig.homeDir, + }); + return makeCheckClaudeProviderStatus( + Effect.promise(() => + probeClaudeAccountCapabilities({ + executable, + env, + cwd: providerHealthProbeCwd, + }), + ), + executable, + serverConfig.homeDir, + ); + }), ), ), checkProviderWhenEnabled( @@ -2286,7 +2442,11 @@ export function makeProviderHealthLive(options?: { resolveProviderBinaryPath( ANTIGRAVITY_PROVIDER, settings.providers.antigravity.binaryPath, - ).pipe(Effect.flatMap(checkAntigravityProviderStatus)), + ).pipe( + Effect.flatMap((binaryPath) => + checkAntigravityProviderStatus(binaryPath, serverConfig.stateDir), + ), + ), ), checkProviderWhenEnabled( settings, @@ -2303,7 +2463,7 @@ export function makeProviderHealthLive(options?: { settings.providers.droid.binaryPath, ).pipe( Effect.flatMap((binaryPath) => - makeCheckDroidProviderStatus(binaryPath, serverConfig.homeDir), + makeCheckDroidProviderStatus(binaryPath, providerHealthProbeCwd), ), ), ), @@ -2510,6 +2670,18 @@ export function makeProviderHealthLive(options?: { reason: "Provider is disabled in Scient settings.", }); } + if (options?.resolveProviderRuntime) { + const runtime = yield* options + .resolveProviderRuntime(provider, getProviderBinaryPath(provider, settings)) + .pipe(Effect.mapError(toUpdateError)); + const blockReason = providerExternalUpdateBlockReason(provider, runtime); + if (blockReason) { + return yield* new ServerProviderUpdateError({ + provider, + reason: blockReason, + }); + } + } const capabilities = yield* getProviderMaintenanceCapabilities(provider).pipe( Effect.mapError(toUpdateError), ); diff --git a/apps/server/src/provider/Layers/ProviderRuntimeManager.test.ts b/apps/server/src/provider/Layers/ProviderRuntimeManager.test.ts index aa9157f79..412ce1e29 100644 --- a/apps/server/src/provider/Layers/ProviderRuntimeManager.test.ts +++ b/apps/server/src/provider/Layers/ProviderRuntimeManager.test.ts @@ -10,13 +10,16 @@ import { describe, expect, it } from "vitest"; import { ServerConfig } from "../../config"; import { ProviderRuntimeManager } from "../Services/ProviderRuntimeManager"; import type { ProviderRuntimeCurrentRecord } from "../providerRuntimeTypes"; -import { ProviderRuntimeManagerLive } from "./ProviderRuntimeManager"; +import { + canActivateManagedRuntimeVersion, + ProviderRuntimeManagerLive, +} from "./ProviderRuntimeManager"; function sha256(filePath: string): string { return createHash("sha256").update(readFileSync(filePath)).digest("hex"); } -function resolveAntigravity(baseDir: string) { +function resolveAntigravity(baseDir: string, configuredExecutable?: string) { const configLayer = ServerConfig.layerTest(baseDir, baseDir).pipe( Layer.provide(NodeServices.layer), ); @@ -26,11 +29,44 @@ function resolveAntigravity(baseDir: string) { ).pipe(Layer.provide(NodeServices.layer)); return Effect.gen(function* () { const manager = yield* ProviderRuntimeManager; - return yield* manager.resolve("antigravity"); + return yield* manager.resolve("antigravity", configuredExecutable); }).pipe(Effect.provide(layer), Effect.scoped); } describe("ProviderRuntimeManager managed integrity", () => { + it("allows install or repair at the current version but never downgrades", () => { + expect( + canActivateManagedRuntimeVersion({ currentVersion: null, candidateVersion: "1.1.5" }), + ).toBe(true); + expect( + canActivateManagedRuntimeVersion({ currentVersion: "1.1.4", candidateVersion: "1.1.5" }), + ).toBe(true); + expect( + canActivateManagedRuntimeVersion({ currentVersion: "1.1.5", candidateVersion: "1.1.5" }), + ).toBe(true); + expect( + canActivateManagedRuntimeVersion({ currentVersion: "1.1.5", candidateVersion: "1.1.4" }), + ).toBe(false); + }); + + it("preserves an invalid custom executable as an explicit configuration error", async () => { + const baseDir = mkdtempSync(path.join(os.tmpdir(), "scient-runtime-custom-")); + try { + const configuredExecutable = path.join(baseDir, "missing", "agy"); + const resolved = await Effect.runPromise(resolveAntigravity(baseDir, configuredExecutable)); + + expect(resolved).toMatchObject({ + source: "custom", + executable: null, + canInstall: false, + }); + expect(resolved.message).toContain(configuredExecutable); + expect(resolved.message).toContain("Change or reset this custom path"); + } finally { + rmSync(baseDir, { recursive: true, force: true }); + } + }); + it("revalidates the executable after restart and rejects later corruption", async () => { const baseDir = mkdtempSync(path.join(os.tmpdir(), "scient-runtime-integrity-")); const previousPath = process.env.PATH; diff --git a/apps/server/src/provider/Layers/ProviderRuntimeManager.ts b/apps/server/src/provider/Layers/ProviderRuntimeManager.ts index a861928c0..a10e70582 100644 --- a/apps/server/src/provider/Layers/ProviderRuntimeManager.ts +++ b/apps/server/src/provider/Layers/ProviderRuntimeManager.ts @@ -5,11 +5,13 @@ import Path from "node:path"; import { delimiter as pathDelimiter } from "node:path"; import { + PROVIDER_DISPLAY_NAMES, type ProviderKind, ServerProviderInstallationError, type ServerProviderInstallationState, type ServerProviderRuntimeSource, } from "@synara/contracts"; +import { compareSemverVersions } from "@synara/shared/providerVersions"; import { Effect, Layer, PubSub, Stream } from "effect"; import { ServerConfig } from "../../config"; @@ -39,6 +41,34 @@ import { providerRuntimeTargetId, } from "../providerRuntimeTypes"; +export function canActivateManagedRuntimeVersion(input: { + readonly currentVersion: string | null; + readonly candidateVersion: string; +}): boolean { + return ( + input.currentVersion === null || + compareSemverVersions(input.candidateVersion, input.currentVersion) >= 0 + ); +} + +function managedRuntimeDowngradeMessage(input: { + readonly provider: ProviderKind; + readonly currentVersion: string; + readonly candidateVersion: string; + readonly operation: "install" | "repair"; +}): string | null { + if ( + canActivateManagedRuntimeVersion({ + currentVersion: input.currentVersion, + candidateVersion: input.candidateVersion, + }) + ) { + return null; + } + const action = input.operation === "repair" ? "repair" : "replace"; + return `Scient will not ${action} ${PROVIDER_DISPLAY_NAMES[input.provider]} ${input.currentVersion} with older version ${input.candidateVersion}.`; +} + const PROVIDERS: ReadonlyArray = [ "codex", "claudeAgent", @@ -241,6 +271,30 @@ function findExecutableOnPath(input: { })(); } +async function resolveConfiguredExecutable(input: { + readonly command: string; + readonly pathValue: string; +}): Promise { + const hasPathSeparator = + Path.isAbsolute(input.command) || + input.command.includes(Path.sep) || + (process.platform === "win32" && input.command.includes("/")); + if (!hasPathSeparator) { + return findExecutableOnPath(input); + } + + const stat = await FS.stat(input.command).catch(() => null); + if (!stat?.isFile()) return null; + if (process.platform !== "win32") { + try { + await FS.access(input.command, FS_CONSTANTS.X_OK); + } catch { + return null; + } + } + return FS.realpath(input.command).catch(() => input.command); +} + async function smokeTestExecutable(input: { readonly executable: string; readonly args: ReadonlyArray; @@ -592,6 +646,17 @@ export const ProviderRuntimeManagerLive = Layer.effect( signal: controller.signal, }); + const current = records.get(provider) ?? null; + const downgradeMessage = current + ? managedRuntimeDowngradeMessage({ + provider, + currentVersion: current.runtimeVersion, + candidateVersion: artifact.version, + operation, + }) + : null; + if (downgradeMessage) throw new Error(downgradeMessage); + await FS.mkdir(Path.dirname(finalRelease), { recursive: true }); const existingFinal = await FS.stat(finalRelease).catch(() => null); if (existingFinal) { @@ -599,7 +664,6 @@ export const ProviderRuntimeManagerLive = Layer.effect( finalRelease = releaseRoot(config.stateDir, provider, releaseId); } await FS.rename(stagedRelease, finalRelease); - const current = records.get(provider) ?? null; const finalExecutable = Path.join(finalRelease, managedExecutableRelativePath); const record: ProviderRuntimeCurrentRecord = { version: 1, @@ -697,6 +761,23 @@ export const ProviderRuntimeManagerLive = Layer.effect( message: "The installation plan expired. Review the provider download again.", }); } + const current = records.get(input.provider); + const downgradeMessage = current + ? managedRuntimeDowngradeMessage({ + provider: input.provider, + currentVersion: current.runtimeVersion, + candidateVersion: plan.artifact.version, + operation: "install", + }) + : null; + if (downgradeMessage) { + plans.delete(input.planToken); + return yield* installationError({ + provider: input.provider, + reason: "managed_runtime_unavailable", + message: downgradeMessage, + }); + } plans.delete(input.planToken); yield* startOperation({ provider: input.provider, @@ -728,7 +809,8 @@ export const ProviderRuntimeManagerLive = Layer.effect( const repair: ProviderRuntimeManagerShape["repair"] = (input) => Effect.gen(function* () { - if (!records.has(input.provider)) { + const current = records.get(input.provider); + if (!current) { return yield* installationError({ provider: input.provider, reason: "managed_runtime_unavailable", @@ -750,6 +832,19 @@ export const ProviderRuntimeManagerLive = Layer.effect( message: errorMessage(cause), }), }); + const downgradeMessage = managedRuntimeDowngradeMessage({ + provider: input.provider, + currentVersion: current.runtimeVersion, + candidateVersion: artifact.version, + operation: "repair", + }); + if (downgradeMessage) { + return yield* installationError({ + provider: input.provider, + reason: "managed_runtime_unavailable", + message: downgradeMessage, + }); + } yield* startOperation({ provider: input.provider, operation: "repair", @@ -860,7 +955,7 @@ export const ProviderRuntimeManagerLive = Layer.effect( const explicitCustom = configured.length > 0 && configured !== recipe.executableName; const record = records.get(provider) ?? null; const systemExecutable = explicitCustom - ? configured + ? await resolveConfiguredExecutable({ command: configured, pathValue: basePath }) : await findExecutableOnPath({ command: recipe.executableName, pathValue: basePath }); const source: ServerProviderRuntimeSource = explicitCustom ? "custom" @@ -913,9 +1008,11 @@ export const ProviderRuntimeManagerLive = Layer.effect( message: source === "bundled" ? "Built into Scient." - : source === "missing" - ? "No usable provider runtime was found." - : null, + : source === "custom" && !systemExecutable + ? `The configured executable '${configured}' is unavailable or not executable. Change or reset this custom path in provider settings.` + : source === "missing" + ? "No usable provider runtime was found." + : null, }; }); diff --git a/apps/server/src/provider/Services/ProviderClientStatusProjection.ts b/apps/server/src/provider/Services/ProviderClientStatusProjection.ts new file mode 100644 index 000000000..bae5b3177 --- /dev/null +++ b/apps/server/src/provider/Services/ProviderClientStatusProjection.ts @@ -0,0 +1,24 @@ +/** + * Canonical client-facing provider status projection. + * + * ProviderHealth owns lightweight health snapshots. This service is the only + * path that combines those snapshots with runtime/install capabilities before + * they cross an RPC or subscription boundary. + */ +import type { ServerProviderClientStatus, ServerProviderStatus } from "@synara/contracts"; +import type { Effect, Stream } from "effect"; +import { ServiceMap } from "effect"; + +export interface ProviderClientStatusProjectionShape { + readonly project: ( + statuses: ReadonlyArray, + ) => Effect.Effect>; + readonly getStatuses: Effect.Effect>; + readonly refreshStatuses: Effect.Effect>; + readonly streamChanges: Stream.Stream>; +} + +export class ProviderClientStatusProjection extends ServiceMap.Service< + ProviderClientStatusProjection, + ProviderClientStatusProjectionShape +>()("synara/provider/Services/ProviderClientStatusProjection") {} diff --git a/apps/server/src/provider/Services/ProviderConnection.ts b/apps/server/src/provider/Services/ProviderConnection.ts index 7e858c298..3e43c1e21 100644 --- a/apps/server/src/provider/Services/ProviderConnection.ts +++ b/apps/server/src/provider/Services/ProviderConnection.ts @@ -12,6 +12,7 @@ import type { ServerProviderConnectionError, ServerProviderConnectionResult, ServerProviderConnectionStartInput, + ServerProviderConnectionSubmitAuthorizationCodeInput, } from "@synara/contracts"; import { ServiceMap } from "effect"; import type { Effect } from "effect"; @@ -23,6 +24,9 @@ export interface ProviderConnectionShape { readonly cancel: ( input: ServerProviderConnectionCancelInput, ) => Effect.Effect; + readonly submitAuthorizationCode: ( + input: ServerProviderConnectionSubmitAuthorizationCodeInput, + ) => Effect.Effect; } export class ProviderConnection extends ServiceMap.Service< diff --git a/apps/server/src/provider/antigravityReleaseChannel.ts b/apps/server/src/provider/antigravityReleaseChannel.ts new file mode 100644 index 000000000..5789c3bac --- /dev/null +++ b/apps/server/src/provider/antigravityReleaseChannel.ts @@ -0,0 +1,47 @@ +// FILE: antigravityReleaseChannel.ts +// Purpose: Defines Antigravity's trusted stable manifest and artifact boundaries. +// Layer: Provider runtime infrastructure + +import type { ProviderRuntimeTarget } from "./providerRuntimeTypes"; + +export const MINIMUM_ANTIGRAVITY_CLI_VERSION = "1.1.4"; +export const ANTIGRAVITY_MANIFEST_HOST = + "antigravity-cli-auto-updater-974169037036.us-central1.run.app"; +export const ANTIGRAVITY_ARTIFACT_HOSTS = ["storage.googleapis.com"] as const; +const ANTIGRAVITY_ARTIFACT_PATH_PREFIX = "/antigravity-public/antigravity-cli/"; + +export function antigravityManifestPlatform(target: ProviderRuntimeTarget): string { + const arch = target.arch === "arm64" ? "arm64" : "amd64"; + if (target.platform === "linux" && target.libc === "musl") return `linux_${arch}_musl`; + const os = target.platform === "win32" ? "windows" : target.platform; + return `${os}_${arch}`; +} + +export function antigravityManifestUrl(target: ProviderRuntimeTarget): string { + return `https://${ANTIGRAVITY_MANIFEST_HOST}/manifests/${antigravityManifestPlatform(target)}.json`; +} + +export function validateAntigravityArtifactUrl(input: { + readonly url: string; + readonly version: string; +}): string { + let parsed: URL; + try { + parsed = new URL(input.url); + } catch { + throw new Error("Antigravity release manifest contains an invalid artifact URL."); + } + if ( + parsed.protocol !== "https:" || + !ANTIGRAVITY_ARTIFACT_HOSTS.includes( + parsed.hostname as (typeof ANTIGRAVITY_ARTIFACT_HOSTS)[number], + ) + ) { + throw new Error("Antigravity release manifest uses an untrusted artifact host."); + } + const expectedVersionPrefix = `${ANTIGRAVITY_ARTIFACT_PATH_PREFIX}${input.version}-`; + if (!parsed.pathname.startsWith(expectedVersionPrefix)) { + throw new Error("Antigravity release manifest artifact URL does not match its version."); + } + return parsed.toString(); +} diff --git a/apps/server/src/provider/claudeCapabilities.test.ts b/apps/server/src/provider/claudeCapabilities.test.ts new file mode 100644 index 000000000..4714ac94b --- /dev/null +++ b/apps/server/src/provider/claudeCapabilities.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + probeClaudeAccountCapabilities, + sanitizeClaudeAccountCapabilities, + type ClaudeCapabilitiesQueryFactory, +} from "./claudeCapabilities"; + +describe("Claude account capability probing", () => { + it("returns only non-secret account metadata from SDK initialization", async () => { + let capturedOptions: Parameters[0]["options"] | undefined; + const close = vi.fn(); + const createQuery: ClaudeCapabilitiesQueryFactory = (input) => { + capturedOptions = input.options; + return { + initializationResult: async () => ({ + account: { + email: "scientist@example.test", + organization: "Research Lab", + subscriptionType: "max", + tokenSource: "claude.ai", + apiProvider: "firstParty", + accessToken: "must-not-escape", + }, + }), + close, + }; + }; + + await expect( + probeClaudeAccountCapabilities({ + executable: "/custom/claude", + env: { HOME: "/Users/tester" }, + cwd: "/workspace", + createQuery, + }), + ).resolves.toEqual({ + email: "scientist@example.test", + organization: "Research Lab", + subscriptionType: "max", + tokenSource: "claude.ai", + apiProvider: "firstParty", + }); + expect(capturedOptions).toMatchObject({ + pathToClaudeCodeExecutable: "/custom/claude", + persistSession: false, + allowedTools: [], + cwd: "/workspace", + }); + expect(close).toHaveBeenCalledOnce(); + }); + + it("does not treat an empty initialization account as authentication proof", async () => { + const createQuery: ClaudeCapabilitiesQueryFactory = () => ({ + initializationResult: async () => ({ account: {} }), + close: () => undefined, + }); + + await expect( + probeClaudeAccountCapabilities({ executable: "claude", env: {}, createQuery }), + ).resolves.toBeUndefined(); + }); + + it("times out and closes a stalled SDK probe", async () => { + const close = vi.fn(); + const createQuery: ClaudeCapabilitiesQueryFactory = () => ({ + initializationResult: () => new Promise(() => undefined), + close, + }); + + await expect( + probeClaudeAccountCapabilities({ + executable: "claude", + env: {}, + timeoutMs: 1, + createQuery, + }), + ).resolves.toBeUndefined(); + expect(close).toHaveBeenCalledOnce(); + }); + + it("rejects token-only objects during sanitization", () => { + expect(sanitizeClaudeAccountCapabilities({ accessToken: "secret" })).toBeUndefined(); + }); + + it("does not treat Claude's logged-out sentinel values as authentication proof", () => { + expect( + sanitizeClaudeAccountCapabilities({ + tokenSource: "none", + apiKeySource: "not_configured", + subscriptionType: "unknown", + apiProvider: "firstParty", + }), + ).toBeUndefined(); + }); + + it("does not treat a first-party backend selection alone as a logged-in account", () => { + expect(sanitizeClaudeAccountCapabilities({ apiProvider: "firstParty" })).toBeUndefined(); + expect(sanitizeClaudeAccountCapabilities({ apiProvider: "bedrock" })).toEqual({ + apiProvider: "bedrock", + }); + }); +}); diff --git a/apps/server/src/provider/claudeCapabilities.ts b/apps/server/src/provider/claudeCapabilities.ts new file mode 100644 index 000000000..86d5536bb --- /dev/null +++ b/apps/server/src/provider/claudeCapabilities.ts @@ -0,0 +1,151 @@ +// FILE: claudeCapabilities.ts +// Purpose: Verify Claude account availability through the same SDK initialization used by turns. +// Layer: Provider utility. + +import { + query, + type Options as ClaudeQueryOptions, + type SDKUserMessage, +} from "@anthropic-ai/claude-agent-sdk"; + +export interface ClaudeAccountCapabilities { + readonly email?: string; + readonly organization?: string; + readonly subscriptionType?: string; + readonly tokenSource?: string; + readonly apiKeySource?: string; + readonly apiProvider?: string; +} + +interface ClaudeCapabilitiesQuery { + readonly initializationResult: () => Promise<{ + readonly account?: Record; + }>; + readonly close: () => void; +} + +export type ClaudeCapabilitiesQueryFactory = (input: { + readonly prompt: AsyncIterable; + readonly options: ClaudeQueryOptions; +}) => ClaudeCapabilitiesQuery; + +export interface ClaudeCapabilitiesProbeInput { + readonly executable: string; + readonly env: NodeJS.ProcessEnv; + readonly cwd?: string; + readonly timeoutMs?: number; + readonly createQuery?: ClaudeCapabilitiesQueryFactory; +} + +const DEFAULT_CAPABILITIES_TIMEOUT_MS = 15_000; + +function waitForAbort(signal: AbortSignal): Promise { + if (signal.aborted) return Promise.resolve(); + return new Promise((resolve) => + signal.addEventListener("abort", () => resolve(), { once: true }), + ); +} + +async function* neverSendingPrompt(signal: AbortSignal): AsyncGenerator { + await waitForAbort(signal); + if (false) { + // Keeps the generator correctly typed without ever yielding a user message. + yield undefined as never; + } +} + +function nonEmptyString(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} + +function nonAbsentString(value: unknown): string | undefined { + const normalized = nonEmptyString(value); + if (!normalized) return undefined; + const absenceMarker = normalized.toLowerCase().replaceAll(/[\s_-]+/g, ""); + return absenceMarker === "none" || + absenceMarker === "unknown" || + absenceMarker === "notconfigured" + ? undefined + : normalized; +} + +export function sanitizeClaudeAccountCapabilities( + account: Record | undefined, +): ClaudeAccountCapabilities | undefined { + if (!account) return undefined; + const email = nonEmptyString(account.email); + const organization = nonEmptyString(account.organization); + const subscriptionType = nonAbsentString(account.subscriptionType); + const tokenSource = nonAbsentString(account.tokenSource); + const apiKeySource = nonAbsentString(account.apiKeySource); + const apiProvider = nonEmptyString(account.apiProvider); + const capabilities: ClaudeAccountCapabilities = { + ...(email ? { email } : {}), + ...(organization ? { organization } : {}), + ...(subscriptionType ? { subscriptionType } : {}), + ...(tokenSource ? { tokenSource } : {}), + ...(apiKeySource ? { apiKeySource } : {}), + ...(apiProvider ? { apiProvider } : {}), + }; + const hasAuthenticationEvidence = + email !== undefined || + organization !== undefined || + subscriptionType !== undefined || + tokenSource !== undefined || + apiKeySource !== undefined || + (apiProvider !== undefined && apiProvider !== "firstParty"); + return hasAuthenticationEvidence ? capabilities : undefined; +} + +/** + * Starts Claude only far enough to receive its local initialization payload. + * The prompt stream never yields, so this performs no model request. Credentials + * stay inside Claude; only non-secret account labels are returned. + */ +export async function probeClaudeAccountCapabilities( + input: ClaudeCapabilitiesProbeInput, +): Promise { + const abortController = new AbortController(); + const createQuery: ClaudeCapabilitiesQueryFactory = + input.createQuery ?? ((queryInput) => query(queryInput) as unknown as ClaudeCapabilitiesQuery); + let runtime: ClaudeCapabilitiesQuery | undefined; + let timeoutId: ReturnType | undefined; + + try { + runtime = createQuery({ + prompt: neverSendingPrompt(abortController.signal), + options: { + pathToClaudeCodeExecutable: input.executable, + env: input.env, + persistSession: false, + settingSources: ["user", "project", "local"], + allowedTools: [], + abortController, + stderr: () => {}, + ...(input.cwd ? { cwd: input.cwd } : {}), + }, + }); + + const timeout = new Promise((resolve) => { + timeoutId = setTimeout( + () => resolve(undefined), + input.timeoutMs ?? DEFAULT_CAPABILITIES_TIMEOUT_MS, + ); + }); + const initialization = await Promise.race([ + runtime.initializationResult().then((result) => result), + timeout, + ]); + return initialization ? sanitizeClaudeAccountCapabilities(initialization.account) : undefined; + } catch { + return undefined; + } finally { + if (timeoutId !== undefined) clearTimeout(timeoutId); + abortController.abort(); + try { + runtime?.close(); + } catch { + // Probe cleanup is best effort and must not alter the authentication result. + } + } +} diff --git a/apps/server/src/provider/claudeProcessEnv.test.ts b/apps/server/src/provider/claudeProcessEnv.test.ts index 797d0e00a..74d11f860 100644 --- a/apps/server/src/provider/claudeProcessEnv.test.ts +++ b/apps/server/src/provider/claudeProcessEnv.test.ts @@ -7,7 +7,7 @@ import { describe, it, assert } from "@effect/vitest"; import { buildClaudeProcessEnv } from "./claudeProcessEnv.ts"; describe("claudeProcessEnv", () => { - it("preserves Anthropic Console credentials and excludes subscription OAuth", () => { + it("preserves every Claude-supported credential source", () => { const env = { PATH: "/bin", HOME: "/home/tester", @@ -22,7 +22,7 @@ describe("claudeProcessEnv", () => { assert.equal(result.HOME, "/home/tester"); assert.equal(result.ANTHROPIC_API_KEY, "console-api-key"); assert.equal(result.ANTHROPIC_AUTH_TOKEN, "console-auth-token"); - assert.equal(result.CLAUDE_CODE_OAUTH_TOKEN, undefined); + assert.equal(result.CLAUDE_CODE_OAUTH_TOKEN, "subscription-token"); assert.equal(env.CLAUDE_CODE_OAUTH_TOKEN, "subscription-token"); }); diff --git a/apps/server/src/provider/claudeProcessEnv.ts b/apps/server/src/provider/claudeProcessEnv.ts index d1555952e..2f316504e 100644 --- a/apps/server/src/provider/claudeProcessEnv.ts +++ b/apps/server/src/provider/claudeProcessEnv.ts @@ -1,5 +1,5 @@ // FILE: claudeProcessEnv.ts -// Purpose: Builds Claude subprocess environments for supported third-party authentication. +// Purpose: Builds Claude subprocess environments that match the user's normal Claude CLI session. // Layer: Provider utility shared by Claude runtime sessions and provider health probes. // Exports: Claude subprocess environment sanitization. @@ -11,8 +11,7 @@ export function buildClaudeProcessEnv(input?: { if (input?.homeDir) { env.HOME = input.homeDir; } - delete env.CLAUDE_CODE_OAUTH_TOKEN; - // Preserve Anthropic Console/API and supported cloud-provider credentials, - // but never route Claude.ai subscription OAuth into Scient subprocesses. + // Claude owns these credentials. Preserve every provider-supported auth source + // so health probes, sign-in, and real turns observe the same terminal session. return env; } diff --git a/apps/server/src/provider/providerMaintenance.test.ts b/apps/server/src/provider/providerMaintenance.test.ts index 25d7b3c2c..a250161c0 100644 --- a/apps/server/src/provider/providerMaintenance.test.ts +++ b/apps/server/src/provider/providerMaintenance.test.ts @@ -1,9 +1,12 @@ import { describe, it, assert } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { Effect } from "effect"; import { createProviderVersionAdvisory, deriveNpmGlobalPrefix, parseGenericCliVersion, + resolveProviderMaintenanceCapabilitiesEffect, resolvePackageManagedProviderMaintenance, type PackageManagedProviderMaintenanceDefinition, } from "./providerMaintenance"; @@ -113,8 +116,8 @@ describe("providerMaintenance", () => { }); assert.deepStrictEqual(capabilities.update, { - command: "opencode upgrade --method pnpm", - executable: "opencode", + command: "/Users/test/.local/share/pnpm/opencode upgrade --method pnpm", + executable: "/Users/test/.local/share/pnpm/opencode", args: ["upgrade", "--method", "pnpm"], lockKey: "opencode-native", }); @@ -124,6 +127,55 @@ describe("providerMaintenance", () => { }); }); + it.effect("does not invent an update command for a missing bare executable", () => + Effect.gen(function* () { + const definition = { + provider: "antigravity", + binaryName: "agy", + npmPackageName: null, + homebrew: null, + nativeUpdate: { + executable: "agy", + args: () => ["update"], + lockKey: "antigravity-native", + strategy: "always", + }, + } as const satisfies PackageManagedProviderMaintenanceDefinition; + const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(definition, { + binaryPath: "agy", + env: { PATH: "" }, + platform: "darwin", + }); + + assert.strictEqual(capabilities.update, null); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it("uses the resolved absolute executable for provider-native updates", () => { + const definition = { + provider: "antigravity", + binaryName: "agy", + npmPackageName: null, + homebrew: null, + nativeUpdate: { + executable: "agy", + args: () => ["update"], + lockKey: "antigravity-native", + strategy: "always", + }, + } as const satisfies PackageManagedProviderMaintenanceDefinition; + const capabilities = resolvePackageManagedProviderMaintenance(definition, { + binaryPath: "/Users/test/.local/bin/agy", + }); + + assert.deepStrictEqual(capabilities.update, { + command: "/Users/test/.local/bin/agy update", + executable: "/Users/test/.local/bin/agy", + args: ["update"], + lockKey: "antigravity-native", + }); + }); + it("uses Homebrew updates but keeps npm latest metadata for tapped OpenCode installs", () => { const capabilities = resolvePackageManagedProviderMaintenance(OPENCODE_DEFINITION, { binaryPath: "opencode", diff --git a/apps/server/src/provider/providerMaintenance.ts b/apps/server/src/provider/providerMaintenance.ts index 3540d1e99..51db4b9b5 100644 --- a/apps/server/src/provider/providerMaintenance.ts +++ b/apps/server/src/provider/providerMaintenance.ts @@ -3,6 +3,7 @@ import type { ServerProviderStatus, ServerProviderVersionAdvisory, } from "@synara/contracts"; +import { compareSemverVersions } from "@synara/shared/providerVersions"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -14,13 +15,6 @@ const WINDOWS_EXECUTABLE_EXTENSIONS = ["", ".exe", ".cmd", ".bat"] as const; type ProviderInstallSource = "npm" | "bun" | "pnpm" | "homebrew" | "native" | "unknown"; -interface ParsedSemver { - readonly major: number; - readonly minor: number; - readonly patch: number; - readonly prerelease: ReadonlyArray; -} - export interface ProviderLatestVersionSource { readonly kind: "npm" | "homebrew"; readonly name: string; @@ -74,8 +68,6 @@ const latestVersionCache = new Map< string, { readonly expiresAt: number; readonly version: string | null } >(); -const SEMVER_NUMBER_SEGMENT = /^\d+$/; - function nonEmptyString(value: unknown): string | null { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; } @@ -94,96 +86,7 @@ function normalizeSemverVersion(version: string): string { return prerelease ? `${segments.join(".")}-${prerelease}` : segments.join("."); } -function parseSemver(value: string): ParsedSemver | null { - const [main = "", prerelease] = normalizeSemverVersion(value).split("-", 2); - const segments = main.split("."); - if (segments.length !== 3) { - return null; - } - - const [majorSegment, minorSegment, patchSegment] = segments; - if ( - majorSegment === undefined || - minorSegment === undefined || - patchSegment === undefined || - !SEMVER_NUMBER_SEGMENT.test(majorSegment) || - !SEMVER_NUMBER_SEGMENT.test(minorSegment) || - !SEMVER_NUMBER_SEGMENT.test(patchSegment) - ) { - return null; - } - - return { - major: Number.parseInt(majorSegment, 10), - minor: Number.parseInt(minorSegment, 10), - patch: Number.parseInt(patchSegment, 10), - prerelease: - prerelease - ?.split(".") - .map((segment) => segment.trim()) - .filter((segment) => segment.length > 0) ?? [], - }; -} - -function comparePrereleaseIdentifier(left: string, right: string): number { - const leftNumeric = SEMVER_NUMBER_SEGMENT.test(left); - const rightNumeric = SEMVER_NUMBER_SEGMENT.test(right); - - if (leftNumeric && rightNumeric) { - return Number.parseInt(left, 10) - Number.parseInt(right, 10); - } - if (leftNumeric) { - return -1; - } - if (rightNumeric) { - return 1; - } - return left.localeCompare(right); -} - -export function compareSemverVersions(left: string, right: string): number { - const parsedLeft = parseSemver(left); - const parsedRight = parseSemver(right); - if (!parsedLeft || !parsedRight) { - return left.localeCompare(right); - } - - if (parsedLeft.major !== parsedRight.major) { - return parsedLeft.major - parsedRight.major; - } - if (parsedLeft.minor !== parsedRight.minor) { - return parsedLeft.minor - parsedRight.minor; - } - if (parsedLeft.patch !== parsedRight.patch) { - return parsedLeft.patch - parsedRight.patch; - } - if (parsedLeft.prerelease.length === 0 && parsedRight.prerelease.length === 0) { - return 0; - } - if (parsedLeft.prerelease.length === 0) { - return 1; - } - if (parsedRight.prerelease.length === 0) { - return -1; - } - - const length = Math.max(parsedLeft.prerelease.length, parsedRight.prerelease.length); - for (let index = 0; index < length; index += 1) { - const leftIdentifier = parsedLeft.prerelease[index]; - const rightIdentifier = parsedRight.prerelease[index]; - if (leftIdentifier === undefined) { - return -1; - } - if (rightIdentifier === undefined) { - return 1; - } - const comparison = comparePrereleaseIdentifier(leftIdentifier, rightIdentifier); - if (comparison !== 0) { - return comparison; - } - } - return 0; -} +export { compareSemverVersions } from "@synara/shared/providerVersions"; export function parseGenericCliVersion(output: string): string | null { const match = output.match(/\bv?(\d+\.\d+(?:\.\d+)?(?:-[0-9A-Za-z.-]+)?)\b/); @@ -427,6 +330,7 @@ function makeProviderMaintenanceForInstallSource(input: { readonly commandPath?: string | null; }): ProviderMaintenanceCapabilities { const { definition, installSource, executable, pathPrepend, commandPath } = input; + const resolvedNativeExecutable = commandPath ?? executable; if ( definition.nativeUpdate?.strategy === "always" && !definition.nativeUpdate.excludedInstallSources?.includes(installSource) @@ -435,7 +339,7 @@ function makeProviderMaintenanceForInstallSource(input: { makeNativeProviderMaintenanceCapabilities( definition, installSource, - executable, + resolvedNativeExecutable, pathPrepend, ) ?? makeManualOnlyProviderMaintenanceCapabilities({ @@ -449,7 +353,7 @@ function makeProviderMaintenanceForInstallSource(input: { makeNativeProviderMaintenanceCapabilities( definition, installSource, - executable, + resolvedNativeExecutable, pathPrepend, ) ?? makeManualOnlyProviderMaintenanceCapabilities({ @@ -544,6 +448,17 @@ export function resolvePackageManagedProviderMaintenance( } } + const resolvedCommandPath = nonEmptyString(options?.realCommandPath) ?? binaryPath; + if (definition.nativeUpdate?.strategy === "always" && hasPathSeparator(resolvedCommandPath)) { + return makeProviderMaintenanceForInstallSource({ + definition, + installSource: "unknown", + executable: resolvedCommandPath, + commandPath: resolvedCommandPath, + ...(options?.commandDirectory === undefined ? {} : { pathPrepend: options.commandDirectory }), + }); + } + if (!hasPathSeparator(binaryPath)) { return makeProviderMaintenanceForInstallSource({ definition, @@ -597,9 +512,9 @@ export const resolveProviderMaintenanceCapabilitiesEffect = Effect.fn( } } - return resolvePackageManagedProviderMaintenance(definition, { - ...options, - binaryPath, + return makeManualOnlyProviderMaintenanceCapabilities({ + provider: definition.provider, + packageName: definition.npmPackageName, }); }); diff --git a/apps/server/src/provider/providerRuntimeRecipes.test.ts b/apps/server/src/provider/providerRuntimeRecipes.test.ts new file mode 100644 index 000000000..6eba8e66e --- /dev/null +++ b/apps/server/src/provider/providerRuntimeRecipes.test.ts @@ -0,0 +1,101 @@ +// FILE: providerRuntimeRecipes.test.ts +// Purpose: Verifies trusted moving provider manifests produce safe managed-runtime artifacts. +// Layer: Provider runtime recipe tests + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { ProviderRuntimeTarget } from "./providerRuntimeTypes"; +import { getProviderRuntimeRecipe, ProviderRuntimeRecipeError } from "./providerRuntimeRecipes"; + +const TARGET: ProviderRuntimeTarget = { + platform: "darwin", + arch: "arm64", + cpu: "standard", +}; +const SHA512 = "a".repeat(128); + +function mockManifest(input: { + readonly version: string; + readonly url?: string; + readonly sha512?: string; +}) { + vi.stubGlobal( + "fetch", + vi.fn(async () => + Response.json({ + version: input.version, + url: + input.url ?? + `https://storage.googleapis.com/antigravity-public/antigravity-cli/${input.version}-build/darwin-arm/cli_mac_arm64.tar.gz`, + sha512: input.sha512 ?? SHA512, + }), + ), + ); +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("Antigravity managed runtime recipe", () => { + it("follows a newer stable release on the trusted official manifest", async () => { + mockManifest({ version: "1.1.5" }); + + const artifact = await getProviderRuntimeRecipe("antigravity").resolve( + TARGET, + new AbortController().signal, + ); + + expect(artifact).toMatchObject({ + provider: "antigravity", + version: "1.1.5", + digestAlgorithm: "sha512", + digest: SHA512, + allowedHosts: ["storage.googleapis.com"], + archiveFormat: "tar.gz", + executablePath: "antigravity", + }); + expect(artifact.catalogRevision).toBe(`antigravity:1.1.5:${SHA512}`); + }); + + it.each([ + ["prerelease version", { version: "1.1.6-beta.1" }, "invalid version"], + ["older incompatible version", { version: "1.1.3" }, "minimum compatible version"], + ["invalid digest", { version: "1.1.5", sha512: "not-a-digest" }, "valid SHA-512"], + [ + "untrusted artifact host", + { version: "1.1.5", url: "https://example.com/antigravity-cli/1.1.5-build/agy" }, + "untrusted artifact host", + ], + [ + "mismatched artifact version", + { + version: "1.1.5", + url: "https://storage.googleapis.com/antigravity-public/antigravity-cli/1.1.4-build/darwin-arm/cli_mac_arm64.tar.gz", + }, + "does not match its version", + ], + [ + "different Google Cloud Storage bucket", + { + version: "1.1.5", + url: "https://storage.googleapis.com/untrusted/antigravity-cli/1.1.5-build/darwin-arm/cli_mac_arm64.tar.gz", + }, + "does not match its version", + ], + ] as const)("rejects a %s", async (_label, manifest, expectedMessage) => { + mockManifest(manifest); + + await expect( + getProviderRuntimeRecipe("antigravity").resolve(TARGET, new AbortController().signal), + ).rejects.toThrow(expectedMessage); + }); + + it("preserves recipe error classification for invalid official metadata", async () => { + mockManifest({ version: "1.1.5", sha512: "broken" }); + + await expect( + getProviderRuntimeRecipe("antigravity").resolve(TARGET, new AbortController().signal), + ).rejects.toBeInstanceOf(ProviderRuntimeRecipeError); + }); +}); diff --git a/apps/server/src/provider/providerRuntimeRecipes.ts b/apps/server/src/provider/providerRuntimeRecipes.ts index 57166ae47..9667f344d 100644 --- a/apps/server/src/provider/providerRuntimeRecipes.ts +++ b/apps/server/src/provider/providerRuntimeRecipes.ts @@ -1,5 +1,13 @@ import type { ProviderKind } from "@synara/contracts"; +import { compareSemverVersions, isStableSemver } from "@synara/shared/providerVersions"; +import { + ANTIGRAVITY_ARTIFACT_HOSTS, + ANTIGRAVITY_MANIFEST_HOST, + antigravityManifestUrl, + MINIMUM_ANTIGRAVITY_CLI_VERSION, + validateAntigravityArtifactUrl, +} from "./antigravityReleaseChannel"; import type { ProviderRuntimeArtifact, ProviderRuntimeRecipe, @@ -255,41 +263,53 @@ const claudeRecipe: ProviderRuntimeRecipe = { }, }; -function antigravityPlatform(target: ProviderRuntimeTarget): string { - const arch = target.arch === "arm64" ? "arm64" : "amd64"; - if (target.platform === "linux" && target.libc === "musl") return `linux_${arch}_musl`; - const os = target.platform === "win32" ? "windows" : target.platform; - return `${os}_${arch}`; -} - const antigravityRecipe: ProviderRuntimeRecipe = { provider: "antigravity", executableName: "agy", resolve: async (target, signal) => { - const manifestHost = "antigravity-cli-auto-updater-974169037036.us-central1.run.app"; - const platform = antigravityPlatform(target); const manifest = assertRecord( await fetchJson({ - url: `https://${manifestHost}/manifests/${platform}.json`, + url: antigravityManifestUrl(target), signal, - allowedHosts: [manifestHost], + allowedHosts: [ANTIGRAVITY_MANIFEST_HOST], }), "Antigravity release manifest", ); const version = requiredString(manifest, "version", "Antigravity release manifest"); - if (version !== "1.1.4") { + if (!isStableSemver(version)) { + throw new ProviderRuntimeRecipeError( + "Antigravity's stable release manifest returned an invalid version.", + ); + } + if (compareSemverVersions(version, MINIMUM_ANTIGRAVITY_CLI_VERSION) < 0) { + throw new ProviderRuntimeRecipeError( + `Antigravity ${version} is older than Scient's minimum compatible version ${MINIMUM_ANTIGRAVITY_CLI_VERSION}.`, + ); + } + let url: string; + try { + url = validateAntigravityArtifactUrl({ + url: requiredString(manifest, "url", "Antigravity release manifest"), + version, + }); + } catch (cause) { + throw new ProviderRuntimeRecipeError( + cause instanceof Error ? cause.message : "Antigravity returned an invalid artifact URL.", + { cause }, + ); + } + const digest = requiredString(manifest, "sha512", "Antigravity release manifest").toLowerCase(); + if (!/^[0-9a-f]{128}$/u.test(digest)) { throw new ProviderRuntimeRecipeError( - "A newer Antigravity release is available but has not yet passed Scient's runtime review.", + "Antigravity release manifest does not contain a valid SHA-512 digest.", ); } - const url = requiredString(manifest, "url", "Antigravity release manifest"); - const digest = requiredString(manifest, "sha512", "Antigravity release manifest"); return { provider: "antigravity", version, target, url, - allowedHosts: ["storage.googleapis.com"], + allowedHosts: ANTIGRAVITY_ARTIFACT_HOSTS, digestAlgorithm: "sha512", digest, archiveFormat: target.platform === "win32" ? "raw" : "tar.gz", diff --git a/apps/server/src/provider/providerUpdateRuntimePolicy.test.ts b/apps/server/src/provider/providerUpdateRuntimePolicy.test.ts new file mode 100644 index 000000000..d9297e0cf --- /dev/null +++ b/apps/server/src/provider/providerUpdateRuntimePolicy.test.ts @@ -0,0 +1,46 @@ +// FILE: providerUpdateRuntimePolicy.test.ts +// Purpose: Covers authoritative managed, external, and missing update routing. +// Layer: Provider runtime policy tests + +import { describe, expect, it } from "vitest"; + +import type { ResolvedProviderRuntime } from "./Services/ProviderRuntimeManager"; +import { providerExternalUpdateBlockReason } from "./providerUpdateRuntimePolicy"; + +function runtime(overrides: Partial = {}): ResolvedProviderRuntime { + return { + source: "system", + executable: "/Users/test/.local/bin/agy", + managedVersion: null, + canInstall: false, + canRepair: false, + canRollback: false, + canRemove: false, + message: null, + ...overrides, + }; +} + +describe("providerExternalUpdateBlockReason", () => { + it("allows only a resolved external executable", () => { + expect(providerExternalUpdateBlockReason("antigravity", runtime())).toBeNull(); + }); + + it("routes Scient-managed updates through the verified runtime lifecycle", () => { + expect( + providerExternalUpdateBlockReason( + "antigravity", + runtime({ source: "managed", managedVersion: "1.1.4" }), + ), + ).toContain("verified managed update flow"); + }); + + it("rejects a missing executable with a plain setup instruction", () => { + expect( + providerExternalUpdateBlockReason( + "antigravity", + runtime({ source: "missing", executable: null, canInstall: true }), + ), + ).toBe("Antigravity is not installed. Use Set up to install it before updating."); + }); +}); diff --git a/apps/server/src/provider/providerUpdateRuntimePolicy.ts b/apps/server/src/provider/providerUpdateRuntimePolicy.ts new file mode 100644 index 000000000..fdf709bb2 --- /dev/null +++ b/apps/server/src/provider/providerUpdateRuntimePolicy.ts @@ -0,0 +1,20 @@ +// FILE: providerUpdateRuntimePolicy.ts +// Purpose: Authoritatively route provider updates by runtime ownership and availability. +// Layer: Provider runtime policy + +import { PROVIDER_DISPLAY_NAMES, type ProviderKind } from "@synara/contracts"; + +import type { ResolvedProviderRuntime } from "./Services/ProviderRuntimeManager"; + +export function providerExternalUpdateBlockReason( + provider: ProviderKind, + runtime: ResolvedProviderRuntime, +): string | null { + if (runtime.source === "managed" || runtime.source === "bundled") { + return "This runtime is managed by Scient. Use Scient's verified managed update flow instead."; + } + if (!runtime.executable) { + return `${PROVIDER_DISPLAY_NAMES[provider]} is not installed. Use Set up to install it before updating.`; + } + return null; +} diff --git a/apps/server/src/serverLogger.ts b/apps/server/src/serverLogger.ts index 1b90babaa..a9a1fef4d 100644 --- a/apps/server/src/serverLogger.ts +++ b/apps/server/src/serverLogger.ts @@ -1,15 +1,16 @@ -import fs from "node:fs"; - import { Effect, Logger } from "effect"; import * as Layer from "effect/Layer"; import { ServerConfig } from "./config"; +import { ensurePrivateDirectorySync, ensurePrivateFileSync } from "./privatePathPermissions"; export const ServerLoggerLive = Effect.gen(function* () { const { logsDir, serverLogPath } = yield* ServerConfig; + // Keep the logger safe in isolation as well as behind normal config startup. yield* Effect.sync(() => { - fs.mkdirSync(logsDir, { recursive: true }); + ensurePrivateDirectorySync(logsDir); + ensurePrivateFileSync(serverLogPath); }); const fileLogger = Logger.formatSimple.pipe(Logger.toFile(serverLogPath)); diff --git a/apps/server/src/serverPrivateDirectories.test.ts b/apps/server/src/serverPrivateDirectories.test.ts new file mode 100644 index 000000000..428b8694d --- /dev/null +++ b/apps/server/src/serverPrivateDirectories.test.ts @@ -0,0 +1,168 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { PRIVATE_DIRECTORY_MODE, PrivatePathPermissionError } from "./privatePathPermissions"; +import { + ensurePrivateScientDirectoriesSync, + type ScientDataDirectoryPaths, +} from "@synara/shared/scientDataDirectories"; + +const temporaryRoots: string[] = []; + +function makeRoot(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "scient-private-dirs-")); + temporaryRoots.push(root); + return root; +} + +function makePaths(baseDir: string): ScientDataDirectoryPaths { + const stateDir = path.join(baseDir, "userdata"); + const logsDir = path.join(stateDir, "logs"); + return { + baseDir, + stateDir, + secretsDir: path.join(stateDir, "secrets"), + worktreesDir: path.join(baseDir, "worktrees"), + attachmentsDir: path.join(stateDir, "attachments"), + logsDir, + providerLogsDir: path.join(logsDir, "provider"), + terminalLogsDir: path.join(logsDir, "terminals"), + }; +} + +function permissionMode(targetPath: string): number { + return fs.statSync(targetPath).mode & 0o777; +} + +function orderedDirectoryPaths(paths: ScientDataDirectoryPaths): readonly string[] { + return [ + paths.baseDir, + paths.stateDir, + paths.secretsDir, + paths.attachmentsDir, + paths.logsDir, + paths.providerLogsDir, + paths.terminalLogsDir, + paths.worktreesDir, + ]; +} + +afterEach(() => { + for (const root of temporaryRoots.splice(0)) { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +describe("ensurePrivateScientDirectoriesSync", () => { + it.runIf(process.platform !== "win32")( + "creates every Scient-owned directory as owner-only under common umasks", + () => { + for (const umask of [0o000, 0o002, 0o022]) { + const container = makeRoot(); + const paths = makePaths(path.join(container, `scient-home-${umask.toString(8)}`)); + const previousUmask = process.umask(umask); + try { + ensurePrivateScientDirectoriesSync(paths); + } finally { + process.umask(previousUmask); + } + + for (const directoryPath of Object.values(paths)) { + expect(permissionMode(directoryPath), directoryPath).toBe(PRIVATE_DIRECTORY_MODE); + } + } + }, + ); + + it.runIf(process.platform !== "win32")( + "repairs an existing group-writable application-data tree and is idempotent", + () => { + for (const insecureMode of [0o755, 0o775, 0o777]) { + const baseDir = path.join(makeRoot(), `scient-home-${insecureMode.toString(8)}`); + const paths = makePaths(baseDir); + for (const directoryPath of orderedDirectoryPaths(paths)) { + fs.mkdirSync(directoryPath, { recursive: true }); + fs.chmodSync(directoryPath, insecureMode); + } + + ensurePrivateScientDirectoriesSync(paths); + ensurePrivateScientDirectoriesSync(paths); + + for (const directoryPath of Object.values(paths)) { + expect(permissionMode(directoryPath), directoryPath).toBe(PRIVATE_DIRECTORY_MODE); + } + } + }, + ); + + it.runIf(process.platform !== "win32")( + "refuses symlinks at every managed boundary without repairing their targets", + () => { + for (let targetIndex = 0; targetIndex < 8; targetIndex += 1) { + const container = makeRoot(); + const paths = makePaths(path.join(container, "scient-home")); + const orderedPaths = orderedDirectoryPaths(paths); + for (const precedingPath of orderedPaths.slice(0, targetIndex)) { + fs.mkdirSync(precedingPath, { recursive: true }); + fs.chmodSync(precedingPath, PRIVATE_DIRECTORY_MODE); + } + const externalTarget = path.join(container, `target-${targetIndex}`); + fs.mkdirSync(externalTarget); + fs.chmodSync(externalTarget, 0o775); + fs.symlinkSync(externalTarget, orderedPaths[targetIndex]!, "dir"); + + expect(() => ensurePrivateScientDirectoriesSync(paths)).toThrow(PrivatePathPermissionError); + expect(permissionMode(externalTarget)).toBe(0o775); + } + }, + ); + + it.runIf(process.platform !== "win32")( + "reports a regular file at every expected directory boundary", + () => { + for (let targetIndex = 0; targetIndex < 8; targetIndex += 1) { + const container = makeRoot(); + const paths = makePaths(path.join(container, "scient-home")); + const orderedPaths = orderedDirectoryPaths(paths); + for (const precedingPath of orderedPaths.slice(0, targetIndex)) { + fs.mkdirSync(precedingPath, { recursive: true }); + fs.chmodSync(precedingPath, PRIVATE_DIRECTORY_MODE); + } + fs.writeFileSync(orderedPaths[targetIndex]!, "not-a-directory"); + + expect(() => ensurePrivateScientDirectoriesSync(paths)).toThrow(PrivatePathPermissionError); + } + }, + ); + + it.runIf(process.platform !== "win32")( + "never changes a user project outside Scient application data", + () => { + const container = makeRoot(); + const projectDir = path.join(container, "project"); + fs.mkdirSync(projectDir, { mode: 0o775 }); + fs.chmodSync(projectDir, 0o775); + + ensurePrivateScientDirectoriesSync(makePaths(path.join(container, "scient-home"))); + + expect(permissionMode(projectDir)).toBe(0o775); + }, + ); + + it("creates directories without applying POSIX chmod semantics on Windows", () => { + const baseDir = path.join(makeRoot(), "scient-home"); + const paths = makePaths(baseDir); + fs.mkdirSync(baseDir, { mode: 0o755 }); + const originalMode = permissionMode(baseDir); + + ensurePrivateScientDirectoriesSync(paths, "win32"); + + for (const directoryPath of Object.values(paths)) { + expect(fs.statSync(directoryPath).isDirectory()).toBe(true); + } + if (process.platform !== "win32") expect(permissionMode(baseDir)).toBe(originalMode); + }); +}); diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 28e7d67c2..14f84c2e7 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -30,6 +30,7 @@ describe("ServerSettingsService", () => { expect(settings.providers.grok.binaryPath).toBe("grok"); expect(settings.defaultThreadEnvMode).toBe("local"); expect(settings.enableProviderUpdateChecks).toBe(true); + expect(settings.telemetryPrivacyLevel).toBe("essential"); }); it("persists updates and reloads them", async () => { @@ -41,6 +42,7 @@ describe("ServerSettingsService", () => { yield* service.start; const updated = yield* service.updateSettings({ + telemetryPrivacyLevel: "product", enableAssistantStreaming: true, enableProviderUpdateChecks: false, providers: { @@ -56,9 +58,11 @@ describe("ServerSettingsService", () => { ); expect(result.updated.enableAssistantStreaming).toBe(true); + expect(result.updated.telemetryPrivacyLevel).toBe("product"); expect(result.updated.enableProviderUpdateChecks).toBe(false); expect(result.updated.providers.codex.binaryPath).toBe("/usr/local/bin/codex"); expect(result.parsed).toMatchObject({ + telemetryPrivacyLevel: "product", enableAssistantStreaming: true, enableProviderUpdateChecks: false, providers: { diff --git a/apps/server/src/telemetry/Identify.ts b/apps/server/src/telemetry/Identify.ts index 0f9703f80..4935ae531 100644 --- a/apps/server/src/telemetry/Identify.ts +++ b/apps/server/src/telemetry/Identify.ts @@ -1,59 +1,6 @@ -import { Effect, FileSystem, Path, Random, Schema } from "effect"; -import * as Crypto from "node:crypto"; -import { homedir } from "node:os"; +import { Effect, FileSystem, Random } from "effect"; import { ServerConfig } from "../config"; -const CodexAuthJsonSchema = Schema.Struct({ - tokens: Schema.Struct({ - account_id: Schema.String, - }), -}); - -const ClaudeJsonSchema = Schema.Struct({ - userID: Schema.String, -}); - -class IdentifyUserError extends Schema.TaggedErrorClass()("IdentifyUserError", { - message: Schema.String, - cause: Schema.optional(Schema.Defect), -}) {} - -const hash = (value: string) => - Effect.try({ - try: () => Crypto.createHash("sha256").update(value).digest("hex"), - catch: (error) => - new IdentifyUserError({ - message: "Failed to hash identifier", - cause: error, - }), - }); - -const getCodexAccountId = Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - - const authJsonPath = path.join(homedir(), ".codex", "auth.json"); - const authJson = yield* Effect.flatMap( - fileSystem.readFileString(authJsonPath), - Schema.decodeEffect(Schema.fromJsonString(CodexAuthJsonSchema)), - ); - - return authJson.tokens.account_id; -}); - -const getClaudeUserId = Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - - const claudeJsonPath = path.join(homedir(), ".claude.json"); - const claudeJson = yield* Effect.flatMap( - fileSystem.readFileString(claudeJsonPath), - Schema.decodeEffect(Schema.fromJsonString(ClaudeJsonSchema)), - ); - - return claudeJson.userID; -}); - const upsertAnonymousId = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const { anonymousIdPath } = yield* ServerConfig; @@ -68,29 +15,22 @@ const upsertAnonymousId = Effect.gen(function* () { ), ); - return anonymousId; + const trimmed = anonymousId.trim(); + if (trimmed.length > 0) return trimmed; + + const randomId = yield* Random.nextUUIDv4; + yield* fileSystem.writeFileString(anonymousIdPath, randomId); + return randomId; }); /** - * getTelemetryIdentifier - Users are "identified" by finding the first match of the following, then hashing the value. - * 1. ~/.codex/auth.json tokens.account_id - * 2. ~/.claude.json userID - * 3. SYNARA_HOME anonymous-id file (for example ~/.synara/userdata/anonymous-id) + * Returns a random installation-scoped identifier stored in Scient's state directory. + * It never reads or derives identity from connected AI-provider accounts. */ export const getTelemetryIdentifier = Effect.gen(function* () { - const codexAccountId = yield* Effect.result(getCodexAccountId); - if (codexAccountId._tag === "Success") { - return yield* hash(codexAccountId.success); - } - - const claudeUserId = yield* Effect.result(getClaudeUserId); - if (claudeUserId._tag === "Success") { - return yield* hash(claudeUserId.success); - } - const anonymousId = yield* Effect.result(upsertAnonymousId); if (anonymousId._tag === "Success") { - return yield* hash(anonymousId.success); + return `installation:${anonymousId.success}`; } return null; diff --git a/apps/server/src/telemetry/Layers/AnalyticsService.test.ts b/apps/server/src/telemetry/Layers/AnalyticsService.test.ts index ebcf2efde..98be2af56 100644 --- a/apps/server/src/telemetry/Layers/AnalyticsService.test.ts +++ b/apps/server/src/telemetry/Layers/AnalyticsService.test.ts @@ -7,6 +7,7 @@ import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; import { getTelemetryIdentifier } from "../Identify.ts"; import { AnalyticsService } from "../Services/AnalyticsService.ts"; import { AnalyticsServiceLayerLive } from "./AnalyticsService.ts"; @@ -14,22 +15,39 @@ import { AnalyticsServiceLayerLive } from "./AnalyticsService.ts"; interface RecordedBatchRequest { readonly path: string; readonly body: { - readonly batch?: ReadonlyArray<{ - readonly event?: string; + readonly schema_version?: number; + readonly source?: string; + readonly events?: ReadonlyArray<{ + readonly id?: string; + readonly name?: string; + readonly distinct_id?: string; + readonly session_id?: string; + readonly occurred_at?: string; + readonly privacy_level?: string; + readonly consent_level?: string; readonly properties?: { readonly index?: number; readonly clientType?: string; + readonly prompt?: string; }; }>; } | null; } interface RecordedBatchBody { - readonly batch: ReadonlyArray<{ - readonly event?: string; + readonly schema_version: number; + readonly source: string; + readonly events: ReadonlyArray<{ + readonly id?: string; + readonly name?: string; + readonly distinct_id?: string; + readonly session_id?: string; + readonly privacy_level?: string; + readonly consent_level?: string; readonly properties?: { readonly index?: number; readonly clientType?: string; + readonly prompt?: string; }; }>; } @@ -42,12 +60,14 @@ it.layer(NodeServices.layer)("AnalyticsService test", (it) => { prefix: "synara-telemetry-base-", }); - const telemetryLayer = AnalyticsServiceLayerLive.pipe(Layer.provideMerge(serverConfigLayer)); + const telemetryLayer = AnalyticsServiceLayerLive.pipe( + Layer.provideMerge(serverConfigLayer), + Layer.provideMerge(ServerSettingsService.layerTest({ telemetryPrivacyLevel: "product" })), + ); const configLayer = ConfigProvider.layer( ConfigProvider.fromUnknown({ SYNARA_TELEMETRY_ENABLED: true, - SYNARA_POSTHOG_KEY: "phc_test_key", - SYNARA_POSTHOG_HOST: "", + SYNARA_TELEMETRY_ENDPOINT: "/v1/events", SYNARA_TELEMETRY_FLUSH_BATCH_SIZE: 20, }), ); @@ -80,7 +100,10 @@ it.layer(NodeServices.layer)("AnalyticsService test", (it) => { const analytics = yield* AnalyticsService; for (let index = 0; index < 45; index += 1) { - yield* analytics.record("test.flush.drain", { index }); + yield* analytics.record("test.flush.drain", { + index, + prompt: "must never leave the device", + }); } yield* analytics.flush; @@ -88,16 +111,16 @@ it.layer(NodeServices.layer)("AnalyticsService test", (it) => { const batchRequests = capturedRequests.filter( (request): request is RecordedBatchRequest & { readonly body: RecordedBatchBody } => - Array.isArray(request.body?.batch), + Array.isArray(request.body?.events), ); assert.equal(batchRequests.length, 3); assert.equal( - batchRequests.every((request) => request.path === "/batch/" || request.path === "/batch"), + batchRequests.every((request) => request.path === "/v1/events"), true, ); const deliveredIndexes = batchRequests.flatMap((request) => - request.body.batch - .filter((event) => event.event === "test.flush.drain") + request.body.events + .filter((event) => event.name === "test.flush.drain") .map((event) => event.properties?.index) .filter((index): index is number => typeof index === "number"), ); @@ -109,11 +132,77 @@ it.layer(NodeServices.layer)("AnalyticsService test", (it) => { Array.from({ length: 45 }, (_, index) => index), ); assert.equal( - batchRequests.every((request) => - request.body.batch.every((event) => event.properties?.clientType === "cli-web-client"), + batchRequests.every( + (request) => + request.body.schema_version === 2 && + request.body.source === "desktop" && + request.body.events.every( + (event) => + event.properties?.clientType === "cli-web-client" && + event.properties?.prompt === undefined && + event.privacy_level === "product" && + event.consent_level === "product" && + event.session_id?.startsWith("session:") === true && + event.distinct_id?.startsWith("installation:") === true && + typeof event.id === "string", + ), ), true, ); }), ); + + it.effect("essential mode sends heartbeat but not product workflow events", () => + Effect.gen(function* () { + const capturedRequests: Array = []; + const serverConfigLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "synara-telemetry-essential-", + }); + const telemetryLayer = AnalyticsServiceLayerLive.pipe( + Layer.provideMerge(serverConfigLayer), + Layer.provideMerge(ServerSettingsService.layerTest({ telemetryPrivacyLevel: "essential" })), + ); + const configLayer = ConfigProvider.layer( + ConfigProvider.fromUnknown({ + SYNARA_TELEMETRY_ENABLED: true, + SYNARA_TELEMETRY_ENDPOINT: "/v1/events", + }), + ); + const batchServerLayer = HttpServer.serve( + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const payload = yield* request.json.pipe( + Effect.map((body) => body as RecordedBatchRequest["body"]), + Effect.catch(() => Effect.succeed(null)), + ); + capturedRequests.push({ path: request.url, body: payload }); + return HttpServerResponse.jsonUnsafe({}); + }), + ); + const runtimeLayer = telemetryLayer.pipe( + Layer.provide(configLayer), + Layer.provideMerge(NodeHttpServer.layerTest), + ); + + yield* Effect.gen(function* () { + yield* Layer.launch(batchServerLayer).pipe(Effect.forkScoped); + const analytics = yield* AnalyticsService; + yield* analytics.record("provider.turn.sent", { provider: "codex" }); + yield* analytics.record( + "provider.diagnostic.sample", + { provider: "codex" }, + { privacyLevel: "diagnostic" }, + ); + yield* analytics.record("server.boot.heartbeat", { threadCount: 2, projectCount: 1 }); + yield* analytics.flush; + }).pipe(Effect.provide(runtimeLayer)); + + const names = capturedRequests.flatMap( + (request) => request.body?.events?.map((event) => event.name) ?? [], + ); + assert.deepEqual(names, ["server.boot.heartbeat"]); + assert.equal(capturedRequests[0]?.body?.events?.[0]?.privacy_level, "essential"); + assert.equal(capturedRequests[0]?.body?.events?.[0]?.consent_level, "essential"); + }), + ); }); diff --git a/apps/server/src/telemetry/Layers/AnalyticsService.ts b/apps/server/src/telemetry/Layers/AnalyticsService.ts index 035c050ee..2cacacd8d 100644 --- a/apps/server/src/telemetry/Layers/AnalyticsService.ts +++ b/apps/server/src/telemetry/Layers/AnalyticsService.ts @@ -1,32 +1,91 @@ /** - * AnalyticsServiceLive - Anonymous PostHog telemetry layer. + * AnalyticsServiceLive - First-party ScientFactory telemetry layer. * * Persists a random installation-scoped anonymous id to state dir, buffers - * events in memory, and flushes batches to PostHog over Effect HttpClient. + * events in memory, and flushes batches to the ScientFactory event gateway. * * @module AnalyticsServiceLive */ +import type { TelemetryPrivacyLevel } from "@synara/contracts"; import { Config, DateTime, Effect, Layer, Ref } from "effect"; import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; +import { randomUUID } from "node:crypto"; import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; import { AnalyticsService, type AnalyticsServiceShape } from "../Services/AnalyticsService.ts"; import { getTelemetryIdentifier } from "../Identify.ts"; import { version } from "../../../package.json" with { type: "json" }; interface BufferedAnalyticsEvent { + readonly id: string; readonly event: string; readonly properties?: Readonly>; readonly capturedAt: string; + readonly privacyLevel: Exclude; + readonly consentLevel: Exclude; +} + +const PRIVACY_RANK: Readonly> = { + off: 0, + essential: 1, + product: 2, + diagnostic: 3, + contribution: 4, +}; + +const ALLOWED_PROPERTY_NAMES = new Set([ + "attachmentCount", + "decision", + "hasCwd", + "hasInput", + "hasModel", + "hasResumeCursor", + "index", + "interactionMode", + "model", + "projectCount", + "provider", + "runtimeMode", + "sessionCount", + "strategy", + "target", + "threadCount", + "turns", +]); + +function defaultEventPrivacyLevel(event: string): Exclude { + return event === "server.boot.heartbeat" ? "essential" : "product"; +} + +function canCapture( + configured: TelemetryPrivacyLevel, + required: Exclude, +): boolean { + return PRIVACY_RANK[configured] >= PRIVACY_RANK[required]; +} + +function sanitizedProperties( + properties: Readonly> | undefined, +): Readonly> | undefined { + if (!properties) return undefined; + const safe = Object.fromEntries( + Object.entries(properties).filter( + ([key, value]) => + ALLOWED_PROPERTY_NAMES.has(key) && + (typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" || + value === null), + ), + ); + return Object.keys(safe).length > 0 ? safe : undefined; } const TelemetryEnvConfig = Config.all({ - posthogKey: Config.string("SYNARA_POSTHOG_KEY").pipe( - Config.withDefault("phc_XOWci4oZP4VvLiEyrFqkFjP4CZn55mjYYBMREK5Wd6m"), - ), - posthogHost: Config.string("SYNARA_POSTHOG_HOST").pipe( - Config.withDefault("https://us.i.posthog.com"), + endpoint: Config.string("SYNARA_TELEMETRY_ENDPOINT").pipe( + Config.withDefault("https://events.scientfactory.com/v1/events"), ), enabled: Config.boolean("SYNARA_TELEMETRY_ENABLED").pipe(Config.withDefault(true)), flushBatchSize: Config.number("SYNARA_TELEMETRY_FLUSH_BATCH_SIZE").pipe(Config.withDefault(20)), @@ -39,19 +98,33 @@ const makeAnalyticsService = Effect.gen(function* () { const telemetryConfig = yield* TelemetryEnvConfig.asEffect(); const httpClient = yield* HttpClient.HttpClient; const serverConfig = yield* ServerConfig; + const serverSettings = yield* ServerSettingsService; const identifier = yield* getTelemetryIdentifier; + const sessionId = `session:${randomUUID()}`; const bufferRef = yield* Ref.make>([]); const clientType = serverConfig.mode === "desktop" ? "desktop-app" : "cli-web-client"; - - const enqueueBufferedEvent = (event: string, properties?: Readonly>) => + const currentPrivacyLevel = serverSettings.getSettings.pipe( + Effect.map((settings) => settings.telemetryPrivacyLevel), + Effect.orElseSucceed(() => "essential" as const), + ); + + const enqueueBufferedEvent = ( + event: string, + privacyLevel: Exclude, + consentLevel: Exclude, + properties?: Readonly>, + ) => Effect.flatMap(DateTime.now, (now) => Ref.modify(bufferRef, (current) => { const appended = [ ...current, { + id: randomUUID(), event, ...(properties ? { properties } : {}), capturedAt: DateTime.formatIso(now), + privacyLevel, + consentLevel, } satisfies BufferedAnalyticsEvent, ]; @@ -75,24 +148,29 @@ const makeAnalyticsService = Effect.gen(function* () { if (!telemetryConfig.enabled || !identifier) return; const payload = { - api_key: telemetryConfig.posthogKey, - batch: events.map((event) => ({ - event: event.event, + schema_version: 2, + source: "desktop", + sent_at: new Date().toISOString(), + events: events.map((event) => ({ + id: event.id, + name: event.event, distinct_id: identifier, + session_id: sessionId, + occurred_at: event.capturedAt, + privacy_level: event.privacyLevel, + consent_level: event.consentLevel, properties: { ...event.properties, - $process_person_profile: false, platform: process.platform, wsl: process.env.WSL_DISTRO_NAME, arch: process.arch, synaraCodeVersion: version, clientType, }, - timestamp: event.capturedAt, })), }; - yield* HttpClientRequest.post(`${telemetryConfig.posthogHost}/batch/`).pipe( + yield* HttpClientRequest.post(telemetryConfig.endpoint).pipe( HttpClientRequest.bodyJson(payload), Effect.flatMap(httpClient.execute), Effect.flatMap(HttpClientResponse.filterStatusOk), @@ -101,12 +179,16 @@ const makeAnalyticsService = Effect.gen(function* () { const flush: AnalyticsServiceShape["flush"] = Effect.gen(function* () { while (true) { + const configuredLevel = yield* currentPrivacyLevel; const batch = yield* Ref.modify(bufferRef, (current) => { - if (current.length === 0) { - return [[] as ReadonlyArray, current] as const; + const permitted = current.filter((event) => + canCapture(configuredLevel, event.privacyLevel), + ); + if (permitted.length === 0) { + return [[] as ReadonlyArray, permitted] as const; } - const nextBatch = current.slice(0, telemetryConfig.flushBatchSize); - const remaining = current.slice(nextBatch.length); + const nextBatch = permitted.slice(0, telemetryConfig.flushBatchSize); + const remaining = permitted.slice(nextBatch.length); return [nextBatch, remaining] as const; }); @@ -124,17 +206,28 @@ const makeAnalyticsService = Effect.gen(function* () { } }).pipe(Effect.catch((cause) => Effect.logError("Failed to flush telemetry", { cause }))); - const record: AnalyticsServiceShape["record"] = Effect.fnUntraced(function* (event, properties) { - if (!telemetryConfig.enabled || !identifier) return; + const record: AnalyticsServiceShape["record"] = Effect.fnUntraced( + function* (event, properties, options) { + if (!telemetryConfig.enabled || !identifier) return; + + const configuredLevel = yield* currentPrivacyLevel; + const privacyLevel = options?.privacyLevel ?? defaultEventPrivacyLevel(event); + if (!canCapture(configuredLevel, privacyLevel) || configuredLevel === "off") return; - const enqueueResult = yield* enqueueBufferedEvent(event, properties); - if (enqueueResult.dropped) { - yield* Effect.logDebug("analytics buffer full; dropping oldest event", { - size: enqueueResult.size, + const enqueueResult = yield* enqueueBufferedEvent( event, - }); - } - }); + privacyLevel, + configuredLevel, + sanitizedProperties(properties), + ); + if (enqueueResult.dropped) { + yield* Effect.logDebug("analytics buffer full; dropping oldest event", { + size: enqueueResult.size, + event, + }); + } + }, + ); yield* Effect.forever(Effect.sleep(1000).pipe(Effect.flatMap(() => flush)), { disableYield: true, diff --git a/apps/server/src/telemetry/Services/AnalyticsService.ts b/apps/server/src/telemetry/Services/AnalyticsService.ts index 3395c252f..1bae91209 100644 --- a/apps/server/src/telemetry/Services/AnalyticsService.ts +++ b/apps/server/src/telemetry/Services/AnalyticsService.ts @@ -6,8 +6,14 @@ * * @module AnalyticsService */ +import type { TelemetryPrivacyLevel } from "@synara/contracts"; import { Effect, Layer, ServiceMap } from "effect"; +export interface AnalyticsRecordOptions { + /** Minimum user-selected level required before this event may leave the device. */ + readonly privacyLevel?: Exclude; +} + export interface AnalyticsServiceShape { /** * Capture an event immediately; returns typed failure when capture fails. @@ -15,6 +21,7 @@ export interface AnalyticsServiceShape { readonly record: ( event: string, properties?: Readonly>, + options?: AnalyticsRecordOptions, ) => Effect.Effect; /** diff --git a/apps/server/src/terminal/Layers/Manager.test.ts b/apps/server/src/terminal/Layers/Manager.test.ts index 89c33a760..0091c5dad 100644 --- a/apps/server/src/terminal/Layers/Manager.test.ts +++ b/apps/server/src/terminal/Layers/Manager.test.ts @@ -509,6 +509,35 @@ describe("TerminalManager", () => { manager.dispose(); }); + it("captures an exact monotonic output barrier in reconnect snapshots", async () => { + const { manager, ptyAdapter } = makeManager(); + const outputEvents: Array> = []; + manager.on("event", (event) => { + if (event.type === "output") outputEvents.push(event); + }); + const initial = await manager.open(openInput()); + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + + expect(initial.outputSequence).toBe(0); + expect(initial.outputEpoch).not.toBe(""); + process.emitData("before snapshot\n"); + await waitFor(() => outputEvents.length === 1); + + const snapshot = await manager.open(openInput()); + expect(snapshot.history).toContain("before snapshot"); + expect(snapshot.outputSequence).toBe(1); + expect(snapshot.outputEpoch).toBe(initial.outputEpoch); + + process.emitData("after snapshot\n"); + await waitFor(() => outputEvents.length === 2); + expect(outputEvents.map((event) => event.outputSequence)).toEqual([1, 2]); + expect(outputEvents.every((event) => event.outputEpoch === initial.outputEpoch)).toBe(true); + + manager.dispose(); + }); + it("includes live terminal mode replay preamble in reattach snapshots", async () => { const { manager, ptyAdapter } = makeManager(); await manager.open(openInput()); diff --git a/apps/server/src/terminal/Layers/Manager.ts b/apps/server/src/terminal/Layers/Manager.ts index a6cf16dd5..1a525a565 100644 --- a/apps/server/src/terminal/Layers/Manager.ts +++ b/apps/server/src/terminal/Layers/Manager.ts @@ -2,6 +2,7 @@ // Purpose: Implements server-side terminal sessions, cleanup orchestration, history persistence, and PTY output flow control. // Layer: Terminal infrastructure // Depends on: PTY adapters, process-tree cleanup helpers, shared terminal contracts, and server config. +import { randomUUID } from "node:crypto"; import { EventEmitter } from "node:events"; import fs from "node:fs"; import path from "node:path"; @@ -1001,6 +1002,7 @@ interface KillEscalationHandle { } export class TerminalManagerRuntime extends EventEmitter { + private readonly outputEpoch = randomUUID(); private readonly sessions = new Map(); private readonly logsDir: string; private managedWrapperBinDir: string | null; @@ -1124,6 +1126,8 @@ export class TerminalManagerRuntime extends EventEmitter modeReplayTracker: null, pendingOutputChunks: [], pendingOutputLength: 0, + outputSequence: 0, + outputEpoch: this.outputEpoch, outputFlushTimer: null, streamOutput: input.streamOutput ?? true, outputPaused: false, @@ -1333,6 +1337,8 @@ export class TerminalManagerRuntime extends EventEmitter modeReplayTracker: null, pendingOutputChunks: [], pendingOutputLength: 0, + outputSequence: 0, + outputEpoch: this.outputEpoch, outputFlushTimer: null, // Restart has no headless mode of its own; fresh sessions stream normally // and existing sessions (below) keep whatever mode they were opened with. @@ -1685,6 +1691,7 @@ export class TerminalManagerRuntime extends EventEmitter // history above, but skip the live broadcast so unviewed background output // never reaches the WebSocket fanout. if (session.streamOutput) { + session.outputSequence += 1; this.emitEvent({ type: "output", threadId: session.threadId, @@ -1692,6 +1699,8 @@ export class TerminalManagerRuntime extends EventEmitter createdAt: new Date().toISOString(), data, byteLength, + outputEpoch: session.outputEpoch, + outputSequence: session.outputSequence, }); } if (session.outputAckObserved) { @@ -2476,6 +2485,8 @@ export class TerminalManagerRuntime extends EventEmitter status: session.status, pid: session.pid, history: session.history.toString(), + outputEpoch: session.outputEpoch, + outputSequence: session.outputSequence, ...(replayPreamble.length > 0 ? { replayPreamble } : {}), exitCode: session.exitCode, exitSignal: session.exitSignal, diff --git a/apps/server/src/terminal/Services/Manager.ts b/apps/server/src/terminal/Services/Manager.ts index 4d8615c41..556f6e250 100644 --- a/apps/server/src/terminal/Services/Manager.ts +++ b/apps/server/src/terminal/Services/Manager.ts @@ -63,6 +63,10 @@ export interface TerminalSessionState { pendingOutputChunks: string[]; /** Total UTF-8 byte length of buffered output chunks. */ pendingOutputLength: number; + /** Monotonic barrier for emitted output batches and reconnect snapshots. */ + outputSequence: number; + /** Identifies the server process that owns the output-sequence namespace. */ + outputEpoch: string; /** Timer handle for the next scheduled output flush. */ outputFlushTimer: ReturnType | null; /** diff --git a/apps/server/src/wsRpc.ts b/apps/server/src/wsRpc.ts index 75778171e..66e216d58 100644 --- a/apps/server/src/wsRpc.ts +++ b/apps/server/src/wsRpc.ts @@ -57,6 +57,8 @@ import { discoverSkillsCatalog, synaraSkillsDir } from "./provider/skillsCatalog import { ProviderAdapterRegistry } from "./provider/Services/ProviderAdapterRegistry"; import { ProviderHealth } from "./provider/Services/ProviderHealth"; import { ProviderConnection } from "./provider/Services/ProviderConnection"; +import { ProviderClientStatusProjection } from "./provider/Services/ProviderClientStatusProjection"; +import { providerExternalUpdateBlockReason } from "./provider/providerUpdateRuntimePolicy"; import { ProviderRuntimeManager } from "./provider/Services/ProviderRuntimeManager"; import { ProviderService } from "./provider/Services/ProviderService"; import { listProviderUsage } from "./providerUsage"; @@ -275,6 +277,7 @@ export const makeWsRpcLayer = () => const providerDiscoveryService = yield* ProviderDiscoveryService; const providerHealth = yield* ProviderHealth; const providerConnection = yield* ProviderConnection; + const providerClientStatusProjection = yield* ProviderClientStatusProjection; const providerRuntimeManager = yield* ProviderRuntimeManager; const providerService = yield* ProviderService; const lifecycleEvents = yield* ServerLifecycleEvents; @@ -287,54 +290,6 @@ export const makeWsRpcLayer = () => const workspaceFileSystem = yield* WorkspaceFileSystem; const scientProjectInitialization = new ScientProjectInitializationService(); - const enrichProviderStatuses = ( - statuses: ReadonlyArray, - ) => - serverSettings.getSettings.pipe( - Effect.catch(() => Effect.succeed(null)), - Effect.flatMap((settings) => - Effect.forEach( - statuses, - (status) => - providerRuntimeManager - .resolve(status.provider, settings?.providers[status.provider].binaryPath) - .pipe( - Effect.zip(providerRuntimeManager.getSnapshot(status.provider)), - Effect.map(([runtime, snapshot]) => { - const appManaged = - runtime.source === "managed" || runtime.source === "bundled"; - return { - ...status, - ...(appManaged && status.versionAdvisory - ? { - versionAdvisory: { - ...status.versionAdvisory, - canUpdate: false, - updateCommand: null, - message: "Updates for this runtime are managed by Scient.", - }, - } - : {}), - runtime: { - source: runtime.source, - managedVersion: runtime.managedVersion, - canInstall: runtime.canInstall, - canRepair: runtime.canRepair, - canRollback: runtime.canRollback, - canRemove: runtime.canRemove, - message: runtime.message, - }, - ...(snapshot.installationState - ? { installationState: snapshot.installationState } - : {}), - }; - }), - ), - { concurrency: "unbounded" }, - ), - ), - ); - const isGlobalGitHubCliError = (error: unknown): error is GitHubCliError => error instanceof GitHubCliError && (error.reason === "not-installed" || error.reason === "not-authenticated"); @@ -525,9 +480,7 @@ export const makeWsRpcLayer = () => const loadServerConfig = Effect.gen(function* () { const keybindingsConfig = yield* keybindings.loadConfigState; - const providerStatuses = yield* providerHealth.getStatuses.pipe( - Effect.flatMap(enrichProviderStatuses), - ); + const providerStatuses = yield* providerClientStatusProjection.getStatuses; return { cwd: config.cwd, homeDir: config.homeDir, @@ -768,7 +721,7 @@ export const makeWsRpcLayer = () => [WS_METHODS.projectsListDevServers]: () => rpcEffect(devServerManager.list, "Failed to list dev servers"), [WS_METHODS.subscribeProjectDevServerEvents]: () => - Stream.concat( + bufferLiveWhileInitialStreamLoads( Stream.fromEffect( devServerManager.list.pipe( Effect.map( @@ -1082,36 +1035,51 @@ export const makeWsRpcLayer = () => ), [WS_METHODS.serverRefreshProviders]: () => rpcEffect( - providerHealth.refresh.pipe(Effect.map((providers) => ({ providers }))), + providerClientStatusProjection.refreshStatuses.pipe( + Effect.map((providers) => ({ providers })), + ), "Failed to refresh providers", ), - [WS_METHODS.serverStartProviderConnection]: (input) => providerConnection.start(input), - [WS_METHODS.serverCancelProviderConnection]: (input) => providerConnection.cancel(input), + [WS_METHODS.serverStartProviderConnection]: (input) => + providerConnection.start(input).pipe( + Effect.andThen(providerClientStatusProjection.getStatuses), + Effect.map((providers) => ({ providers })), + ), + [WS_METHODS.serverCancelProviderConnection]: (input) => + providerConnection.cancel(input).pipe( + Effect.andThen(providerClientStatusProjection.getStatuses), + Effect.map((providers) => ({ providers })), + ), + [WS_METHODS.serverSubmitProviderConnectionAuthorizationCode]: (input) => + providerConnection.submitAuthorizationCode(input).pipe( + Effect.andThen(providerClientStatusProjection.getStatuses), + Effect.map((providers) => ({ providers })), + ), [WS_METHODS.serverPrepareProviderInstall]: (input) => providerRuntimeManager.prepareInstall(input.provider), [WS_METHODS.serverInstallProvider]: (input) => providerRuntimeManager.install(input).pipe( - Effect.andThen(providerHealth.getStatuses.pipe(Effect.flatMap(enrichProviderStatuses))), + Effect.andThen(providerClientStatusProjection.getStatuses), Effect.map((providers) => ({ providers })), ), [WS_METHODS.serverCancelProviderInstall]: (input) => providerRuntimeManager.cancel(input).pipe( - Effect.andThen(providerHealth.getStatuses.pipe(Effect.flatMap(enrichProviderStatuses))), + Effect.andThen(providerClientStatusProjection.getStatuses), Effect.map((providers) => ({ providers })), ), [WS_METHODS.serverRepairProvider]: (input) => providerRuntimeManager.repair(input).pipe( - Effect.andThen(providerHealth.getStatuses.pipe(Effect.flatMap(enrichProviderStatuses))), + Effect.andThen(providerClientStatusProjection.getStatuses), Effect.map((providers) => ({ providers })), ), [WS_METHODS.serverRollbackProvider]: (input) => providerRuntimeManager.rollback(input).pipe( - Effect.andThen(providerHealth.getStatuses.pipe(Effect.flatMap(enrichProviderStatuses))), + Effect.andThen(providerClientStatusProjection.getStatuses), Effect.map((providers) => ({ providers })), ), [WS_METHODS.serverRemoveManagedProvider]: (input) => providerRuntimeManager.remove(input).pipe( - Effect.andThen(providerHealth.getStatuses.pipe(Effect.flatMap(enrichProviderStatuses))), + Effect.andThen(providerClientStatusProjection.getStatuses), Effect.map((providers) => ({ providers })), ), [WS_METHODS.serverUpdateProvider]: (input) => @@ -1129,17 +1097,20 @@ export const makeWsRpcLayer = () => settings.providers[input.provider].binaryPath, ), ), - Effect.flatMap((runtime) => - runtime.source === "managed" || runtime.source === "bundled" + Effect.flatMap((runtime) => { + const blockReason = providerExternalUpdateBlockReason(input.provider, runtime); + return blockReason ? Effect.fail( new ServerProviderUpdateError({ provider: input.provider, - reason: - "This runtime is managed by Scient. Use the managed install, repair, or rollback controls instead.", + reason: blockReason, }), ) - : providerHealth.updateProvider(input), - ), + : providerHealth.updateProvider(input).pipe( + Effect.andThen(providerClientStatusProjection.getStatuses), + Effect.map((providers) => ({ providers })), + ); + }), ), [WS_METHODS.serverListWorktrees]: () => Effect.succeed({ worktrees: [] }), [WS_METHODS.serverListLocalServers]: () => @@ -1259,7 +1230,7 @@ export const makeWsRpcLayer = () => "Failed to update keybinding", ), [WS_METHODS.subscribeServerLifecycle]: () => - Stream.concat( + bufferLiveWhileInitialStreamLoads( Stream.fromEffect( lifecycleEvents.snapshot.pipe( Effect.map((snapshot) => @@ -1284,7 +1255,7 @@ export const makeWsRpcLayer = () => ), ), [WS_METHODS.subscribeServerConfig]: () => - Stream.concat( + bufferLiveWhileInitialStreamLoads( Stream.fromEffect( loadServerConfig.pipe( Effect.map( @@ -1303,7 +1274,7 @@ export const makeWsRpcLayer = () => })), ), Stream.merge( - bufferLiveUiStream(providerHealth.streamChanges, { + bufferLiveUiStream(providerClientStatusProjection.streamChanges, { label: "server.provider-statuses", onDroppedEvents: failLiveUiStreamForSnapshotResync, }).pipe( @@ -1325,28 +1296,19 @@ export const makeWsRpcLayer = () => ), ).pipe(Stream.mapError((cause) => toWsRpcError(cause, "Server config stream failed"))), [WS_METHODS.subscribeServerProviderStatuses]: () => - Stream.concat( + bufferLiveWhileInitialStreamLoads( Stream.fromEffect( - providerHealth.getStatuses.pipe( - Effect.flatMap(enrichProviderStatuses), + providerClientStatusProjection.getStatuses.pipe( Effect.map((providers) => ({ providers })), ), ), - Stream.merge( - bufferLiveUiStream(providerHealth.streamChanges, { - label: "server.provider-statuses", - onDroppedEvents: failLiveUiStreamForSnapshotResync, - }), - providerRuntimeManager.streamChanges.pipe( - Stream.mapEffect(() => providerHealth.getStatuses), - ), - ).pipe( - Stream.mapEffect(enrichProviderStatuses), - Stream.map((providers) => ({ providers })), - ), + bufferLiveUiStream(providerClientStatusProjection.streamChanges, { + label: "server.provider-statuses", + onDroppedEvents: failLiveUiStreamForSnapshotResync, + }).pipe(Stream.map((providers) => ({ providers }))), ), [WS_METHODS.subscribeServerSettings]: () => - Stream.concat( + bufferLiveWhileInitialStreamLoads( Stream.fromEffect( serverSettings.getSettings.pipe(Effect.map((settings) => ({ settings }))), ), @@ -1414,7 +1376,7 @@ export const makeWsRpcLayer = () => [WS_METHODS.automationArchiveRun]: (input) => rpcEffect(automationService.archiveRun(input), "Failed to update automation run"), [WS_METHODS.subscribeAutomationEvents]: () => - Stream.merge( + bufferLiveWhileInitialStreamLoads( Stream.fromEffect( automationService.list({}).pipe( Effect.map(({ definitions, runs }) => ({ diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index e5e1a1382..6efa9a708 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -15,6 +15,7 @@ import { type ProviderStartOptions, type ServerSettings, type ServerSettingsPatch, + TelemetryPrivacyLevel, } from "@synara/contracts"; import { getDefaultModel, @@ -156,6 +157,7 @@ const PersistedProviderKind = Schema.Literals([ ); export const AppSettingsSchema = Schema.Struct({ + telemetryPrivacyLevel: TelemetryPrivacyLevel.pipe(withDefaults(() => "essential" as const)), claudeBinaryPath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")), uiDensity: UiDensity.pipe(withDefaults(() => DEFAULT_UI_DENSITY)), chatFontSizePx: Schema.Number.pipe(withDefaults(() => DEFAULT_CHAT_FONT_SIZE_PX)), @@ -510,6 +512,7 @@ function normalizeAppSettings(settings: AppSettings): AppSettings { function serverSettingsToAppSettings(settings: ServerSettings): Partial { return { + telemetryPrivacyLevel: settings.telemetryPrivacyLevel, claudeBinaryPath: settings.providers.claudeAgent.binaryPath, codexBinaryPath: settings.providers.codex.binaryPath, codexHomePath: settings.providers.codex.homePath, @@ -576,6 +579,10 @@ function appSettingsPatchToServerSettingsPatch(patch: Partial): Ser const providers: MutableServerSettingsProvidersPatch = {}; const serverPatch: MutableServerSettingsPatch = {}; + if (patch.telemetryPrivacyLevel !== undefined) { + serverPatch.telemetryPrivacyLevel = patch.telemetryPrivacyLevel; + } + if (hasOwn(patch, "enableAssistantStreaming")) { serverPatch.enableAssistantStreaming = Boolean(patch.enableAssistantStreaming); } @@ -722,6 +729,7 @@ function buildInitialServerSettingsMigrationPatch(settings: AppSettings): Server const defaults = DEFAULT_APP_SETTINGS; for (const key of [ + "telemetryPrivacyLevel", "claudeBinaryPath", "codexBinaryPath", "codexHomePath", diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 27b1986b8..2e3c6dfea 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -157,8 +157,8 @@ export function RuntimeUsageControls({ )} title={ runtimeMode === "full-access" - ? "Full access — click to change permissions" - : "Default permissions — click to change permissions" + ? "Unrestricted agent — the AI provider can act without asking inside the selected workspace. This does not grant macOS Full Disk Access." + : "Ask before changes — the AI provider requests approval for sensitive actions. This does not control macOS folder access." } /> } @@ -170,7 +170,7 @@ export function RuntimeUsageControls({ )} - {runtimeMode === "full-access" ? "Full access" : "Default permissions"} + {runtimeMode === "full-access" ? "Unrestricted" : "Ask first"} - + { @@ -200,13 +200,13 @@ export function RuntimeUsageControls({ > - Full access + Unrestricted agent - Default permissions + Ask before changes diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index e563edf54..10f598ac2 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -44,6 +44,7 @@ import { useSplitViewStore } from "../splitViewStore"; import { useStore } from "../store"; import { createShellSnapshotFromReadModel, + createTestEnvironmentDescriptor, flattenEffectRpcRequestPayload, readEffectRpcClientMessage, sendEffectRpcChunk, @@ -1021,6 +1022,9 @@ function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown { if (tag === WS_METHODS.serverGetConfig) { return fixture.serverConfig; } + if (tag === WS_METHODS.serverGetEnvironment) { + return createTestEnvironmentDescriptor(); + } if (tag === WS_METHODS.gitListBranches) { const cwd = typeof body.cwd === "string" ? body.cwd : null; const branchName = cwd ? (fixture.gitBranchByCwd[cwd] ?? "main") : "main"; @@ -1082,6 +1086,8 @@ function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown { status: "running", pid: 123, history: "", + outputEpoch: "epoch-1", + outputSequence: 0, exitCode: null, exitSignal: null, updatedAt: NOW_ISO, @@ -1250,8 +1256,13 @@ const worker = setupWorker( method === WS_METHODS.subscribeServerProviderStatuses || method === WS_METHODS.subscribeServerSettings || method === WS_METHODS.subscribeTerminalEvents || - method === WS_METHODS.subscribeOrchestrationDomainEvents + method === WS_METHODS.subscribeOrchestrationDomainEvents || + method === WS_METHODS.subscribeProjectDevServerEvents || + method === WS_METHODS.subscribeAutomationEvents ) { + // Keep unasserted streaming subscriptions open. Completing them with a + // unary `{}` response is a protocol error and correctly triggers the + // connection supervisor's recovery path. return; } sendEffectRpcExit(client, parsed.request.id, resolveWsRpc(requestBody)); diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 90e20db70..f76a960ae 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -32,6 +32,7 @@ import { resolveEnvironmentPanelVisible, resolveProjectScriptTerminalTarget, resolveQueuedSteerGateTransition, + resolveComposerRuntimeMode, resolveRuntimeModeAfterApprovalDecision, QUEUED_STEER_GATE_TIMEOUT_MS, sanitizeVoiceErrorMessage, @@ -1795,6 +1796,38 @@ describe("resolveRuntimeModeAfterApprovalDecision", () => { }); }); +describe("resolveComposerRuntimeMode", () => { + it("shows an in-flight server change without letting persisted draft state own it", () => { + expect( + resolveComposerRuntimeMode({ + pendingRuntimeMode: "full-access", + serverRuntimeMode: "approval-required", + draftRuntimeMode: "approval-required", + defaultRuntimeMode: "approval-required", + }), + ).toBe("full-access"); + }); + + it("treats the durable server thread as authoritative over a stale local draft", () => { + expect( + resolveComposerRuntimeMode({ + serverRuntimeMode: "approval-required", + draftRuntimeMode: "full-access", + defaultRuntimeMode: "full-access", + }), + ).toBe("approval-required"); + }); + + it("uses the draft only before the server thread exists", () => { + expect( + resolveComposerRuntimeMode({ + draftRuntimeMode: "approval-required", + defaultRuntimeMode: "full-access", + }), + ).toBe("approval-required"); + }); +}); + describe("resolveQueuedSteerGateTransition", () => { const armedGate = { sawInterruptGap: false, gapStartedAt: null }; const now = 1_000_000; diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 4d345c370..a30e99483 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -88,12 +88,25 @@ export function hasFileUndoSettled(input: { const ALWAYS_ALLOW_RUNTIME_MODE: RuntimeMode = "full-access"; +export function resolveComposerRuntimeMode(input: { + readonly pendingRuntimeMode?: RuntimeMode | null | undefined; + readonly serverRuntimeMode?: RuntimeMode | null | undefined; + readonly draftRuntimeMode?: RuntimeMode | null | undefined; + readonly defaultRuntimeMode: RuntimeMode; +}): RuntimeMode { + return ( + input.pendingRuntimeMode ?? + input.serverRuntimeMode ?? + input.draftRuntimeMode ?? + input.defaultRuntimeMode + ); +} + /** * "Always allow" (acceptForSession) only auto-approves the live provider turn. - * Because the client is the source of truth for runtime mode (it sends it with - * every turn), the choice must also flip the thread to full-access so it survives - * idle-stop and runtime restarts instead of reverting to approval-required on the - * next turn. Returns the runtime mode to persist, or null when nothing changes. + * The choice must also durably flip the server thread to full-access so it + * survives idle-stop and runtime restarts. Returns the runtime mode to persist, + * or null when nothing changes. */ export function resolveRuntimeModeAfterApprovalDecision( currentRuntimeMode: RuntimeMode, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 5613fbd4d..f7dcbb705 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -493,6 +493,7 @@ import { ProjectPicker } from "./chat/ProjectPicker"; import { FolderClosed } from "./FolderClosed"; import { ProviderHealthBanner } from "./chat/ProviderHealthBanner"; import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; +import { CodexAuthenticationRecoveryGate } from "./CodexAuthenticationRecoveryGate"; import { RateLimitBanner, deriveLatestRateLimitStatus, @@ -526,6 +527,7 @@ import { PullRequestDialogState, type QueuedSteerGate, resolveQueuedSteerGateTransition, + resolveComposerRuntimeMode, shouldRenderProviderHealthBanner, resolveRuntimeModeAfterApprovalDecision, revokeBlobPreviewUrl, @@ -1686,8 +1688,30 @@ export default function ChatView({ setIsRevertingCheckpoint(false); } }, [activeThread, pendingFileUndo]); - const runtimeMode = - composerDraft.runtimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE; + const [pendingServerRuntimeMode, setPendingServerRuntimeMode] = useState<{ + readonly threadId: ThreadId; + readonly mode: RuntimeMode; + } | null>(null); + const runtimeMode = resolveComposerRuntimeMode({ + pendingRuntimeMode: + pendingServerRuntimeMode?.threadId === threadId ? pendingServerRuntimeMode.mode : null, + serverRuntimeMode: serverThread?.runtimeMode, + draftRuntimeMode: composerDraft.runtimeMode, + defaultRuntimeMode: DEFAULT_RUNTIME_MODE, + }); + useEffect(() => { + if (serverThread && composerDraft.runtimeMode !== null) { + setComposerDraftRuntimeMode(threadId, null); + } + }, [composerDraft.runtimeMode, serverThread, setComposerDraftRuntimeMode, threadId]); + useEffect(() => { + if ( + pendingServerRuntimeMode?.threadId === threadId && + serverThread?.runtimeMode === pendingServerRuntimeMode.mode + ) { + setPendingServerRuntimeMode(null); + } + }, [pendingServerRuntimeMode, serverThread?.runtimeMode, threadId]); const interactionMode = composerDraft.interactionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE; const isServerThread = serverThread !== undefined; @@ -4747,13 +4771,14 @@ export default function ChatView({ const handleRuntimeModeChange = useCallback( (mode: RuntimeMode) => { if (mode === runtimeMode) return; - setComposerDraftRuntimeMode(threadId, mode); if (isLocalDraftThread) { + setComposerDraftRuntimeMode(threadId, mode); setDraftThreadContext(threadId, { runtimeMode: mode }); } if (serverThread) { const api = readNativeApi(); if (api) { + setPendingServerRuntimeMode({ threadId, mode }); void api.orchestration .dispatchCommand({ type: "thread.runtime-mode.set", @@ -4763,6 +4788,9 @@ export default function ChatView({ createdAt: new Date().toISOString(), }) .catch((error) => { + setPendingServerRuntimeMode((pending) => + pending?.threadId === threadId && pending.mode === mode ? null : pending, + ); toastManager.add({ type: "error", title: "Could not update access mode", @@ -8023,31 +8051,58 @@ export default function ChatView({ setRespondingRequestIds((existing) => existing.includes(requestId) ? existing : [...existing, requestId], ); - // Durably persist "always allow" client-side so the next turn (after an - // idle-stop or runtime restart) keeps full-access instead of asking again. - // The server's session override only covers the current live turn. + // The provider's session override only covers the current live turn. + // Persist "always allow" on the server thread as well so a restart does + // not silently return to approval-required. const durableRuntimeMode = resolveRuntimeModeAfterApprovalDecision(runtimeMode, decision); - if (durableRuntimeMode) { - setComposerDraftRuntimeMode(activeThreadId, durableRuntimeMode); - } - await api.orchestration - .dispatchCommand({ - type: "thread.approval.respond", - commandId: newCommandId(), - threadId: activeThreadId, - requestId, - decision, - createdAt: new Date().toISOString(), - }) - .catch((err: unknown) => { + try { + try { + await api.orchestration.dispatchCommand({ + type: "thread.approval.respond", + commandId: newCommandId(), + threadId: activeThreadId, + requestId, + decision, + createdAt: new Date().toISOString(), + }); + } catch (err: unknown) { setStoreThreadError( activeThreadId, err instanceof Error ? err.message : "Failed to submit approval decision.", ); - }); - setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId)); + return; + } + if (durableRuntimeMode) { + setPendingServerRuntimeMode({ threadId: activeThreadId, mode: durableRuntimeMode }); + try { + await api.orchestration.dispatchCommand({ + type: "thread.runtime-mode.set", + commandId: newCommandId(), + threadId: activeThreadId, + runtimeMode: durableRuntimeMode, + createdAt: new Date().toISOString(), + }); + } catch (err: unknown) { + setPendingServerRuntimeMode((pending) => + pending?.threadId === activeThreadId && pending.mode === durableRuntimeMode + ? null + : pending, + ); + toastManager.add({ + type: "warning", + title: "Approval sent, but agent access was not saved", + description: + err instanceof Error + ? err.message + : "Choose Unrestricted again to keep it for future turns.", + }); + } + } + } finally { + setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId)); + } }, - [activeThreadId, runtimeMode, setComposerDraftRuntimeMode, setStoreThreadError], + [activeThreadId, runtimeMode, setStoreThreadError], ); const onRespondToUserInput = useCallback( @@ -10843,6 +10898,13 @@ export default function ChatView({ onDragLeave={onComposerDragLeave} onDrop={onComposerDrop} > + {/* Subtle accent tint over the whole pane while a file is dragged anywhere over it, signalling that dropping it will attach the file to the composer. */}
{ + afterEach(() => { + useProviderConnectionDialogStore.getState().setOpen(false); + }); + + it("opens the dedicated recovery flow for a projected Codex auth failure", async () => { + const renderGate = () => + render( + , + ); + const screen = await renderGate(); + + try { + await vi.waitFor(() => { + expect(useProviderConnectionDialogStore.getState()).toMatchObject({ + isOpen: true, + provider: "codex", + source: "runtime_authentication_error", + }); + }); + } finally { + await screen.unmount(); + } + + useProviderConnectionDialogStore.getState().setOpen(false); + const remountedScreen = await renderGate(); + try { + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(useProviderConnectionDialogStore.getState().isOpen).toBe(false); + } finally { + await remountedScreen.unmount(); + } + }); + + it("never reopens an already handled recovery event after many newer events", async () => { + const retainedEventId = EventId.makeUnsafe("retained-authentication-error"); + const gate = (eventId: string) => ( + + ); + const screen = await render(gate(retainedEventId)); + + try { + await vi.waitFor(() => expect(useProviderConnectionDialogStore.getState().isOpen).toBe(true)); + for (let index = 0; index < 51; index += 1) { + useProviderConnectionDialogStore.getState().setOpen(false); + await screen.rerender(gate(`newer-authentication-error-${index}`)); + await vi.waitFor(() => + expect(useProviderConnectionDialogStore.getState().isOpen).toBe(true), + ); + } + + useProviderConnectionDialogStore.getState().setOpen(false); + await screen.rerender(gate(retainedEventId)); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(useProviderConnectionDialogStore.getState().isOpen).toBe(false); + } finally { + await screen.unmount(); + } + }); +}); diff --git a/apps/web/src/components/CodexAuthenticationRecoveryGate.tsx b/apps/web/src/components/CodexAuthenticationRecoveryGate.tsx new file mode 100644 index 000000000..eca5d0aae --- /dev/null +++ b/apps/web/src/components/CodexAuthenticationRecoveryGate.tsx @@ -0,0 +1,38 @@ +// Purpose: Bridge a classified Codex runtime authentication failure into the +// single global provider-connection dialog without changing composer state. + +import type { ProviderKind, ServerProviderStatus } from "@synara/contracts"; +import { useEffect } from "react"; + +import { findCodexAuthenticationRecoveryActivityId } from "~/lib/codexAuthRecovery"; +import { useProviderConnectionDialogStore } from "~/providerConnectionDialogStore"; + +// Recovery is offered once per durable runtime-error event for the lifetime of +// the renderer. Do not evict old ids: a thread can be remounted long after its +// activity window has rolled over, and evicting would reopen an already handled +// authentication prompt. +const handledRecoveryEventIds = new Set(); + +function claimRecoveryEvent(eventId: string): boolean { + if (handledRecoveryEventIds.has(eventId)) return false; + handledRecoveryEventIds.add(eventId); + return true; +} + +export function CodexAuthenticationRecoveryGate(props: { + readonly provider: ProviderKind; + readonly sessionStatus: string | null | undefined; + readonly sessionLastErrorEventId: string | null | undefined; + readonly sessionLastErrorClass: string | null | undefined; + readonly providerStatus: ServerProviderStatus | null | undefined; +}) { + const recoveryActivityId = findCodexAuthenticationRecoveryActivityId(props); + + useEffect(() => { + if (!recoveryActivityId) return; + if (!claimRecoveryEvent(recoveryActivityId)) return; + useProviderConnectionDialogStore.getState().openDialog("codex", "runtime_authentication_error"); + }, [recoveryActivityId]); + + return null; +} diff --git a/apps/web/src/components/ConnectionRecoveryNotifications.browser.tsx b/apps/web/src/components/ConnectionRecoveryNotifications.browser.tsx new file mode 100644 index 000000000..4cb4ac7e0 --- /dev/null +++ b/apps/web/src/components/ConnectionRecoveryNotifications.browser.tsx @@ -0,0 +1,209 @@ +// FILE: ConnectionRecoveryNotifications.browser.tsx +// Purpose: Browser integration coverage for connection-recovery notices and toast behavior. +// Layer: Browser UI test + +import "../index.css"; + +import { + RouterProvider, + createMemoryHistory, + createRootRoute, + createRouter, +} from "@tanstack/react-router"; +import type { DesktopBridge } from "@synara/contracts"; +import { page, userEvent } from "vitest/browser"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { render } from "vitest-browser-react"; + +import { emitWsTransportState } from "../wsTransportEvents"; +import { ConnectionRecoveryNotifications } from "./ConnectionRecoveryNotifications"; +import { ToastProvider, toastManager } from "./ui/toast"; + +let activeHarnessCleanup: (() => Promise) | null = null; + +function RecoveryHarness() { + return ( + + + + + ); +} + +async function mountRecoveryHarness() { + const host = document.createElement("div"); + document.body.append(host); + const rootRoute = createRootRoute({ component: RecoveryHarness }); + const router = createRouter({ + history: createMemoryHistory({ initialEntries: ["/"] }), + routeTree: rootRoute, + }); + const screen = await render(, { container: host }); + + let cleanedUp = false; + const cleanup = async () => { + if (cleanedUp) return; + cleanedUp = true; + await screen.unmount(); + toastManager.close(); + host.remove(); + if (activeHarnessCleanup === cleanup) activeHarnessCleanup = null; + }; + activeHarnessCleanup = cleanup; + return cleanup; +} + +describe("connection recovery toast integration", () => { + beforeEach(() => { + emitWsTransportState("open"); + }); + + afterEach(async () => { + emitWsTransportState("open"); + await activeHarnessCleanup?.(); + Reflect.deleteProperty(window, "desktopBridge"); + vi.restoreAllMocks(); + toastManager.close(); + document.body.innerHTML = ""; + }); + + it("shows the real delayed recovery flow with accessible keyboard actions", async () => { + const openLogsDirectory = vi.fn(() => Promise.resolve()); + Object.defineProperty(window, "desktopBridge", { + configurable: true, + value: { + diagnostics: { openLogsDirectory }, + } as unknown as DesktopBridge, + }); + const writeText = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue(undefined); + const cleanup = await mountRecoveryHarness(); + + await page.getByRole("button", { name: "Focus before notifications" }).click(); + emitWsTransportState("reconnecting"); + + await new Promise((resolve) => window.setTimeout(resolve, 300)); + expect(document.body.textContent).not.toContain("Reconnecting…"); + + await expect + .poll(() => document.body.textContent, { timeout: 3_000 }) + .toContain("Reconnecting…"); + expect(document.body.textContent).toContain( + "Scient is restoring its local connection. Open chats remain on this computer.", + ); + expect(document.body.textContent).not.toContain("Copy diagnostics"); + + const notificationRegion = document.querySelector( + '[role="region"][aria-label="Notifications"]', + ); + expect(notificationRegion?.getAttribute("aria-live")).toBe("polite"); + expect(notificationRegion?.getAttribute("aria-relevant")).toContain("text"); + + await expect + .poll(() => document.body.textContent, { timeout: 11_000 }) + .toContain("Scient is still reconnecting"); + await expect + .poll(() => document.body.textContent) + .toContain("Copy the connection summary or open the logs for details."); + + const detailsDialog = page.getByRole("dialog", { name: "Scient is still reconnecting" }); + await expect.element(detailsDialog).toBeVisible(); + + await page.getByRole("button", { name: "Focus before notifications" }).click(); + await userEvent.keyboard("{Tab}"); + expect(document.activeElement?.getAttribute("role")).toBe("dialog"); + await userEvent.keyboard("{Tab}"); + expect(document.activeElement?.getAttribute("aria-label")).toBe("Copy diagnostics"); + await new Promise((resolve) => window.setTimeout(resolve, 300)); + const copyStartedAt = Date.now(); + await userEvent.keyboard("{Enter}"); + + await expect.poll(() => writeText.mock.calls.length).toBe(1); + const copiedDiagnostics = writeText.mock.calls[0]?.[0] ?? ""; + expect(copiedDiagnostics).toContain("Scient connection diagnostics"); + expect(copiedDiagnostics).toContain("Transport state: reconnecting"); + const generatedAt = copiedDiagnostics.match(/^Generated: (.+)$/m)?.[1]; + expect(generatedAt).toBeDefined(); + expect(new Date(generatedAt!).getTime()).toBeGreaterThanOrEqual(copyStartedAt); + await expect + .poll(() => document.activeElement?.getAttribute("aria-label")) + .toBe("Copied diagnostics"); + + await userEvent.keyboard("{Tab}"); + expect(document.activeElement?.textContent).toContain("Open logs"); + await userEvent.keyboard("{Enter}"); + await expect.poll(() => openLogsDirectory.mock.calls.length).toBe(1); + + emitWsTransportState("open"); + await expect.poll(() => document.body.textContent).toContain("Reconnected"); + expect(notificationRegion?.textContent).toContain( + "Scient is connected to its local service again.", + ); + + await cleanup(); + }, 20_000); + + it("dismisses the real recovery notice through Escape and its close control", async () => { + const cleanup = await mountRecoveryHarness(); + + emitWsTransportState("reconnecting"); + await expect + .poll(() => document.body.textContent, { timeout: 3_000 }) + .toContain("Reconnecting…"); + await page.getByRole("button", { name: "Focus before notifications" }).click(); + await userEvent.keyboard("{Tab}"); + expect(document.activeElement?.getAttribute("role")).toBe("dialog"); + await userEvent.keyboard("{Escape}"); + await expect.poll(() => document.body.textContent).not.toContain("Reconnecting…"); + emitWsTransportState("open"); + expect(document.body.textContent).not.toContain("Reconnected"); + + emitWsTransportState("reconnecting"); + await expect + .poll(() => document.body.textContent, { timeout: 3_000 }) + .toContain("Reconnecting…"); + await page.getByRole("button", { name: "Dismiss toast" }).click(); + await expect.poll(() => document.body.textContent).not.toContain("Reconnecting…"); + emitWsTransportState("open"); + expect(document.body.textContent).not.toContain("Reconnected"); + + await cleanup(); + }, 8_000); + + it("counts auto-dismiss time only while the window is visible and focused", async () => { + const cleanup = await mountRecoveryHarness(); + const hasFocus = vi.spyOn(document, "hasFocus").mockReturnValue(false); + toastManager.add({ + title: "Reconnected", + data: { allowCrossThreadVisibility: true, dismissAfterVisibleMs: 100 }, + timeout: 0, + }); + + await new Promise((resolve) => window.setTimeout(resolve, 200)); + expect(document.body.textContent).toContain("Reconnected"); + + hasFocus.mockReturnValue(true); + window.dispatchEvent(new FocusEvent("focus")); + await expect.poll(() => document.body.textContent).not.toContain("Reconnected"); + await cleanup(); + }); + + it("shows a visible error when diagnostic copy fails", async () => { + const cleanup = await mountRecoveryHarness(); + vi.spyOn(navigator.clipboard, "writeText").mockRejectedValue(new Error("Clipboard denied")); + vi.spyOn(document, "execCommand").mockReturnValue(false); + toastManager.add({ + title: "Scient is still reconnecting", + data: { + allowCrossThreadVisibility: true, + copyLabel: "diagnostics", + copyText: "Scient connection diagnostics", + }, + timeout: 0, + }); + + await page.getByRole("button", { name: "Copy diagnostics" }).click(); + + await expect.poll(() => document.body.textContent).toContain("Could not copy diagnostics"); + await cleanup(); + }); +}); diff --git a/apps/web/src/components/ConnectionRecoveryNotifications.tsx b/apps/web/src/components/ConnectionRecoveryNotifications.tsx new file mode 100644 index 000000000..5c0f7b757 --- /dev/null +++ b/apps/web/src/components/ConnectionRecoveryNotifications.tsx @@ -0,0 +1,126 @@ +// FILE: ConnectionRecoveryNotifications.tsx +// Purpose: Surfaces calm, actionable local-service connection recovery status. +// Layer: Global web application notifications + +import { useEffect, useRef } from "react"; + +import { APP_VERSION } from "../branding"; +import { + ConnectionRecoveryNoticeController, + formatConnectionRecoveryDiagnostics, +} from "../connectionRecoveryNotice"; +import { addWsTransportStateListener } from "../wsTransportEvents"; +import { toastManager } from "./ui/toast"; + +type RecoveryToastId = ReturnType; + +export function ConnectionRecoveryNotifications() { + const toastIdRef = useRef(null); + + useEffect(() => { + const clearToast = () => { + if (toastIdRef.current !== null) toastManager.close(toastIdRef.current); + toastIdRef.current = null; + }; + + let controller: ConnectionRecoveryNoticeController; + controller = new ConnectionRecoveryNoticeController({ + onClear: clearToast, + onRecovered: () => { + const toastId = toastIdRef.current; + if (toastId === null) return; + toastManager.update(toastId, { + type: "success", + title: "Reconnected", + description: "Scient is connected to its local service again.", + actionProps: undefined, + onClose: undefined, + data: { + allowCrossThreadVisibility: true, + dismissAfterVisibleMs: 3_000, + showDescription: true, + }, + timeout: 0, + }); + }, + onShow: () => { + toastIdRef.current = toastManager.add({ + type: "loading", + title: "Reconnecting…", + description: + "Scient is restoring its local connection. Open chats remain on this computer.", + onClose: () => { + controller.dismissCurrentOutage(); + toastIdRef.current = null; + }, + data: { allowCrossThreadVisibility: true, showDescription: true }, + timeout: 0, + }); + }, + onShowDetails: (stateStartedAt) => { + const openLogs = window.desktopBridge?.diagnostics?.openLogsDirectory; + const nextToast = { + type: "warning" as const, + title: "Scient is still reconnecting", + description: + "Scient keeps trying automatically. Copy the connection summary or open the logs for details.", + actionProps: undefined, + onClose: () => { + controller.dismissCurrentOutage(); + toastIdRef.current = null; + }, + data: { + allowCrossThreadVisibility: true, + copyLabel: "diagnostics", + copyText: () => + formatConnectionRecoveryDiagnostics({ + appVersion: APP_VERSION, + desktopApp: Boolean(window.desktopBridge), + generatedAt: new Date(), + navigatorOnline: typeof navigator.onLine === "boolean" ? navigator.onLine : null, + platform: navigator.platform, + state: "reconnecting", + stateStartedAt, + visibility: document.visibilityState, + }), + ...(openLogs + ? { + secondaryActionProps: { + children: "Open logs", + onClick: () => { + void openLogs().catch((error: unknown) => { + toastManager.add({ + type: "error", + title: "Could not open logs", + description: + error instanceof Error + ? error.message + : "The logs folder could not be opened.", + }); + }); + }, + }, + } + : {}), + }, + timeout: 0, + }; + if (toastIdRef.current === null) { + toastIdRef.current = toastManager.add(nextToast); + } else { + toastManager.update(toastIdRef.current, nextToast); + } + }, + }); + + const unsubscribe = addWsTransportStateListener((state) => controller.handleState(state), { + replayLatest: true, + }); + return () => { + unsubscribe(); + controller.dispose(); + }; + }, []); + + return null; +} diff --git a/apps/web/src/components/EventRouter.browser.tsx b/apps/web/src/components/EventRouter.browser.tsx index 61b24c771..14af5e33c 100644 --- a/apps/web/src/components/EventRouter.browser.tsx +++ b/apps/web/src/components/EventRouter.browser.tsx @@ -26,6 +26,7 @@ import { getRouter } from "../router"; import { useStore } from "../store"; import { createShellSnapshotFromReadModel, + createTestEnvironmentDescriptor, flattenEffectRpcRequestPayload, readEffectRpcClientMessage, sendEffectRpcChunk, @@ -54,6 +55,10 @@ interface EffectRpcStreamHandle { acknowledgedChunkCount: number; } +interface ClosableEffectRpcWebSocketClient extends EffectRpcWebSocketClient { + readonly close: (code?: number, reason?: string) => void; +} + let fixture: TestFixture; let serverLifecycleStream: EffectRpcStreamHandle | null = null; let shellStream: EffectRpcStreamHandle | null = null; @@ -68,6 +73,7 @@ const subscribeThreadRequestCountById = new Map(); let subscribeThreadRequests: ThreadId[] = []; let replayEvents: OrchestrationEvent[] = []; let replayRequestCursors: number[] = []; +let activeWsClient: ClosableEffectRpcWebSocketClient | null = null; const mountedAppCleanups = new Set<() => Promise>(); const wsLink = ws.link(/ws(s)?:\/\/.*/); @@ -233,6 +239,9 @@ function resolveWsRpc(tag: string, body?: unknown): unknown { if (tag === WS_METHODS.serverGetConfig) { return fixture.serverConfig; } + if (tag === WS_METHODS.serverGetEnvironment) { + return createTestEnvironmentDescriptor(); + } if (tag === WS_METHODS.gitListBranches) { return { isRepo: true, @@ -275,6 +284,7 @@ function resolveWsRpc(tag: string, body?: unknown): unknown { const worker = setupWorker( wsLink.addEventListener("connection", ({ client }) => { + activeWsClient = client; client.addEventListener("message", (event) => { if (typeof event.data !== "string") { return; @@ -495,6 +505,7 @@ describe("EventRouter scoped orchestration sync", () => { document.body.innerHTML = ""; serverLifecycleStream = null; shellStream = null; + activeWsClient = null; threadStreamByThreadId.clear(); delayNextThreadSnapshot = false; localStorage.clear(); @@ -1254,4 +1265,115 @@ describe("EventRouter scoped orchestration sync", () => { await mounted.cleanup(); } }); + + it("reconnects once and rebuilds scoped subscriptions from fresh server snapshots", async () => { + const mounted = await mountApp(); + + try { + await vi.waitFor( + () => { + expect(subscribeShellRequestCount).toBe(1); + expect(subscribeThreadRequestCountById.get(THREAD_ID)).toBe(1); + expect(activeWsClient).not.toBeNull(); + }, + { timeout: 4_000, interval: 16 }, + ); + + fixture = { + ...fixture, + snapshot: { + ...fixture.snapshot, + snapshotSequence: fixture.snapshot.snapshotSequence + 1, + threads: fixture.snapshot.threads.map((thread) => + thread.id === THREAD_ID ? { ...thread, title: "Recovered after reconnect" } : thread, + ), + }, + }; + activeWsClient?.close(1012, "test server restart"); + + await vi.waitFor( + () => { + expect(subscribeShellRequestCount).toBe(2); + expect(subscribeThreadRequestCountById.get(THREAD_ID)).toBe(2); + expect(getThreadFromState(useStore.getState(), THREAD_ID)?.title).toBe( + "Recovered after reconnect", + ); + }, + { timeout: 5_000, interval: 16 }, + ); + } finally { + fixture = buildFixture(); + await mounted.cleanup(); + } + }); + + it("does not let an older reconnect snapshot overwrite already-applied events", async () => { + const mounted = await mountApp(); + const latestMessageId = MessageId.makeUnsafe("msg-before-reconnect-boundary"); + + try { + await sendShellEventPush({ + kind: "thread-upserted", + sequence: 3, + thread: { + ...createShellSnapshotFromReadModel(fixture.snapshot).threads[0]!, + title: "Newest local title", + }, + }); + await sendThreadEventPush({ + sequence: 3, + eventId: EventId.makeUnsafe("event-before-reconnect-boundary"), + aggregateKind: "thread", + aggregateId: THREAD_ID, + occurredAt: "2026-03-04T12:00:05.000Z", + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "thread.message-sent", + payload: { + threadId: THREAD_ID, + messageId: latestMessageId, + role: "assistant", + text: "Already applied before reconnect", + turnId: TurnId.makeUnsafe("turn-before-reconnect-boundary"), + source: "native", + streaming: false, + createdAt: "2026-03-04T12:00:05.000Z", + updatedAt: "2026-03-04T12:00:05.000Z", + }, + }); + await vi.waitFor(() => { + const thread = getThreadFromState(useStore.getState(), THREAD_ID); + expect(thread?.title).toBe("Newest local title"); + expect(thread?.messages.some((message) => message.id === latestMessageId)).toBe(true); + }); + + fixture = { + ...fixture, + snapshot: { + ...fixture.snapshot, + snapshotSequence: 2, + threads: fixture.snapshot.threads.map((thread) => + thread.id === THREAD_ID ? { ...thread, title: "Older reconnect title" } : thread, + ), + }, + }; + activeWsClient?.close(1012, "test stale reconnect snapshot"); + + await vi.waitFor( + () => { + expect(subscribeShellRequestCount).toBe(2); + expect(subscribeThreadRequestCountById.get(THREAD_ID)).toBe(2); + const thread = getThreadFromState(useStore.getState(), THREAD_ID); + expect(thread?.title).toBe("Newest local title"); + expect(thread?.messages.some((message) => message.id === latestMessageId)).toBe(true); + }, + { timeout: 5_000, interval: 16 }, + ); + } finally { + fixture = buildFixture(); + await mounted.cleanup(); + } + }); }); diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index 7917f35ef..482b5b602 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -21,6 +21,7 @@ import { getRouter } from "../router"; import { useStore } from "../store"; import { createShellSnapshotFromReadModel, + createTestEnvironmentDescriptor, flattenEffectRpcRequestPayload, readEffectRpcClientMessage, sendEffectRpcChunk, @@ -166,6 +167,9 @@ function resolveWsRpc(tag: string): unknown { if (tag === WS_METHODS.serverGetConfig) { return fixture.serverConfig; } + if (tag === WS_METHODS.serverGetEnvironment) { + return createTestEnvironmentDescriptor(); + } if (tag === WS_METHODS.gitListBranches) { return { isRepo: true, @@ -242,8 +246,13 @@ const worker = setupWorker( method === WS_METHODS.subscribeServerProviderStatuses || method === WS_METHODS.subscribeServerSettings || method === WS_METHODS.subscribeTerminalEvents || - method === WS_METHODS.subscribeOrchestrationDomainEvents + method === WS_METHODS.subscribeOrchestrationDomainEvents || + method === WS_METHODS.subscribeProjectDevServerEvents || + method === WS_METHODS.subscribeAutomationEvents ) { + // Keep unasserted streaming subscriptions open. Completing them with a + // unary `{}` response is a protocol error and correctly triggers the + // connection supervisor's recovery path. return; } sendEffectRpcExit(client, parsed.request.id, resolveWsRpc(method)); diff --git a/apps/web/src/components/ProviderConnectionDialog.browser.tsx b/apps/web/src/components/ProviderConnectionDialog.browser.tsx index b9c6077e7..14df1e47b 100644 --- a/apps/web/src/components/ProviderConnectionDialog.browser.tsx +++ b/apps/web/src/components/ProviderConnectionDialog.browser.tsx @@ -16,11 +16,21 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { render } from "vitest-browser-react"; import { serverQueryKeys } from "~/lib/serverReactQuery"; +import { applyProviderStatusesToCache } from "~/lib/providerStatusCache"; import { readNativeApi } from "~/nativeApi"; import { useProviderConnectionDialogStore } from "~/providerConnectionDialogStore"; import { ProviderConnectionDialog } from "./ProviderConnectionDialog"; const checkedAt = "2026-07-19T12:00:00.000Z"; +const systemRuntime = { + source: "system" as const, + managedVersion: null, + canInstall: false, + canRepair: false, + canRollback: false, + canRemove: false, + message: null, +}; function createConfig(provider: ServerProviderStatus): ServerConfig { return { @@ -41,7 +51,10 @@ function createQueryClient(provider: ServerProviderStatus) { } function installNativeApi(overrides: { + refreshProviders?: ReturnType; startProviderConnection?: ReturnType; + cancelProviderConnection?: ReturnType; + submitProviderConnectionAuthorizationCode?: ReturnType; prepareProviderInstall?: ReturnType; installProvider?: ReturnType; openExternal?: ReturnType; @@ -56,9 +69,19 @@ function installNativeApi(overrides: { ...baseApi, server: { ...baseApi.server, + ...(overrides.refreshProviders ? { refreshProviders: overrides.refreshProviders } : {}), ...(overrides.startProviderConnection ? { startProviderConnection: overrides.startProviderConnection } : {}), + ...(overrides.cancelProviderConnection + ? { cancelProviderConnection: overrides.cancelProviderConnection } + : {}), + ...(overrides.submitProviderConnectionAuthorizationCode + ? { + submitProviderConnectionAuthorizationCode: + overrides.submitProviderConnectionAuthorizationCode, + } + : {}), ...(overrides.prepareProviderInstall ? { prepareProviderInstall: overrides.prepareProviderInstall } : {}), @@ -82,17 +105,27 @@ function installNativeApi(overrides: { describe("ProviderConnectionDialog", () => { afterEach(() => { useProviderConnectionDialogStore.getState().setOpen(false); + document.documentElement.style.removeProperty("--app-font-size-ui"); document.body.innerHTML = ""; vi.restoreAllMocks(); }); it("starts official browser sign-in and shows background progress", async () => { + const initialProvider = { + provider: "codex", + status: "warning", + available: true, + authStatus: "unauthenticated", + checkedAt, + runtime: systemRuntime, + } satisfies ServerProviderStatus; const waitingProvider = { provider: "codex", status: "warning", available: true, authStatus: "unauthenticated", checkedAt, + runtime: systemRuntime, connectionState: { operationId: "connect-codex-1", method: "codex_browser", @@ -103,14 +136,12 @@ describe("ProviderConnectionDialog", () => { }, } satisfies ServerProviderStatus; const startProviderConnection = vi.fn().mockResolvedValue({ providers: [waitingProvider] }); - const restoreNativeApi = installNativeApi({ startProviderConnection }); - const queryClient = createQueryClient({ - provider: "codex", - status: "warning", - available: true, - authStatus: "unauthenticated", - checkedAt, + const refreshProviders = vi.fn().mockResolvedValue({ providers: [initialProvider] }); + const restoreNativeApi = installNativeApi({ + refreshProviders, + startProviderConnection, }); + const queryClient = createQueryClient(initialProvider); useProviderConnectionDialogStore.getState().openDialog("codex", "settings"); const screen = await render( @@ -132,7 +163,7 @@ describe("ProviderConnectionDialog", () => { await expect .element(page.getByText("Finish signing in in the browser window.")) .toBeVisible(); - await expect.element(page.getByRole("button", { name: "Cancel sign in" })).toBeVisible(); + await expect.element(page.getByRole("button", { name: "Cancel sign-in" })).toBeVisible(); await expect.element(page.getByText(/sign in continues in the background/u)).toBeVisible(); } finally { await screen.unmount(); @@ -141,12 +172,316 @@ describe("ProviderConnectionDialog", () => { } }); + it("keeps every active sign-in action inside the dialog at the largest UI text size", async () => { + document.documentElement.style.setProperty("--app-font-size-ui", "18px"); + const activeProvider = { + provider: "codex", + status: "warning", + available: true, + authStatus: "unauthenticated", + checkedAt, + runtime: systemRuntime, + connectionState: { + operationId: "connect-codex-large-text", + method: "codex_browser", + status: "waiting_for_browser", + startedAt: checkedAt, + finishedAt: null, + message: "Finish signing in in the browser window.", + }, + } satisfies ServerProviderStatus; + const restoreNativeApi = installNativeApi({}); + const queryClient = createQueryClient(activeProvider); + useProviderConnectionDialogStore.getState().openDialog("codex", "settings"); + + const screen = await render( + + + , + ); + + try { + await vi.waitFor(() => { + const popup = document.querySelector('[data-slot="dialog-popup"]'); + const buttons = Array.from( + popup?.querySelectorAll('[data-slot="button"]') ?? [], + ).filter((button) => button.getAttribute("aria-label") !== "Close"); + + expect(popup, "Expected the provider dialog popup.").toBeTruthy(); + expect(buttons).toHaveLength(3); + + const popupRect = popup!.getBoundingClientRect(); + for (const button of buttons) { + const buttonRect = button.getBoundingClientRect(); + expect(buttonRect.left).toBeGreaterThanOrEqual(popupRect.left); + expect(buttonRect.right).toBeLessThanOrEqual(popupRect.right); + } + }); + } finally { + await screen.unmount(); + queryClient.clear(); + restoreNativeApi(); + } + }); + + it("forces a fresh Codex login after a classified runtime auth failure", async () => { + const authenticatedProvider = { + provider: "codex", + status: "ready", + available: true, + authStatus: "authenticated", + requiresProviderAccount: true, + checkedAt, + runtime: systemRuntime, + } satisfies ServerProviderStatus; + const waitingProvider = { + ...authenticatedProvider, + status: "warning", + authStatus: "unauthenticated", + connectionState: { + operationId: "reconnect-codex-1", + method: "codex_browser", + status: "waiting_for_browser", + startedAt: checkedAt, + finishedAt: null, + message: "Finish reconnecting Codex in the browser window.", + }, + } satisfies ServerProviderStatus; + const refreshProviders = vi.fn().mockResolvedValue({ providers: [authenticatedProvider] }); + const startProviderConnection = vi.fn().mockResolvedValue({ providers: [waitingProvider] }); + const restoreNativeApi = installNativeApi({ refreshProviders, startProviderConnection }); + const queryClient = createQueryClient(authenticatedProvider); + useProviderConnectionDialogStore.getState().openDialog("codex", "runtime_authentication_error"); + + const screen = await render( + + + , + ); + + try { + await expect.element(page.getByRole("button", { name: "Reconnect Codex" })).toBeVisible(); + await page.getByRole("button", { name: "Reconnect Codex" }).click(); + await vi.waitFor(() => { + expect(startProviderConnection).toHaveBeenCalledWith({ + provider: "codex", + method: "codex_browser", + mode: "reauthenticate", + }); + }); + await expect + .element(page.getByText("Finish reconnecting Codex in the browser window.")) + .toBeVisible(); + } finally { + await screen.unmount(); + queryClient.clear(); + restoreNativeApi(); + } + }); + + it("does not mistake a stale connected operation for completion of the new recovery attempt", async () => { + const staleConnectedProvider = { + provider: "codex", + status: "ready", + available: true, + authStatus: "authenticated", + requiresProviderAccount: true, + checkedAt, + runtime: systemRuntime, + connectionState: { + operationId: "stale-connected-operation", + method: "codex_browser", + status: "connected", + startedAt: checkedAt, + finishedAt: checkedAt, + message: "An older sign-in completed.", + }, + } satisfies ServerProviderStatus; + const newlyConnectedProvider = { + ...staleConnectedProvider, + connectionState: { + ...staleConnectedProvider.connectionState, + operationId: "new-connected-operation", + message: "The new sign-in completed.", + }, + } satisfies ServerProviderStatus; + let resolveStartProviderConnection: + | ((value: { providers: ServerProviderStatus[] }) => void) + | undefined; + const startProviderConnection = vi.fn( + () => + new Promise<{ providers: ServerProviderStatus[] }>((resolve) => { + resolveStartProviderConnection = resolve; + }), + ); + const refreshProviders = vi.fn().mockResolvedValue({ providers: [staleConnectedProvider] }); + const restoreNativeApi = installNativeApi({ refreshProviders, startProviderConnection }); + const queryClient = createQueryClient(staleConnectedProvider); + useProviderConnectionDialogStore.getState().openDialog("codex", "runtime_authentication_error"); + + const screen = await render( + + + , + ); + + try { + await page.getByRole("button", { name: "Reconnect Codex" }).click(); + await vi.waitFor(() => expect(startProviderConnection).toHaveBeenCalledTimes(1)); + await expect.element(page.getByRole("button", { name: "Done" })).not.toBeInTheDocument(); + + resolveStartProviderConnection?.({ providers: [newlyConnectedProvider] }); + await expect.element(page.getByRole("button", { name: "Done" })).toBeVisible(); + } finally { + await screen.unmount(); + queryClient.clear(); + restoreNativeApi(); + } + }); + + it.each(["failed", "cancelled"] as const)( + "keeps a %s recovery attempt retryable and forced despite stale authenticated health", + async (operationStatus) => { + const terminalProvider = { + provider: "codex", + status: "ready", + available: true, + authStatus: "authenticated", + requiresProviderAccount: true, + checkedAt, + runtime: systemRuntime, + connectionState: { + operationId: `reconnect-codex-${operationStatus}`, + method: "codex_browser", + status: operationStatus, + startedAt: checkedAt, + finishedAt: checkedAt, + message: `Codex reconnect ${operationStatus}.`, + }, + } satisfies ServerProviderStatus; + const waitingProvider = { + ...terminalProvider, + status: "warning", + authStatus: "unauthenticated", + connectionState: { + ...terminalProvider.connectionState, + operationId: `reconnect-codex-${operationStatus}-retry`, + status: "waiting_for_browser", + finishedAt: null, + message: "Finish reconnecting Codex in the browser window.", + }, + } satisfies ServerProviderStatus; + const refreshProviders = vi.fn().mockResolvedValue({ providers: [terminalProvider] }); + const startProviderConnection = vi.fn().mockResolvedValue({ providers: [waitingProvider] }); + const restoreNativeApi = installNativeApi({ refreshProviders, startProviderConnection }); + const queryClient = createQueryClient(terminalProvider); + useProviderConnectionDialogStore + .getState() + .openDialog("codex", "runtime_authentication_error"); + + const screen = await render( + + + , + ); + + try { + await expect.element(page.getByRole("button", { name: "Try again" })).toBeVisible(); + await expect.element(page.getByRole("button", { name: "Done" })).not.toBeInTheDocument(); + await page.getByRole("button", { name: "Try again" }).click(); + await vi.waitFor(() => + expect(startProviderConnection).toHaveBeenCalledWith({ + provider: "codex", + method: "codex_browser", + mode: "reauthenticate", + }), + ); + } finally { + await screen.unmount(); + queryClient.clear(); + restoreNativeApi(); + } + }, + ); + + it("keeps restart inside an active recovery attempt in forced reauthentication mode", async () => { + const activeProvider = { + provider: "codex", + status: "warning", + available: true, + authStatus: "authenticated", + requiresProviderAccount: true, + checkedAt, + runtime: systemRuntime, + connectionState: { + operationId: "reconnect-codex-active", + method: "codex_browser", + status: "waiting_for_browser", + startedAt: new Date().toISOString(), + finishedAt: null, + message: "Finish reconnecting Codex.", + }, + } satisfies ServerProviderStatus; + const cancelledProvider = { + ...activeProvider, + connectionState: { + ...activeProvider.connectionState, + status: "cancelled", + finishedAt: new Date().toISOString(), + message: "Codex reconnect cancelled.", + }, + } satisfies ServerProviderStatus; + const restartedProvider = { + ...activeProvider, + connectionState: { + ...activeProvider.connectionState, + operationId: "reconnect-codex-restarted", + }, + } satisfies ServerProviderStatus; + const cancelProviderConnection = vi.fn().mockResolvedValue({ providers: [cancelledProvider] }); + const startProviderConnection = vi.fn().mockResolvedValue({ providers: [restartedProvider] }); + const restoreNativeApi = installNativeApi({ + cancelProviderConnection, + startProviderConnection, + }); + const queryClient = createQueryClient(activeProvider); + useProviderConnectionDialogStore.getState().openDialog("codex", "runtime_authentication_error"); + + const screen = await render( + + + , + ); + + try { + await page.getByRole("button", { name: "Restart sign in" }).click(); + await vi.waitFor(() => { + expect(cancelProviderConnection).toHaveBeenCalledWith({ + provider: "codex", + operationId: "reconnect-codex-active", + }); + expect(startProviderConnection).toHaveBeenCalledWith({ + provider: "codex", + method: "codex_browser", + mode: "reauthenticate", + }); + }); + expect(cancelProviderConnection.mock.invocationCallOrder[0]).toBeLessThan( + startProviderConnection.mock.invocationCallOrder[0]!, + ); + } finally { + await screen.unmount(); + queryClient.clear(); + restoreNativeApi(); + } + }); + it.each([ { provider: "claudeAgent", - method: "claude_console", + method: "claude_account", title: "Connect Claude", - primaryLabel: "Connect Anthropic Console", + primaryLabel: "Connect Claude", }, { provider: "antigravity", @@ -174,12 +509,21 @@ describe("ProviderConnectionDialog", () => { }>)( "starts the guided $provider connection flow", async ({ provider, method, title, primaryLabel }) => { + const initialProvider = { + provider, + status: "warning", + available: true, + authStatus: "unauthenticated", + checkedAt, + runtime: systemRuntime, + } satisfies ServerProviderStatus; const waitingProvider = { provider, status: "warning", available: true, authStatus: "unauthenticated", checkedAt, + runtime: systemRuntime, connectionState: { operationId: `connect-${provider}-1`, method, @@ -190,14 +534,12 @@ describe("ProviderConnectionDialog", () => { }, } satisfies ServerProviderStatus; const startProviderConnection = vi.fn().mockResolvedValue({ providers: [waitingProvider] }); - const restoreNativeApi = installNativeApi({ startProviderConnection }); - const queryClient = createQueryClient({ - provider, - status: "warning", - available: true, - authStatus: "unauthenticated", - checkedAt, + const refreshProviders = vi.fn().mockResolvedValue({ providers: [initialProvider] }); + const restoreNativeApi = installNativeApi({ + refreshProviders, + startProviderConnection, }); + const queryClient = createQueryClient(initialProvider); useProviderConnectionDialogStore.getState().openDialog(provider, "settings"); const screen = await render( @@ -223,16 +565,22 @@ describe("ProviderConnectionDialog", () => { }, ); - it("opens official installation guidance when the provider is missing", async () => { - const openExternal = vi.fn().mockResolvedValue(undefined); - const restoreNativeApi = installNativeApi({ openExternal }); - const queryClient = createQueryClient({ + it("preserves an invalid custom executable choice instead of replacing it", async () => { + const unavailableProvider = { provider: "claudeAgent", status: "error", available: false, authStatus: "unknown", checkedAt, - }); + runtime: { + ...systemRuntime, + source: "custom", + message: "The configured executable is unavailable.", + }, + } satisfies ServerProviderStatus; + const refreshProviders = vi.fn().mockResolvedValue({ providers: [unavailableProvider] }); + const restoreNativeApi = installNativeApi({ refreshProviders }); + const queryClient = createQueryClient(unavailableProvider); useProviderConnectionDialogStore.getState().openDialog("claudeAgent", "provider_picker"); const screen = await render( @@ -242,10 +590,14 @@ describe("ProviderConnectionDialog", () => { ); try { - await page.getByRole("button", { name: "Open installation guide" }).click(); - await vi.waitFor(() => { - expect(openExternal).toHaveBeenCalledWith("https://code.claude.com/docs/en/installation"); - }); + await expect + .element(page.getByText("The configured executable is unavailable.")) + .toBeVisible(); + await expect + .element(page.getByRole("button", { name: "Open installation guide" })) + .not.toBeInTheDocument(); + await page.getByRole("button", { name: "Check again" }).click(); + await vi.waitFor(() => expect(refreshProviders).toHaveBeenCalledTimes(2)); } finally { await screen.unmount(); queryClient.clear(); @@ -253,61 +605,536 @@ describe("ProviderConnectionDialog", () => { } }); - it("requires reviewed consent before starting a managed installation", async () => { - const installingProvider = { - provider: "antigravity", + it("offers Claude SSO and Console as explicit alternative methods", async () => { + const provider = { + provider: "claudeAgent", status: "error", - available: false, - authStatus: "unknown", + available: true, + authStatus: "unauthenticated", checkedAt, - runtime: { - source: "missing", - managedVersion: null, - canInstall: false, - canRepair: false, - canRollback: false, - canRemove: false, - message: "No usable provider runtime was found.", - }, - installationState: { - operationId: "install-antigravity-1", - operation: "install", - status: "downloading", - startedAt: checkedAt, + runtime: systemRuntime, + } satisfies ServerProviderStatus; + const waitingProvider = { + ...provider, + connectionState: { + operationId: "connect-claude-sso-1", + method: "claude_sso", + status: "waiting_for_browser", + startedAt: new Date().toISOString(), finishedAt: null, - message: "Downloading Antigravity 1.1.4.", - version: "1.1.4", - bytesDownloaded: 0, - totalBytes: 46_664_998, + message: "Finish organization sign-in.", }, } satisfies ServerProviderStatus; - const prepareProviderInstall = vi.fn().mockResolvedValue({ - provider: "antigravity", - planToken: "reviewed-plan-1", - version: "1.1.4", - target: "darwin-arm64", - sourceHost: "storage.googleapis.com", - downloadBytes: 46_664_998, - expiresAt: "2026-07-19T12:10:00.000Z", - }); - const installProvider = vi.fn().mockResolvedValue({ providers: [installingProvider] }); - const restoreNativeApi = installNativeApi({ prepareProviderInstall, installProvider }); - const queryClient = createQueryClient({ - provider: "antigravity", - status: "error", - available: false, - authStatus: "unknown", - checkedAt, - runtime: { - source: "missing", - managedVersion: null, - canInstall: true, - canRepair: false, - canRollback: false, - canRemove: false, - message: "No usable provider runtime was found.", + const refreshProviders = vi.fn().mockResolvedValue({ providers: [provider] }); + const startProviderConnection = vi.fn().mockResolvedValue({ providers: [waitingProvider] }); + const restoreNativeApi = installNativeApi({ refreshProviders, startProviderConnection }); + const queryClient = createQueryClient(provider); + useProviderConnectionDialogStore.getState().openDialog("claudeAgent", "settings"); + + const screen = await render( + + + , + ); + + try { + await expect + .element(page.getByRole("button", { name: /Work or organization SSO/u })) + .toBeVisible(); + await expect + .element(page.getByRole("button", { name: /Anthropic Console \/ API/u })) + .toBeVisible(); + await page.getByRole("button", { name: /Work or organization SSO/u }).click(); + await vi.waitFor(() => + expect(startProviderConnection).toHaveBeenCalledWith({ + provider: "claudeAgent", + method: "claude_sso", + }), + ); + } finally { + await screen.unmount(); + queryClient.clear(); + restoreNativeApi(); + } + }); + + it("refreshes on open and never starts sign-in for an existing terminal account", async () => { + const unauthenticated = { + provider: "claudeAgent", + status: "error", + available: true, + authStatus: "unauthenticated", + checkedAt, + runtime: systemRuntime, + } satisfies ServerProviderStatus; + const authenticated = { + ...unauthenticated, + status: "ready", + authStatus: "authenticated", + authLabel: "Claude Max Subscription", + } satisfies ServerProviderStatus; + const refreshProviders = vi.fn().mockResolvedValue({ providers: [authenticated] }); + const startProviderConnection = vi.fn(); + const restoreNativeApi = installNativeApi({ refreshProviders, startProviderConnection }); + const queryClient = createQueryClient(unauthenticated); + useProviderConnectionDialogStore.getState().openDialog("claudeAgent", "provider_picker"); + + const screen = await render( + + + , + ); + + try { + await expect.element(page.getByRole("button", { name: "Done" })).toBeVisible(); + expect(startProviderConnection).not.toHaveBeenCalled(); + } finally { + await screen.unmount(); + queryClient.clear(); + restoreNativeApi(); + } + }); + + it("reopens an active attempt with cancel, restart, and timeout controls", async () => { + const active = { + provider: "claudeAgent", + status: "error", + available: true, + authStatus: "unauthenticated", + checkedAt, + runtime: systemRuntime, + connectionState: { + operationId: "connect-claude-active", + method: "claude_account", + status: "waiting_for_browser", + startedAt: new Date().toISOString(), + finishedAt: null, + message: "Finish signing in to Claude.", + }, + } satisfies ServerProviderStatus; + const cancelled = { + ...active, + connectionState: { + ...active.connectionState, + status: "cancelled", + finishedAt: new Date().toISOString(), + message: "Sign in was cancelled.", }, + } satisfies ServerProviderStatus; + const restarted = { + ...active, + connectionState: { ...active.connectionState, operationId: "connect-claude-restarted" }, + } satisfies ServerProviderStatus; + const refreshProviders = vi.fn().mockResolvedValue({ providers: [active] }); + const cancelProviderConnection = vi.fn().mockResolvedValue({ providers: [cancelled] }); + const startProviderConnection = vi.fn().mockResolvedValue({ providers: [restarted] }); + const restoreNativeApi = installNativeApi({ + refreshProviders, + cancelProviderConnection, + startProviderConnection, }); + const queryClient = createQueryClient(active); + useProviderConnectionDialogStore.getState().openDialog("claudeAgent", "provider_picker"); + + const screen = await render( + + + , + ); + + try { + await expect.element(page.getByRole("button", { name: "Cancel sign-in" })).toBeVisible(); + await expect.element(page.getByRole("button", { name: "Restart sign in" })).toBeVisible(); + await expect.element(page.getByText(/Automatic timeout in/u)).toBeVisible(); + await page.getByRole("button", { name: "Restart sign in" }).click(); + await vi.waitFor(() => { + expect(cancelProviderConnection).toHaveBeenCalledWith({ + provider: "claudeAgent", + operationId: "connect-claude-active", + }); + expect(startProviderConnection).toHaveBeenCalledWith({ + provider: "claudeAgent", + method: "claude_account", + }); + }); + expect(cancelProviderConnection.mock.invocationCallOrder[0]).toBeLessThan( + startProviderConnection.mock.invocationCallOrder[0]!, + ); + } finally { + await screen.unmount(); + queryClient.clear(); + restoreNativeApi(); + } + }); + + it("reopens the validated xAI authorization page without terminal use", async () => { + const authorizationUrl = + "https://auth.x.ai/oauth2/authorize?response_type=code&redirect_uri=http%3A%2F%2F127.0.0.1%3A50418%2Fcallback&state=test-state&code_challenge=test-challenge"; + const active = { + provider: "grok", + status: "error", + available: true, + authStatus: "unauthenticated", + checkedAt, + runtime: systemRuntime, + connectionState: { + operationId: "connect-grok-active", + method: "grok_browser", + status: "waiting_for_browser", + startedAt: new Date().toISOString(), + finishedAt: null, + message: "Finish signing in to Grok.", + authorizationUrl, + }, + installationState: { + operationId: "install-grok-finished", + operation: "install", + status: "installed", + startedAt: checkedAt, + finishedAt: checkedAt, + message: "Grok is installed and verified.", + }, + } satisfies ServerProviderStatus; + const openExternal = vi.fn().mockResolvedValue(undefined); + const cancelled = { + ...active, + connectionState: { + ...active.connectionState, + status: "cancelled", + finishedAt: checkedAt, + message: "Sign in was cancelled.", + }, + } satisfies ServerProviderStatus; + const cancelProviderConnection = vi.fn().mockResolvedValue({ providers: [cancelled] }); + const restoreNativeApi = installNativeApi({ openExternal, cancelProviderConnection }); + const queryClient = createQueryClient(active); + useProviderConnectionDialogStore.getState().openDialog("grok", "provider_picker"); + + const screen = await render( + + + , + ); + + try { + const progressActions = page.getByRole("group", { name: "Sign-in progress actions" }); + await progressActions.getByRole("button", { name: "Open browser again" }).click(); + await vi.waitFor(() => expect(openExternal).toHaveBeenCalledWith(authorizationUrl)); + await expect + .element(page.getByPlaceholder("Paste authorization code")) + .not.toBeInTheDocument(); + await expect + .element(page.getByRole("button", { name: "Cancel installation" })) + .not.toBeInTheDocument(); + await page.getByRole("button", { name: "Cancel sign-in" }).click(); + await vi.waitFor(() => + expect(cancelProviderConnection).toHaveBeenCalledWith({ + provider: "grok", + operationId: "connect-grok-active", + }), + ); + } finally { + await screen.unmount(); + queryClient.clear(); + restoreNativeApi(); + } + }); + + it("reopens the validated Google authorization page without terminal use", async () => { + const authorizationUrl = + "https://accounts.google.com/o/oauth2/auth?response_type=code&redirect_uri=https%3A%2F%2Fantigravity.google%2Foauth-callback&client_id=test-client&state=test-state&code_challenge=test-challenge&code_challenge_method=S256"; + const active = { + provider: "antigravity", + status: "error", + available: true, + authStatus: "unauthenticated", + checkedAt, + runtime: systemRuntime, + connectionState: { + operationId: "connect-antigravity-active", + method: "antigravity_browser", + status: "waiting_for_browser", + startedAt: new Date().toISOString(), + finishedAt: null, + message: "Finish signing in to Google.", + authorizationUrl, + }, + } satisfies ServerProviderStatus; + const openExternal = vi.fn().mockResolvedValue(undefined); + const restoreNativeApi = installNativeApi({ openExternal }); + const queryClient = createQueryClient(active); + useProviderConnectionDialogStore.getState().openDialog("antigravity", "provider_picker"); + + const screen = await render( + + + , + ); + + try { + await page.getByRole("button", { name: "Open browser again" }).click(); + await vi.waitFor(() => expect(openExternal).toHaveBeenCalledWith(authorizationUrl)); + await expect.element(page.getByText(/Automatic timeout in (?:10:00|9:5\d)/u)).toBeVisible(); + } finally { + await screen.unmount(); + queryClient.clear(); + restoreNativeApi(); + } + }); + + it("stops showing the OAuth countdown while Antigravity verifies the account", async () => { + const verifying = { + provider: "antigravity", + status: "error", + available: true, + authStatus: "unauthenticated", + checkedAt, + runtime: systemRuntime, + connectionState: { + operationId: "connect-antigravity-verifying", + method: "antigravity_browser", + status: "verifying", + startedAt: new Date().toISOString(), + finishedAt: null, + message: "Verifying the connection.", + }, + } satisfies ServerProviderStatus; + const restoreNativeApi = installNativeApi({}); + const queryClient = createQueryClient(verifying); + useProviderConnectionDialogStore.getState().openDialog("antigravity", "provider_picker"); + + const screen = await render( + + + , + ); + + try { + await expect.element(page.getByText("Verifying the connection.")).toBeVisible(); + await expect.element(page.getByText(/Automatic timeout in/u)).not.toBeInTheDocument(); + } finally { + await screen.unmount(); + queryClient.clear(); + restoreNativeApi(); + } + }); + + it.each(["resolve", "reject"] as const)( + "clears Google's one-time code immediately and ignores a late %s after cancellation", + async (lateSettlement) => { + const active = { + provider: "antigravity", + status: "error", + available: true, + authStatus: "unauthenticated", + checkedAt, + runtime: systemRuntime, + connectionState: { + operationId: "connect-antigravity-code", + method: "antigravity_browser", + status: "waiting_for_browser", + startedAt: new Date().toISOString(), + finishedAt: null, + message: "Finish signing in to Google, then paste the code here.", + }, + } satisfies ServerProviderStatus; + const cancelled = { + ...active, + connectionState: { + ...active.connectionState, + status: "cancelled", + finishedAt: new Date().toISOString(), + message: "Sign in was cancelled.", + }, + } satisfies ServerProviderStatus; + let resolveSubmission!: (result: { providers: ServerProviderStatus[] }) => void; + let rejectSubmission!: (error: Error) => void; + const pendingSubmission = new Promise<{ providers: ServerProviderStatus[] }>( + (resolve, reject) => { + resolveSubmission = resolve; + rejectSubmission = reject; + }, + ); + const submitProviderConnectionAuthorizationCode = vi.fn().mockReturnValue(pendingSubmission); + const cancelProviderConnection = vi.fn().mockResolvedValue({ providers: [cancelled] }); + const restoreNativeApi = installNativeApi({ + submitProviderConnectionAuthorizationCode, + cancelProviderConnection, + }); + const queryClient = createQueryClient(active); + useProviderConnectionDialogStore.getState().openDialog("antigravity", "provider_picker"); + + const screen = await render( + + + , + ); + + try { + const submitButton = page.getByRole("button", { name: "Submit code" }); + await expect.element(submitButton).toBeDisabled(); + await page.getByPlaceholder("Paste authorization code").fill("4/test-code-123"); + await submitButton.click(); + await vi.waitFor(() => + expect(submitProviderConnectionAuthorizationCode).toHaveBeenCalledWith({ + provider: "antigravity", + operationId: "connect-antigravity-code", + authorizationCode: "4/test-code-123", + }), + ); + await expect.element(page.getByText("Code submitted. Finishing sign in.")).toBeVisible(); + await expect + .element(page.getByPlaceholder("Paste authorization code")) + .not.toBeInTheDocument(); + const cancelButton = page.getByRole("button", { name: "Cancel sign-in" }); + const restartButton = page.getByRole("button", { name: "Restart sign in" }); + await expect.element(cancelButton).not.toBeDisabled(); + await expect.element(restartButton).not.toBeDisabled(); + expect(submitProviderConnectionAuthorizationCode).toHaveBeenCalledTimes(1); + await cancelButton.click(); + await vi.waitFor(() => + expect(cancelProviderConnection).toHaveBeenCalledWith({ + provider: "antigravity", + operationId: "connect-antigravity-code", + }), + ); + await expect.element(page.getByText("Sign in was cancelled.")).toBeVisible(); + + if (lateSettlement === "resolve") resolveSubmission({ providers: [active] }); + else rejectSubmission(new Error("Stale authorization-code failure.")); + await Promise.resolve(); + await Promise.resolve(); + + await expect.element(page.getByText("Sign in was cancelled.")).toBeVisible(); + await expect + .element(page.getByText("Stale authorization-code failure.")) + .not.toBeInTheDocument(); + expect( + queryClient.getQueryData(serverQueryKeys.config())?.providers[0] + ?.connectionState?.status, + ).toBe("cancelled"); + } finally { + await screen.unmount(); + queryClient.clear(); + restoreNativeApi(); + } + }, + ); + + it("retries the same Claude sign-in method after a failed attempt", async () => { + const failed = { + provider: "claudeAgent", + status: "error", + available: true, + authStatus: "unauthenticated", + checkedAt, + runtime: systemRuntime, + connectionState: { + operationId: "connect-claude-failed", + method: "claude_sso", + status: "failed", + startedAt: checkedAt, + finishedAt: checkedAt, + message: "Organization sign-in was not completed.", + }, + } satisfies ServerProviderStatus; + const restarted = { + ...failed, + connectionState: { + ...failed.connectionState, + operationId: "connect-claude-retry", + status: "waiting_for_browser", + finishedAt: null, + }, + } satisfies ServerProviderStatus; + const refreshProviders = vi.fn().mockResolvedValue({ providers: [failed] }); + const startProviderConnection = vi.fn().mockResolvedValue({ providers: [restarted] }); + const restoreNativeApi = installNativeApi({ refreshProviders, startProviderConnection }); + const queryClient = createQueryClient(failed); + useProviderConnectionDialogStore.getState().openDialog("claudeAgent", "provider_picker"); + + const screen = await render( + + + , + ); + + try { + await page.getByRole("button", { name: "Try again" }).click(); + await vi.waitFor(() => + expect(startProviderConnection).toHaveBeenCalledWith({ + provider: "claudeAgent", + method: "claude_sso", + }), + ); + } finally { + await screen.unmount(); + queryClient.clear(); + restoreNativeApi(); + } + }); + + it("requires explicit consent before installing the trusted latest release", async () => { + const initialProvider = { + provider: "antigravity", + status: "error", + available: false, + authStatus: "unknown", + checkedAt, + runtime: { + source: "missing", + managedVersion: null, + canInstall: true, + canRepair: false, + canRollback: false, + canRemove: false, + message: "No usable provider runtime was found.", + }, + } satisfies ServerProviderStatus; + const installingProvider = { + provider: "antigravity", + status: "error", + available: false, + authStatus: "unknown", + checkedAt, + runtime: { + source: "missing", + managedVersion: null, + canInstall: false, + canRepair: false, + canRollback: false, + canRemove: false, + message: "No usable provider runtime was found.", + }, + installationState: { + operationId: "install-antigravity-1", + operation: "install", + status: "downloading", + startedAt: checkedAt, + finishedAt: null, + message: "Downloading Antigravity 1.1.5.", + version: "1.1.5", + bytesDownloaded: 0, + totalBytes: 46_664_998, + }, + } satisfies ServerProviderStatus; + const prepareProviderInstall = vi.fn().mockResolvedValue({ + provider: "antigravity", + planToken: "trusted-plan-1", + version: "1.1.5", + target: "darwin-arm64", + sourceHost: "storage.googleapis.com", + downloadBytes: 46_664_998, + expiresAt: "2026-07-19T12:10:00.000Z", + }); + const installProvider = vi.fn().mockResolvedValue({ providers: [installingProvider] }); + const refreshProviders = vi.fn().mockResolvedValue({ providers: [initialProvider] }); + const restoreNativeApi = installNativeApi({ + refreshProviders, + prepareProviderInstall, + installProvider, + }); + const queryClient = createQueryClient(initialProvider); useProviderConnectionDialogStore.getState().openDialog("antigravity", "settings"); const screen = await render( @@ -318,17 +1145,105 @@ describe("ProviderConnectionDialog", () => { try { await page.getByRole("button", { name: "Install Antigravity" }).click(); - await expect.element(page.getByText("Ready to install version 1.1.4")).toBeVisible(); + await expect.element(page.getByText("Ready to install version 1.1.5")).toBeVisible(); expect(installProvider).not.toHaveBeenCalled(); await page.getByRole("button", { name: "Download and install" }).click(); await vi.waitFor(() => { expect(installProvider).toHaveBeenCalledWith({ provider: "antigravity", - planToken: "reviewed-plan-1", + planToken: "trusted-plan-1", + }); + }); + await expect.element(page.getByText("Downloading Antigravity 1.1.5.")).toBeVisible(); + await expect.element(page.getByRole("button", { name: "Cancel installation" })).toBeVisible(); + } finally { + await screen.unmount(); + queryClient.clear(); + restoreNativeApi(); + } + }); + + it("updates a managed runtime through the verified install lifecycle", async () => { + const currentProvider = { + provider: "antigravity", + status: "ready", + available: true, + authStatus: "authenticated", + version: "1.1.4", + checkedAt, + runtime: { + source: "managed", + managedVersion: "1.1.4", + canInstall: false, + canRepair: true, + canRollback: false, + canRemove: true, + message: null, + }, + versionAdvisory: { + status: "unknown", + currentVersion: "1.1.4", + latestVersion: null, + updateCommand: null, + canUpdate: false, + checkedAt, + message: "Updates for this runtime are managed by Scient.", + }, + } satisfies ServerProviderStatus; + const updatingProvider = { + ...currentProvider, + installationState: { + operationId: "update-antigravity-1", + operation: "install", + status: "downloading", + startedAt: checkedAt, + finishedAt: null, + message: "Downloading Antigravity 1.1.5.", + version: "1.1.5", + bytesDownloaded: 0, + totalBytes: null, + }, + } satisfies ServerProviderStatus; + const prepareProviderInstall = vi.fn().mockResolvedValue({ + provider: "antigravity", + planToken: "managed-update-plan-1", + version: "1.1.5", + target: "darwin-arm64", + sourceHost: "storage.googleapis.com", + downloadBytes: null, + expiresAt: "2026-07-21T12:10:00.000Z", + }); + const installProvider = vi.fn().mockResolvedValue({ providers: [updatingProvider] }); + const refreshProviders = vi.fn().mockResolvedValue({ providers: [currentProvider] }); + const restoreNativeApi = installNativeApi({ + refreshProviders, + prepareProviderInstall, + installProvider, + }); + const queryClient = createQueryClient(currentProvider); + useProviderConnectionDialogStore.getState().openDialog("antigravity", "managed_update"); + + const screen = await render( + + + , + ); + + try { + await expect.element(page.getByRole("heading", { name: "Update Antigravity" })).toBeVisible(); + await page.getByRole("button", { name: "Check latest version" }).click(); + await expect.element(page.getByText("Ready to update from 1.1.4 to 1.1.5")).toBeVisible(); + expect(installProvider).not.toHaveBeenCalled(); + + await page.getByRole("button", { name: "Download and update" }).click(); + await vi.waitFor(() => { + expect(installProvider).toHaveBeenCalledWith({ + provider: "antigravity", + planToken: "managed-update-plan-1", }); }); - await expect.element(page.getByText("Downloading Antigravity 1.1.4.")).toBeVisible(); + await expect.element(page.getByText("Downloading Antigravity 1.1.5.")).toBeVisible(); await expect.element(page.getByRole("button", { name: "Cancel installation" })).toBeVisible(); } finally { await screen.unmount(); @@ -336,4 +1251,45 @@ describe("ProviderConnectionDialog", () => { restoreNativeApi(); } }); + + it("keeps managed installation available after a complete provider refresh", async () => { + const antigravity = { + provider: "antigravity", + status: "error", + available: false, + authStatus: "unknown", + checkedAt, + runtime: { + source: "missing", + managedVersion: null, + canInstall: true, + canRepair: false, + canRollback: false, + canRemove: false, + message: "No usable provider runtime was found.", + }, + } satisfies ServerProviderStatus; + const queryClient = createQueryClient(antigravity); + useProviderConnectionDialogStore.getState().openDialog("antigravity", "settings"); + + const screen = await render( + + + , + ); + + try { + await expect.element(page.getByRole("button", { name: "Install Antigravity" })).toBeVisible(); + applyProviderStatusesToCache(queryClient, [ + { ...antigravity, checkedAt: "2026-07-20T16:05:00.000Z" }, + ]); + await expect.element(page.getByRole("button", { name: "Install Antigravity" })).toBeVisible(); + await expect + .element(page.getByRole("button", { name: "Open installation guide" })) + .not.toBeInTheDocument(); + } finally { + await screen.unmount(); + queryClient.clear(); + } + }); }); diff --git a/apps/web/src/components/ProviderConnectionDialog.tsx b/apps/web/src/components/ProviderConnectionDialog.tsx index fa6c09c29..711a8a6d3 100644 --- a/apps/web/src/components/ProviderConnectionDialog.tsx +++ b/apps/web/src/components/ProviderConnectionDialog.tsx @@ -2,20 +2,21 @@ // Purpose: One plain-language setup and recovery flow for AI providers. // Layer: Shared UI component -import type { - ServerConfig, - ServerProviderInstallPlan, - ServerProviderStatus, -} from "@synara/contracts"; -import { useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query"; -import { useEffect, useState } from "react"; +import type { ServerProviderConnectionMethod, ServerProviderInstallPlan } from "@synara/contracts"; +import { compareSemverVersions } from "@synara/shared/providerVersions"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useEffect, useRef, useState } from "react"; import { + CLAUDE_CONNECTION_METHOD_OPTIONS, describeProviderConnection, + describeManagedProviderUpdate, providerConnectionMethod, providerInstallUrl, } from "~/lib/providerConnectionPresentation"; -import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery"; +import { serverConfigQueryOptions } from "~/lib/serverReactQuery"; +import { applyProviderStatusesToCache } from "~/lib/providerStatusCache"; +import { cn } from "~/lib/utils"; import { ensureNativeApi } from "~/nativeApi"; import { useProviderConnectionDialogStore } from "~/providerConnectionDialogStore"; import { PROVIDER_ICON_COMPONENT_BY_PROVIDER } from "./ProviderIcon"; @@ -29,37 +30,131 @@ import { DialogPopup, DialogTitle, } from "./ui/dialog"; +import { Input } from "./ui/input"; import { Spinner } from "./ui/spinner"; -function updateProviderStatuses( - queryClient: QueryClient, - providers: ReadonlyArray, -) { - const current = queryClient.getQueryData(serverQueryKeys.config()); - if (current) { - queryClient.setQueryData(serverQueryKeys.config(), { ...current, providers }); - } +const CONNECTION_TIMEOUT_MS = 10 * 60 * 1_000; +const ANTIGRAVITY_CONNECTION_TIMEOUT_MS = 10 * 60 * 1_000; + +function formatRemainingTime(startedAt: string, nowMs: number, timeoutMs: number): string { + const elapsedMs = Math.max(0, nowMs - Date.parse(startedAt)); + const remainingSeconds = Math.max(0, Math.ceil((timeoutMs - elapsedMs) / 1_000)); + const minutes = Math.floor(remainingSeconds / 60); + const seconds = remainingSeconds % 60; + return `${minutes}:${seconds.toString().padStart(2, "0")}`; } export function ProviderConnectionDialog() { - const { isOpen, provider, setOpen } = useProviderConnectionDialogStore(); + const { isOpen, provider, source, setOpen } = useProviderConnectionDialogStore(); const configQuery = useQuery({ ...serverConfigQueryOptions(), enabled: isOpen }); const queryClient = useQueryClient(); const [actionPending, setActionPending] = useState(false); const [actionError, setActionError] = useState(null); const [installPlan, setInstallPlan] = useState(null); - + const [managedUpdateStarted, setManagedUpdateStarted] = useState(false); + const [clockMs, setClockMs] = useState(() => Date.now()); + const [runtimeReconnectBaselineOperationId, setRuntimeReconnectBaselineOperationId] = useState< + string | null | undefined + >(undefined); + const [authorizationCode, setAuthorizationCode] = useState(""); + const [submittedAuthorizationCodeOperationId, setSubmittedAuthorizationCodeOperationId] = + useState(null); + const activeAuthorizationCodeSubmissionRef = useRef<{ + readonly operationId: string; + } | null>(null); + const activeConnectionOperationIdRef = useRef(null); const status = provider ? configQuery.data?.providers.find((entry) => entry.provider === provider) : undefined; - const presentation = provider ? describeProviderConnection(provider, status) : null; + const runtimeReauthenticationFlow = + isOpen && provider === "codex" && source === "runtime_authentication_error"; + const runtimeReconnectCompleted = + runtimeReconnectBaselineOperationId !== undefined && + status?.connectionState?.status === "connected" && + status.connectionState.operationId !== runtimeReconnectBaselineOperationId; + const runtimeReconnectRequired = runtimeReauthenticationFlow && !runtimeReconnectCompleted; + const connectionPresentation = provider + ? describeProviderConnection(provider, status, { + forceReconnect: runtimeReconnectRequired, + }) + : null; + const managedUpdateFlow = + source === "managed_update" && status?.runtime?.source === "managed" && provider !== null; + const presentation = + managedUpdateFlow && provider && status + ? describeManagedProviderUpdate({ + provider, + status, + plan: installPlan, + updateStarted: managedUpdateStarted, + }) + : connectionPresentation; const Icon = provider ? PROVIDER_ICON_COMPONENT_BY_PROVIDER[provider] : null; + const activeConnection = + status?.connectionState && + ["starting", "waiting_for_browser", "verifying"].includes(status.connectionState.status) + ? status.connectionState + : null; + activeConnectionOperationIdRef.current = activeConnection?.operationId ?? null; + + useEffect(() => { + setRuntimeReconnectBaselineOperationId(undefined); + setManagedUpdateStarted(false); + }, [isOpen, provider, source]); useEffect(() => { setActionPending(false); setActionError(null); setInstallPlan(null); - }, [isOpen, provider]); + if (!isOpen || !provider || activeConnection) return; + + let disposed = false; + setActionPending(true); + void ensureNativeApi() + .server.refreshProviders() + .then((result) => { + if (!disposed) applyProviderStatusesToCache(queryClient, result.providers); + }) + .catch((error) => { + if (!disposed) { + setActionError( + error instanceof Error ? error.message : "Scient could not check this connection.", + ); + } + }) + .finally(() => { + if (!disposed) setActionPending(false); + }); + return () => { + disposed = true; + }; + }, [isOpen, provider, queryClient, activeConnection]); + + useEffect(() => { + if (!isOpen || !activeConnection) return; + setClockMs(Date.now()); + const intervalId = window.setInterval(() => setClockMs(Date.now()), 1_000); + return () => window.clearInterval(intervalId); + }, [isOpen, activeConnection]); + + useEffect(() => { + setAuthorizationCode(""); + const operationId = activeConnection?.operationId; + if (activeAuthorizationCodeSubmissionRef.current?.operationId !== operationId) { + activeAuthorizationCodeSubmissionRef.current = null; + } + if (operationId) { + setSubmittedAuthorizationCodeOperationId((submitted) => + submitted === operationId ? submitted : null, + ); + } else { + setSubmittedAuthorizationCodeOperationId(null); + } + }, [activeConnection?.operationId]); + + useEffect(() => { + if (!isOpen) setAuthorizationCode(""); + }, [isOpen]); if (!provider || !presentation || !Icon) return null; const startsProviderSignIn = @@ -80,29 +175,127 @@ export function ProviderConnectionDialog() { const refresh = () => runAction(async () => { const result = await ensureNativeApi().server.refreshProviders(); - updateProviderStatuses(queryClient, result.providers); + applyProviderStatusesToCache(queryClient, result.providers); }); - const startSignIn = () => - runAction(async () => { - const method = providerConnectionMethod(provider); - if (!method) throw new Error("In-app sign in is not supported for this provider yet."); - const result = await ensureNativeApi().server.startProviderConnection({ provider, method }); - updateProviderStatuses(queryClient, result.providers); - }); + const performStartSignIn = async (requestedMethod?: ServerProviderConnectionMethod) => { + const previousMethod = status?.connectionState?.method; + const method = + requestedMethod ?? + (previousMethod !== "claude_subscription" ? previousMethod : undefined) ?? + providerConnectionMethod(provider); + if (!method) throw new Error("In-app sign in is not supported for this provider yet."); + const reauthenticate = runtimeReauthenticationFlow; + if (reauthenticate) { + setRuntimeReconnectBaselineOperationId(status?.connectionState?.operationId ?? null); + } + try { + const result = await ensureNativeApi().server.startProviderConnection({ + provider, + method, + ...(reauthenticate ? { mode: "reauthenticate" as const } : {}), + }); + applyProviderStatusesToCache(queryClient, result.providers); + } catch (error) { + if (reauthenticate) setRuntimeReconnectBaselineOperationId(undefined); + throw error; + } + }; + + const startSignIn = (method?: ServerProviderConnectionMethod) => + runAction(() => performStartSignIn(method)); + + const invalidateAuthorizationCodeSubmission = (operationId: string) => { + if (activeAuthorizationCodeSubmissionRef.current?.operationId === operationId) { + activeAuthorizationCodeSubmissionRef.current = null; + } + setAuthorizationCode(""); + setSubmittedAuthorizationCodeOperationId((submitted) => + submitted === operationId ? null : submitted, + ); + }; const cancelSignIn = () => { const operationId = status?.connectionState?.operationId; if (!operationId) return Promise.resolve(); + invalidateAuthorizationCodeSubmission(operationId); return runAction(async () => { const result = await ensureNativeApi().server.cancelProviderConnection({ provider, operationId, }); - updateProviderStatuses(queryClient, result.providers); + applyProviderStatusesToCache(queryClient, result.providers); + }); + }; + + const restartSignIn = () => { + const operation = status?.connectionState; + if (!operation) return Promise.resolve(); + invalidateAuthorizationCodeSubmission(operation.operationId); + return runAction(async () => { + const cancelled = await ensureNativeApi().server.cancelProviderConnection({ + provider, + operationId: operation.operationId, + }); + applyProviderStatusesToCache(queryClient, cancelled.providers); + await performStartSignIn(operation.method); }); }; + const reopenAuthorization = () => { + const authorizationUrl = activeConnection?.authorizationUrl; + if (!authorizationUrl) return Promise.resolve(); + return runAction(() => ensureNativeApi().shell.openExternal(authorizationUrl)); + }; + + const submitAuthorizationCode = () => { + if (provider !== "antigravity" || !activeConnection) return Promise.resolve(); + const code = authorizationCode.trim(); + if (!code) return Promise.resolve(); + const operationId = activeConnection.operationId; + if (activeAuthorizationCodeSubmissionRef.current?.operationId === operationId) { + return Promise.resolve(); + } + const submission = { operationId }; + activeAuthorizationCodeSubmissionRef.current = submission; + setAuthorizationCode(""); + setSubmittedAuthorizationCodeOperationId(operationId); + setActionError(null); + void ensureNativeApi() + .server.submitProviderConnectionAuthorizationCode({ + provider, + operationId, + authorizationCode: code, + }) + .then((result) => { + if ( + activeAuthorizationCodeSubmissionRef.current !== submission || + activeConnectionOperationIdRef.current !== operationId + ) { + return; + } + activeAuthorizationCodeSubmissionRef.current = null; + applyProviderStatusesToCache(queryClient, result.providers); + setSubmittedAuthorizationCodeOperationId((submitted) => + submitted === operationId ? null : submitted, + ); + }) + .catch((error) => { + if ( + activeAuthorizationCodeSubmissionRef.current !== submission || + activeConnectionOperationIdRef.current !== operationId + ) { + return; + } + activeAuthorizationCodeSubmissionRef.current = null; + setSubmittedAuthorizationCodeOperationId((submitted) => + submitted === operationId ? null : submitted, + ); + setActionError(error instanceof Error ? error.message : "The code could not be submitted."); + }); + return Promise.resolve(); + }; + const install = () => runAction(async () => { if (!installPlan) { @@ -114,8 +307,9 @@ export function ProviderConnectionDialog() { provider, planToken: installPlan.planToken, }); + if (managedUpdateFlow) setManagedUpdateStarted(true); setInstallPlan(null); - updateProviderStatuses(queryClient, result.providers); + applyProviderStatusesToCache(queryClient, result.providers); }); const cancelInstall = () => { @@ -126,7 +320,7 @@ export function ProviderConnectionDialog() { provider, operationId, }); - updateProviderStatuses(queryClient, result.providers); + applyProviderStatusesToCache(queryClient, result.providers); }); }; @@ -167,21 +361,142 @@ export function ProviderConnectionDialog() {
- +

{presentation.description}

{busy ? ( -
- - You can close this dialog; sign in continues in the background. +
+
+ + + {presentation.busy + ? "You can close this dialog; sign in continues in the background." + : "Checking the current provider state."} + +
+ {activeConnection && activeConnection.status !== "verifying" ? ( +

+ Automatic timeout in{" "} + {formatRemainingTime( + activeConnection.startedAt, + clockMs, + provider === "antigravity" + ? ANTIGRAVITY_CONNECTION_TIMEOUT_MS + : CONNECTION_TIMEOUT_MS, + )} +

+ ) : null} + {presentation.busy && activeConnection ? ( +
+ {(provider === "grok" || provider === "antigravity") && + activeConnection.authorizationUrl ? ( + + ) : null} + {presentation.canRestart ? ( + + ) : null} +
+ ) : null} +
+ ) : null} + + {provider === "antigravity" && activeConnection?.status === "waiting_for_browser" ? ( +
{ + event.preventDefault(); + void submitAuthorizationCode(); + }} + > + {submittedAuthorizationCodeOperationId === activeConnection.operationId ? ( +

+ Code submitted. Finishing sign in. +

+ ) : ( + <> + +
+ setAuthorizationCode(event.target.value)} + /> + +
+ + )} +
+ ) : null} + + {provider === "claudeAgent" && presentation.primaryAction === "sign_in" ? ( +
+

Other sign-in methods

+ {CLAUDE_CONNECTION_METHOD_OPTIONS.slice(1).map((option) => ( + + ))}
) : null} {installPlan ? (
-

Ready to install version {installPlan.version}

+

+ {managedUpdateFlow && status?.runtime?.managedVersion + ? compareSemverVersions(installPlan.version, status.runtime.managedVersion) > 0 + ? `Ready to update from ${status.runtime.managedVersion} to ${installPlan.version}` + : `Latest stable version: ${installPlan.version}` + : `Ready to install version ${installPlan.version}`} +

{installPlan.downloadBytes ? `${(installPlan.downloadBytes / 1_048_576).toFixed(1)} MB from ${installPlan.sourceHost}` @@ -201,27 +516,42 @@ export function ProviderConnectionDialog() { ) : null}

- {startsProviderSignIn - ? "Scient starts the provider's official sign-in. Passwords and account tokens stay with the provider and are never stored in Scient." - : "Installation and sign-in happen directly with the provider. Passwords and account tokens are never entered into or stored in Scient."} + {managedUpdateFlow + ? "Scient downloads the latest compatible release from the provider's trusted stable channel, verifies its digest, tests it, and keeps the previous working release available for rollback." + : provider === "antigravity" && startsProviderSignIn + ? "Passwords and account tokens stay with Google. Scient sends this one-time code only to the local Antigravity process and never stores it." + : startsProviderSignIn + ? "Scient starts the provider's official sign-in. Passwords and account tokens stay with the provider and are never stored in Scient." + : "Installation and sign-in happen directly with the provider. Passwords and account tokens are never entered into or stored in Scient."}

- + {presentation.canCancel ? ( ) : null} {presentation.primaryAction !== "none" ? ( - ) : null} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index d02525b55..ecbe82feb 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -132,7 +132,7 @@ import { import { gitRemoveWorktreeMutationOptions, gitResolvePullRequestQueryOptions, - gitStatusQueryOptions, + passiveGitStatusQueryOptions, } from "../lib/gitReactQuery"; import { providerComposerCapabilitiesQueryOptions, @@ -5101,9 +5101,8 @@ export default function Sidebar() { ); const threadGitStatusQueries = useQueries({ queries: threadGitStatusCwds.map((cwd) => ({ - ...gitStatusQueryOptions(cwd), + ...passiveGitStatusQueryOptions(cwd), staleTime: 30_000, - refetchInterval: 60_000, })), }); const threadStoredPrTargets = useMemo( diff --git a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx index 4f44fe982..a171bb333 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx @@ -606,7 +606,7 @@ describe("ProviderModelPicker", () => { } }); - it("keeps warning providers selectable when they are still available", async () => { + it("routes unknown Claude authentication through a fresh connection check", async () => { const mounted = await mountPicker({ provider: "codex", model: "gpt-5-codex", @@ -637,8 +637,62 @@ describe("ProviderModelPicker", () => { expect(document.body.textContent ?? "").toContain("Claude"); }); - await expect.element(page.getByText("Sign in")).not.toBeInTheDocument(); - await expect.element(page.getByText("Unavailable")).not.toBeInTheDocument(); + await expect.element(page.getByText("Check connection")).toBeInTheDocument(); + await page.getByRole("menuitem", { name: /Claude.*Check connection/u }).click(); + await vi.waitFor(() => { + expect(useProviderConnectionDialogStore.getState()).toMatchObject({ + isOpen: true, + provider: "claudeAgent", + source: "provider_picker", + }); + }); + } finally { + await mounted.cleanup(); + } + }); + + it("reopens an active Claude sign-in from the same provider row", async () => { + const mounted = await mountPicker({ + provider: "codex", + model: "gpt-5-codex", + lockedProvider: null, + providers: [ + { + provider: "codex", + status: "ready", + available: true, + authStatus: "authenticated", + checkedAt: "2026-04-10T10:00:00.000Z", + }, + { + provider: "claudeAgent", + status: "error", + available: true, + authStatus: "unauthenticated", + checkedAt: "2026-04-10T10:00:00.000Z", + connectionState: { + operationId: "claude-connect-1", + method: "claude_account", + status: "waiting_for_browser", + startedAt: "2026-04-10T10:00:00.000Z", + finishedAt: null, + message: "Finish signing in.", + }, + }, + ], + }); + + try { + await page.getByRole("button").click(); + await expect.element(page.getByText("Connecting")).toBeInTheDocument(); + await page.getByRole("menuitem", { name: /Claude.*Connecting/u }).click(); + await vi.waitFor(() => { + expect(useProviderConnectionDialogStore.getState()).toMatchObject({ + isOpen: true, + provider: "claudeAgent", + source: "provider_picker", + }); + }); } finally { await mounted.cleanup(); } diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index 462d882dd..d39c49544 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -46,6 +46,7 @@ import { } from "../../lib/modelFavorites"; import { Skeleton } from "../ui/skeleton"; import { useProviderConnectionDialogStore } from "~/providerConnectionDialogStore"; +import { isProviderUsable } from "~/lib/providerAvailability"; function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is { value: ProviderKind; @@ -73,6 +74,18 @@ function resolveLiveProviderAvailability(provider: ServerProviderStatus | undefi }; } + const connectionStatus = provider.connectionState?.status; + if ( + connectionStatus === "starting" || + connectionStatus === "waiting_for_browser" || + connectionStatus === "verifying" + ) { + return { + disabled: true, + label: "Connecting", + }; + } + if (provider.authStatus === "unauthenticated") { return { disabled: true, @@ -80,6 +93,13 @@ function resolveLiveProviderAvailability(provider: ServerProviderStatus | undefi }; } + if (!isProviderUsable(provider)) { + return { + disabled: true, + label: "Check connection", + }; + } + return { disabled: false, label: null, diff --git a/apps/web/src/components/terminal/terminalRuntime.ts b/apps/web/src/components/terminal/terminalRuntime.ts index 87de059d0..0f27d9b1d 100644 --- a/apps/web/src/components/terminal/terminalRuntime.ts +++ b/apps/web/src/components/terminal/terminalRuntime.ts @@ -46,10 +46,17 @@ import { writeSystemMessage, } from "./terminalRuntimeAppearance"; import { terminalEventDispatcher } from "./terminalEventDispatcher"; -import type { - TerminalRuntimeConfig, - TerminalRuntimeEntry, - TerminalRuntimeViewState, +import { + acceptTerminalOutputSequence, + acceptTerminalSnapshotBarrier, + finishTerminalSnapshotReconcile, + requestTerminalSnapshotReconcile, + supersedeTerminalSnapshotCapture, + supersedeTerminalSnapshotCaptureAndTakeBuffered, + type TerminalOutputEvent, + type TerminalRuntimeConfig, + type TerminalRuntimeEntry, + type TerminalRuntimeViewState, } from "./terminalRuntimeTypes"; import { waitForTerminalFontReady } from "./terminalFontSettle"; import { observeTerminalWriteParsed } from "./terminalPerformance"; @@ -682,46 +689,113 @@ async function sendTerminalInput( } } +function beginTerminalSnapshotCapture(entry: TerminalRuntimeEntry): number { + entry.snapshotReconcileActive = true; + entry.snapshotBufferedOutputEvents.length = 0; + return ++entry.snapshotReconcileRequestId; +} + +function deliverTerminalOutputEvent(entry: TerminalRuntimeEntry, event: TerminalOutputEvent): void { + if (!acceptTerminalOutputSequence(entry, event.outputEpoch, event.outputSequence)) { + acknowledgeParsedOutput(entry, event.byteLength ?? terminalByteLength(event.data)); + return; + } + setRuntimeStatus(entry, "ready"); + scheduleWrite(entry, event.data, event.byteLength ?? terminalByteLength(event.data)); +} + +function finishTerminalSnapshotCapture(entry: TerminalRuntimeEntry, requestId: number): void { + if (entry.disposed || entry.snapshotReconcileRequestId !== requestId) return; + const shouldRetry = finishTerminalSnapshotReconcile(entry); + const buffered = entry.snapshotBufferedOutputEvents + .splice(0) + .toSorted((left, right) => left.outputSequence - right.outputSequence); + for (const event of buffered) deliverTerminalOutputEvent(entry, event); + if (shouldRetry) { + queueMicrotask(() => { + if (entry.disposed || entry.hasHandledExit) return; + if (entry.opened) { + reconcileTerminalSnapshot(entry); + } else { + openTerminal(entry); + } + }); + } +} + +function flushAndSupersedeTerminalSnapshotCapture(entry: TerminalRuntimeEntry): void { + const buffered = supersedeTerminalSnapshotCaptureAndTakeBuffered(entry).toSorted( + (left, right) => left.outputSequence - right.outputSequence, + ); + for (const event of buffered) deliverTerminalOutputEvent(entry, event); +} + +function acknowledgeAndSupersedeTerminalSnapshotCapture(entry: TerminalRuntimeEntry): void { + const buffered = supersedeTerminalSnapshotCaptureAndTakeBuffered(entry); + for (const event of buffered) { + acknowledgeParsedOutput(entry, event.byteLength ?? terminalByteLength(event.data)); + } +} + +function completeTerminalSnapshotCapture( + entry: TerminalRuntimeEntry, + requestId: number, + snapshot: TerminalSessionSnapshot, + onComplete?: () => void, +): void { + if (entry.disposed || entry.snapshotReconcileRequestId !== requestId) { + return; + } + if (!entry.opened || entry.hasHandledExit) { + finishTerminalSnapshotCapture(entry, requestId); + return; + } + + const finish = () => { + if (entry.disposed || entry.snapshotReconcileRequestId !== requestId) return; + finishTerminalSnapshotCapture(entry, requestId); + setRuntimeStatus(entry, "ready"); + onComplete?.(); + }; + + // The sequence is the server-side barrier for this exact history. Live + // output is buffered while the snapshot RPC is in flight and only events + // newer than the barrier are appended after the authoritative replay. + if (acceptTerminalSnapshotBarrier(entry, snapshot.outputEpoch, snapshot.outputSequence)) { + if (snapshotHasReplayPayload(snapshot)) { + replaySnapshot(entry, snapshot, finish); + return; + } + } + finish(); +} + function reconcileTerminalSnapshot(entry: TerminalRuntimeEntry): void { - if (entry.disposed || !entry.opened || entry.hasHandledExit) return; + if (entry.disposed || !entry.opened || entry.hasHandledExit) { + return; + } + if (!requestTerminalSnapshotReconcile(entry)) return; const api = readNativeApi(); if (!api) return; - const outputEventVersionAtRequest = entry.outputEventVersion; - const requestId = ++entry.snapshotReconcileRequestId; + if (entry.snapshotReconcileTimer !== null) { + window.clearTimeout(entry.snapshotReconcileTimer); + entry.snapshotReconcileTimer = null; + } + + const requestId = beginTerminalSnapshotCapture(entry); setRuntimeStatus(entry, "connecting"); void api.terminal .open(buildOpenInput(entry)) .then((snapshot) => { - if ( - entry.disposed || - !entry.opened || - entry.hasHandledExit || - entry.snapshotReconcileRequestId !== requestId - ) { - return; - } - - if (entry.outputEventVersion !== outputEventVersionAtRequest) { - return; - } - - if (snapshotHasReplayPayload(snapshot)) { - replaySnapshot(entry, snapshot, () => { - if (!entry.disposed && entry.snapshotReconcileRequestId === requestId) { - setRuntimeStatus(entry, "ready"); - } - }); - return; - } - - setRuntimeStatus(entry, "ready"); + completeTerminalSnapshotCapture(entry, requestId, snapshot); }) .catch((error) => { if (entry.disposed || !entry.opened || entry.snapshotReconcileRequestId !== requestId) { return; } + finishTerminalSnapshotCapture(entry, requestId); setRuntimeStatus(entry, "error"); writeSystemMessage( entry.terminal, @@ -822,8 +896,13 @@ export function createRuntimeEntry(config: TerminalRuntimeConfig): TerminalRunti pendingWriteLength: 0, pendingWriteBytes: 0, linkMatchCache: new Map(), - outputEventVersion: 0, + lastOutputEpoch: null, + lastOutputSequence: 0, + snapshotReconcileActive: false, + snapshotBufferedOutputEvents: [], + snapshotReconcileQueued: false, snapshotReconcileRequestId: 0, + snapshotReconcileTimer: null, webglLoadFrame: null, themeRefreshFrame: 0, themeObserver: null, @@ -870,7 +949,7 @@ export function createRuntimeEntry(config: TerminalRuntimeConfig): TerminalRunti reconcileTerminalSnapshot(entry); return; } - if (state === "connecting" || state === "closed") { + if (state === "connecting" || state === "reconnecting") { setRuntimeStatus(entry, "connecting"); } }); @@ -1006,14 +1085,19 @@ export function createRuntimeEntry(config: TerminalRuntimeConfig): TerminalRunti entry.terminalId, (event) => { if (event.type === "output") { - setRuntimeStatus(entry, "ready"); - entry.outputEventVersion += 1; - scheduleWrite(entry, event.data, event.byteLength ?? terminalByteLength(event.data)); + if (entry.snapshotReconcileActive) { + entry.snapshotBufferedOutputEvents.push(event); + } else { + deliverTerminalOutputEvent(entry, event); + } return; } if (event.type === "started" || event.type === "restarted") { entry.hasHandledExit = false; + supersedeTerminalSnapshotCapture(entry); + entry.lastOutputEpoch = event.snapshot.outputEpoch; + entry.lastOutputSequence = event.snapshot.outputSequence; const shouldReplaySnapshot = event.type === "restarted" || snapshotHasReplayPayload(event.snapshot); if (shouldReplaySnapshot) { @@ -1025,6 +1109,7 @@ export function createRuntimeEntry(config: TerminalRuntimeConfig): TerminalRunti } if (event.type === "cleared") { + acknowledgeAndSupersedeTerminalSnapshotCapture(entry); entry.titleInputBuffer = ""; entry.linkMatchCache.clear(); clearPendingWrites(entry); @@ -1055,6 +1140,7 @@ export function createRuntimeEntry(config: TerminalRuntimeConfig): TerminalRunti } if (event.type === "exited") { + flushAndSupersedeTerminalSnapshotCapture(entry); flushPendingWrites(entry); const details = [ typeof event.exitCode === "number" ? `code ${event.exitCode}` : null, @@ -1091,53 +1177,30 @@ function openTerminal(entry: TerminalRuntimeEntry): void { entry.lastSentResize = null; entry.opened = true; setRuntimeStatus(entry, "connecting"); - const outputEventVersionAtOpen = entry.outputEventVersion; const openInput = buildOpenInput(entry); + const requestId = beginTerminalSnapshotCapture(entry); void api.terminal .open(openInput) .then((snapshot) => { - if (entry.disposed) return; - if ( - snapshotHasReplayPayload(snapshot) && - entry.outputEventVersion === outputEventVersionAtOpen - ) { - replaySnapshot(entry, snapshot, () => setRuntimeStatus(entry, "ready")); - } else if (entry.outputEventVersion === outputEventVersionAtOpen) { - setRuntimeStatus(entry, "ready"); - window.setTimeout(() => { - if ( - entry.disposed || - !entry.opened || - entry.outputEventVersion !== outputEventVersionAtOpen - ) { - return; - } - void api.terminal - .open(openInput) - .then((nextSnapshot) => { - if ( - entry.disposed || - entry.outputEventVersion !== outputEventVersionAtOpen || - !snapshotHasReplayPayload(nextSnapshot) - ) { - return; - } - replaySnapshot(entry, nextSnapshot, () => setRuntimeStatus(entry, "ready")); - }) - .catch(() => { - // Best-effort recovery only; the original open already succeeded. - }); - }, OPEN_SNAPSHOT_RECONCILE_DELAY_MS); - } - if (entry.viewState.autoFocus) { - window.requestAnimationFrame(() => { - entry.terminal.focus(); - }); - } + const shouldReconcileEmptySnapshot = !snapshotHasReplayPayload(snapshot); + completeTerminalSnapshotCapture(entry, requestId, snapshot, () => { + if (shouldReconcileEmptySnapshot && entry.lastOutputSequence === snapshot.outputSequence) { + entry.snapshotReconcileTimer = window.setTimeout(() => { + entry.snapshotReconcileTimer = null; + reconcileTerminalSnapshot(entry); + }, OPEN_SNAPSHOT_RECONCILE_DELAY_MS); + } + if (entry.viewState.autoFocus) { + window.requestAnimationFrame(() => { + entry.terminal.focus(); + }); + } + }); }) .catch((error) => { if (entry.disposed) return; + finishTerminalSnapshotCapture(entry, requestId); entry.opened = false; setRuntimeStatus(entry, "error"); writeSystemMessage(entry.terminal, describeErrorMessage(error, "Failed to open terminal")); @@ -1209,6 +1272,13 @@ export function disposeRuntimeEntry(entry: TerminalRuntimeEntry): void { // Closing a terminal should not synchronously paint queued output into a buffer // that is about to be destroyed; acknowledge and drop it to keep close latency low. clearPendingWrites(entry); + if (entry.snapshotReconcileTimer !== null) { + window.clearTimeout(entry.snapshotReconcileTimer); + entry.snapshotReconcileTimer = null; + } + entry.snapshotReconcileActive = false; + entry.snapshotBufferedOutputEvents.length = 0; + entry.snapshotReconcileQueued = false; entry.unsubscribeTerminalEvents?.(); entry.unsubscribeTerminalEvents = null; entry.querySuppressionDispose?.(); diff --git a/apps/web/src/components/terminal/terminalRuntimeTypes.test.ts b/apps/web/src/components/terminal/terminalRuntimeTypes.test.ts index 4d6183ce2..4f99f0d3d 100644 --- a/apps/web/src/components/terminal/terminalRuntimeTypes.test.ts +++ b/apps/web/src/components/terminal/terminalRuntimeTypes.test.ts @@ -4,10 +4,111 @@ import { describe, expect, it } from "vitest"; -import { buildTerminalRuntimeKey } from "./terminalRuntimeTypes"; +import { + acceptTerminalOutputSequence, + acceptTerminalSnapshotBarrier, + buildTerminalRuntimeKey, + finishTerminalSnapshotReconcile, + requestTerminalSnapshotReconcile, + supersedeTerminalSnapshotCapture, + supersedeTerminalSnapshotCaptureAndTakeBuffered, +} from "./terminalRuntimeTypes"; describe("buildTerminalRuntimeKey", () => { it("builds a thread-scoped runtime key for terminal persistence", () => { expect(buildTerminalRuntimeKey("thread-123", "terminal-abc")).toBe("thread-123::terminal-abc"); }); }); + +describe("terminal snapshot capture", () => { + it.each(["clear", "restart"])( + "makes an in-flight snapshot stale when a %s event wins the race", + () => { + const capture = { + snapshotReconcileActive: true, + snapshotBufferedOutputEvents: [ + { + type: "output" as const, + threadId: "thread-1", + terminalId: "terminal-1", + createdAt: new Date().toISOString(), + data: "stale", + outputEpoch: "epoch-1", + outputSequence: 1, + }, + ], + snapshotReconcileQueued: true, + snapshotReconcileRequestId: 7, + }; + + expect(supersedeTerminalSnapshotCapture(capture)).toBe(true); + expect(capture.snapshotReconcileActive).toBe(false); + expect(capture.snapshotBufferedOutputEvents).toEqual([]); + expect(capture.snapshotReconcileQueued).toBe(false); + expect(capture.snapshotReconcileRequestId).toBe(8); + expect(supersedeTerminalSnapshotCapture(capture)).toBe(false); + }, + ); + + it("queues one replacement when a reconnect signal arrives behind a wedged capture", () => { + const capture = { + snapshotReconcileActive: true, + snapshotBufferedOutputEvents: [], + snapshotReconcileQueued: false, + snapshotReconcileRequestId: 1, + }; + + expect(requestTerminalSnapshotReconcile(capture)).toBe(false); + expect(requestTerminalSnapshotReconcile(capture)).toBe(false); + expect(capture.snapshotReconcileQueued).toBe(true); + expect(finishTerminalSnapshotReconcile(capture)).toBe(true); + expect(capture.snapshotReconcileActive).toBe(false); + expect(capture.snapshotReconcileQueued).toBe(false); + expect(requestTerminalSnapshotReconcile(capture)).toBe(true); + }); + + it("returns buffered output when an exit supersedes a capture so callers can flush it first", () => { + const older = { + type: "output" as const, + threadId: "thread-1", + terminalId: "terminal-1", + createdAt: new Date().toISOString(), + data: "before exit", + byteLength: 11, + outputEpoch: "epoch-1", + outputSequence: 2, + }; + const capture = { + snapshotReconcileActive: true, + snapshotBufferedOutputEvents: [older], + snapshotReconcileQueued: true, + snapshotReconcileRequestId: 4, + }; + + expect(supersedeTerminalSnapshotCaptureAndTakeBuffered(capture)).toEqual([older]); + expect(capture).toMatchObject({ + snapshotReconcileActive: false, + snapshotBufferedOutputEvents: [], + snapshotReconcileQueued: false, + snapshotReconcileRequestId: 5, + }); + }); +}); + +describe("terminal output barriers", () => { + it("resets the live sequence namespace when the server epoch changes", () => { + const barrier = { lastOutputEpoch: "server-old", lastOutputSequence: 42 }; + + expect(acceptTerminalOutputSequence(barrier, "server-new", 1)).toBe(true); + expect(barrier).toEqual({ lastOutputEpoch: "server-new", lastOutputSequence: 1 }); + expect(acceptTerminalOutputSequence(barrier, "server-new", 1)).toBe(false); + }); + + it("rejects older snapshots only within the same server epoch", () => { + const barrier = { lastOutputEpoch: "server-old", lastOutputSequence: 42 }; + + expect(acceptTerminalSnapshotBarrier(barrier, "server-old", 41)).toBe(false); + expect(acceptTerminalSnapshotBarrier(barrier, "server-new", 0)).toBe(true); + expect(barrier).toEqual({ lastOutputEpoch: "server-new", lastOutputSequence: 0 }); + }); +}); diff --git a/apps/web/src/components/terminal/terminalRuntimeTypes.ts b/apps/web/src/components/terminal/terminalRuntimeTypes.ts index 7c60df77d..8c5938180 100644 --- a/apps/web/src/components/terminal/terminalRuntimeTypes.ts +++ b/apps/web/src/components/terminal/terminalRuntimeTypes.ts @@ -5,6 +5,7 @@ import { FitAddon } from "@xterm/addon-fit"; import { SearchAddon } from "@xterm/addon-search"; import { WebglAddon } from "@xterm/addon-webgl"; +import type { TerminalEvent } from "@synara/contracts"; import { type TerminalActivityState, type TerminalCliKind } from "@synara/shared/terminalThreads"; import { Terminal, type IDisposable } from "@xterm/xterm"; import type { TerminalLinkMatch } from "../../terminal-links"; @@ -48,8 +49,92 @@ export interface TerminalPendingWrite { queuedAt: number; } +export type TerminalOutputEvent = Extract; + +export interface TerminalSnapshotCaptureState { + snapshotReconcileActive: boolean; + snapshotBufferedOutputEvents: TerminalOutputEvent[]; + snapshotReconcileQueued: boolean; + snapshotReconcileRequestId: number; +} + +/** Coalesces an open-state reconcile signal instead of dropping it behind an active capture. */ +export function requestTerminalSnapshotReconcile(state: TerminalSnapshotCaptureState): boolean { + if (!state.snapshotReconcileActive) { + state.snapshotReconcileQueued = false; + return true; + } + state.snapshotReconcileQueued = true; + return false; +} + +/** Finishes one capture and reports whether a coalesced replacement must now run. */ +export function finishTerminalSnapshotReconcile(state: TerminalSnapshotCaptureState): boolean { + state.snapshotReconcileActive = false; + const shouldRetry = state.snapshotReconcileQueued; + state.snapshotReconcileQueued = false; + return shouldRetry; +} + +/** + * A clear/restart/start event is authoritative and causally supersedes an + * in-flight snapshot. Cancel that capture so its older history can never be + * painted after the control event. + */ +export function supersedeTerminalSnapshotCapture(state: TerminalSnapshotCaptureState): boolean { + if (!state.snapshotReconcileActive) return false; + supersedeTerminalSnapshotCaptureAndTakeBuffered(state); + return true; +} + +/** Invalidates one capture and returns its buffered output for ordered flush or explicit ACK. */ +export function supersedeTerminalSnapshotCaptureAndTakeBuffered( + state: TerminalSnapshotCaptureState, +): TerminalOutputEvent[] { + if (!state.snapshotReconcileActive) return []; + state.snapshotReconcileActive = false; + state.snapshotReconcileQueued = false; + state.snapshotReconcileRequestId += 1; + return state.snapshotBufferedOutputEvents.splice(0); +} + export type TerminalRuntimeStatus = "connecting" | "replaying" | "ready" | "error"; +export interface TerminalOutputBarrier { + lastOutputEpoch: string | null; + lastOutputSequence: number; +} + +/** Advances a live-output barrier, resetting its sequence namespace after a server restart. */ +export function acceptTerminalOutputSequence( + barrier: TerminalOutputBarrier, + outputEpoch: string, + outputSequence: number, +): boolean { + if (outputEpoch !== barrier.lastOutputEpoch) { + barrier.lastOutputEpoch = outputEpoch; + barrier.lastOutputSequence = 0; + } + if (outputSequence <= barrier.lastOutputSequence) return false; + barrier.lastOutputSequence = outputSequence; + return true; +} + +/** Applies an authoritative snapshot unless a newer event in the same epoch already arrived. */ +export function acceptTerminalSnapshotBarrier( + barrier: TerminalOutputBarrier, + outputEpoch: string, + outputSequence: number, +): boolean { + if (outputEpoch !== barrier.lastOutputEpoch) { + barrier.lastOutputEpoch = outputEpoch; + barrier.lastOutputSequence = 0; + } + if (outputSequence < barrier.lastOutputSequence) return false; + barrier.lastOutputSequence = outputSequence; + return true; +} + export interface TerminalRuntimeEntry { runtimeKey: string; threadId: string; @@ -83,8 +168,13 @@ export interface TerminalRuntimeEntry { pendingWriteLength: number; pendingWriteBytes: number; linkMatchCache: Map; - outputEventVersion: number; + lastOutputEpoch: string | null; + lastOutputSequence: number; + snapshotReconcileActive: boolean; + snapshotBufferedOutputEvents: TerminalOutputEvent[]; + snapshotReconcileQueued: boolean; snapshotReconcileRequestId: number; + snapshotReconcileTimer: number | null; webglLoadFrame: number | null; themeRefreshFrame: number; themeObserver: MutationObserver | null; diff --git a/apps/web/src/components/ui/toast.tsx b/apps/web/src/components/ui/toast.tsx index 8ae0e9c73..e499385ad 100644 --- a/apps/web/src/components/ui/toast.tsx +++ b/apps/web/src/components/ui/toast.tsx @@ -34,7 +34,9 @@ import { type ThreadToastData = { allowCrossThreadVisibility?: boolean; - copyText?: string; + copyLabel?: string; + copyText?: string | (() => string); + showDescription?: boolean; onClose?: () => void; secondaryActionProps?: React.ComponentProps; threadId?: ThreadId | null; @@ -60,7 +62,12 @@ const TOAST_ICONS = { } as const; function shouldUseCompactToast(toast: ToastObject): boolean { - return !toast.data?.copyText && !toast.actionProps && !toast.data?.secondaryActionProps; + return ( + !toast.data?.showDescription && + !toast.data?.copyText && + !toast.actionProps && + !toast.data?.secondaryActionProps + ); } function isArchiveUndoToast(toast: ToastObject): boolean { @@ -209,32 +216,44 @@ function ThreadToastVisibleAutoDismiss({ function ToastActions({ actionProps, + copyLabel, copyText, secondaryActionProps, }: { actionProps: ToastObject["actionProps"]; - copyText: string | undefined; + copyLabel: string | undefined; + copyText: string | (() => string) | undefined; secondaryActionProps: ThreadToastData["secondaryActionProps"]; }) { - const { copyToClipboard, isCopied } = useCopyToClipboard(); + const { copyToClipboard, isCopied } = useCopyToClipboard({ + onError: (error) => { + toastManager.add({ + type: "error", + title: copyLabel ? `Could not copy ${copyLabel}` : "Could not copy text", + description: error.message, + }); + }, + }); if (!actionProps && !copyText && !secondaryActionProps) return null; + const copyActionLabel = copyLabel ? `Copy ${copyLabel}` : "Copy error message"; + return (
{copyText && ( )} {actionProps && ( @@ -436,6 +455,7 @@ function ToastSurface({ {!compact ? ( diff --git a/apps/web/src/connectionRecoveryNotice.test.ts b/apps/web/src/connectionRecoveryNotice.test.ts new file mode 100644 index 000000000..1b7e5c0e0 --- /dev/null +++ b/apps/web/src/connectionRecoveryNotice.test.ts @@ -0,0 +1,122 @@ +// FILE: connectionRecoveryNotice.test.ts +// Purpose: Verifies recovery-notice timing and privacy-safe diagnostic copy. +// Layer: Web connection recovery presentation tests + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + CONNECTION_DETAILS_DELAY_MS, + CONNECTION_NOTICE_DELAY_MS, + ConnectionRecoveryNoticeController, + formatConnectionRecoveryDiagnostics, +} from "./connectionRecoveryNotice"; + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("connection recovery notice", () => { + it("waits before notifying and reserves detailed help for sustained outages", () => { + expect(CONNECTION_NOTICE_DELAY_MS).toBeGreaterThanOrEqual(1_000); + expect(CONNECTION_DETAILS_DELAY_MS).toBeGreaterThan(CONNECTION_NOTICE_DELAY_MS); + }); + + it("stays silent for initial connection and brief reconnects", async () => { + vi.useFakeTimers(); + const callbacks = { + onClear: vi.fn(), + onRecovered: vi.fn(), + onShow: vi.fn(), + onShowDetails: vi.fn(), + }; + const controller = new ConnectionRecoveryNoticeController(callbacks); + + controller.handleState("connecting"); + controller.handleState("open"); + controller.handleState("reconnecting"); + await vi.advanceTimersByTimeAsync(CONNECTION_NOTICE_DELAY_MS - 1); + controller.handleState("open"); + + expect(callbacks.onShow).not.toHaveBeenCalled(); + expect(callbacks.onShowDetails).not.toHaveBeenCalled(); + expect(callbacks.onRecovered).not.toHaveBeenCalled(); + }); + + it("uses one notice through delayed details and recovery", async () => { + vi.useFakeTimers(); + const callbacks = { + onClear: vi.fn(), + onRecovered: vi.fn(), + onShow: vi.fn(), + onShowDetails: vi.fn(), + }; + const controller = new ConnectionRecoveryNoticeController(callbacks); + + controller.handleState("reconnecting"); + await vi.advanceTimersByTimeAsync(CONNECTION_NOTICE_DELAY_MS); + expect(callbacks.onShow).toHaveBeenCalledOnce(); + await vi.advanceTimersByTimeAsync(CONNECTION_DETAILS_DELAY_MS - CONNECTION_NOTICE_DELAY_MS); + expect(callbacks.onShowDetails).toHaveBeenCalledOnce(); + + controller.handleState("open"); + expect(callbacks.onRecovered).toHaveBeenCalledOnce(); + }); + + it("cancels stale timers and respects manual dismissal across repeated cycles", async () => { + vi.useFakeTimers(); + const callbacks = { + onClear: vi.fn(), + onRecovered: vi.fn(), + onShow: vi.fn(), + onShowDetails: vi.fn(), + }; + const controller = new ConnectionRecoveryNoticeController(callbacks); + + controller.handleState("reconnecting"); + await vi.advanceTimersByTimeAsync(CONNECTION_NOTICE_DELAY_MS); + controller.dismissCurrentOutage(); + await vi.advanceTimersByTimeAsync(CONNECTION_DETAILS_DELAY_MS); + controller.handleState("open"); + expect(callbacks.onShowDetails).not.toHaveBeenCalled(); + expect(callbacks.onRecovered).not.toHaveBeenCalled(); + + controller.handleState("reconnecting"); + controller.handleState("open"); + await vi.advanceTimersByTimeAsync(CONNECTION_DETAILS_DELAY_MS); + expect(callbacks.onShow).toHaveBeenCalledOnce(); + }); + + it("formats bounded local diagnostics without project, URL, command, or content fields", () => { + const diagnostics = formatConnectionRecoveryDiagnostics({ + appVersion: "0.5.7", + desktopApp: true, + generatedAt: new Date("2026-07-21T00:00:12.000Z"), + navigatorOnline: true, + platform: "Linux x86_64", + state: "reconnecting", + stateStartedAt: new Date("2026-07-21T00:00:00.000Z"), + visibility: "visible", + }); + + expect(diagnostics).toContain("Transport state: reconnecting"); + expect(diagnostics).toContain("Elapsed: 12s"); + expect(diagnostics).toContain("Platform: Linux x86_64"); + expect(diagnostics).not.toMatch(/project|conversation|command line|websocket url|token/i); + }); + + it("never reports a negative elapsed duration when clocks move backwards", () => { + const diagnostics = formatConnectionRecoveryDiagnostics({ + appVersion: "0.5.7", + desktopApp: false, + generatedAt: new Date("2026-07-21T00:00:00.000Z"), + navigatorOnline: null, + platform: "", + state: "connecting", + stateStartedAt: new Date("2026-07-21T00:00:03.000Z"), + visibility: "", + }); + + expect(diagnostics).toContain("Elapsed: 0s"); + expect(diagnostics).toContain("Browser online: unknown"); + }); +}); diff --git a/apps/web/src/connectionRecoveryNotice.ts b/apps/web/src/connectionRecoveryNotice.ts new file mode 100644 index 000000000..3d1da28e9 --- /dev/null +++ b/apps/web/src/connectionRecoveryNotice.ts @@ -0,0 +1,139 @@ +// FILE: connectionRecoveryNotice.ts +// Purpose: Owns privacy-safe copy and timing policy for local-service recovery notices. +// Layer: Web connection recovery presentation logic + +import type { WsTransportState } from "./wsTransportEvents"; + +export const CONNECTION_NOTICE_DELAY_MS = 1_500; +export const CONNECTION_DETAILS_DELAY_MS = 10_000; + +export interface ConnectionRecoveryNoticeCallbacks { + readonly onClear: () => void; + readonly onRecovered: () => void; + readonly onShow: (stateStartedAt: Date) => void; + readonly onShowDetails: (stateStartedAt: Date) => void; +} + +export interface ConnectionRecoveryNoticeClock { + readonly clearTimeout: (timer: ReturnType) => void; + readonly now: () => Date; + readonly setTimeout: (callback: () => void, delayMs: number) => ReturnType; +} + +const systemClock: ConnectionRecoveryNoticeClock = { + clearTimeout: (timer) => globalThis.clearTimeout(timer), + now: () => new Date(), + setTimeout: (callback, delayMs) => globalThis.setTimeout(callback, delayMs), +}; + +/** + * Owns one post-ready reconnect notice. Initial connection remains on the + * existing startup surface, and every transition cancels stale timers. + */ +export class ConnectionRecoveryNoticeController { + readonly #callbacks: ConnectionRecoveryNoticeCallbacks; + readonly #clock: ConnectionRecoveryNoticeClock; + #detailsTimer: ReturnType | null = null; + #dismissed = false; + #noticeTimer: ReturnType | null = null; + #reconnecting = false; + #visible = false; + + constructor( + callbacks: ConnectionRecoveryNoticeCallbacks, + clock: ConnectionRecoveryNoticeClock = systemClock, + ) { + this.#callbacks = callbacks; + this.#clock = clock; + } + + handleState(state: WsTransportState): void { + if (state === "reconnecting") { + if (this.#reconnecting) return; + this.#reset(); + this.#callbacks.onClear(); + this.#reconnecting = true; + const stateStartedAt = this.#clock.now(); + this.#noticeTimer = this.#clock.setTimeout(() => { + this.#noticeTimer = null; + if (!this.#reconnecting || this.#dismissed) return; + this.#visible = true; + this.#callbacks.onShow(stateStartedAt); + }, CONNECTION_NOTICE_DELAY_MS); + this.#detailsTimer = this.#clock.setTimeout(() => { + this.#detailsTimer = null; + if (!this.#reconnecting || this.#dismissed || !this.#visible) return; + this.#callbacks.onShowDetails(stateStartedAt); + }, CONNECTION_DETAILS_DELAY_MS); + return; + } + + const shouldAnnounceRecovery = state === "open" && this.#visible && !this.#dismissed; + this.#reset(); + if (shouldAnnounceRecovery) this.#callbacks.onRecovered(); + else this.#callbacks.onClear(); + } + + dismissCurrentOutage(): void { + if (!this.#reconnecting) return; + this.#dismissed = true; + this.#visible = false; + this.#cancelTimer("details"); + } + + dispose(): void { + this.#reset(); + this.#callbacks.onClear(); + } + + #cancelTimer(kind: "details" | "notice"): void { + const timer = kind === "details" ? this.#detailsTimer : this.#noticeTimer; + if (timer !== null) this.#clock.clearTimeout(timer); + if (kind === "details") this.#detailsTimer = null; + else this.#noticeTimer = null; + } + + #reset(): void { + this.#cancelTimer("notice"); + this.#cancelTimer("details"); + this.#dismissed = false; + this.#reconnecting = false; + this.#visible = false; + } +} + +export interface ConnectionRecoveryDiagnosticsInput { + readonly appVersion: string; + readonly desktopApp: boolean; + readonly generatedAt: Date; + readonly navigatorOnline: boolean | null; + readonly platform: string; + readonly state: WsTransportState; + readonly stateStartedAt: Date; + readonly visibility: string; +} + +/** + * Produces a bounded local summary that intentionally excludes URLs, paths, + * project names, conversation content, process command lines, and credentials. + */ +export function formatConnectionRecoveryDiagnostics( + input: ConnectionRecoveryDiagnosticsInput, +): string { + const elapsedSeconds = Math.max( + 0, + Math.round((input.generatedAt.getTime() - input.stateStartedAt.getTime()) / 1_000), + ); + return [ + "Scient connection diagnostics", + `Generated: ${input.generatedAt.toISOString()}`, + `App version: ${input.appVersion}`, + `Transport state: ${input.state}`, + `State started: ${input.stateStartedAt.toISOString()}`, + `Elapsed: ${elapsedSeconds}s`, + `Platform: ${input.platform || "unknown"}`, + `Desktop app: ${input.desktopApp ? "yes" : "no"}`, + `Browser online: ${input.navigatorOnline === null ? "unknown" : input.navigatorOnline ? "yes" : "no"}`, + `Window visibility: ${input.visibility || "unknown"}`, + ].join("\n"); +} diff --git a/apps/web/src/connectionSupervisor.test.ts b/apps/web/src/connectionSupervisor.test.ts new file mode 100644 index 000000000..8ed774cfc --- /dev/null +++ b/apps/web/src/connectionSupervisor.test.ts @@ -0,0 +1,328 @@ +// FILE: connectionSupervisor.test.ts +// Purpose: Locks single-owner connection retry, generation, and wake-probe behavior. +// Layer: Web transport lifecycle tests +// Depends on: ConnectionSupervisor and deterministic timers. + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { ConnectionSupervisor, type ConnectionSupervisorSession } from "./connectionSupervisor"; + +interface TestSession { + readonly id: number; +} + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, reject, resolve }; +} + +function makeHarness( + connect: (generation: number, signal: AbortSignal) => Promise = async ( + generation, + ) => ({ + id: generation, + }), + timing?: { readonly retryResetAfterMs?: number }, +) { + const closed: Array> = []; + const ready: Array> = []; + const retries: Array<{ attempt: number; delayMs: number; reason: string }> = []; + const probe = vi.fn(async () => undefined); + const supervisor = new ConnectionSupervisor({ + connect, + close: (session) => { + closed.push(session); + }, + probe, + random: () => 0.5, + ...timing, + onReady: (session) => ready.push(session), + onRetryScheduled: (retry) => retries.push(retry), + }); + return { closed, probe, ready, retries, supervisor }; +} + +beforeEach(() => { + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("ConnectionSupervisor", () => { + it("shares one validated generation across concurrent waiters", async () => { + const first = deferred(); + const connect = vi.fn(() => first.promise); + const harness = makeHarness(connect); + + const left = harness.supervisor.waitForSession(); + const right = harness.supervisor.waitForSession(); + expect(connect).toHaveBeenCalledOnce(); + + first.resolve({ id: 10 }); + + await expect(left).resolves.toEqual({ generation: 1, value: { id: 10 } }); + await expect(right).resolves.toEqual({ generation: 1, value: { id: 10 } }); + expect(harness.ready).toHaveLength(1); + expect(harness.supervisor.snapshot.phase).toBe("ready"); + }); + + it("backs off 1, 2, 4, 8, and 16 seconds with one retry owner", async () => { + const connect = vi.fn(async () => { + throw new Error("offline"); + }); + const harness = makeHarness(connect); + + harness.supervisor.start(); + await vi.advanceTimersByTimeAsync(0); + for (const delay of [1_000, 2_000, 4_000, 8_000]) { + await vi.advanceTimersByTimeAsync(delay); + } + + expect(harness.retries.map(({ delayMs }) => delayMs)).toEqual([ + 1_000, 2_000, 4_000, 8_000, 16_000, + ]); + expect(connect).toHaveBeenCalledTimes(5); + expect(vi.getTimerCount()).toBe(1); + harness.supervisor.dispose(); + }); + + it("never lets positive jitter exceed the configured retry ceiling", async () => { + const supervisor = new ConnectionSupervisor({ + connect: async () => { + throw new Error("offline"); + }, + close: () => undefined, + probe: async () => undefined, + random: () => 1, + retryBaseDelayMs: 16_000, + retryJitterRatio: 0.2, + retryMaxDelayMs: 16_000, + }); + + supervisor.start(); + await vi.advanceTimersByTimeAsync(0); + + expect(supervisor.snapshot.retryDelayMs).toBe(16_000); + supervisor.dispose(); + }); + + it("keeps escalating across short-lived ready connections", async () => { + const harness = makeHarness(); + const first = await harness.supervisor.waitForSession(); + + harness.supervisor.invalidate(first.generation, "first short-lived socket"); + await vi.advanceTimersByTimeAsync(1_000); + const second = await harness.supervisor.waitForSession(); + harness.supervisor.invalidate(second.generation, "second short-lived socket"); + + expect(harness.retries.map(({ delayMs }) => delayMs)).toEqual([1_000, 2_000]); + }); + + it("forgives earlier failures after the injectable stable-readiness window", async () => { + const harness = makeHarness(undefined, { retryResetAfterMs: 25 }); + const first = await harness.supervisor.waitForSession(); + + harness.supervisor.invalidate(first.generation, "brief outage"); + await vi.advanceTimersByTimeAsync(1_000); + const stable = await harness.supervisor.waitForSession(); + await vi.advanceTimersByTimeAsync(25); + harness.supervisor.invalidate(stable.generation, "later outage"); + + expect(harness.retries.map(({ delayMs }) => delayMs)).toEqual([1_000, 1_000]); + }); + + it("times out a wedged connect and closes its late result", async () => { + const pending = deferred(); + let connectSignal: AbortSignal | undefined; + const harness = makeHarness((_generation, signal) => { + connectSignal = signal; + return pending.promise; + }); + + harness.supervisor.start(); + await vi.advanceTimersByTimeAsync(15_000); + + expect(harness.retries).toEqual([ + { + attempt: 0, + delayMs: 1_000, + reason: "Connection generation 1 timed out after 15000ms", + }, + ]); + expect(connectSignal?.aborted).toBe(true); + + pending.resolve({ id: 1 }); + await vi.advanceTimersByTimeAsync(0); + expect(harness.closed).toContainEqual({ generation: 1, value: { id: 1 } }); + }); + + it("bounds the whole replacement attempt while old-session cleanup is still pending", async () => { + const closeFinished = deferred(); + const connect = vi.fn(async (generation: number) => ({ id: generation })); + const retries: Array<{ attempt: number; delayMs: number; reason: string }> = []; + const supervisor = new ConnectionSupervisor({ + connect, + close: () => closeFinished.promise, + closeTimeoutMs: 5_000, + connectTimeoutMs: 250, + probe: async () => undefined, + random: () => 0.5, + onRetryScheduled: (retry) => retries.push(retry), + }); + const first = await supervisor.waitForSession(); + + supervisor.invalidate(first.generation, "replace"); + await vi.advanceTimersByTimeAsync(1_250); + + expect(connect).toHaveBeenCalledOnce(); + expect(retries.at(-1)).toMatchObject({ + delayMs: 2_000, + reason: "Connection generation 2 timed out after 250ms", + }); + supervisor.dispose(); + closeFinished.resolve(); + }); + + it("settles a caller waiting on an unavailable connection without stopping recovery", async () => { + const neverConnects = deferred(); + const harness = makeHarness(() => neverConnects.promise); + const waiting = harness.supervisor.waitForSession({ timeoutMs: 250 }); + const rejection = expect(waiting).rejects.toThrow("Connection unavailable after 250ms"); + + await vi.advanceTimersByTimeAsync(250); + + await rejection; + expect(harness.supervisor.snapshot.phase).toBe("connecting"); + harness.supervisor.dispose(); + neverConnects.resolve({ id: 1 }); + }); + + it("ignores stale failures after a replacement generation becomes ready", async () => { + const harness = makeHarness(); + const first = await harness.supervisor.waitForSession(); + + expect(harness.supervisor.invalidate(first.generation, "socket closed")).toBe(true); + await vi.advanceTimersByTimeAsync(1_000); + const second = await harness.supervisor.waitForSession(); + + expect(second.generation).toBe(2); + expect(harness.supervisor.invalidate(first.generation, "late stream exit")).toBe(false); + expect(harness.supervisor.currentSession).toEqual(second); + expect(harness.retries).toHaveLength(1); + }); + + it("waits for the old session to close before opening its replacement", async () => { + const closeFinished = deferred(); + const connect = vi.fn(async (generation: number) => ({ id: generation })); + const supervisor = new ConnectionSupervisor({ + connect, + close: () => closeFinished.promise, + probe: async () => undefined, + random: () => 0.5, + }); + const first = await supervisor.waitForSession(); + + supervisor.invalidate(first.generation, "socket closed"); + await vi.advanceTimersByTimeAsync(1_000); + expect(connect).toHaveBeenCalledOnce(); + + closeFinished.resolve(); + await vi.advanceTimersByTimeAsync(0); + const second = await supervisor.waitForSession(); + expect(second.generation).toBe(2); + expect(connect).toHaveBeenCalledTimes(2); + supervisor.dispose(); + }); + + it("recovers after bounded teardown when an old session never disposes", async () => { + const neverCloses = deferred(); + const onError = vi.fn(); + const connect = vi.fn(async (generation: number) => ({ id: generation })); + const supervisor = new ConnectionSupervisor({ + connect, + close: () => neverCloses.promise, + closeTimeoutMs: 250, + probe: async () => undefined, + random: () => 0.5, + onError, + }); + const first = await supervisor.waitForSession(); + + supervisor.invalidate(first.generation, "socket closed"); + await vi.advanceTimersByTimeAsync(249); + expect(connect).toHaveBeenCalledOnce(); + + await vi.advanceTimersByTimeAsync(751); + const second = await supervisor.waitForSession(); + expect(second.generation).toBe(2); + expect(connect).toHaveBeenCalledTimes(2); + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining("disposal timed out") }), + "generation 1 invalidation", + ); + + supervisor.dispose(); + neverCloses.resolve(); + }); + + it("probes a ready session and reconnects when the probe fails", async () => { + const harness = makeHarness(); + const first = await harness.supervisor.waitForSession(); + harness.probe.mockRejectedValueOnce(new Error("stale socket")); + + await harness.supervisor.probe("resume"); + + expect(harness.closed).toEqual([first]); + expect(harness.supervisor.snapshot).toMatchObject({ + phase: "reconnecting", + retryDelayMs: 1_000, + }); + await harness.supervisor.probe("window focus"); + const second = await harness.supervisor.waitForSession(); + expect(second.generation).toBe(2); + }); + + it("does not let an old generation's probe suppress probing its replacement", async () => { + const harness = makeHarness(); + const first = await harness.supervisor.waitForSession(); + const oldProbe = deferred(); + harness.probe.mockImplementationOnce(() => oldProbe.promise).mockResolvedValueOnce(undefined); + + const firstProbe = harness.supervisor.probe("first focus"); + harness.supervisor.invalidate(first.generation, "stream closed"); + await vi.advanceTimersByTimeAsync(1_000); + const second = await harness.supervisor.waitForSession(); + await harness.supervisor.probe("second focus"); + + expect(second.generation).toBe(2); + expect(harness.probe).toHaveBeenCalledTimes(2); + oldProbe.resolve(undefined); + await firstProbe; + }); + + it("closes a connect result that arrives after disposal", async () => { + const pending = deferred(); + let connectSignal: AbortSignal | undefined; + const harness = makeHarness((_generation, signal) => { + connectSignal = signal; + return pending.promise; + }); + const waiting = harness.supervisor.waitForSession(); + + harness.supervisor.dispose(); + expect(connectSignal?.aborted).toBe(true); + pending.resolve({ id: 1 }); + + await expect(waiting).rejects.toThrow("disposed"); + await vi.advanceTimersByTimeAsync(0); + expect(harness.closed).toEqual([{ generation: 1, value: { id: 1 } }]); + expect(harness.supervisor.snapshot.phase).toBe("disposed"); + }); +}); diff --git a/apps/web/src/connectionSupervisor.ts b/apps/web/src/connectionSupervisor.ts new file mode 100644 index 000000000..dff12c4dc --- /dev/null +++ b/apps/web/src/connectionSupervisor.ts @@ -0,0 +1,422 @@ +// FILE: connectionSupervisor.ts +// Purpose: Owns one desired browser-to-server connection across retries and wake probes. +// Layer: Web transport lifecycle +// Exports: ConnectionSupervisor and its observable lifecycle snapshot. + +export type ConnectionSupervisorPhase = "connecting" | "ready" | "reconnecting" | "disposed"; + +export interface ConnectionSupervisorSession { + readonly generation: number; + readonly value: T; +} + +export interface ConnectionSupervisorSnapshot { + readonly phase: ConnectionSupervisorPhase; + readonly generation: number | null; + readonly retryAttempt: number; + readonly retryDelayMs: number | null; +} + +export interface ConnectionSupervisorOptions { + readonly connect: (generation: number, signal: AbortSignal) => Promise; + readonly close: (session: ConnectionSupervisorSession) => Promise | void; + readonly probe: (session: ConnectionSupervisorSession) => Promise; + readonly onReady?: (session: ConnectionSupervisorSession) => void; + readonly onInvalidated?: (session: ConnectionSupervisorSession, reason: string) => void; + readonly onSnapshot?: (snapshot: ConnectionSupervisorSnapshot) => void; + readonly onError?: (error: unknown, context: string) => void; + readonly onRetryScheduled?: (input: { + readonly attempt: number; + readonly delayMs: number; + readonly reason: string; + }) => void; + readonly setTimer?: typeof setTimeout; + readonly clearTimer?: typeof clearTimeout; + readonly random?: () => number; + readonly retryBaseDelayMs?: number; + readonly retryMaxDelayMs?: number; + readonly retryJitterRatio?: number; + /** Maximum duration of one complete connection creation attempt. */ + readonly connectTimeoutMs?: number; + /** Healthy time required before prior retry failures are forgiven. */ + readonly retryResetAfterMs?: number; + /** + * Maximum time replacement creation waits for an old session to dispose. + * A timed-out session remains stale by generation and may finish disposing in + * the background, but it cannot indefinitely block recovery. + */ + readonly closeTimeoutMs?: number; +} + +interface SessionWaiter { + readonly resolve: (session: ConnectionSupervisorSession) => void; + readonly reject: (error: Error) => void; +} + +const DEFAULT_RETRY_BASE_DELAY_MS = 1_000; +const DEFAULT_RETRY_MAX_DELAY_MS = 16_000; +const DEFAULT_RETRY_JITTER_RATIO = 0.2; +const DEFAULT_CLOSE_TIMEOUT_MS = 5_000; +const DEFAULT_CONNECT_TIMEOUT_MS = 15_000; +const DEFAULT_RETRY_RESET_AFTER_MS = 30_000; + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** + * Serializes connection creation, invalidation, retry, and wake probing. Callers + * may report the same broken generation more than once; only the current generation + * can change state, so late stream exits cannot replace a healthy session. + */ +export class ConnectionSupervisor { + readonly #options: ConnectionSupervisorOptions; + readonly #setTimer: typeof setTimeout; + readonly #clearTimer: typeof clearTimeout; + readonly #random: () => number; + + #desiredRunning = false; + #disposed = false; + #generation = 0; + #active: ConnectionSupervisorSession | null = null; + #connectInFlight: Promise | null = null; + #connectAbort: { readonly generation: number; readonly controller: AbortController } | null = + null; + #closeInFlight: Promise | null = null; + #probeInFlight: { readonly generation: number; readonly promise: Promise } | null = null; + #retryTimer: ReturnType | null = null; + #retryResetTimer: ReturnType | null = null; + #retryAttempt = 0; + #hasBeenReady = false; + #snapshot: ConnectionSupervisorSnapshot = { + phase: "connecting", + generation: null, + retryAttempt: 0, + retryDelayMs: null, + }; + readonly #waiters = new Set>(); + + constructor(options: ConnectionSupervisorOptions) { + this.#options = options; + this.#setTimer = options.setTimer ?? globalThis.setTimeout.bind(globalThis); + this.#clearTimer = options.clearTimer ?? globalThis.clearTimeout.bind(globalThis); + this.#random = options.random ?? Math.random; + } + + get snapshot(): ConnectionSupervisorSnapshot { + return this.#snapshot; + } + + get currentSession(): ConnectionSupervisorSession | null { + return this.#active; + } + + start(): void { + if (this.#disposed) return; + this.#desiredRunning = true; + if (!this.#active && !this.#connectInFlight && !this.#retryTimer) { + this.#beginConnect(); + } + } + + waitForSession(options?: { + readonly timeoutMs?: number; + }): Promise> { + if (this.#disposed) { + return Promise.reject(new Error("Connection supervisor disposed")); + } + if (this.#active) return Promise.resolve(this.#active); + this.start(); + return new Promise((resolve, reject) => { + let timeout: ReturnType | null = null; + const waiter: SessionWaiter = { + resolve: (session) => { + if (timeout !== null) this.#clearTimer(timeout); + this.#waiters.delete(waiter); + resolve(session); + }, + reject: (error) => { + if (timeout !== null) this.#clearTimer(timeout); + this.#waiters.delete(waiter); + reject(error); + }, + }; + this.#waiters.add(waiter); + const timeoutMs = options?.timeoutMs; + if (timeoutMs !== undefined) { + timeout = this.#setTimer( + () => { + waiter.reject(new Error(`Connection unavailable after ${Math.max(0, timeoutMs)}ms`)); + }, + Math.max(0, timeoutMs), + ); + } + }); + } + + invalidate(generation: number, reason: string): boolean { + const active = this.#active; + if (this.#disposed || !active || active.generation !== generation) return false; + + this.#active = null; + this.#clearRetryResetTimer(); + this.#options.onInvalidated?.(active, reason); + this.#close(active, `generation ${generation} invalidation`); + this.#scheduleRetry(reason); + return true; + } + + probe(reason: string): Promise { + if (this.#disposed) return Promise.resolve(); + this.start(); + if (!this.#active) { + this.#retryNow(); + return this.#connectInFlight ?? Promise.resolve(); + } + if (this.#probeInFlight?.generation === this.#active.generation) { + return this.#probeInFlight.promise; + } + + const session = this.#active; + const probe = this.#options + .probe(session) + .catch((error: unknown) => { + if (this.#active?.generation !== session.generation) return; + this.#options.onError?.(error, `generation ${session.generation} wake probe`); + this.invalidate(session.generation, `${reason}: ${errorMessage(error)}`); + }) + .finally(() => { + if (this.#probeInFlight?.promise === probe) this.#probeInFlight = null; + }); + this.#probeInFlight = { generation: session.generation, promise: probe }; + return probe; + } + + dispose(): void { + if (this.#disposed) return; + this.#disposed = true; + this.#desiredRunning = false; + this.#clearRetryTimer(); + this.#clearRetryResetTimer(); + this.#connectAbort?.controller.abort(new Error("Connection supervisor disposed")); + this.#connectAbort = null; + this.#generation += 1; + + const active = this.#active; + this.#active = null; + if (active) { + this.#options.onInvalidated?.(active, "disposed"); + this.#close(active, `generation ${active.generation} disposal`); + } + const error = new Error("Connection supervisor disposed"); + for (const waiter of this.#waiters) waiter.reject(error); + this.#waiters.clear(); + this.#publish({ + phase: "disposed", + generation: null, + retryAttempt: this.#retryAttempt, + retryDelayMs: null, + }); + } + + #beginConnect(): void { + if (this.#disposed || !this.#desiredRunning || this.#active || this.#connectInFlight) { + return; + } + this.#clearRetryTimer(); + const generation = ++this.#generation; + const controller = new AbortController(); + this.#connectAbort = { generation, controller }; + this.#publish({ + phase: this.#hasBeenReady ? "reconnecting" : "connecting", + generation, + retryAttempt: this.#retryAttempt, + retryDelayMs: null, + }); + + const connectResult = this.#connectWithTimeout(generation, controller); + const connecting = connectResult + .then((value) => { + const session = { generation, value } satisfies ConnectionSupervisorSession; + if (this.#disposed || !this.#desiredRunning || generation !== this.#generation) { + this.#close(session, `stale generation ${generation}`); + return; + } + this.#active = session; + this.#hasBeenReady = true; + this.#publish({ + phase: "ready", + generation, + retryAttempt: this.#retryAttempt, + retryDelayMs: null, + }); + this.#armRetryReset(session); + for (const waiter of this.#waiters) waiter.resolve(session); + this.#waiters.clear(); + this.#options.onReady?.(session); + }) + .catch((error: unknown) => { + if (this.#disposed || !this.#desiredRunning || generation !== this.#generation) return; + this.#options.onError?.(error, `generation ${generation} connect`); + this.#scheduleRetry(errorMessage(error)); + }) + .finally(() => { + if (this.#connectAbort?.generation === generation) this.#connectAbort = null; + if (this.#connectInFlight === connecting) { + this.#connectInFlight = null; + if (!this.#disposed && this.#desiredRunning && !this.#active && !this.#retryTimer) { + this.#beginConnect(); + } + } + }); + this.#connectInFlight = connecting; + } + + async #connectWithTimeout(generation: number, controller: AbortController): Promise { + const timeoutMs = Math.max(0, this.#options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS); + let acceptResult = true; + let timeout: ReturnType | null = null; + let removeAbortListener: () => void = () => undefined; + const connect = (async () => { + if (this.#closeInFlight) await this.#closeInFlight; + if (controller.signal.aborted) throw controller.signal.reason; + return this.#options.connect(generation, controller.signal); + })().then((value) => { + if (!acceptResult || controller.signal.aborted) { + this.#close({ generation, value }, `late abandoned generation ${generation}`); + throw ( + controller.signal.reason ?? new Error(`Connection generation ${generation} abandoned`) + ); + } + return value; + }); + const aborted = new Promise((_, reject) => { + const onAbort = () => + reject( + controller.signal.reason ?? new Error(`Connection generation ${generation} aborted`), + ); + if (controller.signal.aborted) { + onAbort(); + return; + } + controller.signal.addEventListener("abort", onAbort, { once: true }); + removeAbortListener = () => controller.signal.removeEventListener("abort", onAbort); + }); + timeout = this.#setTimer(() => { + controller.abort( + new Error(`Connection generation ${generation} timed out after ${timeoutMs}ms`), + ); + }, timeoutMs); + try { + return await Promise.race([connect, aborted]); + } finally { + acceptResult = false; + removeAbortListener(); + if (timeout !== null) this.#clearTimer(timeout); + } + } + + #armRetryReset(session: ConnectionSupervisorSession): void { + this.#clearRetryResetTimer(); + if (this.#retryAttempt === 0) return; + const delayMs = Math.max(0, this.#options.retryResetAfterMs ?? DEFAULT_RETRY_RESET_AFTER_MS); + this.#retryResetTimer = this.#setTimer(() => { + this.#retryResetTimer = null; + if (this.#disposed || this.#active?.generation !== session.generation) return; + this.#retryAttempt = 0; + this.#publish({ + phase: "ready", + generation: session.generation, + retryAttempt: 0, + retryDelayMs: null, + }); + }, delayMs); + } + + #scheduleRetry(reason: string): void { + if (this.#disposed || !this.#desiredRunning || this.#retryTimer) return; + const attempt = this.#retryAttempt; + const baseDelay = this.#options.retryBaseDelayMs ?? DEFAULT_RETRY_BASE_DELAY_MS; + const maxDelay = this.#options.retryMaxDelayMs ?? DEFAULT_RETRY_MAX_DELAY_MS; + const jitterRatio = Math.max( + 0, + Math.min(this.#options.retryJitterRatio ?? DEFAULT_RETRY_JITTER_RATIO, 1), + ); + const exponentialDelay = Math.min(baseDelay * 2 ** attempt, maxDelay); + const jitterMultiplier = 1 + (this.#random() * 2 - 1) * jitterRatio; + const delayMs = Math.min( + maxDelay, + Math.max(0, Math.round(exponentialDelay * jitterMultiplier)), + ); + this.#retryAttempt += 1; + this.#publish({ + phase: this.#hasBeenReady ? "reconnecting" : "connecting", + generation: null, + retryAttempt: this.#retryAttempt, + retryDelayMs: delayMs, + }); + this.#options.onRetryScheduled?.({ attempt, delayMs, reason }); + this.#retryTimer = this.#setTimer(() => { + this.#retryTimer = null; + this.#beginConnect(); + }, delayMs); + } + + #retryNow(): void { + if (this.#disposed || !this.#desiredRunning || this.#active) return; + if (this.#retryTimer) { + this.#clearRetryTimer(); + } + this.#beginConnect(); + } + + #clearRetryTimer(): void { + if (!this.#retryTimer) return; + this.#clearTimer(this.#retryTimer); + this.#retryTimer = null; + } + + #clearRetryResetTimer(): void { + if (!this.#retryResetTimer) return; + this.#clearTimer(this.#retryResetTimer); + this.#retryResetTimer = null; + } + + #close(session: ConnectionSupervisorSession, context: string): void { + const previousClose = this.#closeInFlight ?? Promise.resolve(); + const closing = previousClose + .then(() => this.#closeWithTimeout(session)) + .catch((error: unknown) => { + this.#options.onError?.(error, context); + }) + .finally(() => { + if (this.#closeInFlight === closing) this.#closeInFlight = null; + }); + this.#closeInFlight = closing; + } + + async #closeWithTimeout(session: ConnectionSupervisorSession): Promise { + const timeoutMs = Math.max(0, this.#options.closeTimeoutMs ?? DEFAULT_CLOSE_TIMEOUT_MS); + let timeout: ReturnType | null = null; + const close = Promise.resolve().then(() => this.#options.close(session)); + const timedOut = new Promise((_, reject) => { + timeout = this.#setTimer(() => { + reject( + new Error( + `Connection generation ${session.generation} disposal timed out after ${timeoutMs}ms`, + ), + ); + }, timeoutMs); + }); + try { + await Promise.race([close, timedOut]); + } finally { + if (timeout !== null) this.#clearTimer(timeout); + } + } + + #publish(snapshot: ConnectionSupervisorSnapshot): void { + this.#snapshot = snapshot; + this.#options.onSnapshot?.(snapshot); + } +} diff --git a/apps/web/src/hooks/useProviderAuthRefreshOnFocus.ts b/apps/web/src/hooks/useProviderAuthRefreshOnFocus.ts deleted file mode 100644 index da6e45848..000000000 --- a/apps/web/src/hooks/useProviderAuthRefreshOnFocus.ts +++ /dev/null @@ -1,19 +0,0 @@ -// FILE: useProviderAuthRefreshOnFocus.ts -// Purpose: Re-probe provider auth status when the window regains focus/visibility, -// so account changes made outside the app (e.g. `claude login` / logout / adding -// an account in a terminal) reflect without restarting the app. -// Layer: Web UI hooks -// Exports: useProviderAuthRefreshOnFocus - -import { useProviderStatusRefresh } from "./useProviderStatusRefresh"; - -// Minimum gap between window-focus-triggered provider auth re-probes, so rapid -// focus/visibility changes can't spawn redundant CLI probes on the server. -const PROVIDER_AUTH_REFRESH_MIN_INTERVAL_MS = 15_000; - -export function useProviderAuthRefreshOnFocus(): void { - useProviderStatusRefresh({ - minIntervalMs: PROVIDER_AUTH_REFRESH_MIN_INTERVAL_MS, - refreshOnFocus: true, - }); -} diff --git a/apps/web/src/hooks/useProviderStatusRefresh.browser.tsx b/apps/web/src/hooks/useProviderStatusRefresh.browser.tsx new file mode 100644 index 000000000..943a0a4a8 --- /dev/null +++ b/apps/web/src/hooks/useProviderStatusRefresh.browser.tsx @@ -0,0 +1,138 @@ +import type { ServerConfig, ServerProviderStatus } from "@synara/contracts"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { render } from "vitest-browser-react"; + +import { serverQueryKeys } from "../lib/serverReactQuery"; +import { readNativeApi } from "../nativeApi"; +import { useProviderStatusRefresh } from "./useProviderStatusRefresh"; + +const runtime = { + source: "missing" as const, + managedVersion: null, + canInstall: true, + canRepair: false, + canRollback: false, + canRemove: false, + message: "No usable provider runtime was found.", +}; + +function antigravity(checkedAt: string): ServerProviderStatus { + return { + provider: "antigravity", + status: "error", + available: false, + authStatus: "unknown", + checkedAt, + runtime, + }; +} + +function config(provider: ServerProviderStatus): ServerConfig { + return { + cwd: "/repo/project", + worktreesDir: "/repo/.codex/worktrees", + keybindingsConfigPath: "/repo/keybindings.json", + keybindings: [], + issues: [], + providers: [provider], + availableEditors: [], + }; +} + +function RefreshHarness() { + useProviderStatusRefresh({}); + return null; +} + +function ScheduledRefreshHarness() { + useProviderStatusRefresh({ initialDelayMs: 0 }); + return null; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("useProviderStatusRefresh", () => { + it("does not launch provider probes merely because the app regains focus", async () => { + const queryClient = new QueryClient(); + queryClient.setQueryData( + serverQueryKeys.config(), + config(antigravity("2026-07-20T16:00:00.000Z")), + ); + const refreshProviders = vi.fn().mockResolvedValue({ providers: [] }); + const previousNativeApi = window.nativeApi; + const baseApi = readNativeApi(); + if (!baseApi) throw new Error("Expected browser native API fixture."); + Object.defineProperty(window, "nativeApi", { + configurable: true, + value: { + ...baseApi, + server: { ...baseApi.server, refreshProviders }, + }, + }); + + const screen = await render( + + + , + ); + + try { + window.dispatchEvent(new Event("focus")); + document.dispatchEvent(new Event("visibilitychange")); + await new Promise((resolve) => window.setTimeout(resolve, 25)); + expect(refreshProviders).not.toHaveBeenCalled(); + } finally { + await screen.unmount(); + queryClient.clear(); + Object.defineProperty(window, "nativeApi", { + configurable: true, + value: previousNativeApi, + }); + } + }); + + it("preserves managed-install capability after a scheduled refresh", async () => { + const queryClient = new QueryClient(); + queryClient.setQueryData( + serverQueryKeys.config(), + config(antigravity("2026-07-20T16:00:00.000Z")), + ); + const refreshProviders = vi.fn().mockResolvedValue({ + providers: [antigravity("2026-07-20T16:05:00.000Z")], + }); + const previousNativeApi = window.nativeApi; + const baseApi = readNativeApi(); + if (!baseApi) throw new Error("Expected browser native API fixture."); + Object.defineProperty(window, "nativeApi", { + configurable: true, + value: { + ...baseApi, + server: { ...baseApi.server, refreshProviders }, + }, + }); + + const screen = await render( + + + , + ); + + try { + await vi.waitFor(() => expect(refreshProviders).toHaveBeenCalledTimes(1)); + expect( + queryClient.getQueryData(serverQueryKeys.config())?.providers[0]?.runtime + ?.canInstall, + ).toBe(true); + } finally { + await screen.unmount(); + queryClient.clear(); + Object.defineProperty(window, "nativeApi", { + configurable: true, + value: previousNativeApi, + }); + } + }); +}); diff --git a/apps/web/src/hooks/useProviderStatusRefresh.ts b/apps/web/src/hooks/useProviderStatusRefresh.ts index 455ae0e9c..a95b72f93 100644 --- a/apps/web/src/hooks/useProviderStatusRefresh.ts +++ b/apps/web/src/hooks/useProviderStatusRefresh.ts @@ -1,15 +1,15 @@ // FILE: useProviderStatusRefresh.ts -// Purpose: Shared provider-status refresh hooks — focus/periodic version checks plus an +// Purpose: Shared provider-status refresh hooks — scheduled version checks plus an // imperative refresh callback for UI affordances (voice auth retry, banners). // Layer: Web hooks // Exports: useProviderStatusRefresh, useRefreshProviderStatusesNow import { useCallback, useEffect } from "react"; -import { type QueryClient, useQueryClient } from "@tanstack/react-query"; -import type { ServerConfig, ServerProviderStatus } from "@synara/contracts"; +import { useQueryClient } from "@tanstack/react-query"; +import type { ServerProviderStatus } from "@synara/contracts"; import { toastManager } from "../components/ui/toast"; import { readNativeApi } from "../nativeApi"; -import { serverQueryKeys } from "../lib/serverReactQuery"; +import { applyProviderStatusesToCache } from "../lib/providerStatusCache"; export type RefreshProviderStatusesOptions = { readonly silent?: boolean; @@ -19,15 +19,6 @@ export type RefreshProviderStatusesNow = ( options?: RefreshProviderStatusesOptions, ) => Promise; -function writeProviderStatusesToConfigCache( - queryClient: QueryClient, - providers: readonly ServerProviderStatus[], -) { - queryClient.setQueryData(serverQueryKeys.config(), (current) => - current ? { ...current, providers } : current, - ); -} - /** * Imperative one-shot provider-status refresh: re-checks providers on the server * and folds the result into the cached server config. Surfaces failures as a toast. @@ -40,7 +31,7 @@ export function useRefreshProviderStatusesNow(): RefreshProviderStatusesNow { if (!api) return null; try { const result = await api.server.refreshProviders(); - writeProviderStatusesToConfigCache(queryClient, result.providers); + applyProviderStatusesToCache(queryClient, result.providers); return result.providers; } catch (error) { if (!options?.silent) { @@ -62,8 +53,6 @@ type ProviderStatusRefreshOptions = { readonly enabled?: boolean; readonly initialDelayMs?: number; readonly intervalMs?: number; - readonly minIntervalMs?: number; - readonly refreshOnFocus?: boolean; }; export function useProviderStatusRefresh(options: ProviderStatusRefreshOptions): void { @@ -71,8 +60,6 @@ export function useProviderStatusRefresh(options: ProviderStatusRefreshOptions): const enabled = options.enabled ?? true; const initialDelayMs = options.initialDelayMs; const intervalMs = options.intervalMs; - const minIntervalMs = options.minIntervalMs ?? 0; - const refreshOnFocus = options.refreshOnFocus ?? false; useEffect(() => { if (!enabled || typeof window === "undefined" || typeof document === "undefined") { @@ -80,27 +67,21 @@ export function useProviderStatusRefresh(options: ProviderStatusRefreshOptions): } let disposed = false; - let lastRefreshAtMs = 0; const refreshProviderStatuses = () => { if (document.visibilityState !== "visible") { return; } - const nowMs = Date.now(); - if (minIntervalMs > 0 && nowMs - lastRefreshAtMs < minIntervalMs) { - return; - } const api = readNativeApi(); if (!api) { return; } - lastRefreshAtMs = nowMs; void api.server .refreshProviders() .then((result) => { if (disposed) { return; } - writeProviderStatusesToConfigCache(queryClient, result.providers); + applyProviderStatusesToCache(queryClient, result.providers); }) .catch(() => undefined); }; @@ -114,11 +95,6 @@ export function useProviderStatusRefresh(options: ProviderStatusRefreshOptions): ? window.setInterval(refreshProviderStatuses, intervalMs) : null; - if (refreshOnFocus) { - window.addEventListener("focus", refreshProviderStatuses); - document.addEventListener("visibilitychange", refreshProviderStatuses); - } - return () => { disposed = true; if (initialRefreshId !== null) { @@ -127,10 +103,6 @@ export function useProviderStatusRefresh(options: ProviderStatusRefreshOptions): if (refreshIntervalId !== null) { window.clearInterval(refreshIntervalId); } - if (refreshOnFocus) { - window.removeEventListener("focus", refreshProviderStatuses); - document.removeEventListener("visibilitychange", refreshProviderStatuses); - } }; - }, [enabled, initialDelayMs, intervalMs, minIntervalMs, queryClient, refreshOnFocus]); + }, [enabled, initialDelayMs, intervalMs, queryClient]); } diff --git a/apps/web/src/lib/automationDraft.ts b/apps/web/src/lib/automationDraft.ts index 698ad1575..da20b3abf 100644 --- a/apps/web/src/lib/automationDraft.ts +++ b/apps/web/src/lib/automationDraft.ts @@ -99,8 +99,8 @@ export function buildAutomationDraftWarnings(input: { if (input.runtimeMode === "full-access") { warnings.push({ id: "full-access", - title: "Full access", - detail: "Scheduled full-access runs can make changes without per-step approval.", + title: "Unrestricted agent", + detail: "Scheduled unrestricted runs can make changes without per-step approval.", requiresAcknowledgement: true, }); } diff --git a/apps/web/src/lib/codexAuthRecovery.test.ts b/apps/web/src/lib/codexAuthRecovery.test.ts new file mode 100644 index 000000000..7bafe892b --- /dev/null +++ b/apps/web/src/lib/codexAuthRecovery.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from "vitest"; + +import { findCodexAuthenticationRecoveryActivityId } from "./codexAuthRecovery"; + +const authenticationEventId = "auth-event"; + +const standardCodexStatus = { + provider: "codex", + status: "ready", + available: true, + authStatus: "authenticated", + requiresProviderAccount: true, + checkedAt: "2026-07-21T10:00:00.000Z", +} as const; + +describe("findCodexAuthenticationRecoveryActivityId", () => { + it("offers recovery for the latest classified Codex auth failure", () => { + expect( + findCodexAuthenticationRecoveryActivityId({ + provider: "codex", + sessionStatus: "error", + sessionLastErrorEventId: authenticationEventId, + sessionLastErrorClass: "authentication_error", + providerStatus: standardCodexStatus, + }), + ).toBe(authenticationEventId); + }); + + it("does not reopen recovery after the thread session has recovered", () => { + expect( + findCodexAuthenticationRecoveryActivityId({ + provider: "codex", + sessionStatus: "ready", + sessionLastErrorEventId: authenticationEventId, + sessionLastErrorClass: "authentication_error", + providerStatus: standardCodexStatus, + }), + ).toBeNull(); + }); + + it("requires both durable error identity and the persisted authentication class", () => { + expect( + findCodexAuthenticationRecoveryActivityId({ + provider: "codex", + sessionStatus: "error", + sessionLastErrorEventId: null, + sessionLastErrorClass: "authentication_error", + providerStatus: standardCodexStatus, + }), + ).toBeNull(); + expect( + findCodexAuthenticationRecoveryActivityId({ + provider: "codex", + sessionStatus: "error", + sessionLastErrorEventId: authenticationEventId, + sessionLastErrorClass: null, + providerStatus: standardCodexStatus, + }), + ).toBeNull(); + }); + + it("never routes a custom Codex provider into ChatGPT sign-in", () => { + expect( + findCodexAuthenticationRecoveryActivityId({ + provider: "codex", + sessionStatus: "error", + sessionLastErrorEventId: authenticationEventId, + sessionLastErrorClass: "authentication_error", + providerStatus: { ...standardCodexStatus, requiresProviderAccount: false }, + }), + ).toBeNull(); + }); + + it("ignores generic runtime errors and non-Codex providers", () => { + expect( + findCodexAuthenticationRecoveryActivityId({ + provider: "codex", + sessionStatus: "error", + sessionLastErrorEventId: "generic-error", + sessionLastErrorClass: "provider_error", + providerStatus: standardCodexStatus, + }), + ).toBeNull(); + expect( + findCodexAuthenticationRecoveryActivityId({ + provider: "claudeAgent", + sessionStatus: "error", + sessionLastErrorEventId: authenticationEventId, + sessionLastErrorClass: "authentication_error", + providerStatus: { ...standardCodexStatus, provider: "claudeAgent" }, + }), + ).toBeNull(); + }); + + it("does not revive an old auth failure after a newer unrelated runtime error", () => { + expect( + findCodexAuthenticationRecoveryActivityId({ + provider: "codex", + sessionStatus: "error", + sessionLastErrorEventId: "newer-generic-error", + sessionLastErrorClass: "provider_error", + providerStatus: standardCodexStatus, + }), + ).toBeNull(); + }); + + it("does not reopen an old auth failure after a later failed turn", () => { + expect( + findCodexAuthenticationRecoveryActivityId({ + provider: "codex", + sessionStatus: "error", + sessionLastErrorEventId: "later-failed-turn", + sessionLastErrorClass: null, + providerStatus: standardCodexStatus, + }), + ).toBeNull(); + }); +}); diff --git a/apps/web/src/lib/codexAuthRecovery.ts b/apps/web/src/lib/codexAuthRecovery.ts new file mode 100644 index 000000000..d6f947c3f --- /dev/null +++ b/apps/web/src/lib/codexAuthRecovery.ts @@ -0,0 +1,22 @@ +import type { ProviderKind, ServerProviderStatus } from "@synara/contracts"; + +export function findCodexAuthenticationRecoveryActivityId(input: { + readonly provider: ProviderKind; + readonly sessionStatus: string | null | undefined; + readonly sessionLastErrorEventId: string | null | undefined; + readonly sessionLastErrorClass: string | null | undefined; + readonly providerStatus: ServerProviderStatus | null | undefined; +}): string | null { + if ( + input.provider !== "codex" || + input.sessionStatus !== "error" || + !input.sessionLastErrorEventId || + input.sessionLastErrorClass !== "authentication_error" || + !input.providerStatus || + input.providerStatus.requiresProviderAccount !== true + ) { + return null; + } + + return input.sessionLastErrorEventId; +} diff --git a/apps/web/src/lib/gitReactQuery.test.ts b/apps/web/src/lib/gitReactQuery.test.ts index 5515a0dc3..152101894 100644 --- a/apps/web/src/lib/gitReactQuery.test.ts +++ b/apps/web/src/lib/gitReactQuery.test.ts @@ -10,6 +10,7 @@ import { gitPreparePullRequestThreadMutationOptions, gitPullMutationOptions, gitRunStackedActionMutationOptions, + passiveGitStatusQueryOptions, } from "./gitReactQuery"; describe("gitMutationKeys", () => { @@ -121,3 +122,12 @@ describe("git working tree diff query options", () => { expect(options.refetchInterval).toBe(GIT_WORKING_TREE_DIFF_LIVE_REFETCH_INTERVAL_MS); }); }); + +describe("passive git status query options", () => { + it("relies on domain invalidation instead of focus or timer polling", () => { + const options = passiveGitStatusQueryOptions("/repo/a"); + + expect(options.refetchOnWindowFocus).toBe(false); + expect(options.refetchInterval).toBe(false); + }); +}); diff --git a/apps/web/src/lib/gitReactQuery.ts b/apps/web/src/lib/gitReactQuery.ts index 3bd44cac8..2c775e3b7 100644 --- a/apps/web/src/lib/gitReactQuery.ts +++ b/apps/web/src/lib/gitReactQuery.ts @@ -105,6 +105,21 @@ export function gitStatusQueryOptions(cwd: string | null) { }); } +/** + * Passive status for compact/background surfaces. It loads once and still + * responds to scoped git/file invalidations, but never touches a project merely + * because the app regained focus or a timer fired. + */ +export function passiveGitStatusQueryOptions( + cwd: string | null, +): ReturnType { + return { + ...gitStatusQueryOptions(cwd), + refetchOnWindowFocus: false as const, + refetchInterval: false as const, + }; +} + export function gitGithubRepositoryQueryOptions(cwd: string | null, enabled = true) { return queryOptions({ queryKey: gitQueryKeys.githubRepository(cwd), diff --git a/apps/web/src/lib/providerAvailability.test.ts b/apps/web/src/lib/providerAvailability.test.ts index b7fab3f00..5121be9b5 100644 --- a/apps/web/src/lib/providerAvailability.test.ts +++ b/apps/web/src/lib/providerAvailability.test.ts @@ -142,6 +142,35 @@ describe("isProviderUsable", () => { ).toBe(false); expect( isProviderUsable({ ...BASE_STATUS, available: true, status: "ready", authStatus: "unknown" }), + ).toBe(false); + expect( + isProviderUsable({ + ...BASE_STATUS, + provider: "codex", + available: true, + status: "ready", + authStatus: "unknown", + requiresProviderAccount: false, + }), + ).toBe(true); + expect( + isProviderUsable({ + ...BASE_STATUS, + provider: "codex", + available: true, + status: "ready", + authStatus: "unknown", + requiresProviderAccount: true, + }), + ).toBe(false); + expect( + isProviderUsable({ + ...BASE_STATUS, + provider: "opencode", + available: true, + status: "ready", + authStatus: "unknown", + }), ).toBe(true); expect( isProviderUsable({ @@ -196,6 +225,19 @@ describe("resolveProviderSendAvailabilityWithRefresh", () => { expect(refreshStatuses).toHaveBeenCalledTimes(1); }); + it("rechecks unknown authentication before blocking an explicit-auth provider", async () => { + const refreshStatuses = vi.fn(async () => [READY_STATUS]); + + await expect( + resolveProviderSendAvailabilityWithRefresh({ + provider: "antigravity", + statuses: [{ ...READY_STATUS, authStatus: "unknown" }], + refreshStatuses, + }), + ).resolves.toMatchObject({ usable: true }); + expect(refreshStatuses).toHaveBeenCalledTimes(1); + }); + it("keeps the original blocked reason when refresh fails", async () => { await expect( resolveProviderSendAvailabilityWithRefresh({ @@ -218,5 +260,19 @@ describe("providerUnavailableReason", () => { "Antigravity is not authenticated yet.", ); expect(providerUnavailableReason(BASE_STATUS)).toBe(BASE_STATUS.message); + expect( + providerUnavailableReason({ + ...READY_STATUS, + authStatus: "unknown", + connectionState: { + operationId: "connection-1", + method: "antigravity_browser", + status: "waiting_for_browser", + startedAt: BASE_STATUS.checkedAt, + finishedAt: null, + message: "Waiting for browser.", + }, + }), + ).toBe("Antigravity sign-in is still in progress."); }); }); diff --git a/apps/web/src/lib/providerAvailability.ts b/apps/web/src/lib/providerAvailability.ts index 92150dee9..965505424 100644 --- a/apps/web/src/lib/providerAvailability.ts +++ b/apps/web/src/lib/providerAvailability.ts @@ -15,6 +15,26 @@ export type ProviderStatusRefresh = () => Promise< readonly ServerProviderStatus[] | null | undefined >; +const PROVIDERS_REQUIRING_VERIFIED_AUTH = new Set([ + "codex", + "claudeAgent", + "cursor", + "antigravity", + "grok", + "droid", +]); + +export function providerRequiresVerifiedAuth(provider: ProviderKind): boolean { + return PROVIDERS_REQUIRING_VERIFIED_AUTH.has(provider); +} + +export function providerStatusRequiresVerifiedAuth(status: ServerProviderStatus): boolean { + if (status.requiresProviderAccount === false) { + return false; + } + return providerRequiresVerifiedAuth(status.provider); +} + export function normalizeCustomBinaryPath(value: string | null | undefined): string | null { if (typeof value !== "string") { return null; @@ -51,6 +71,9 @@ export function normalizeProviderStatusForLocalConfig(input: { status: "ready", authStatus: status.authStatus, checkedAt: status.checkedAt, + ...(status.requiresProviderAccount !== undefined + ? { requiresProviderAccount: status.requiresProviderAccount } + : {}), ...(status.authType ? { authType: status.authType } : {}), ...(status.authLabel ? { authLabel: status.authLabel } : {}), ...(status.voiceTranscriptionAvailable !== undefined @@ -72,7 +95,10 @@ export function isProviderUsable(status: ServerProviderStatus | null | undefined // Missing status means the health check has not confirmed an installed provider yet. return false; } - return status.available && status.status === "ready" && status.authStatus !== "unauthenticated"; + const authUsable = providerStatusRequiresVerifiedAuth(status) + ? status.authStatus === "authenticated" + : status.authStatus !== "unauthenticated"; + return status.available && status.status === "ready" && authUsable; } export function providerUnavailableReason(status: ServerProviderStatus | null | undefined): string { @@ -80,6 +106,14 @@ export function providerUnavailableReason(status: ServerProviderStatus | null | return "Provider status is still loading."; } const providerLabel = PROVIDER_DISPLAY_NAMES[status.provider] ?? status.provider; + const connectionStatus = status.connectionState?.status; + if ( + connectionStatus === "starting" || + connectionStatus === "waiting_for_browser" || + connectionStatus === "verifying" + ) { + return `${providerLabel} sign-in is still in progress.`; + } if (status.authStatus === "unauthenticated") { return `${providerLabel} is not authenticated yet.`; } @@ -111,7 +145,7 @@ export function resolveProviderSendAvailability(input: { } function shouldRefreshBeforeBlocking(status: ServerProviderStatus | null): boolean { - return !status || !status.available || status.authStatus === "unauthenticated"; + return !isProviderUsable(status); } // Re-check a blocked provider once before surfacing stale install/auth state to the user. diff --git a/apps/web/src/lib/providerConnectionPresentation.test.ts b/apps/web/src/lib/providerConnectionPresentation.test.ts index fe675f391..a1091b8a2 100644 --- a/apps/web/src/lib/providerConnectionPresentation.test.ts +++ b/apps/web/src/lib/providerConnectionPresentation.test.ts @@ -2,7 +2,9 @@ import type { ServerProviderStatus } from "@synara/contracts"; import { describe, expect, it } from "vitest"; import { + CLAUDE_CONNECTION_METHOD_OPTIONS, describeProviderConnection, + describeManagedProviderUpdate, providerConnectionMethod, providerInstallUrl, } from "./providerConnectionPresentation"; @@ -16,9 +18,104 @@ const BASE_STATUS: ServerProviderStatus = { }; describe("provider connection presentation", () => { + const managedAntigravityStatus = { + ...BASE_STATUS, + provider: "antigravity", + status: "ready", + authStatus: "authenticated", + version: "1.1.4", + runtime: { + source: "managed", + managedVersion: "1.1.4", + canInstall: false, + canRepair: true, + canRollback: false, + canRemove: true, + message: null, + }, + } as const satisfies ServerProviderStatus; + + it("reviews a newer trusted managed release before updating", () => { + const checking = describeManagedProviderUpdate({ + provider: "antigravity", + status: managedAntigravityStatus, + plan: null, + updateStarted: false, + }); + const ready = describeManagedProviderUpdate({ + provider: "antigravity", + status: managedAntigravityStatus, + plan: { + provider: "antigravity", + planToken: "plan-1", + version: "1.1.5", + target: "darwin-arm64", + sourceHost: "storage.googleapis.com", + downloadBytes: null, + expiresAt: "2026-07-21T11:00:00.000Z", + }, + updateStarted: false, + }); + + expect(checking.primaryLabel).toBe("Check latest version"); + expect(ready.primaryAction).toBe("install"); + expect(ready.primaryLabel).toBe("Update Antigravity"); + expect(ready.description).toContain("trusted stable channel"); + }); + + it("does not reinstall or downgrade a managed provider", () => { + for (const version of ["1.1.4", "1.1.3"]) { + const presentation = describeManagedProviderUpdate({ + provider: "antigravity", + status: managedAntigravityStatus, + plan: { + provider: "antigravity", + planToken: `plan-${version}`, + version, + target: "darwin-arm64", + sourceHost: "storage.googleapis.com", + downloadBytes: null, + expiresAt: "2026-07-21T11:00:00.000Z", + }, + updateStarted: false, + }); + expect(presentation.primaryAction).toBe("done"); + } + }); + + it.each(["failed", "cancelled"] as const)( + "shows the managed update %s message and offers a safe retry", + (status) => { + const presentation = describeManagedProviderUpdate({ + provider: "antigravity", + status: { + ...managedAntigravityStatus, + installationState: { + operationId: "operation-1", + operation: "install", + status, + startedAt: "2026-07-21T11:00:00.000Z", + finishedAt: "2026-07-21T11:01:00.000Z", + message: + status === "failed" + ? "The verified runtime failed its smoke test." + : "The update was cancelled.", + }, + }, + plan: null, + updateStarted: true, + }); + + expect(presentation.description).toContain(status === "failed" ? "smoke test" : "cancelled"); + expect(presentation.primaryAction).toBe("install"); + expect(presentation.primaryLabel).toBe("Try again"); + expect(presentation.canCancel).toBe(false); + }, + ); + it("maps supported providers to fixed browser sign-in methods", () => { expect(providerConnectionMethod("codex")).toBe("codex_browser"); - expect(providerConnectionMethod("claudeAgent")).toBe("claude_console"); + expect(providerConnectionMethod("claudeAgent")).toBe("claude_account"); expect(providerConnectionMethod("cursor")).toBe("cursor_browser"); expect(providerConnectionMethod("antigravity")).toBe("antigravity_browser"); expect(providerConnectionMethod("grok")).toBe("grok_browser"); @@ -62,21 +159,23 @@ describe("provider connection presentation", () => { expect(presentation.description).toContain("credentials stay with Codex"); }); - it("explains Claude's Anthropic Console requirement before sign-in", () => { + it("offers terminal-equivalent Claude sign-in with explicit alternatives", () => { const presentation = describeProviderConnection("claudeAgent", { ...BASE_STATUS, provider: "claudeAgent", - message: - "This Claude account type cannot be used by Scient. Connect through Anthropic Console instead.", }); expect(presentation.primaryAction).toBe("sign_in"); - expect(presentation.primaryLabel).toBe("Connect Anthropic Console"); - expect(presentation.description).toContain("API billing"); - expect(presentation.description).toContain("not a Claude.ai subscription"); + expect(presentation.primaryLabel).toBe("Connect Claude"); + expect(presentation.description).toContain("already connected in your terminal"); + expect(CLAUDE_CONNECTION_METHOD_OPTIONS.map((option) => option.method)).toEqual([ + "claude_account", + "claude_sso", + "claude_console", + ]); }); it.each(["starting", "waiting_for_browser", "verifying"] as const)( - "keeps %s operations cancellable and non-actionable", + "keeps %s operations cancellable and restartable", (operationStatus) => { const presentation = describeProviderConnection("codex", { ...BASE_STATUS, @@ -91,7 +190,8 @@ describe("provider connection presentation", () => { }); expect(presentation.busy).toBe(true); expect(presentation.canCancel).toBe(true); - expect(presentation.primaryAction).toBe("none"); + expect(presentation.primaryAction).toBe("done"); + expect(presentation.canRestart).toBe(true); }, ); @@ -123,6 +223,49 @@ describe("provider connection presentation", () => { expect(presentation.primaryAction).toBe("done"); }); + it.each(["failed", "cancelled"] as const)( + "keeps a forced recovery retryable after a %s attempt despite stale authenticated health", + (operationStatus) => { + const presentation = describeProviderConnection( + "codex", + { + ...BASE_STATUS, + status: "ready", + authStatus: "authenticated", + requiresProviderAccount: true, + connectionState: { + operationId: "recovery-operation-1", + method: "codex_browser", + status: operationStatus, + startedAt: "2026-07-19T10:00:00.000Z", + finishedAt: "2026-07-19T10:01:00.000Z", + message: `Recovery ${operationStatus}.`, + }, + }, + { forceReconnect: true }, + ); + + expect(presentation.primaryAction).toBe("sign_in"); + expect(presentation.primaryLabel).toBe("Try again"); + }, + ); + + it("never offers account recovery when Codex account ownership is unknown or custom", () => { + for (const requiresProviderAccount of [undefined, false] as const) { + const presentation = describeProviderConnection( + "codex", + { + ...BASE_STATUS, + status: "ready", + authStatus: "authenticated", + requiresProviderAccount, + }, + { forceReconnect: true }, + ); + expect(presentation.primaryAction).toBe("check_again"); + } + }); + it("turns failures into a safe retry", () => { const presentation = describeProviderConnection("codex", { ...BASE_STATUS, @@ -138,4 +281,23 @@ describe("provider connection presentation", () => { expect(presentation.primaryAction).toBe("sign_in"); expect(presentation.primaryLabel).toBe("Try again"); }); + + it("turns a rejected Grok OAuth operation into a fresh browser retry", () => { + const presentation = describeProviderConnection("grok", { + ...BASE_STATUS, + provider: "grok", + connectionState: { + operationId: "grok-oauth-1", + method: "grok_browser", + status: "failed", + startedAt: "2026-07-21T00:00:00.000Z", + finishedAt: "2026-07-21T00:01:00.000Z", + message: "Grok authorization was not completed.", + }, + }); + + expect(presentation.primaryAction).toBe("sign_in"); + expect(presentation.primaryLabel).toBe("Try again"); + expect(presentation.description).toContain("Grok authorization"); + }); }); diff --git a/apps/web/src/lib/providerConnectionPresentation.ts b/apps/web/src/lib/providerConnectionPresentation.ts index 5d56da43a..6bdc3dc93 100644 --- a/apps/web/src/lib/providerConnectionPresentation.ts +++ b/apps/web/src/lib/providerConnectionPresentation.ts @@ -6,8 +6,10 @@ import { PROVIDER_DISPLAY_NAMES, type ProviderKind, type ServerProviderConnectionMethod, + type ServerProviderInstallPlan, type ServerProviderStatus, } from "@synara/contracts"; +import { compareSemverVersions } from "@synara/shared/providerVersions"; const PROVIDER_INSTALL_URLS: Partial> = { codex: "https://help.openai.com/en/articles/11096431", @@ -25,7 +27,7 @@ export function providerConnectionMethod( provider: ProviderKind, ): ServerProviderConnectionMethod | null { if (provider === "codex") return "codex_browser"; - if (provider === "claudeAgent") return "claude_console"; + if (provider === "claudeAgent") return "claude_account"; if (provider === "cursor") return "cursor_browser"; if (provider === "antigravity") return "antigravity_browser"; if (provider === "grok") return "grok_browser"; @@ -33,6 +35,31 @@ export function providerConnectionMethod( return null; } +export const CLAUDE_CONNECTION_METHOD_OPTIONS: ReadonlyArray<{ + readonly method: Extract< + ServerProviderConnectionMethod, + "claude_account" | "claude_sso" | "claude_console" + >; + readonly label: string; + readonly description: string; +}> = [ + { + method: "claude_account", + label: "Claude account", + description: "Use the same account Claude uses in your terminal.", + }, + { + method: "claude_sso", + label: "Work or organization SSO", + description: "Force your organization's SSO sign-in.", + }, + { + method: "claude_console", + label: "Anthropic Console / API", + description: "Use a Console account with API billing.", + }, +]; + export function providerInstallUrl(provider: ProviderKind): string | null { return PROVIDER_INSTALL_URLS[provider] ?? null; } @@ -56,11 +83,93 @@ export interface ProviderConnectionPresentation { readonly primaryLabel: string; readonly busy: boolean; readonly canCancel: boolean; + readonly canRestart?: boolean; +} + +export function describeManagedProviderUpdate(input: { + readonly provider: ProviderKind; + readonly status: ServerProviderStatus; + readonly plan: ServerProviderInstallPlan | null; + readonly updateStarted: boolean; +}): ProviderConnectionPresentation { + const label = PROVIDER_DISPLAY_NAMES[input.provider] ?? input.provider; + const title = `Update ${label}`; + const installation = input.status.installationState; + if ( + input.updateStarted && + installation && + !["installed", "succeeded", "failed", "cancelled"].includes(installation.status) + ) { + return { + title, + description: installation.message, + primaryAction: "none", + primaryLabel: "Updating…", + busy: true, + canCancel: true, + }; + } + if (input.updateStarted && installation?.status === "installed") { + return { + title, + description: + `${label} ${installation.version ?? ""} is installed, verified, and ready to use.`.replace( + /\s+/gu, + " ", + ), + primaryAction: "done", + primaryLabel: "Done", + busy: false, + canCancel: false, + }; + } + if ( + input.updateStarted && + !input.plan && + (installation?.status === "failed" || installation?.status === "cancelled") + ) { + return { + title, + description: installation.message, + primaryAction: "install", + primaryLabel: "Try again", + busy: false, + canCancel: false, + }; + } + const currentVersion = input.status.runtime?.managedVersion; + if (input.plan && currentVersion) { + const comparison = compareSemverVersions(input.plan.version, currentVersion); + if (comparison <= 0) { + return { + title, + description: + comparison === 0 + ? `${label} ${currentVersion} is already the latest stable version.` + : `${label} ${currentVersion} is newer than the provider's current stable version ${input.plan.version}. Scient will not downgrade it.`, + primaryAction: "done", + primaryLabel: "Done", + busy: false, + canCancel: false, + }; + } + } + return { + title, + description: input.plan + ? `Scient found ${label} ${input.plan.version} on the provider's trusted stable channel. The verified download is ready below.` + : `Scient will check ${label}'s trusted stable channel and prepare the latest compatible version.`, + primaryAction: "install", + primaryLabel: input.plan ? `Update ${label}` : "Check latest version", + busy: false, + canCancel: false, + }; } export function describeProviderConnection( provider: ProviderKind, status: ServerProviderStatus | null | undefined, + options?: { readonly forceReconnect?: boolean }, ): ProviderConnectionPresentation { const title = providerConnectionTitle(provider); const label = PROVIDER_DISPLAY_NAMES[provider] ?? provider; @@ -90,14 +199,59 @@ export function describeProviderConnection( return { title, description: operation.message, - primaryAction: "none", - primaryLabel: "Working…", + primaryAction: "done", + primaryLabel: "Continue in background", busy: true, canCancel: true, + canRestart: true, + }; + } + + if (options?.forceReconnect && provider === "codex") { + if (status?.available && status.requiresProviderAccount === true) { + if (operation?.status === "failed" || operation?.status === "cancelled") { + return { + title, + description: operation.message, + primaryAction: "sign_in", + primaryLabel: "Try again", + busy: false, + canCancel: false, + }; + } + return { + title, + description: + "Codex reported that its account session is no longer authorized. Reconnect in the browser, then press Send again; your draft and attachments stay here.", + primaryAction: "sign_in", + primaryLabel: "Reconnect Codex", + busy: false, + canCancel: false, + }; + } + return { + title, + description: + "Scient needs to verify that this Codex runtime uses your OpenAI account before opening account recovery.", + primaryAction: "check_again", + primaryLabel: "Check again", + busy: false, + canCancel: false, }; } if (status?.available && status.authStatus === "authenticated") { + if (status.status !== "ready") { + return { + title, + description: + status.message ?? `${label} is connected, but Scient cannot load it right now.`, + primaryAction: "check_again", + primaryLabel: "Check again", + busy: false, + canCancel: false, + }; + } return { title, description: `${label} is connected and ready to use.`, @@ -158,6 +312,16 @@ export function describeProviderConnection( canCancel: false, }; } + if (status.runtime?.source === "custom" && status.runtime.message) { + return { + title, + description: status.runtime.message, + primaryAction: "check_again", + primaryLabel: "Check again", + busy: false, + canCancel: false, + }; + } if (status.runtime?.canInstall) { return { title, @@ -184,9 +348,9 @@ export function describeProviderConnection( return { title, description: - "Scient connects Claude through an Anthropic Console account with API billing, not a Claude.ai subscription. Anthropic handles the secure browser sign-in and keeps your credentials.", + "Scient uses Claude's official sign-in and automatically detects an account already connected in your terminal. Claude keeps your credentials.", primaryAction: "sign_in", - primaryLabel: "Connect Anthropic Console", + primaryLabel: "Connect Claude", busy: false, canCancel: false, }; diff --git a/apps/web/src/lib/providerDiscoveryReactQuery.test.ts b/apps/web/src/lib/providerDiscoveryReactQuery.test.ts index 9712178d1..db62e8975 100644 --- a/apps/web/src/lib/providerDiscoveryReactQuery.test.ts +++ b/apps/web/src/lib/providerDiscoveryReactQuery.test.ts @@ -9,7 +9,14 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { isInitialModelDiscoveryPending, + providerAgentsQueryOptions, + providerCommandsQueryOptions, + providerComposerCapabilitiesQueryOptions, providerModelsQueryOptions, + providerPluginsQueryOptions, + providerReadPluginQueryOptions, + providerSkillsQueryOptions, + skillsCatalogQueryOptions, } from "./providerDiscoveryReactQuery"; import * as nativeApi from "../nativeApi"; @@ -63,7 +70,10 @@ describe("providerModelsQueryOptions", () => { const listModels = mockListModels( vi.fn().mockRejectedValue(new Error("Cursor CLI is not installed or not on PATH")), ); - const options = providerModelsQueryOptions({ provider: "cursor", enabled: true }); + const options = providerModelsQueryOptions({ + provider: "cursor", + enabled: true, + }); expect(options.retry).toBe(0); const queryClient = new QueryClient(); @@ -79,9 +89,34 @@ describe("providerModelsQueryOptions", () => { expect(providerModelsQueryOptions({ provider: "droid" }).retry).toBe(0); }); + it("never re-runs provider discovery just because the window regained focus", () => { + const options = [ + providerComposerCapabilitiesQueryOptions("codex"), + providerModelsQueryOptions({ provider: "codex" }), + providerAgentsQueryOptions({ provider: "codex" }), + providerCommandsQueryOptions({ provider: "codex", cwd: "/repo" }), + providerSkillsQueryOptions({ provider: "codex", cwd: "/repo" }), + providerPluginsQueryOptions({ provider: "codex", cwd: "/repo" }), + providerReadPluginQueryOptions({ + provider: "codex", + marketplacePath: "/marketplace", + pluginName: "example", + cwd: "/repo", + }), + skillsCatalogQueryOptions({ cwd: "/repo" }), + ]; + + for (const option of options) { + expect(option.refetchOnWindowFocus).toBe(false); + } + }); + it("surfaces real errors instead of masking them as empty catalogs", async () => { mockListModels(vi.fn().mockRejectedValue(new Error("discovery exploded"))); - const options = providerModelsQueryOptions({ provider: "cursor", enabled: true }); + const options = providerModelsQueryOptions({ + provider: "cursor", + enabled: true, + }); const queryClient = new QueryClient(); await expect(queryClient.fetchQuery(options)).rejects.toThrow("discovery exploded"); @@ -97,7 +132,10 @@ describe("providerModelsQueryOptions", () => { const listModels = mockListModels( vi.fn().mockResolvedValueOnce(catalog).mockRejectedValue(new Error("cursor went away")), ); - const options = providerModelsQueryOptions({ provider: "cursor", enabled: true }); + const options = providerModelsQueryOptions({ + provider: "cursor", + enabled: true, + }); const queryClient = new QueryClient(); await expect(queryClient.fetchQuery(options)).resolves.toEqual(catalog); @@ -114,7 +152,10 @@ describe("providerModelsQueryOptions", () => { cached: false, }; mockListModels(vi.fn().mockResolvedValue(catalog)); - const options = providerModelsQueryOptions({ provider: "codex", enabled: true }); + const options = providerModelsQueryOptions({ + provider: "codex", + enabled: true, + }); const queryClient = new QueryClient(); await expect(queryClient.fetchQuery(options)).resolves.toEqual(catalog); diff --git a/apps/web/src/lib/providerDiscoveryReactQuery.ts b/apps/web/src/lib/providerDiscoveryReactQuery.ts index a8514deed..5dc3ec471 100644 --- a/apps/web/src/lib/providerDiscoveryReactQuery.ts +++ b/apps/web/src/lib/providerDiscoveryReactQuery.ts @@ -45,6 +45,11 @@ const EMPTY_PLUGINS_RESULT: ProviderListPluginsResult = { cached: false, }; +// Provider discovery can spawn CLIs and inspect provider/project configuration. +// Refresh it through explicit UI actions and domain invalidation, never as a +// side effect of returning to the Scient window. +const PROVIDER_DISCOVERY_REFETCH_ON_WINDOW_FOCUS = false; + export const providerDiscoveryQueryKeys = { all: ["provider-discovery"] as const, composerCapabilities: (provider: ProviderKind) => @@ -91,6 +96,7 @@ export function providerComposerCapabilitiesQueryOptions(provider: ProviderKind) return api.provider.getComposerCapabilities({ provider }); }, staleTime: Infinity, + refetchOnWindowFocus: PROVIDER_DISCOVERY_REFETCH_ON_WINDOW_FOCUS, }); } @@ -117,6 +123,7 @@ export function providerSkillsQueryOptions(input: { }, enabled: (input.enabled ?? true) && input.cwd !== null, staleTime: 30_000, + refetchOnWindowFocus: PROVIDER_DISCOVERY_REFETCH_ON_WINDOW_FOCUS, placeholderData: (previous) => previous ?? EMPTY_SKILLS_RESULT, }); } @@ -134,6 +141,7 @@ export function skillsCatalogQueryOptions(input?: { cwd?: string | null; enabled }, enabled: input?.enabled ?? true, staleTime: 30_000, + refetchOnWindowFocus: PROVIDER_DISCOVERY_REFETCH_ON_WINDOW_FOCUS, placeholderData: (previous) => previous, }); } @@ -183,6 +191,7 @@ export function providerCommandsQueryOptions(input: { }, enabled: (input.enabled ?? true) && input.cwd !== null, staleTime: 30_000, + refetchOnWindowFocus: PROVIDER_DISCOVERY_REFETCH_ON_WINDOW_FOCUS, placeholderData: (previous) => previous ?? EMPTY_COMMANDS_RESULT, }); } @@ -232,7 +241,7 @@ export function providerModelsQueryOptions(input: { // fast so the picker settles to static options instead of spinning (#103). retry: input.provider === "droid" || input.provider === "cursor" ? 0 : 3, staleTime: input.provider === "droid" ? 5 * 60_000 : 60_000, - ...(input.provider === "droid" ? { refetchOnWindowFocus: false } : {}), + refetchOnWindowFocus: PROVIDER_DISCOVERY_REFETCH_ON_WINDOW_FOCUS, placeholderData: (previous) => previous ?? EMPTY_MODELS_RESULT, }); } @@ -259,6 +268,7 @@ export function providerAgentsQueryOptions(input: { }, enabled: input.enabled ?? true, staleTime: 60_000, + refetchOnWindowFocus: PROVIDER_DISCOVERY_REFETCH_ON_WINDOW_FOCUS, placeholderData: (previous) => previous ?? EMPTY_AGENTS_RESULT, }); } @@ -281,6 +291,7 @@ export function providerPluginsQueryOptions(input: { }, enabled: input.enabled ?? true, staleTime: 30_000, + refetchOnWindowFocus: PROVIDER_DISCOVERY_REFETCH_ON_WINDOW_FOCUS, placeholderData: (previous) => previous ?? EMPTY_PLUGINS_RESULT, }); } @@ -313,6 +324,7 @@ export function providerReadPluginQueryOptions(input: { }, enabled: input.enabled ?? true, staleTime: 60_000, + refetchOnWindowFocus: PROVIDER_DISCOVERY_REFETCH_ON_WINDOW_FOCUS, }); } diff --git a/apps/web/src/lib/providerStatusCache.test.ts b/apps/web/src/lib/providerStatusCache.test.ts new file mode 100644 index 000000000..b62250951 --- /dev/null +++ b/apps/web/src/lib/providerStatusCache.test.ts @@ -0,0 +1,104 @@ +import type { ServerConfig, ServerProviderStatus } from "@synara/contracts"; +import { QueryClient } from "@tanstack/react-query"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { serverQueryKeys } from "./serverReactQuery"; +import { + applyProviderStatusesToCache, + resetProviderStatusCacheGuardForTests, +} from "./providerStatusCache"; + +const checkedAt = "2026-07-20T16:00:00.000Z"; + +function provider(runtime = true): ServerProviderStatus { + return { + provider: "antigravity", + status: "error", + available: false, + authStatus: "unknown", + checkedAt, + ...(runtime + ? { + runtime: { + source: "missing" as const, + managedVersion: null, + canInstall: true, + canRepair: false, + canRollback: false, + canRemove: false, + message: "No usable provider runtime was found.", + }, + } + : {}), + }; +} + +function config(status: ServerProviderStatus): ServerConfig { + return { + cwd: "/repo/project", + worktreesDir: "/repo/.codex/worktrees", + keybindingsConfigPath: "/repo/keybindings.json", + keybindings: [], + issues: [], + providers: [status], + availableEditors: [], + }; +} + +afterEach(() => { + resetProviderStatusCacheGuardForTests(); + vi.restoreAllMocks(); +}); + +describe("provider status cache invariant", () => { + it("does not replace a complete status with an incomplete refresh", async () => { + const queryClient = new QueryClient(); + queryClient.setQueryData(serverQueryKeys.config(), config(provider())); + const requestSnapshot = vi.fn().mockResolvedValue(config(provider())); + + expect(applyProviderStatusesToCache(queryClient, [provider(false)], { requestSnapshot })).toBe( + false, + ); + expect( + queryClient.getQueryData(serverQueryKeys.config())?.providers[0]?.runtime + ?.canInstall, + ).toBe(true); + + await vi.waitFor(() => expect(requestSnapshot).toHaveBeenCalledTimes(1)); + }); + + it("attempts only one bounded resynchronization for repeated incomplete payloads", async () => { + const queryClient = new QueryClient(); + queryClient.setQueryData(serverQueryKeys.config(), config(provider())); + const requestSnapshot = vi.fn().mockResolvedValue(config(provider(false))); + + applyProviderStatusesToCache(queryClient, [provider(false)], { requestSnapshot }); + applyProviderStatusesToCache(queryClient, [provider(false)], { requestSnapshot }); + + await vi.waitFor(() => expect(requestSnapshot).toHaveBeenCalledTimes(1)); + expect( + queryClient.getQueryData(serverQueryKeys.config())?.providers[0]?.runtime + ?.canInstall, + ).toBe(true); + }); + + it("accepts a later complete payload and resets the compatibility guard", async () => { + const queryClient = new QueryClient(); + queryClient.setQueryData(serverQueryKeys.config(), config(provider())); + const requestSnapshot = vi.fn().mockResolvedValue(config(provider(false))); + + applyProviderStatusesToCache(queryClient, [provider(false)], { requestSnapshot }); + await vi.waitFor(() => expect(requestSnapshot).toHaveBeenCalledTimes(1)); + + const connected = { + ...provider(), + status: "ready" as const, + available: true, + authStatus: "authenticated" as const, + }; + expect(applyProviderStatusesToCache(queryClient, [connected])).toBe(true); + expect( + queryClient.getQueryData(serverQueryKeys.config())?.providers[0]?.available, + ).toBe(true); + }); +}); diff --git a/apps/web/src/lib/providerStatusCache.ts b/apps/web/src/lib/providerStatusCache.ts new file mode 100644 index 000000000..e18496f30 --- /dev/null +++ b/apps/web/src/lib/providerStatusCache.ts @@ -0,0 +1,88 @@ +import type { + ServerConfig, + ServerProviderClientStatus, + ServerProviderStatus, +} from "@synara/contracts"; +import type { QueryClient } from "@tanstack/react-query"; + +import { ensureNativeApi } from "../nativeApi"; +import { serverQueryKeys } from "./serverReactQuery"; + +type ProviderStatusResyncState = { + attempted: boolean; + reported: boolean; +}; + +let resyncStateByQueryClient = new WeakMap(); + +export function areProviderStatusesComplete( + providers: ReadonlyArray, +): providers is ReadonlyArray { + return providers.every((provider) => provider.runtime !== undefined); +} + +function stateFor(queryClient: QueryClient): ProviderStatusResyncState { + const existing = resyncStateByQueryClient.get(queryClient); + if (existing) return existing; + const created = { attempted: false, reported: false }; + resyncStateByQueryClient.set(queryClient, created); + return created; +} + +function writeProviders(queryClient: QueryClient, providers: ReadonlyArray) { + queryClient.setQueryData(serverQueryKeys.config(), (current) => + current ? { ...current, providers } : current, + ); +} + +export function applyProviderStatusesToCache( + queryClient: QueryClient, + providers: ReadonlyArray, + options?: { + readonly requestSnapshot?: () => Promise; + }, +): boolean { + const state = stateFor(queryClient); + if (areProviderStatusesComplete(providers)) { + writeProviders(queryClient, providers); + state.attempted = false; + state.reported = false; + return true; + } + + const current = queryClient.getQueryData(serverQueryKeys.config()); + if (!current || !areProviderStatusesComplete(current.providers)) { + writeProviders(queryClient, providers); + } + + if (!state.reported) { + state.reported = true; + console.error( + "Provider status invariant violated: received a legacy/incomplete provider snapshot. Requesting one complete resynchronization.", + ); + } + + if (state.attempted) return false; + state.attempted = true; + const requestSnapshot = options?.requestSnapshot ?? (() => ensureNativeApi().server.getConfig()); + void requestSnapshot() + .then((snapshot) => { + if (!areProviderStatusesComplete(snapshot.providers)) { + console.error( + "Provider status resynchronization remained incomplete; preserving the last complete snapshot.", + ); + return; + } + queryClient.setQueryData(serverQueryKeys.config(), snapshot); + state.attempted = false; + state.reported = false; + }) + .catch((error) => { + console.error("Provider status resynchronization failed.", error); + }); + return false; +} + +export function resetProviderStatusCacheGuardForTests() { + resyncStateByQueryClient = new WeakMap(); +} diff --git a/apps/web/src/lib/serverReactQuery.test.ts b/apps/web/src/lib/serverReactQuery.test.ts index f5658c51a..e46edb02e 100644 --- a/apps/web/src/lib/serverReactQuery.test.ts +++ b/apps/web/src/lib/serverReactQuery.test.ts @@ -36,7 +36,7 @@ describe("serverLocalServersQueryOptions", () => { expect(options.enabled).toBe(true); expect(options.refetchInterval).toBe(false); - expect(options.refetchOnWindowFocus).toBe(true); + expect(options.refetchOnWindowFocus).toBe(false); }); it("uses visible polling while a Synara-owned project run is active", () => { diff --git a/apps/web/src/lib/serverReactQuery.ts b/apps/web/src/lib/serverReactQuery.ts index 9823a0fc1..b546fbd70 100644 --- a/apps/web/src/lib/serverReactQuery.ts +++ b/apps/web/src/lib/serverReactQuery.ts @@ -124,12 +124,17 @@ export function serverLocalServersQueryOptions( export function sidebarLocalServersQueryOptions(input: { hasActiveProjectRun: boolean; hasProjects: boolean; -}) { +}): ReturnType { const enabled = input.hasProjects || input.hasActiveProjectRun; - return serverLocalServersQueryOptions({ - enabled, - refetchInterval: input.hasActiveProjectRun ? LOCAL_SERVERS_VISIBLE_REFETCH_INTERVAL_MS : false, - }); + return { + ...serverLocalServersQueryOptions({ + enabled, + refetchInterval: input.hasActiveProjectRun + ? LOCAL_SERVERS_VISIBLE_REFETCH_INTERVAL_MS + : false, + }), + refetchOnWindowFocus: false as const, + }; } const STUDIO_THREAD_OUTPUTS_STALE_TIME_MS = 10_000; @@ -154,7 +159,9 @@ export function studioThreadOutputsQueryOptions(input: { }, enabled: (input.enabled ?? true) && threadId !== null, staleTime: STUDIO_THREAD_OUTPUTS_STALE_TIME_MS, - refetchOnWindowFocus: true, + // File/checkpoint domain events already invalidate this query. Returning to + // Scient should not itself read the active project tree. + refetchOnWindowFocus: false, refetchOnReconnect: true, }); } diff --git a/apps/web/src/projectTerminalRunner.test.ts b/apps/web/src/projectTerminalRunner.test.ts index 5f11f2cf3..f8084d7fd 100644 --- a/apps/web/src/projectTerminalRunner.test.ts +++ b/apps/web/src/projectTerminalRunner.test.ts @@ -11,6 +11,8 @@ describe("runProjectCommandInTerminal", () => { status: "running", pid: 1234, history: "", + outputEpoch: "epoch-1", + outputSequence: 0, exitCode: null, exitSignal: null, updatedAt: "2026-01-01T00:00:00.000Z", diff --git a/apps/web/src/providerConnectionDialogStore.ts b/apps/web/src/providerConnectionDialogStore.ts index dcde20340..1bb6f804e 100644 --- a/apps/web/src/providerConnectionDialogStore.ts +++ b/apps/web/src/providerConnectionDialogStore.ts @@ -11,7 +11,9 @@ export type ProviderConnectionSource = | "health_banner" | "settings" | "empty_state" - | "runtime_error"; + | "runtime_error" + | "runtime_authentication_error" + | "managed_update"; interface ProviderConnectionDialogStore { isOpen: boolean; diff --git a/apps/web/src/providerUpdates.test.ts b/apps/web/src/providerUpdates.test.ts index bec6a217f..ca67d107c 100644 --- a/apps/web/src/providerUpdates.test.ts +++ b/apps/web/src/providerUpdates.test.ts @@ -51,6 +51,7 @@ function serverSettings(overrides: Partial = {}): S }; return { + telemetryPrivacyLevel: "essential", enableAssistantStreaming: false, enableProviderUpdateChecks: true, defaultThreadEnvMode: "local", @@ -268,4 +269,80 @@ describe("shouldOfferProviderUpdateAction", () => { ), ).toBe(true); }); + + it("never offers an update action when the provider executable is unavailable", () => { + expect( + shouldOfferProviderUpdateAction( + providerStatus("antigravity", { + available: false, + status: "error", + authStatus: "unknown", + version: null, + versionAdvisory: { + status: "unknown", + currentVersion: null, + latestVersion: null, + updateCommand: "agy update", + canUpdate: true, + checkedAt: "2026-07-21T10:00:00.000Z", + message: null, + }, + }), + ), + ).toBe(false); + }); + + it("routes an available Scient-managed runtime through its managed update flow", () => { + expect( + shouldOfferProviderUpdateAction( + providerStatus("antigravity", { + runtime: { + source: "managed", + managedVersion: "1.1.4", + canInstall: false, + canRepair: true, + canRollback: false, + canRemove: true, + message: null, + }, + versionAdvisory: { + status: "unknown", + currentVersion: "1.1.4", + latestVersion: null, + updateCommand: null, + canUpdate: false, + checkedAt: "2026-07-21T10:00:00.000Z", + message: "Updates for this runtime are managed by Scient.", + }, + }), + ), + ).toBe(true); + }); + + it("does not broaden managed latest-channel updates to other providers", () => { + expect( + shouldOfferProviderUpdateAction( + providerStatus("grok", { + runtime: { + source: "managed", + managedVersion: "0.1.0", + canInstall: false, + canRepair: true, + canRollback: false, + canRemove: true, + message: null, + }, + versionAdvisory: { + status: "unknown", + currentVersion: "0.1.0", + latestVersion: null, + updateCommand: null, + canUpdate: false, + checkedAt: "2026-07-21T10:00:00.000Z", + message: "Updates for this runtime are managed by Scient.", + }, + }), + ), + ).toBe(false); + }); }); diff --git a/apps/web/src/providerUpdates.ts b/apps/web/src/providerUpdates.ts index 5439fac96..ad6c79f83 100644 --- a/apps/web/src/providerUpdates.ts +++ b/apps/web/src/providerUpdates.ts @@ -78,6 +78,10 @@ export function isProviderUpdateActive(provider: ServerProviderStatus): boolean export function shouldOfferProviderUpdateAction(provider: ServerProviderStatus): boolean { const advisory = provider.versionAdvisory; + if (!provider.available) return false; + if (provider.runtime?.source === "managed") { + return provider.provider === "antigravity" && provider.runtime.managedVersion !== null; + } return ( advisory?.canUpdate === true && advisory.updateCommand !== null && @@ -101,6 +105,7 @@ export function shouldShowProviderUpdateStatus(input: ProviderUpdateVisibilityIn const hiddenProviderSet = input.hiddenProviderSet ?? new Set(input.hiddenProviders ?? []); if ( !advisory || + !input.provider.available || input.serverSettings?.enableProviderUpdateChecks === false || advisory.status !== "behind_latest" || advisory.latestVersion === null || diff --git a/apps/web/src/routes/-automations.shared.tsx b/apps/web/src/routes/-automations.shared.tsx index 1869b7287..9ab2c1ed2 100644 --- a/apps/web/src/routes/-automations.shared.tsx +++ b/apps/web/src/routes/-automations.shared.tsx @@ -1210,8 +1210,8 @@ export function AutomationDialog({
); + const telemetryLevelDescriptions = { + off: "No analytics events leave this installation.", + essential: "Share only basic reliability signals, such as an anonymous startup heartbeat.", + product: "Also share feature and workflow usage so ScientFactory can improve the product.", + diagnostic: "Also allow bounded technical diagnostics for failures and performance problems.", + contribution: + "Allow explicitly marked contribution events in addition to diagnostics. Normal analytics still exclude research content.", + } as const; + + const renderDataPrivacyPanel = () => ( +
+ + + updateSettings({ telemetryPrivacyLevel: defaults.telemetryPrivacyLevel }) + } + /> + ) : null + } + control={ + { + if ( + value === "off" || + value === "essential" || + value === "product" || + value === "diagnostic" || + value === "contribution" + ) { + updateSettings({ telemetryPrivacyLevel: value }); + } + }} + ariaLabel="Telemetry privacy level" + valueContent={ + { + off: "Zero telemetry", + essential: "Essential", + product: "Product analytics", + diagnostic: "Diagnostics", + contribution: "Contribution", + }[settings.telemetryPrivacyLevel] + } + > + Zero telemetry + Essential + Product analytics + Diagnostics + Contribution + + } + /> + + + + + + +
+ ); + const renderWorktreesPanel = () => { if (serverWorktreesQuery.isLoading) { return ( @@ -3008,7 +3087,7 @@ function SettingsRouteView() { updateState === "running" || updatingProviders.has(providerStatus.provider); const canUpdateProvider = - updateAdvisory?.canUpdate === true && !isProviderUpdateActive; + shouldOfferProviderUpdateAction(providerStatus) && !isProviderUpdateActive; const updateLabel = providerUpdateStatusLabel(providerStatus); return ( @@ -3017,18 +3096,18 @@ function SettingsRouteView() { title={PROVIDER_DISPLAY_NAMES[providerStatus.provider]} description={updateLabel || undefined} actions={ - updateAdvisory?.canUpdate ? ( + shouldOfferProviderUpdateAction(providerStatus) ? (