From 80fa2cc340c02fe5676796221da94b1b6a384a3d Mon Sep 17 00:00:00 2001 From: yaacovcorcos Date: Mon, 20 Jul 2026 19:23:16 +0300 Subject: [PATCH 01/68] Route telemetry through ScientFactory gateway (#43) --- apps/server/src/telemetry/Identify.ts | 80 +++---------------- .../telemetry/Layers/AnalyticsService.test.ts | 43 +++++++--- .../src/telemetry/Layers/AnalyticsService.ts | 29 ++++--- 3 files changed, 57 insertions(+), 95 deletions(-) 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..9159ffb71 100644 --- a/apps/server/src/telemetry/Layers/AnalyticsService.test.ts +++ b/apps/server/src/telemetry/Layers/AnalyticsService.test.ts @@ -14,8 +14,14 @@ 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 occurred_at?: string; + readonly privacy_level?: string; readonly properties?: { readonly index?: number; readonly clientType?: string; @@ -25,8 +31,13 @@ interface RecordedBatchRequest { } 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 privacy_level?: string; readonly properties?: { readonly index?: number; readonly clientType?: string; @@ -46,8 +57,7 @@ it.layer(NodeServices.layer)("AnalyticsService test", (it) => { 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, }), ); @@ -88,16 +98,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,8 +119,17 @@ 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 === 1 && + request.body.source === "desktop" && + request.body.events.every( + (event) => + event.properties?.clientType === "cli-web-client" && + event.privacy_level === "product" && + event.distinct_id?.startsWith("installation:") === true && + typeof event.id === "string", + ), ), true, ); diff --git a/apps/server/src/telemetry/Layers/AnalyticsService.ts b/apps/server/src/telemetry/Layers/AnalyticsService.ts index 035c050ee..95e5adb8d 100644 --- a/apps/server/src/telemetry/Layers/AnalyticsService.ts +++ b/apps/server/src/telemetry/Layers/AnalyticsService.ts @@ -1,14 +1,15 @@ /** - * 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 { 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 { AnalyticsService, type AnalyticsServiceShape } from "../Services/AnalyticsService.ts"; @@ -16,17 +17,15 @@ 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; } 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)), @@ -49,6 +48,7 @@ const makeAnalyticsService = Effect.gen(function* () { const appended = [ ...current, { + id: randomUUID(), event, ...(properties ? { properties } : {}), capturedAt: DateTime.formatIso(now), @@ -75,24 +75,27 @@ 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: 1, + source: "desktop", + sent_at: new Date().toISOString(), + events: events.map((event) => ({ + id: event.id, + name: event.event, distinct_id: identifier, + occurred_at: event.capturedAt, + privacy_level: "product", 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), From e55716c1dee00b0618f7e374c6a092cefef1927b Mon Sep 17 00:00:00 2001 From: yaacovcorcos Date: Mon, 20 Jul 2026 19:41:47 +0300 Subject: [PATCH 02/68] Fix unsigned macOS release signatures (#44) --- docs/release.md | 5 +- scripts/adhoc-sign-mac-app.cjs | 49 ++++++++++ .../build-desktop-artifact-mac-config.test.ts | 25 +++++- scripts/build-desktop-artifact.ts | 26 ++++++ scripts/lib/desktop-platform-build-config.ts | 6 +- scripts/lib/mac-artifact-signature.ts | 90 +++++++++++++++++++ scripts/lib/mac-update-zip-finalize.ts | 9 +- scripts/mac-update-zip-finalize.test.ts | 48 ++++++++++ 8 files changed, 247 insertions(+), 11 deletions(-) create mode 100644 scripts/adhoc-sign-mac-app.cjs create mode 100644 scripts/lib/mac-artifact-signature.ts create mode 100644 scripts/mac-update-zip-finalize.test.ts diff --git a/docs/release.md b/docs/release.md index 4a8148336..d972d2391 100644 --- a/docs/release.md +++ b/docs/release.md @@ -57,7 +57,7 @@ This document covers build-only native validation, promotion through the protect - macOS metadata note: - The build initially emits `latest-mac.yml` for both Intel and Apple Silicon. - The workflow merges the per-arch macOS metadata, then keeps the merged manifest as `latest-mac.yml` and copies it to `scient-mac.yml` for stable releases. - - The desktop build script repacks the macOS update `.zip` with `ditto`, verifies Electron framework symlinks, extracts the zip, validates the extracted app signature, patches the matching `latest-mac*.yml` hash/size, and removes the stale `.zip.blockmap`. + - The desktop build script gives unsigned early-access apps a complete ad-hoc signature before packaging, repacks the macOS update `.zip` with `ditto`, verifies Electron framework symlinks and both source/extracted app signatures, validates the app inside the final DMG, patches the matching `latest-mac*.yml` hash/size, and removes the stale `.zip.blockmap`. - macOS updater downloads intentionally use the full zip payload so Squirrel.Mac installs the exact signed archive validated by release build. - Local smoke test: - Run `bun run release:smoke:mac-update -- --skip-build --build-version 0.1.5` on macOS after local desktop/server/web dist files exist. @@ -137,6 +137,9 @@ Unsigned behavior is platform-specific: Unknown Publisher or SmartScreen warning before installation continues. - macOS checks and downloads the update inside Scient, then opens the downloaded ZIP in Finder. The user must replace Scient in Applications and reopen it. + Early-access bundles are ad-hoc signed so macOS can verify their internal + integrity, but they remain unnotarized and can still show an unidentified + developer warning that the user must explicitly bypass once. - Linux keeps the existing AppImage behavior. Never enable unsigned publication while a platform has a partial signing-secret diff --git a/scripts/adhoc-sign-mac-app.cjs b/scripts/adhoc-sign-mac-app.cjs new file mode 100644 index 000000000..f9f8ed93c --- /dev/null +++ b/scripts/adhoc-sign-mac-app.cjs @@ -0,0 +1,49 @@ +// FILE: adhoc-sign-mac-app.cjs +// Purpose: Gives unsigned early-access macOS bundles a complete ad-hoc signature before packaging. +// Layer: electron-builder afterPack hook +// Depends on: macOS codesign and the staged Electron application bundle. + +const { execFileSync, spawnSync } = require("node:child_process"); +const { join, resolve } = require("node:path"); + +const EXPECTED_BUNDLE_IDENTIFIER = "com.scientfactory.scient"; + +module.exports = async function adhocSignMacApp(context) { + if (context.electronPlatformName !== "darwin") { + return; + } + + const productFilename = context.packager.appInfo.productFilename; + const appPath = join(context.appOutDir, `${productFilename}.app`); + const configuredEntitlements = context.packager.platformSpecificBuildOptions.entitlements; + const args = ["--force", "--deep", "--sign", "-", "--options", "runtime"]; + + if (typeof configuredEntitlements === "string" && configuredEntitlements.length > 0) { + args.push("--entitlements", resolve(context.packager.projectDir, configuredEntitlements)); + } + args.push(appPath); + + console.log(`Ad-hoc signing unsigned macOS bundle: ${appPath}`); + execFileSync("/usr/bin/codesign", args, { stdio: "inherit" }); + execFileSync("/usr/bin/codesign", ["--verify", "--deep", "--strict", "--verbose=2", appPath], { + stdio: "inherit", + }); + + const detailResult = spawnSync("/usr/bin/codesign", ["-dv", "--verbose=4", appPath], { + encoding: "utf8", + }); + if (detailResult.status !== 0) { + throw new Error( + `Could not inspect ad-hoc signature: ${(detailResult.stderr || detailResult.stdout || "").trim()}`, + ); + } + const details = `${detailResult.stdout || ""}\n${detailResult.stderr || ""}`; + if (!details.includes(`Identifier=${EXPECTED_BUNDLE_IDENTIFIER}`)) { + throw new Error( + `Ad-hoc signed bundle has the wrong identifier; expected ${EXPECTED_BUNDLE_IDENTIFIER}.`, + ); + } + if (!details.includes("Signature=adhoc") || !details.includes("TeamIdentifier=not set")) { + throw new Error("Unsigned macOS bundle was not sealed with the expected ad-hoc identity."); + } +}; diff --git a/scripts/build-desktop-artifact-mac-config.test.ts b/scripts/build-desktop-artifact-mac-config.test.ts index 87114c0dd..42b978772 100644 --- a/scripts/build-desktop-artifact-mac-config.test.ts +++ b/scripts/build-desktop-artifact-mac-config.test.ts @@ -5,6 +5,7 @@ import { MAC_APPSNAP_HELPER_ASAR_EXCLUSION, MAC_APPSNAP_HELPER_BUNDLE_PATH, MAC_APPSNAP_HELPER_STAGE_PATH, + MAC_ADHOC_SIGN_HOOK_PATH, MAC_ENTITLEMENTS_PATH, MAC_INHERITED_ENTITLEMENTS_PATH, MICROPHONE_USAGE_DESCRIPTION, @@ -18,12 +19,14 @@ describe("createDesktopPlatformBuildConfig", () => { it("adds explicit microphone entitlements to macOS builds", () => { const config = createDesktopPlatformBuildConfig({ platform: "mac", + signed: false, target: "dmg", }); const mac = config.mac as Record; const extendInfo = mac.extendInfo as Record; assert.deepStrictEqual(mac.target, ["dmg", "zip"]); + assert.equal(config.afterPack, MAC_ADHOC_SIGN_HOOK_PATH); assert.equal(mac.icon, "icon.icns"); assert.deepStrictEqual(config.asarUnpack, ["node_modules/node-pty/**"]); assert.equal(mac.hardenedRuntime, true); @@ -37,7 +40,11 @@ describe("createDesktopPlatformBuildConfig", () => { "apps/desktop/native/appsnap/build/scient-appsnap-helper", ); assert.equal(MAC_APPSNAP_HELPER_ASAR_EXCLUSION, "!apps/desktop/native/appsnap/build/**"); - assert.deepStrictEqual(config.files, ["**/*", MAC_APPSNAP_HELPER_ASAR_EXCLUSION]); + assert.deepStrictEqual(config.files, [ + "**/*", + MAC_APPSNAP_HELPER_ASAR_EXCLUSION, + `!${MAC_ADHOC_SIGN_HOOK_PATH}`, + ]); assert.deepStrictEqual(config.extraFiles, [ { from: "apps/desktop/native/appsnap/build/scient-appsnap-helper", @@ -48,6 +55,22 @@ describe("createDesktopPlatformBuildConfig", () => { assert.equal(extendInfo.NSScreenCaptureUsageDescription, undefined); }); + it("uses ad-hoc sealing only for unsigned macOS bundles", () => { + const unsigned = createDesktopPlatformBuildConfig({ + platform: "mac", + signed: false, + target: "dmg", + }); + const signed = createDesktopPlatformBuildConfig({ + platform: "mac", + signed: true, + target: "dmg", + }); + + assert.equal(unsigned.afterPack, MAC_ADHOC_SIGN_HOOK_PATH); + assert.equal(signed.afterPack, undefined); + }); + it("keeps non-macOS platform configs complete", () => { const linux = createDesktopPlatformBuildConfig({ platform: "linux", diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 421721ed7..9cc7a444c 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -15,11 +15,13 @@ import serverPackageJson from "../apps/server/package.json" with { type: "json" import { BRAND_ASSET_PATHS } from "./lib/brand-assets.ts"; import { createDesktopPlatformBuildConfig, + MAC_ADHOC_SIGN_HOOK_PATH, MAC_APPSNAP_HELPER_STAGE_PATH, validateDesktopNativeBuildHost, } from "./lib/desktop-platform-build-config.ts"; import { SCIENT_PRODUCTION_BUNDLE_ID } from "@synara/shared/desktopIdentity"; import { parseBooleanEnvValue } from "./lib/env-bool.ts"; +import { verifySingleMacDmgSignature } from "./lib/mac-artifact-signature.ts"; import { finalizeMacUpdateZip } from "./lib/mac-update-zip-finalize.ts"; import { createReleaseInstallManifest, @@ -757,6 +759,7 @@ const createBuildConfig = Effect.fn("createBuildConfig")(function* ( const platformBuildConfigInput = { platform, + signed, target, ...(windowsAzureSignOptions ? { windowsAzureSignOptions } : {}), } as const; @@ -947,6 +950,12 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( if (options.platform === "mac") { yield* stageMacAppSnapHelper(stageAppDir, options.arch, options.verbose); + if (!options.signed) { + const hookSourcePath = path.join(repoRoot, MAC_ADHOC_SIGN_HOOK_PATH); + const hookStagePath = path.join(stageAppDir, MAC_ADHOC_SIGN_HOOK_PATH); + yield* fs.makeDirectory(path.dirname(hookStagePath), { recursive: true }); + yield* fs.copyFile(hookSourcePath, hookStagePath); + } } // electron-builder is filtering out stageResourcesDir directory in the AppImage for production @@ -1070,6 +1079,23 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( } } + if (options.platform === "mac" && options.target === "dmg") { + yield* Effect.log("[desktop-artifact] Verifying final macOS DMG signature..."); + yield* Effect.try({ + try: () => + verifySingleMacDmgSignature({ + stageDistDir, + requireDeveloperSignature: options.signed, + verbose: options.verbose, + }), + catch: (cause) => + new BuildScriptError({ + message: "macOS DMG signature verification failed.", + cause, + }), + }); + } + const stageEntries = yield* fs.readDirectory(stageDistDir); yield* fs.makeDirectory(options.outputDir, { recursive: true }); diff --git a/scripts/lib/desktop-platform-build-config.ts b/scripts/lib/desktop-platform-build-config.ts index c28a59ede..711af1905 100644 --- a/scripts/lib/desktop-platform-build-config.ts +++ b/scripts/lib/desktop-platform-build-config.ts @@ -10,6 +10,7 @@ export const MAC_INHERITED_ENTITLEMENTS_PATH = "apps/desktop/resources/entitlements.mac.inherit.plist"; export const MAC_APPSNAP_HELPER_STAGE_PATH = "apps/desktop/native/appsnap/build/scient-appsnap-helper"; +export const MAC_ADHOC_SIGN_HOOK_PATH = "scripts/adhoc-sign-mac-app.cjs"; export const MAC_APPSNAP_HELPER_ASAR_EXCLUSION = "!apps/desktop/native/appsnap/build/**"; export const MAC_APPSNAP_HELPER_BUNDLE_PATH = "Contents/Helpers/scient-appsnap-helper"; export const WINDOWS_INSTALLER_GUID = "368107a8-afe6-5db5-ab3b-d4f331684868"; @@ -17,6 +18,7 @@ const MAC_DMG_ICON_PATH = "icon.icns"; export const NODE_PTY_ASAR_UNPACK_GLOBS = ["node_modules/node-pty/**"] as const; export interface DesktopPlatformBuildConfig { + readonly afterPack?: string; readonly asarUnpack?: ReadonlyArray; readonly extraFiles?: ReadonlyArray>; readonly files?: ReadonlyArray; @@ -28,6 +30,7 @@ export interface DesktopPlatformBuildConfig { export interface CreateDesktopPlatformBuildConfigInput { readonly platform: "linux" | "mac" | "win"; + readonly signed?: boolean; readonly target: string; readonly windowsAzureSignOptions?: Record; } @@ -84,7 +87,8 @@ export function createDesktopPlatformBuildConfig( return { ...nativePackaging, - files: ["**/*", MAC_APPSNAP_HELPER_ASAR_EXCLUSION], + ...(input.signed === true ? {} : { afterPack: MAC_ADHOC_SIGN_HOOK_PATH }), + files: ["**/*", MAC_APPSNAP_HELPER_ASAR_EXCLUSION, `!${MAC_ADHOC_SIGN_HOOK_PATH}`], extraFiles: [ { from: MAC_APPSNAP_HELPER_STAGE_PATH, diff --git a/scripts/lib/mac-artifact-signature.ts b/scripts/lib/mac-artifact-signature.ts new file mode 100644 index 000000000..403dfa9fd --- /dev/null +++ b/scripts/lib/mac-artifact-signature.ts @@ -0,0 +1,90 @@ +// FILE: mac-artifact-signature.ts +// Purpose: Verifies macOS app signatures in unpacked bundles and final DMG release artifacts. +// Layer: Release/build helper +// Depends on: macOS codesign and hdiutil. + +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const COMMAND_OUTPUT_MAX_BUFFER_BYTES = 64 * 1024 * 1024; + +function runCommand( + command: string, + args: ReadonlyArray, + options: { readonly verbose?: boolean } = {}, +): string { + const result = spawnSync(command, [...args], { + encoding: "utf8", + maxBuffer: COMMAND_OUTPUT_MAX_BUFFER_BYTES, + }); + if (options.verbose && result.stdout) process.stdout.write(result.stdout); + if (options.verbose && result.stderr) process.stderr.write(result.stderr); + if (result.status !== 0) { + throw new Error( + `${command} ${args.join(" ")} failed: ${(result.stderr || result.stdout || "").trim()}`, + ); + } + return `${result.stdout || ""}\n${result.stderr || ""}`; +} + +export function verifyMacAppSignature( + appBundlePath: string, + requireDeveloperSignature: boolean, +): void { + runCommand("codesign", ["--verify", "--deep", "--strict", "--verbose=4", appBundlePath]); + const details = runCommand("codesign", ["-dv", "--verbose=4", appBundlePath]); + + if (requireDeveloperSignature) { + if ( + !details.includes("Authority=Developer ID Application:") || + details.includes("TeamIdentifier=not set") + ) { + throw new Error( + `Signed macOS update bundle must use a Developer ID Application identity: ${appBundlePath}`, + ); + } + } else if (!details.includes("Signature=adhoc") || !details.includes("TeamIdentifier=not set")) { + throw new Error( + `Unsigned macOS update bundle must have a complete ad-hoc signature: ${appBundlePath}`, + ); + } +} + +export function verifySingleMacDmgSignature(options: { + readonly stageDistDir: string; + readonly requireDeveloperSignature: boolean; + readonly verbose?: boolean; +}): string { + if (process.platform !== "darwin") { + throw new Error("macOS DMG signature verification must run on macOS."); + } + + const dmgNames = readdirSync(options.stageDistDir).filter((entry) => entry.endsWith(".dmg")); + if (dmgNames.length !== 1 || !dmgNames[0]) { + throw new Error(`Expected one macOS DMG in ${options.stageDistDir}, found ${dmgNames.length}.`); + } + + const dmgPath = join(options.stageDistDir, dmgNames[0]); + const mountPath = mkdtempSync(join(tmpdir(), "scient-dmg-signature-")); + let attached = false; + try { + runCommand("hdiutil", ["attach", "-readonly", "-nobrowse", "-mountpoint", mountPath, dmgPath], { + verbose: options.verbose === true, + }); + attached = true; + + const appNames = readdirSync(mountPath).filter((entry) => entry.endsWith(".app")); + if (appNames.length !== 1 || !appNames[0]) { + throw new Error(`Expected one macOS app in ${dmgNames[0]}, found ${appNames.length}.`); + } + verifyMacAppSignature(join(mountPath, appNames[0]), options.requireDeveloperSignature); + return dmgPath; + } finally { + if (attached) { + runCommand("hdiutil", ["detach", mountPath]); + } + rmSync(mountPath, { recursive: true, force: true }); + } +} diff --git a/scripts/lib/mac-update-zip-finalize.ts b/scripts/lib/mac-update-zip-finalize.ts index ac5582cdb..7b5fc2903 100644 --- a/scripts/lib/mac-update-zip-finalize.ts +++ b/scripts/lib/mac-update-zip-finalize.ts @@ -26,6 +26,7 @@ import { resolveSingleTopLevelMacAppBundle, updateMacUpdateManifestZipEntry, } from "./mac-update-zip.ts"; +import { verifyMacAppSignature } from "./mac-artifact-signature.ts"; export interface FinalizeMacUpdateZipOptions { readonly stageDistDir: string; @@ -124,14 +125,6 @@ function assertMacZipFrameworkSymlinks(zipPath: string): string { return appBundleName; } -function verifyMacAppSignature(appBundlePath: string, requireSignature: boolean): void { - const codeResourcesPath = join(appBundlePath, "Contents", "_CodeSignature", "CodeResources"); - if (!requireSignature && !existsSync(codeResourcesPath)) { - return; - } - runTextCommand("codesign", ["--verify", "--deep", "--strict", "--verbose=4", appBundlePath]); -} - function computeSha512Base64(filePath: string): Promise { return new Promise((resolve, reject) => { const hash = createHash("sha512"); diff --git a/scripts/mac-update-zip-finalize.test.ts b/scripts/mac-update-zip-finalize.test.ts new file mode 100644 index 000000000..b0ec80018 --- /dev/null +++ b/scripts/mac-update-zip-finalize.test.ts @@ -0,0 +1,48 @@ +import { execFileSync } from "node:child_process"; +import { chmodSync, copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { assert, describe, it } from "@effect/vitest"; + +import { verifyMacAppSignature } from "./lib/mac-artifact-signature.ts"; + +const runOnMac = process.platform === "darwin" ? it : it.skip; + +describe("verifyMacAppSignature", () => { + runOnMac("rejects malformed unsigned bundles and accepts complete ad-hoc signatures", () => { + const root = mkdtempSync(join(tmpdir(), "scient-adhoc-signature-test-")); + const appPath = join(root, "Scient.app"); + const contentsPath = join(appPath, "Contents"); + const executablePath = join(contentsPath, "MacOS", "Scient"); + + try { + mkdirSync(join(contentsPath, "MacOS"), { recursive: true }); + copyFileSync("/usr/bin/true", executablePath); + chmodSync(executablePath, 0o755); + writeFileSync( + join(contentsPath, "Info.plist"), + ` + + +CFBundleExecutableScient +CFBundleIdentifiercom.scientfactory.scient +CFBundleNameScient +CFBundlePackageTypeAPPL + +`, + ); + + assert.throws(() => verifyMacAppSignature(appPath, false)); + + execFileSync("/usr/bin/codesign", ["--force", "--deep", "--sign", "-", appPath]); + assert.doesNotThrow(() => verifyMacAppSignature(appPath, false)); + assert.throws( + () => verifyMacAppSignature(appPath, true), + /Developer ID Application identity/, + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); From 1d72ef9560330febf7690617dda8f82e6aa7ed7b Mon Sep 17 00:00:00 2001 From: yaacovcorcos Date: Mon, 20 Jul 2026 23:47:47 +0300 Subject: [PATCH 03/68] Add consent-aware desktop analytics (#47) --- apps/server/src/main.test.ts | 2 + apps/server/src/main.ts | 9 +- apps/server/src/serverSettings.test.ts | 4 + .../telemetry/Layers/AnalyticsService.test.ts | 76 ++++++++++- .../src/telemetry/Layers/AnalyticsService.ts | 124 +++++++++++++++--- .../telemetry/Services/AnalyticsService.ts | 7 + apps/web/src/appSettings.ts | 8 ++ apps/web/src/providerUpdates.test.ts | 1 + apps/web/src/routes/_chat.settings.tsx | 76 +++++++++++ apps/web/src/settingsNavigation.ts | 9 ++ apps/web/src/wsNativeApi.test.ts | 1 + docs/analytics.md | 31 +++++ packages/contracts/src/settings.ts | 11 ++ 13 files changed, 337 insertions(+), 22 deletions(-) create mode 100644 docs/analytics.md diff --git a/apps/server/src/main.test.ts b/apps/server/src/main.test.ts index 9acf01a41..47c10f3e3 100644 --- a/apps/server/src/main.test.ts +++ b/apps/server/src/main.test.ts @@ -16,6 +16,7 @@ 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 { ServerSettingsService } from "./serverSettings"; import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; import { Server, type ServerShape } from "./effectServer"; @@ -70,6 +71,7 @@ const testLayer = Layer.mergeAll( openBrowser: (_target: string) => Effect.void, openInEditor: () => Effect.void, } satisfies OpenShape), + ServerSettingsService.layerTest(), AnalyticsService.layerTest, FetchHttpClient.layer, NodeServices.layer, diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 80cd3a290..5cc9d95ff 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -32,7 +32,7 @@ import { ProviderRuntimeManager } from "./provider/Services/ProviderRuntimeManag 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"; @@ -255,6 +255,11 @@ 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), @@ -264,7 +269,7 @@ const LayerLive = (input: CliInput) => { Layer.provideMerge(providerSessionReaperLayer), Layer.provideMerge(SqlitePersistence.layerConfig), Layer.provideMerge(ServerLoggerLive), - Layer.provideMerge(AnalyticsServiceLayerLive), + Layer.provideMerge(analyticsLayer), Layer.provideMerge(ServerConfigLive(input)), ); }; 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/Layers/AnalyticsService.test.ts b/apps/server/src/telemetry/Layers/AnalyticsService.test.ts index 9159ffb71..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"; @@ -20,11 +21,14 @@ interface RecordedBatchRequest { 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; @@ -37,10 +41,13 @@ interface RecordedBatchBody { 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; }; }>; } @@ -53,7 +60,10 @@ 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, @@ -90,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; @@ -121,12 +134,15 @@ it.layer(NodeServices.layer)("AnalyticsService test", (it) => { assert.equal( batchRequests.every( (request) => - request.body.schema_version === 1 && + 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", ), @@ -135,4 +151,58 @@ it.layer(NodeServices.layer)("AnalyticsService test", (it) => { ); }), ); + + 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 95e5adb8d..2cacacd8d 100644 --- a/apps/server/src/telemetry/Layers/AnalyticsService.ts +++ b/apps/server/src/telemetry/Layers/AnalyticsService.ts @@ -7,11 +7,13 @@ * @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" }; @@ -21,6 +23,64 @@ interface BufferedAnalyticsEvent { 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({ @@ -38,11 +98,22 @@ 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 = [ @@ -52,6 +123,8 @@ const makeAnalyticsService = Effect.gen(function* () { event, ...(properties ? { properties } : {}), capturedAt: DateTime.formatIso(now), + privacyLevel, + consentLevel, } satisfies BufferedAnalyticsEvent, ]; @@ -75,15 +148,17 @@ const makeAnalyticsService = Effect.gen(function* () { if (!telemetryConfig.enabled || !identifier) return; const payload = { - schema_version: 1, + 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: "product", + privacy_level: event.privacyLevel, + consent_level: event.consentLevel, properties: { ...event.properties, platform: process.platform, @@ -104,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; }); @@ -127,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/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/providerUpdates.test.ts b/apps/web/src/providerUpdates.test.ts index bec6a217f..56e6321c8 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", diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index 1f529b241..9f98f8358 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -1054,6 +1054,7 @@ function SettingsRouteView() { settings.piBinaryPath !== defaults.piBinaryPath || settings.piAgentDir !== defaults.piAgentDir; const changedSettingLabels = [ + ...(settings.telemetryPrivacyLevel !== defaults.telemetryPrivacyLevel ? ["Data sharing"] : []), ...(theme !== "system" ? ["Theme"] : []), ...(!isDefaultActiveTheme ? [`${resolvedTheme === "dark" ? "Dark" : "Light"} theme pack`] : []), ...(settings.defaultProvider !== defaults.defaultProvider ? ["Default provider"] : []), @@ -2489,6 +2490,79 @@ function SettingsRouteView() { ); + 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 ( @@ -3668,6 +3742,8 @@ function SettingsRouteView() { return renderNotificationsPanel(); case "behavior": return renderBehaviorPanel(); + case "data-privacy": + return renderDataPrivacyPanel(); case "appsnap": return renderAppSnapPanel(); case "shortcuts": diff --git a/apps/web/src/settingsNavigation.ts b/apps/web/src/settingsNavigation.ts index f5c046605..cb9b9b1f6 100644 --- a/apps/web/src/settingsNavigation.ts +++ b/apps/web/src/settingsNavigation.ts @@ -9,6 +9,7 @@ export const SETTINGS_SECTION_IDS = [ "appearance", "notifications", "behavior", + "data-privacy", "appsnap", "shortcuts", "worktrees", @@ -96,6 +97,14 @@ export const SETTINGS_NAV_ITEMS: readonly SettingsNavItem[] = [ icon: "settings-slider-hor", eyebrow: "Interaction rules", }, + { + id: "data-privacy", + group: "app", + label: "Data & Privacy", + description: "Choose which product and reliability data Scient may share.", + icon: "shield-check", + eyebrow: "Data controls", + }, { id: "appsnap", group: "app", diff --git a/apps/web/src/wsNativeApi.test.ts b/apps/web/src/wsNativeApi.test.ts index 93c3fa77d..ae5a57105 100644 --- a/apps/web/src/wsNativeApi.test.ts +++ b/apps/web/src/wsNativeApi.test.ts @@ -286,6 +286,7 @@ describe("wsNativeApi", () => { const payload = { settings: { + telemetryPrivacyLevel: "essential", enableAssistantStreaming: true, enableProviderUpdateChecks: true, defaultThreadEnvMode: "local", diff --git a/docs/analytics.md b/docs/analytics.md new file mode 100644 index 000000000..a4fe3b06a --- /dev/null +++ b/docs/analytics.md @@ -0,0 +1,31 @@ +# Scient analytics and identity + +Scient sends telemetry through ScientFactory's first-party gateway at `events.scientfactory.com`. The gateway stores canonical events in a ScientFactory-owned Cloudflare D1 database before any optional PostHog forwarding. + +## User controls + +The server-authoritative `telemetryPrivacyLevel` setting is available under **Settings → Data & Privacy**: + +| Level | Behavior | +| -------------- | -------------------------------------------------------------------------- | +| `off` | No analytics events leave the installation. | +| `essential` | Basic reliability events only. This is the default. | +| `product` | Essential events plus product and workflow usage. | +| `diagnostic` | Product events plus bounded technical diagnostics. | +| `contribution` | Diagnostic events plus events from separately explicit contribution flows. | + +Lowering the setting immediately prevents higher-level buffered events from being sent. `SYNARA_TELEMETRY_ENABLED=false` remains an emergency and test override that disables the transport completely. + +## Identity + +Each installation receives a random UUID stored in Scient's state directory as `anonymous-id`; transport prefixes it with `installation:`. Each server process creates a new random `session:` UUID. Neither value is derived from the user's name, email, device fingerprint, project, files, or connected provider accounts. + +Future Scient accounts may link an installation to an opaque `account:` UUID through the gateway's authenticated service-to-service endpoint. Desktop clients cannot claim account identifiers directly. ScientFactory's database remains the canonical identity map; PostHog receives only opaque aliases. + +## Content boundary + +The analytics layer allowlists primitive product properties. Normal telemetry excludes prompts, messages, research documents, source text, filenames, file paths, project names, thread identifiers, provider account identifiers, credentials, and generated scientific content. Contribution-level content requires a separate explicit action and must not be added to ordinary `AnalyticsService.record` properties. + +## Event classification + +`server.boot.heartbeat` is `essential`. Existing `provider.*` workflow events are `product`. New diagnostic or contribution events should extend the typed capture contract with an explicit required level before being introduced; event names must not silently infer a less restrictive category. diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 6f1b4ed9c..d1988f635 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -97,7 +97,17 @@ export const SkillsServerSettings = Schema.Struct({ }); export type SkillsServerSettings = typeof SkillsServerSettings.Type; +export const TelemetryPrivacyLevel = Schema.Literals([ + "off", + "essential", + "product", + "diagnostic", + "contribution", +]); +export type TelemetryPrivacyLevel = typeof TelemetryPrivacyLevel.Type; + export const ServerSettings = Schema.Struct({ + telemetryPrivacyLevel: TelemetryPrivacyLevel.pipe(Schema.withDecodingDefault(() => "essential")), enableAssistantStreaming: Schema.Boolean.pipe(Schema.withDecodingDefault(() => true)), enableProviderUpdateChecks: Schema.Boolean.pipe(Schema.withDecodingDefault(() => true)), defaultThreadEnvMode: ThreadEnvironmentMode.pipe(Schema.withDecodingDefault(() => "local")), @@ -138,6 +148,7 @@ const ProviderSettingsBasePatch = { }; export const ServerSettingsPatch = Schema.Struct({ + telemetryPrivacyLevel: Schema.optionalKey(TelemetryPrivacyLevel), enableAssistantStreaming: Schema.optionalKey(Schema.Boolean), enableProviderUpdateChecks: Schema.optionalKey(Schema.Boolean), defaultThreadEnvMode: Schema.optionalKey(ThreadEnvironmentMode), From 4e4019c0797118817df675d2786afb612b886897 Mon Sep 17 00:00:00 2001 From: yaacovcorcos Date: Tue, 21 Jul 2026 02:37:57 +0300 Subject: [PATCH 04/68] Fix Claude terminal auth and connection recovery (#52) * fix provider status refresh invariants * fix Claude auth recovery --- apps/server/src/main.ts | 7 + .../ProviderClientStatusProjection.test.ts | 208 ++++++++++++ .../Layers/ProviderClientStatusProjection.ts | 114 +++++++ .../Layers/ProviderConnection.test.ts | 100 ++++-- .../src/provider/Layers/ProviderConnection.ts | 64 ++-- .../provider/Layers/ProviderHealth.test.ts | 79 ++++- .../src/provider/Layers/ProviderHealth.ts | 136 ++++++-- .../Layers/ProviderRuntimeManager.test.ts | 22 +- .../provider/Layers/ProviderRuntimeManager.ts | 34 +- .../ProviderClientStatusProjection.ts | 24 ++ .../src/provider/claudeCapabilities.test.ts | 103 ++++++ .../server/src/provider/claudeCapabilities.ts | 151 +++++++++ .../src/provider/claudeProcessEnv.test.ts | 4 +- apps/server/src/provider/claudeProcessEnv.ts | 7 +- apps/server/src/wsRpc.ts | 106 ++---- .../ProviderConnectionDialog.browser.tsx | 302 +++++++++++++++++- .../components/ProviderConnectionDialog.tsx | 156 +++++++-- .../chat/ProviderModelPicker.browser.tsx | 60 +++- .../components/chat/ProviderModelPicker.tsx | 20 ++ .../hooks/useProviderAuthRefreshOnFocus.ts | 2 + .../useProviderStatusRefresh.browser.tsx | 95 ++++++ .../web/src/hooks/useProviderStatusRefresh.ts | 19 +- apps/web/src/lib/providerAvailability.test.ts | 36 +++ apps/web/src/lib/providerAvailability.ts | 28 +- .../providerConnectionPresentation.test.ts | 22 +- .../src/lib/providerConnectionPresentation.ts | 58 +++- apps/web/src/lib/providerStatusCache.test.ts | 104 ++++++ apps/web/src/lib/providerStatusCache.ts | 88 +++++ apps/web/src/routes/__root.tsx | 6 +- apps/web/vitest.browser.config.ts | 1 + packages/contracts/src/server.test.ts | 8 + packages/contracts/src/server.ts | 18 ++ 32 files changed, 1931 insertions(+), 251 deletions(-) create mode 100644 apps/server/src/provider/Layers/ProviderClientStatusProjection.test.ts create mode 100644 apps/server/src/provider/Layers/ProviderClientStatusProjection.ts create mode 100644 apps/server/src/provider/Services/ProviderClientStatusProjection.ts create mode 100644 apps/server/src/provider/claudeCapabilities.test.ts create mode 100644 apps/server/src/provider/claudeCapabilities.ts create mode 100644 apps/web/src/hooks/useProviderStatusRefresh.browser.tsx create mode 100644 apps/web/src/lib/providerStatusCache.test.ts create mode 100644 apps/web/src/lib/providerStatusCache.ts diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 5cc9d95ff..fdc8f59eb 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -27,6 +27,7 @@ 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"; @@ -248,6 +249,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. @@ -266,6 +272,7 @@ const LayerLive = (input: CliInput) => { Layer.provideMerge(providerLayer), Layer.provideMerge(providerHealthLayer), Layer.provideMerge(providerConnectionLayer), + Layer.provideMerge(providerClientStatusProjectionLayer), Layer.provideMerge(providerSessionReaperLayer), Layer.provideMerge(SqlitePersistence.layerConfig), Layer.provideMerge(ServerLoggerLive), 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..3313ab3ca 100644 --- a/apps/server/src/provider/Layers/ProviderConnection.test.ts +++ b/apps/server/src/provider/Layers/ProviderConnection.test.ts @@ -101,13 +101,15 @@ function makeConnectionTestLayer(input?: { readonly onPtyKill?: () => void; readonly droidAuthenticationProbe?: typeof probeDroidAcpAuthentication; readonly modelsAvailable?: boolean; + readonly initiallyAuthenticated?: boolean; readonly onListModels?: (input: { readonly provider: ProviderKind; readonly binaryPath?: string; }) => void; }) { let connectionState: ServerProviderConnectionState | undefined; - let authenticated = false; + let authenticated = input?.initiallyAuthenticated ?? false; + let refreshCalls = 0; const status = (): ServerProviderStatus => ({ provider: input?.provider ?? "claudeAgent", status: authenticated ? "ready" : "error", @@ -119,7 +121,10 @@ function makeConnectionTestLayer(input?: { 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"), @@ -242,8 +247,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", @@ -360,7 +374,7 @@ describe("ProviderConnectionLive", () => { ); }); - it("starts Claude login with fixed argv and verifies before connecting", async () => { + it("starts terminal-equivalent Claude login and verifies before connecting", async () => { const onSpawn = vi.fn(); const onListModels = vi.fn(); const fixture = makeConnectionTestLayer({ onSpawn, onListModels }); @@ -370,7 +384,7 @@ describe("ProviderConnectionLive", () => { const connection = yield* ProviderConnection; const started = yield* connection.start({ provider: "claudeAgent", - method: "claude_console", + method: "claude_account", }); expect(started.providers[0]?.connectionState?.operationId).toBeTruthy(); yield* Effect.sleep(Duration.millis(20)); @@ -381,7 +395,7 @@ describe("ProviderConnectionLive", () => { expect(onSpawn).toHaveBeenCalledTimes(1); expect(onSpawn.mock.calls[0]?.[0]).toMatchObject({ command: "claude", - args: ["auth", "login", "--console"], + args: ["auth", "login"], }); expect(onListModels).toHaveBeenCalledWith({ provider: "claudeAgent", @@ -451,7 +465,7 @@ describe("ProviderConnectionLive", () => { expect(onSpawn).not.toHaveBeenCalled(); }); - it("rejects a duplicate operation for the same provider", async () => { + it("returns the existing operation for a duplicate start", async () => { const fixture = makeConnectionTestLayer({ hanging: true }); await Effect.runPromise( @@ -461,22 +475,72 @@ describe("ProviderConnectionLive", () => { provider: "claudeAgent", method: "claude_console", }); - const duplicate = yield* Effect.result( - connection.start({ - provider: "claudeAgent", - method: "claude_console", - }), - ); - expect(duplicate._tag).toBe("Failure"); - if (duplicate._tag === "Failure") { - expect(duplicate.failure.reason).toBe("already_running"); - } + 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("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..d935a4ca6 100644 --- a/apps/server/src/provider/Layers/ProviderConnection.ts +++ b/apps/server/src/provider/Layers/ProviderConnection.ts @@ -24,7 +24,6 @@ import { buildCodexProcessEnv } from "../../codexProcessEnv"; import { resolveBaseCodexHomePath } from "../../codexHomePaths"; import { ServerSettingsService } from "../../serverSettings"; import { collectUint8StreamText } from "../../stream/collectUint8StreamText"; -import { acquireClaudeAuthStatusLock } from "../claudeAuthStatusLock"; import { buildClaudeProcessEnv } from "../claudeProcessEnv"; import { buildCursorAgentCommand } from "../acp/CursorAcpCommand"; import { probeDroidAcpAuthentication } from "../acp/DroidAcpSupport"; @@ -48,7 +47,6 @@ interface ConnectionCommand { readonly args: ReadonlyArray; readonly env: NodeJS.ProcessEnv; readonly waitingMessage: string; - readonly lock?: "claude-auth"; readonly strategy?: "antigravity-pty"; } @@ -59,7 +57,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,6 +76,12 @@ 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"]; } @@ -171,13 +175,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({ @@ -331,8 +328,12 @@ 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; }); @@ -414,28 +415,15 @@ export function makeProviderConnectionLive(options?: { } }).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 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)); @@ -444,6 +432,12 @@ export function makeProviderConnectionLive(options?: { return yield* commandResult.failure; } const command = commandResult.success; + const refreshedBeforeStart = yield* providerHealth.refresh; + const currentStatus = refreshedBeforeStart.find((status) => status.provider === provider); + if (currentStatus?.available && currentStatus.authStatus === "authenticated") { + yield* releaseProvider(provider, ""); + return { providers: refreshedBeforeStart }; + } const operationId = randomUUID(); const startedAt = new Date().toISOString(); const state = (input: { @@ -469,7 +463,7 @@ export function makeProviderConnectionLive(options?: { provider, state({ status: "waiting_for_browser", message: command.waitingMessage }), ); - const connectionProcess = + const connectionProcess: Effect.Effect = provider === "droid" ? (options?.droidAuthenticationProbe ?? probeDroidAcpAuthentication)({ binaryPath: command.executable, @@ -478,7 +472,7 @@ export function makeProviderConnectionLive(options?: { }).pipe(Effect.as(0)) : command.strategy === "antigravity-pty" ? runAntigravityConnection(command) - : runWithOptionalLock(command, runCommand(command).pipe(Effect.scoped)); + : runCommand(command).pipe(Effect.scoped); const exitCodeResult = yield* connectionProcess.pipe( Effect.timeoutOption(timeout), Effect.result, @@ -592,7 +586,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,6 +593,9 @@ 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) => { @@ -624,6 +620,10 @@ 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 }; }, diff --git a/apps/server/src/provider/Layers/ProviderHealth.test.ts b/apps/server/src/provider/Layers/ProviderHealth.test.ts index ab373a04a..ee3bb0b76 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.test.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.test.ts @@ -1251,12 +1251,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 +1273,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 +1352,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 +1373,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 +1398,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 +1425,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 +1720,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", () => { diff --git a/apps/server/src/provider/Layers/ProviderHealth.ts b/apps/server/src/provider/Layers/ProviderHealth.ts index 8c4d24674..76b171e79 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.ts @@ -65,6 +65,10 @@ import { } from "../acp/DroidAcpSupport"; import { parseClaudeAuthStatusFromOutput } from "../claudeAuthStatus"; import { acquireClaudeAuthStatusLock } from "../claudeAuthStatusLock"; +import { + probeClaudeAccountCapabilities, + type ClaudeAccountCapabilities, +} from "../claudeCapabilities"; import { buildClaudeProcessEnv } from "../claudeProcessEnv"; import { detailFromResult, @@ -957,8 +961,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 +1010,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 +1079,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 +1107,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 +1133,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 +1159,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; @@ -2270,9 +2339,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: serverConfig.cwd, + }), + ), + executable, + serverConfig.homeDir, + ); + }), ), ), checkProviderWhenEnabled( diff --git a/apps/server/src/provider/Layers/ProviderRuntimeManager.test.ts b/apps/server/src/provider/Layers/ProviderRuntimeManager.test.ts index aa9157f79..08751e3b8 100644 --- a/apps/server/src/provider/Layers/ProviderRuntimeManager.test.ts +++ b/apps/server/src/provider/Layers/ProviderRuntimeManager.test.ts @@ -16,7 +16,7 @@ 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 +26,29 @@ 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("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..eb6585b7d 100644 --- a/apps/server/src/provider/Layers/ProviderRuntimeManager.ts +++ b/apps/server/src/provider/Layers/ProviderRuntimeManager.ts @@ -241,6 +241,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; @@ -860,7 +884,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 +937,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/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/wsRpc.ts b/apps/server/src/wsRpc.ts index 75778171e..52dd9bc9d 100644 --- a/apps/server/src/wsRpc.ts +++ b/apps/server/src/wsRpc.ts @@ -57,6 +57,7 @@ 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 { ProviderRuntimeManager } from "./provider/Services/ProviderRuntimeManager"; import { ProviderService } from "./provider/Services/ProviderService"; import { listProviderUsage } from "./providerUsage"; @@ -275,6 +276,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 +289,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 +479,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, @@ -1082,36 +1034,46 @@ 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.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) => @@ -1138,7 +1100,10 @@ export const makeWsRpcLayer = () => "This runtime is managed by Scient. Use the managed install, repair, or rollback controls instead.", }), ) - : providerHealth.updateProvider(input), + : providerHealth.updateProvider(input).pipe( + Effect.andThen(providerClientStatusProjection.getStatuses), + Effect.map((providers) => ({ providers })), + ), ), ), [WS_METHODS.serverListWorktrees]: () => Effect.succeed({ worktrees: [] }), @@ -1303,7 +1268,7 @@ export const makeWsRpcLayer = () => })), ), Stream.merge( - bufferLiveUiStream(providerHealth.streamChanges, { + bufferLiveUiStream(providerClientStatusProjection.streamChanges, { label: "server.provider-statuses", onDroppedEvents: failLiveUiStreamForSnapshotResync, }).pipe( @@ -1327,23 +1292,14 @@ export const makeWsRpcLayer = () => [WS_METHODS.subscribeServerProviderStatuses]: () => Stream.concat( 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( diff --git a/apps/web/src/components/ProviderConnectionDialog.browser.tsx b/apps/web/src/components/ProviderConnectionDialog.browser.tsx index b9c6077e7..4e4db807c 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,9 @@ function createQueryClient(provider: ServerProviderStatus) { } function installNativeApi(overrides: { + refreshProviders?: ReturnType; startProviderConnection?: ReturnType; + cancelProviderConnection?: ReturnType; prepareProviderInstall?: ReturnType; installProvider?: ReturnType; openExternal?: ReturnType; @@ -56,9 +68,13 @@ function installNativeApi(overrides: { ...baseApi, server: { ...baseApi.server, + ...(overrides.refreshProviders ? { refreshProviders: overrides.refreshProviders } : {}), ...(overrides.startProviderConnection ? { startProviderConnection: overrides.startProviderConnection } : {}), + ...(overrides.cancelProviderConnection + ? { cancelProviderConnection: overrides.cancelProviderConnection } + : {}), ...(overrides.prepareProviderInstall ? { prepareProviderInstall: overrides.prepareProviderInstall } : {}), @@ -93,6 +109,7 @@ describe("ProviderConnectionDialog", () => { available: true, authStatus: "unauthenticated", checkedAt, + runtime: systemRuntime, connectionState: { operationId: "connect-codex-1", method: "codex_browser", @@ -110,6 +127,7 @@ describe("ProviderConnectionDialog", () => { available: true, authStatus: "unauthenticated", checkedAt, + runtime: systemRuntime, }); useProviderConnectionDialogStore.getState().openDialog("codex", "settings"); @@ -144,9 +162,9 @@ describe("ProviderConnectionDialog", () => { it.each([ { provider: "claudeAgent", - method: "claude_console", + method: "claude_account", title: "Connect Claude", - primaryLabel: "Connect Anthropic Console", + primaryLabel: "Connect Claude", }, { provider: "antigravity", @@ -180,6 +198,7 @@ describe("ProviderConnectionDialog", () => { available: true, authStatus: "unauthenticated", checkedAt, + runtime: systemRuntime, connectionState: { operationId: `connect-${provider}-1`, method, @@ -197,6 +216,7 @@ describe("ProviderConnectionDialog", () => { available: true, authStatus: "unauthenticated", checkedAt, + runtime: systemRuntime, }); useProviderConnectionDialogStore.getState().openDialog(provider, "settings"); @@ -223,16 +243,175 @@ 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( + + + , + ); + + try { + 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(); + restoreNativeApi(); + } + }); + + it("offers Claude SSO and Console as explicit alternative methods", async () => { + const provider = { + provider: "claudeAgent", + status: "error", + available: true, + authStatus: "unauthenticated", + 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: "Finish organization sign-in.", + }, + } satisfies ServerProviderStatus; + 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( @@ -242,10 +421,76 @@ describe("ProviderConnectionDialog", () => { ); try { - await page.getByRole("button", { name: "Open installation guide" }).click(); + 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(openExternal).toHaveBeenCalledWith("https://code.claude.com/docs/en/installation"); + 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("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(); @@ -336,4 +581,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..2f3d6ba2b 100644 --- a/apps/web/src/components/ProviderConnectionDialog.tsx +++ b/apps/web/src/components/ProviderConnectionDialog.tsx @@ -2,20 +2,18 @@ // 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 type { ServerProviderConnectionMethod, ServerProviderInstallPlan } from "@synara/contracts"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useEffect, useState } from "react"; import { + CLAUDE_CONNECTION_METHOD_OPTIONS, describeProviderConnection, providerConnectionMethod, providerInstallUrl, } from "~/lib/providerConnectionPresentation"; -import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery"; +import { serverConfigQueryOptions } from "~/lib/serverReactQuery"; +import { applyProviderStatusesToCache } from "~/lib/providerStatusCache"; import { ensureNativeApi } from "~/nativeApi"; import { useProviderConnectionDialogStore } from "~/providerConnectionDialogStore"; import { PROVIDER_ICON_COMPONENT_BY_PROVIDER } from "./ProviderIcon"; @@ -31,14 +29,14 @@ import { } from "./ui/dialog"; 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; + +function formatRemainingTime(startedAt: string, nowMs: number): string { + const elapsedMs = Math.max(0, nowMs - Date.parse(startedAt)); + const remainingSeconds = Math.max(0, Math.ceil((CONNECTION_TIMEOUT_MS - elapsedMs) / 1_000)); + const minutes = Math.floor(remainingSeconds / 60); + const seconds = remainingSeconds % 60; + return `${minutes}:${seconds.toString().padStart(2, "0")}`; } export function ProviderConnectionDialog() { @@ -48,18 +46,53 @@ export function ProviderConnectionDialog() { const [actionPending, setActionPending] = useState(false); const [actionError, setActionError] = useState(null); const [installPlan, setInstallPlan] = useState(null); + const [clockMs, setClockMs] = useState(() => Date.now()); const status = provider ? configQuery.data?.providers.find((entry) => entry.provider === provider) : undefined; const presentation = provider ? describeProviderConnection(provider, status) : null; 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; 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]); if (!provider || !presentation || !Icon) return null; const startsProviderSignIn = @@ -80,16 +113,22 @@ 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 result = await ensureNativeApi().server.startProviderConnection({ provider, method }); + applyProviderStatusesToCache(queryClient, result.providers); + }; + + const startSignIn = (method?: ServerProviderConnectionMethod) => + runAction(() => performStartSignIn(method)); const cancelSignIn = () => { const operationId = status?.connectionState?.operationId; @@ -99,7 +138,20 @@ export function ProviderConnectionDialog() { provider, operationId, }); - updateProviderStatuses(queryClient, result.providers); + applyProviderStatusesToCache(queryClient, result.providers); + }); + }; + + const restartSignIn = () => { + const operation = status?.connectionState; + if (!operation) return Promise.resolve(); + return runAction(async () => { + const cancelled = await ensureNativeApi().server.cancelProviderConnection({ + provider, + operationId: operation.operationId, + }); + applyProviderStatusesToCache(queryClient, cancelled.providers); + await performStartSignIn(operation.method); }); }; @@ -115,7 +167,7 @@ export function ProviderConnectionDialog() { planToken: installPlan.planToken, }); setInstallPlan(null); - updateProviderStatuses(queryClient, result.providers); + applyProviderStatusesToCache(queryClient, result.providers); }); const cancelInstall = () => { @@ -126,7 +178,7 @@ export function ProviderConnectionDialog() { provider, operationId, }); - updateProviderStatuses(queryClient, result.providers); + applyProviderStatusesToCache(queryClient, result.providers); }); }; @@ -173,9 +225,43 @@ export function ProviderConnectionDialog() {

{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 ? ( +

+ Automatic timeout in {formatRemainingTime(activeConnection.startedAt, clockMs)} +

+ ) : null} +
+ ) : null} + + {provider === "claudeAgent" && presentation.primaryAction === "sign_in" ? ( +
+

Other sign-in methods

+ {CLAUDE_CONNECTION_METHOD_OPTIONS.slice(1).map((option) => ( + + ))}
) : null} @@ -208,6 +294,16 @@ export function ProviderConnectionDialog() { + {presentation.canRestart ? ( + + ) : null} {presentation.canCancel ? (
) : null} + {provider === "grok" && activeConnection?.authorizationUrl ? ( + + ) : null} + {provider === "claudeAgent" && presentation.primaryAction === "sign_in" ? (

Other sign-in methods

diff --git a/apps/web/src/lib/providerConnectionPresentation.test.ts b/apps/web/src/lib/providerConnectionPresentation.test.ts index df76eb90b..ac6139d43 100644 --- a/apps/web/src/lib/providerConnectionPresentation.test.ts +++ b/apps/web/src/lib/providerConnectionPresentation.test.ts @@ -142,4 +142,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/docs/plans/managed-provider-installation.md b/docs/plans/managed-provider-installation.md index 302e356d1..884c30f8e 100644 --- a/docs/plans/managed-provider-installation.md +++ b/docs/plans/managed-provider-installation.md @@ -143,8 +143,9 @@ The installation service must: - Source: official xAI native stable artifact. - Verification now: pinned macOS arm64 URL, byte size, and reviewed SHA-256; Scient does not execute the vendor installer script. - Smoke test: `grok --version`. -- Authentication now: `grok login`, which opens provider-owned browser sign-in. -- Device-code/browser-launch fallback is not yet exposed by Scient and remains a packaged release follow-up. +- Authentication now: `grok login --oauth`, which opens xAI's direct browser authorization flow + and avoids the terminal-only device-code prompt used by plain `grok login`. +- If automatic browser launch fails, Scient exposes only the validated transient xAI OAuth URL so the user can reopen the same sign-in without a terminal. Device-code fallback remains a separate release follow-up because it requires a code handoff. - Auth verification: `grok models` must return an explicit positive account/key marker and a usable model catalog; signed-out output is never mistaken for success. - Managed and ACP invocations suppress Grok's native auto-update path. - Other operating systems and architectures remain safely unsupported until their artifacts and checksums are reviewed. @@ -198,7 +199,7 @@ The installation service must: ## Missing optional capabilities - Missing Git does not block installation, authentication, ordinary projects, or conversations. Git-dependent agent features are described as optional capabilities. -- Missing browser support must eventually fall back to device-code login where the provider offers it, with copy-link and copy-code actions. That fallback is not part of the current implementation. +- Missing browser support can use a validated reopen-link action for Grok's direct OAuth flow. Providers that require device-code login still need dedicated copy-link and copy-code actions. - Missing `xdg-open`, PowerShell scripts, shell profile, curl, tar, unzip, or checksum utilities is handled inside Scient. - Offline, proxy, TLS, disk-space, permission, and unsupported-target failures preserve the active runtime and provide actionable recovery. @@ -261,7 +262,7 @@ For every released OS/architecture combination, use a clean VM or clean account - [ ] Prove Codex, Claude Console, Antigravity, and Cursor login with fresh provider accounts in a packaged app. - [ ] Keep Claude.ai subscription login unavailable unless Anthropic gives written authorization; the implemented default is the permitted Console/API route. - [ ] Prove Grok and Droid with fresh provider accounts in a packaged app, including browser-launch failure and restart continuity. -- [ ] Add and package-test device-code/copy-link fallback for Codex and Grok where supported. +- [ ] Package-test Grok's validated OAuth reopen-link fallback and add device-code/copy-link fallback for providers that still require code handoff. - [ ] Add the signed catalog and reviewed release-monitoring pipeline. - [ ] Run the clean-machine matrix for every OS/architecture Scient intends to claim. - [ ] Record platform signing/notarization evidence and confirm managed-runtime updater behavior for each provider. diff --git a/packages/contracts/src/server.test.ts b/packages/contracts/src/server.test.ts index f6f65da90..ea1c72070 100644 --- a/packages/contracts/src/server.test.ts +++ b/packages/contracts/src/server.test.ts @@ -68,15 +68,18 @@ describe("provider connection contracts", () => { checkedAt: "2026-07-19T10:00:00.000Z", connectionState: { operationId: "operation-1", - method: "codex_browser", + method: "grok_browser", status: "waiting_for_browser", startedAt: "2026-07-19T10:00:00.000Z", finishedAt: null, message: "Finish signing in in the browser.", + authorizationUrl: + "https://auth.x.ai/oauth2/authorize?response_type=code&state=transient-test-state", }, }); expect(decoded.connectionState?.status).toBe("waiting_for_browser"); + expect(decoded.connectionState?.authorizationUrl).toContain("https://auth.x.ai/"); expect(Object.keys(decoded.connectionState ?? {})).not.toContain("token"); expect(Object.keys(decoded.connectionState ?? {})).not.toContain("output"); }); diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 01069db13..caa1e3423 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -121,6 +121,7 @@ export const ServerProviderConnectionState = Schema.Struct({ startedAt: IsoDateTime, finishedAt: Schema.NullOr(IsoDateTime), message: TrimmedNonEmptyString, + authorizationUrl: Schema.optionalKey(TrimmedNonEmptyString.check(Schema.isMaxLength(8_192))), }); export type ServerProviderConnectionState = typeof ServerProviderConnectionState.Type; From 80580281d13a6a7d45ee58cf655a99a53e4ea2f6 Mon Sep 17 00:00:00 2001 From: yaacovcorcos Date: Tue, 21 Jul 2026 10:03:32 +0300 Subject: [PATCH 06/68] Secure Scient state initialization (#48) * Secure Scient state initialization * Allow legacy migration state test * fix(security): harden private state files * test(security): lock private file boundaries * fix(security): reject unsafe private file nodes --- .github/workflows/ci.yml | 5 + .../src/desktopScientDataDirectories.test.ts | 78 ++++++++ .../src/desktopScientDataDirectories.ts | 31 ++++ apps/desktop/src/main.ts | 9 +- apps/desktop/src/rotatingFileSink.test.ts | 16 ++ apps/server/src/config.ts | 16 +- apps/server/src/main.test.ts | 73 ++++++++ apps/server/src/main.ts | 9 + .../src/persistence/Layers/Sqlite.test.ts | 36 ++++ apps/server/src/persistence/Layers/Sqlite.ts | 26 +++ apps/server/src/privatePathPermissions.ts | 137 +++----------- apps/server/src/serverLogger.ts | 7 +- .../src/serverPrivateDirectories.test.ts | 168 ++++++++++++++++++ packages/shared/package.json | 8 + packages/shared/src/logging.ts | 8 + .../shared/src/privatePathPermissions.test.ts | 78 ++++++++ packages/shared/src/privatePathPermissions.ts | 124 +++++++++++++ packages/shared/src/scientDataDirectories.ts | 46 +++++ scripts/check-brand-identity.ts | 1 + 19 files changed, 758 insertions(+), 118 deletions(-) create mode 100644 apps/desktop/src/desktopScientDataDirectories.test.ts create mode 100644 apps/desktop/src/desktopScientDataDirectories.ts create mode 100644 apps/server/src/serverPrivateDirectories.test.ts create mode 100644 packages/shared/src/privatePathPermissions.test.ts create mode 100644 packages/shared/src/privatePathPermissions.ts create mode 100644 packages/shared/src/scientDataDirectories.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c56fbf319..f86f6704e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -135,6 +135,11 @@ 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: Exercise Windows release staging run: bun run release:smoke 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/main.ts b/apps/desktop/src/main.ts index 774a99d76..b30b47d96 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -155,6 +155,7 @@ import { repairBrowserProfileFromBridgeManifest, seedDesktopUserDataProfileFromPapiLab, } from "./desktopUserDataProfile"; +import { ensurePrivateDesktopScientDataDirectoriesSync } from "./desktopScientDataDirectories"; import { seedScientHomeFromPapiLab } from "./legacyPapiLabHomeMigration"; import { isBrokenPipeError } from "./desktopProcessErrors"; import { @@ -229,7 +230,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 +245,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"); 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/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/main.test.ts b/apps/server/src/main.test.ts index 47c10f3e3..24548a698 100644 --- a/apps/server/src/main.test.ts +++ b/apps/server/src/main.test.ts @@ -16,6 +16,7 @@ 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"; @@ -143,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); }), ); @@ -240,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 fdc8f59eb..d0e7684f6 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -10,6 +10,7 @@ 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 { DEFAULT_PORT, deriveServerPaths, @@ -175,6 +176,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( 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/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/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/packages/shared/package.json b/packages/shared/package.json index 8c59343b8..4758a1849 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -24,6 +24,14 @@ "types": "./src/logging.ts", "import": "./src/logging.ts" }, + "./privatePathPermissions": { + "types": "./src/privatePathPermissions.ts", + "import": "./src/privatePathPermissions.ts" + }, + "./scientDataDirectories": { + "types": "./src/scientDataDirectories.ts", + "import": "./src/scientDataDirectories.ts" + }, "./errorMessages": { "types": "./src/errorMessages.ts", "import": "./src/errorMessages.ts" diff --git a/packages/shared/src/logging.ts b/packages/shared/src/logging.ts index 98fee9464..8ce66424b 100644 --- a/packages/shared/src/logging.ts +++ b/packages/shared/src/logging.ts @@ -1,6 +1,8 @@ import fs from "node:fs"; import path from "node:path"; +import { ensurePrivateFileSync, repairPrivateFileSync } from "./privatePathPermissions"; + export interface RotatingFileSinkOptions { readonly filePath: string; readonly maxBytes: number; @@ -29,6 +31,11 @@ export class RotatingFileSink { this.throwOnError = options.throwOnError ?? false; fs.mkdirSync(path.dirname(this.filePath), { recursive: true }); + ensurePrivateFileSync(this.filePath); + for (let index = 1; index <= this.maxFiles; index += 1) { + const backupPath = this.withSuffix(index); + if (fs.existsSync(backupPath)) repairPrivateFileSync(backupPath); + } this.pruneOverflowBackups(); this.currentSize = this.readCurrentSize(); } @@ -42,6 +49,7 @@ export class RotatingFileSink { this.rotate(); } + ensurePrivateFileSync(this.filePath); fs.appendFileSync(this.filePath, buffer); this.currentSize += buffer.length; diff --git a/packages/shared/src/privatePathPermissions.test.ts b/packages/shared/src/privatePathPermissions.test.ts new file mode 100644 index 000000000..211e5e43c --- /dev/null +++ b/packages/shared/src/privatePathPermissions.test.ts @@ -0,0 +1,78 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { + ensurePrivateFileSync, + PrivatePathPermissionError, + repairPrivateFileSync, +} from "./privatePathPermissions"; + +const temporaryRoots: string[] = []; + +function makeTemporaryRoot(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "scient-private-file-test-")); + temporaryRoots.push(root); + return root; +} + +afterEach(() => { + for (const root of temporaryRoots.splice(0)) { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +describe("ensurePrivateFileSync", () => { + it("creates an owner-only file even under a permissive umask", () => { + if (process.platform === "win32") return; + const filePath = path.join(makeTemporaryRoot(), "private.log"); + const previousUmask = process.umask(0o000); + try { + ensurePrivateFileSync(filePath); + } finally { + process.umask(previousUmask); + } + + expect(fs.statSync(filePath).mode & 0o777).toBe(0o600); + }); + + it("repairs an existing regular file without changing its contents", () => { + if (process.platform === "win32") return; + const filePath = path.join(makeTemporaryRoot(), "state.sqlite"); + fs.writeFileSync(filePath, "existing data", { mode: 0o664 }); + + ensurePrivateFileSync(filePath); + + expect(fs.readFileSync(filePath, "utf8")).toBe("existing data"); + expect(fs.statSync(filePath).mode & 0o777).toBe(0o600); + }); + + it("refuses a symlink without repairing or changing its target", () => { + if (process.platform === "win32") return; + const root = makeTemporaryRoot(); + const targetPath = path.join(root, "outside.log"); + const linkedPath = path.join(root, "server.log"); + fs.writeFileSync(targetPath, "outside", { mode: 0o664 }); + fs.chmodSync(targetPath, 0o664); + fs.symlinkSync(targetPath, linkedPath, "file"); + + expect(() => ensurePrivateFileSync(linkedPath)).toThrow(PrivatePathPermissionError); + expect(fs.readFileSync(targetPath, "utf8")).toBe("outside"); + expect(fs.statSync(targetPath).mode & 0o777).toBe(0o664); + }); + + it("rejects FIFOs instead of blocking while opening them", () => { + if (process.platform === "win32") return; + const root = makeTemporaryRoot(); + const ensurePath = path.join(root, "ensure.fifo"); + const repairPath = path.join(root, "repair.fifo"); + execFileSync("mkfifo", [ensurePath]); + execFileSync("mkfifo", [repairPath]); + + expect(() => ensurePrivateFileSync(ensurePath)).toThrow(PrivatePathPermissionError); + expect(() => repairPrivateFileSync(repairPath)).toThrow(PrivatePathPermissionError); + }); +}); diff --git a/packages/shared/src/privatePathPermissions.ts b/packages/shared/src/privatePathPermissions.ts new file mode 100644 index 000000000..57e864ca2 --- /dev/null +++ b/packages/shared/src/privatePathPermissions.ts @@ -0,0 +1,124 @@ +import fs from "node:fs"; + +export const PRIVATE_DIRECTORY_MODE = 0o700; +export const PRIVATE_FILE_MODE = 0o600; +export const PRIVATE_EXECUTABLE_FILE_MODE = 0o700; + +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; + } +} + +export function withPrivatePathContext( + operation: string, + targetPath: string, + action: () => T, +): T { + try { + return action(); + } catch (cause) { + if (cause instanceof PrivatePathPermissionError) throw cause; + throw new PrivatePathPermissionError(operation, targetPath, cause); + } +} + +export function supportsPosixPermissions(platform: NodeJS.Platform = process.platform): boolean { + return platform !== "win32"; +} + +export function ensurePrivateDirectorySync( + directoryPath: string, + platform: NodeJS.Platform = process.platform, +): void { + withPrivatePathContext("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 = withPrivatePathContext("open without following symlinks", directoryPath, () => + fs.openSync(directoryPath, directoryFlags), + ); + try { + withPrivatePathContext("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 = withPrivatePathContext("open without following symlinks", filePath, () => + fs.openSync( + filePath, + fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK, + ), + ); + try { + withPrivatePathContext("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)) { + const descriptor = withPrivatePathContext("create", filePath, () => + fs.openSync(filePath, fs.constants.O_WRONLY | fs.constants.O_CREAT, PRIVATE_FILE_MODE), + ); + 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 | + fs.constants.O_NONBLOCK; + const descriptor = withPrivatePathContext("create without following symlinks", filePath, () => + fs.openSync(filePath, flags, targetMode), + ); + try { + withPrivatePathContext("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); + } +} diff --git a/packages/shared/src/scientDataDirectories.ts b/packages/shared/src/scientDataDirectories.ts new file mode 100644 index 000000000..6009b6441 --- /dev/null +++ b/packages/shared/src/scientDataDirectories.ts @@ -0,0 +1,46 @@ +import { ensurePrivateDirectorySync } from "./privatePathPermissions"; + +export interface ScientDataDirectoryPaths { + readonly baseDir: string; + readonly stateDir: string; + readonly secretsDir: string; + readonly worktreesDir: string; + readonly attachmentsDir: string; + readonly logsDir: string; + readonly providerLogsDir: string; + readonly terminalLogsDir: string; +} + +type ScientStateDirectoryPaths = Omit; + +/** Secures children of an application-data home without modifying the home itself. */ +export function ensurePrivateScientStateDirectoriesSync( + paths: ScientStateDirectoryPaths, + platform: NodeJS.Platform = process.platform, +): void { + const privateDirectories = [ + paths.stateDir, + paths.secretsDir, + paths.attachmentsDir, + paths.logsDir, + paths.providerLogsDir, + paths.terminalLogsDir, + ]; + + for (const directoryPath of new Set(privateDirectories)) { + ensurePrivateDirectorySync(directoryPath, platform); + } +} + +/** + * Creates or repairs every security-boundary directory derived from + * SCIENT_HOME. User-selected project/workspace paths are deliberately absent. + */ +export function ensurePrivateScientDirectoriesSync( + paths: ScientDataDirectoryPaths, + platform: NodeJS.Platform = process.platform, +): void { + ensurePrivateDirectorySync(paths.baseDir, platform); + ensurePrivateScientStateDirectoriesSync(paths, platform); + ensurePrivateDirectorySync(paths.worktreesDir, platform); +} diff --git a/scripts/check-brand-identity.ts b/scripts/check-brand-identity.ts index 282bccf8d..bbefc9603 100644 --- a/scripts/check-brand-identity.ts +++ b/scripts/check-brand-identity.ts @@ -300,6 +300,7 @@ function containsForbiddenIdentity(value: string): boolean { const legacyPapiLabCompatibilityPaths = new Set([ "apps/desktop/src/desktopStorageMigration.ts", "apps/desktop/src/desktopStorageMigration.test.ts", + "apps/desktop/src/desktopScientDataDirectories.test.ts", "apps/desktop/src/desktopUserDataProfile.ts", "apps/desktop/src/desktopUserDataProfile.test.ts", "apps/desktop/src/legacyPapiLabHomeMigration.ts", From a577f50a317f8375a565801ce6e243053be19768 Mon Sep 17 00:00:00 2001 From: yaacovcorcos Date: Tue, 21 Jul 2026 10:18:38 +0300 Subject: [PATCH 07/68] Supervise the desktop backend lifecycle (#49) * Supervise the desktop backend lifecycle * fix(desktop): preserve backend lifecycle ownership * fix(desktop): replace semantically unready backends * fix(desktop): close backend lifecycle races --- .github/workflows/ci.yml | 5 + apps/desktop/src/backendProcessTree.test.ts | 89 ++++ apps/desktop/src/backendProcessTree.ts | 73 ++++ .../src/backendStartupReadiness.test.ts | 50 ++- apps/desktop/src/backendStartupReadiness.ts | 25 +- .../src/desktopBackendSupervisor.test.ts | 349 ++++++++++++++++ apps/desktop/src/desktopBackendSupervisor.ts | 355 ++++++++++++++++ apps/desktop/src/main.ts | 389 +++++++++--------- apps/server/src/desktopParentShutdown.test.ts | 68 +++ apps/server/src/desktopParentShutdown.ts | 42 ++ apps/server/src/main.ts | 5 +- packages/shared/package.json | 4 + packages/shared/src/backendControl.ts | 26 ++ 13 files changed, 1273 insertions(+), 207 deletions(-) create mode 100644 apps/desktop/src/backendProcessTree.test.ts create mode 100644 apps/desktop/src/backendProcessTree.ts create mode 100644 apps/desktop/src/desktopBackendSupervisor.test.ts create mode 100644 apps/desktop/src/desktopBackendSupervisor.ts create mode 100644 apps/server/src/desktopParentShutdown.test.ts create mode 100644 apps/server/src/desktopParentShutdown.ts create mode 100644 packages/shared/src/backendControl.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f86f6704e..d79dd31d2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -140,6 +140,11 @@ jobs: 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/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..93122d547 --- /dev/null +++ b/apps/desktop/src/desktopBackendSupervisor.test.ts @@ -0,0 +1,349 @@ +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 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/main.ts b/apps/desktop/src/main.ts index b30b47d96..3594601b1 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -43,6 +43,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 +58,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, @@ -280,7 +290,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 = ""; @@ -288,8 +298,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; @@ -529,7 +537,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, { @@ -549,8 +558,19 @@ 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 ensureInitialBackendWindowOpen(baseUrl: string): void { @@ -1142,7 +1162,7 @@ function handleFatalStartupError(stage: string, error: unknown): void { `Stage: ${stage}\n${message}${detail}`, ); } - stopBackend(); + stopBackend(`fatal startup: ${stage}`); restoreStdIoCapture?.(); app.quit(); } @@ -2500,7 +2520,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(); @@ -2739,45 +2759,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; +class MissingBackendEntryError extends Error { + constructor(readonly entryPath: string) { + super(`Missing packaged server entry at ${entryPath}`); + this.name = "MissingBackendEntryError"; } - - cancelBackendReadinessWait(); - try { - await reserveBackendEndpoint("backend restart"); - } catch (error) { - scheduleBackendRestart( - `failed to reserve restart port after ${reason}: ${formatErrorMessage(error)}`, - ); - return; - } - - 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; @@ -2789,11 +2788,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; @@ -2805,120 +2805,142 @@ 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); + 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); + 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); + }); } } -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; @@ -2940,26 +2962,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 { @@ -2969,13 +2995,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}`, + ); }); } @@ -3453,6 +3483,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, { @@ -3544,39 +3587,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) => { 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.ts b/apps/server/src/main.ts index d0e7684f6..02e459937 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -11,6 +11,7 @@ import { Config, Data, Effect, FileSystem, Layer, Option, Path, Schema, ServiceM 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, @@ -378,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/packages/shared/package.json b/packages/shared/package.json index 4758a1849..400001c61 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -84,6 +84,10 @@ "types": "./src/browserSession.ts", "import": "./src/browserSession.ts" }, + "./backendControl": { + "types": "./src/backendControl.ts", + "import": "./src/backendControl.ts" + }, "./conversationEdit": { "types": "./src/conversationEdit.ts", "import": "./src/conversationEdit.ts" diff --git a/packages/shared/src/backendControl.ts b/packages/shared/src/backendControl.ts new file mode 100644 index 000000000..fd10ae5e6 --- /dev/null +++ b/packages/shared/src/backendControl.ts @@ -0,0 +1,26 @@ +export const SCIENT_BACKEND_SHUTDOWN_MESSAGE_TYPE = "scient.backend.shutdown"; + +export interface ScientBackendShutdownMessage { + readonly type: typeof SCIENT_BACKEND_SHUTDOWN_MESSAGE_TYPE; + readonly reason: string; +} + +export function makeScientBackendShutdownMessage(reason: string): ScientBackendShutdownMessage { + return { + type: SCIENT_BACKEND_SHUTDOWN_MESSAGE_TYPE, + reason, + }; +} + +export function isScientBackendShutdownMessage( + message: unknown, +): message is ScientBackendShutdownMessage { + return ( + typeof message === "object" && + message !== null && + "type" in message && + message.type === SCIENT_BACKEND_SHUTDOWN_MESSAGE_TYPE && + "reason" in message && + typeof message.reason === "string" + ); +} From e1a4d4d57585f5981c5d7f3ebaaf66dc52999e88 Mon Sep 17 00:00:00 2001 From: yaacovcorcos Date: Tue, 21 Jul 2026 10:39:57 +0300 Subject: [PATCH 08/68] Supervise desktop connection recovery (#50) * Supervise desktop connection recovery * Isolate provider dialog browser fixtures * fix(desktop): bind activation readiness generation * fix(web): enforce single-owner connection recovery * style(web): format connection recovery * fix(web): satisfy connection transport types * fix(web): harden stream and terminal recovery * fix(web): fail closed on stuck stream cancellation * fix(web): preserve terminal recovery ordering * fix(web): clamp reconnect jitter ceilings * fix(desktop): preserve connection wake after restack --- .../src/desktopBackendSupervisor.test.ts | 40 + apps/desktop/src/desktopConnectionWake.ts | 6 + .../src/initialBackendWindowOpen.test.ts | 17 + apps/desktop/src/initialBackendWindowOpen.ts | 2 + apps/desktop/src/main.ts | 39 +- apps/desktop/src/preload.ts | 11 + .../src/terminal/Layers/Manager.test.ts | 29 + apps/server/src/terminal/Layers/Manager.ts | 11 + apps/server/src/terminal/Services/Manager.ts | 4 + apps/server/src/wsRpc.ts | 12 +- apps/web/src/components/ChatView.browser.tsx | 13 +- .../src/components/EventRouter.browser.tsx | 122 +++ .../components/KeybindingsToast.browser.tsx | 11 +- .../ProviderConnectionDialog.browser.tsx | 80 +- .../components/terminal/terminalRuntime.ts | 218 +++-- .../terminal/terminalRuntimeTypes.test.ts | 103 ++- .../terminal/terminalRuntimeTypes.ts | 92 ++- apps/web/src/connectionSupervisor.test.ts | 328 ++++++++ apps/web/src/connectionSupervisor.ts | 422 ++++++++++ apps/web/src/projectTerminalRunner.test.ts | 2 + apps/web/src/routes/__root.tsx | 3 + apps/web/src/terminalActivity.test.ts | 4 + apps/web/src/test/effectRpcWebSocketMock.ts | 17 +- apps/web/src/wsNativeApi.test.ts | 2 + apps/web/src/wsTransport.test.ts | 232 +++++- apps/web/src/wsTransport.ts | 759 ++++++++++++++---- apps/web/src/wsTransportEvents.ts | 2 +- packages/contracts/src/ipc.ts | 4 + packages/contracts/src/terminal.test.ts | 6 + packages/contracts/src/terminal.ts | 8 + 30 files changed, 2307 insertions(+), 292 deletions(-) create mode 100644 apps/desktop/src/desktopConnectionWake.ts create mode 100644 apps/web/src/connectionSupervisor.test.ts create mode 100644 apps/web/src/connectionSupervisor.ts diff --git a/apps/desktop/src/desktopBackendSupervisor.test.ts b/apps/desktop/src/desktopBackendSupervisor.test.ts index 93122d547..9a14ed07d 100644 --- a/apps/desktop/src/desktopBackendSupervisor.test.ts +++ b/apps/desktop/src/desktopBackendSupervisor.test.ts @@ -313,6 +313,46 @@ describe("DesktopBackendSupervisor", () => { 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(); 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/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 3594601b1..d5d954a2b 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, @@ -76,6 +78,7 @@ import { } from "./bundleSwapDetection"; import { waitForBackendStartupReady } from "./backendStartupReadiness"; import { showDesktopConfirmDialog } from "./confirmDialog"; +import { DESKTOP_CONNECTION_WAKE_CHANNEL } from "./desktopConnectionWake"; import { LSREGISTER_PATH, parseLastLaunchVersion, @@ -467,6 +470,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; @@ -573,7 +583,14 @@ async function waitForBackendWindowReady(baseUrl: string): Promise<"listening" | return source; } -function ensureInitialBackendWindowOpen(baseUrl: string): void { +function restartBackendAfterReadinessFailure(generation: number): void { + writeDesktopLogHeader( + `backend generation=${generation} readiness failed; scheduling replacement`, + ); + void backendSupervisor?.restartGeneration(generation, "readiness check failed"); +} + +function ensureInitialBackendWindowOpen(baseUrl: string, generation?: number): void { openInitialBackendWindow({ isDevelopment, baseUrl, @@ -592,6 +609,11 @@ function ensureInitialBackendWindowOpen(baseUrl: string): void { warn: (message, error) => { console.warn(message, error); }, + ...(generation === undefined + ? {} + : { + onReadinessFailure: () => restartBackendAfterReadinessFailure(generation), + }), }); } @@ -2828,6 +2850,7 @@ function handleBackendGenerationStarted(generation: DesktopBackendGeneration): v `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"); @@ -2837,7 +2860,7 @@ function handleBackendGenerationStarted(generation: DesktopBackendGeneration): v } const hadWindow = (mainWindow ?? BrowserWindow.getAllWindows()[0] ?? null) !== null; - ensureInitialBackendWindowOpen(backendHttpUrl); + ensureInitialBackendWindowOpen(backendHttpUrl, generation.number); if (hadWindow && backendInitialWindowOpenInFlight === null) { void waitForBackendWindowReady(backendHttpUrl) .then((source) => { @@ -2846,6 +2869,7 @@ function handleBackendGenerationStarted(generation: DesktopBackendGeneration): v .catch((error) => { if (isBackendReadinessAborted(error)) return; console.warn("[desktop] restarted backend readiness check timed out", error); + restartBackendAfterReadinessFailure(generation.number); }); } } @@ -3657,13 +3681,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..eb9a5e55d 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -9,6 +9,7 @@ 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"; const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; const SAVE_FILE_CHANNEL = "desktop:save-file"; @@ -45,6 +46,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 { 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 52dd9bc9d..c3d908bb3 100644 --- a/apps/server/src/wsRpc.ts +++ b/apps/server/src/wsRpc.ts @@ -720,7 +720,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( @@ -1224,7 +1224,7 @@ export const makeWsRpcLayer = () => "Failed to update keybinding", ), [WS_METHODS.subscribeServerLifecycle]: () => - Stream.concat( + bufferLiveWhileInitialStreamLoads( Stream.fromEffect( lifecycleEvents.snapshot.pipe( Effect.map((snapshot) => @@ -1249,7 +1249,7 @@ export const makeWsRpcLayer = () => ), ), [WS_METHODS.subscribeServerConfig]: () => - Stream.concat( + bufferLiveWhileInitialStreamLoads( Stream.fromEffect( loadServerConfig.pipe( Effect.map( @@ -1290,7 +1290,7 @@ export const makeWsRpcLayer = () => ), ).pipe(Stream.mapError((cause) => toWsRpcError(cause, "Server config stream failed"))), [WS_METHODS.subscribeServerProviderStatuses]: () => - Stream.concat( + bufferLiveWhileInitialStreamLoads( Stream.fromEffect( providerClientStatusProjection.getStatuses.pipe( Effect.map((providers) => ({ providers })), @@ -1302,7 +1302,7 @@ export const makeWsRpcLayer = () => }).pipe(Stream.map((providers) => ({ providers }))), ), [WS_METHODS.subscribeServerSettings]: () => - Stream.concat( + bufferLiveWhileInitialStreamLoads( Stream.fromEffect( serverSettings.getSettings.pipe(Effect.map((settings) => ({ settings }))), ), @@ -1370,7 +1370,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/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/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 5e6635ba3..fadff29ce 100644 --- a/apps/web/src/components/ProviderConnectionDialog.browser.tsx +++ b/apps/web/src/components/ProviderConnectionDialog.browser.tsx @@ -103,6 +103,14 @@ describe("ProviderConnectionDialog", () => { }); 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", @@ -120,15 +128,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, - runtime: systemRuntime, + 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( @@ -192,6 +197,14 @@ 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", @@ -209,15 +222,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, - runtime: systemRuntime, + 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( @@ -540,6 +550,22 @@ describe("ProviderConnectionDialog", () => { }); it("requires reviewed consent before starting a managed installation", 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", @@ -577,23 +603,13 @@ describe("ProviderConnectionDialog", () => { 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: [initialProvider] }); + const restoreNativeApi = installNativeApi({ + refreshProviders, + prepareProviderInstall, + installProvider, }); + const queryClient = createQueryClient(initialProvider); useProviderConnectionDialogStore.getState().openDialog("antigravity", "settings"); const screen = await render( 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/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/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/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 94b41cb2c..65d398228 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -1245,6 +1245,9 @@ function EventRouter() { const unsubShellEvent = api.orchestration.onShellEvent((item) => { if (item.kind === "snapshot") { + if (item.snapshot.snapshotSequence < shellSnapshotSequence) { + return; + } shellSnapshotSequence = item.snapshot.snapshotSequence; syncServerShellSnapshot(item.snapshot); reconcilePromotedDraftsFromShellThreads(item.snapshot.threads); diff --git a/apps/web/src/terminalActivity.test.ts b/apps/web/src/terminalActivity.test.ts index eefcf9a3e..a51795959 100644 --- a/apps/web/src/terminalActivity.test.ts +++ b/apps/web/src/terminalActivity.test.ts @@ -10,6 +10,8 @@ const snapshot: TerminalSessionSnapshot = { status: "running", pid: 1234, history: "", + outputEpoch: "epoch-1", + outputSequence: 0, exitCode: null, exitSignal: null, updatedAt: "2026-01-01T00:00:00.000Z", @@ -71,6 +73,8 @@ describe("terminalActivityFromEvent", () => { ...eventBase(), type: "output", data: "hello", + outputEpoch: "epoch-1", + outputSequence: 1, }), ).toBeNull(); expect( diff --git a/apps/web/src/test/effectRpcWebSocketMock.ts b/apps/web/src/test/effectRpcWebSocketMock.ts index 7c9376df5..360a22156 100644 --- a/apps/web/src/test/effectRpcWebSocketMock.ts +++ b/apps/web/src/test/effectRpcWebSocketMock.ts @@ -3,7 +3,12 @@ // Layer: Web test utility // Exports: helpers for request parsing plus Exit/Chunk/Pong responses. -import type { OrchestrationReadModel, OrchestrationShellSnapshot } from "@synara/contracts"; +import { + EnvironmentId, + type ExecutionEnvironmentDescriptor, + type OrchestrationReadModel, + type OrchestrationShellSnapshot, +} from "@synara/contracts"; export interface EffectRpcWebSocketClient { readonly send: (data: string) => void; @@ -157,3 +162,13 @@ export function createShellSnapshotFromReadModel( updatedAt: snapshot.updatedAt, }; } + +export function createTestEnvironmentDescriptor(): ExecutionEnvironmentDescriptor { + return { + environmentId: EnvironmentId.makeUnsafe("test-environment"), + label: "Browser test", + platform: { os: "linux", arch: "x64" }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }; +} diff --git a/apps/web/src/wsNativeApi.test.ts b/apps/web/src/wsNativeApi.test.ts index ae5a57105..40ace20f6 100644 --- a/apps/web/src/wsNativeApi.test.ts +++ b/apps/web/src/wsNativeApi.test.ts @@ -348,6 +348,8 @@ describe("wsNativeApi", () => { createdAt: "2026-02-24T00:00:00.000Z", type: "output", data: "hello", + outputEpoch: "epoch-1", + outputSequence: 1, } as const; emitPush(WS_CHANNELS.terminalEvent, terminalEvent); diff --git a/apps/web/src/wsTransport.test.ts b/apps/web/src/wsTransport.test.ts index e60db61f4..7c0cdc3a1 100644 --- a/apps/web/src/wsTransport.test.ts +++ b/apps/web/src/wsTransport.test.ts @@ -5,8 +5,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { WS_CHANNELS } from "@synara/contracts"; +import { Exit, Stream } from "effect"; -import { shouldKeepServerLifecycleStream, WsTransport } from "./wsTransport"; +import { + EFFECT_RPC_RETRY_CONFIG, + isConnectionProtocolFailure, + isConnectionTransportFailure, + shouldKeepServerLifecycleStream, + streamRestartDelayMs, + WsTransport, +} from "./wsTransport"; type WsEventType = "open" | "message" | "close" | "error"; type WsListener = (event?: { data?: unknown }) => void; @@ -57,6 +65,56 @@ class MockWebSocket { const originalWebSocket = globalThis.WebSocket; +function makeStreamHarness() { + const transport = new WsTransport("ws://localhost:3020"); + transport.dispose(); + const exits: Array<(exit: Exit.Exit) => void> = []; + const cancels: Array> = []; + const runtime = { + runCallback: vi.fn( + ( + _effect: unknown, + options: { readonly onExit: (exit: Exit.Exit) => void }, + ) => { + exits.push(options.onExit); + const cancel = vi.fn(); + cancels.push(cancel); + return cancel; + }, + ), + }; + const session = { + generation: 1, + value: { client: {}, clientScope: {}, runtime }, + }; + const supervisor = { + currentSession: session, + dispose: vi.fn(), + invalidate: vi.fn(), + }; + const internals = transport as unknown as { + disposed: boolean; + supervisor: typeof supervisor; + startStream: ( + activeSession: typeof session, + key: string, + streamFactory: () => unknown, + listener: (event: unknown) => void, + options: { readonly isDesired: () => boolean; readonly replace?: boolean }, + ) => void; + stopStream: (key: string) => void; + }; + internals.disposed = false; + internals.supervisor = supervisor; + const start = (replace = true) => + internals.startStream(session, "test.stream", () => Stream.never, vi.fn(), { + isDesired: () => true, + replace, + }); + const stop = () => internals.stopStream("test.stream"); + return { cancels, exits, runtime, start, stop, supervisor, transport }; +} + beforeEach(() => { sockets.length = 0; vi.stubEnv("VITE_WS_URL", ""); @@ -73,12 +131,184 @@ beforeEach(() => { }); afterEach(() => { + vi.useRealTimers(); globalThis.WebSocket = originalWebSocket; vi.unstubAllEnvs(); vi.restoreAllMocks(); }); describe("WsTransport", () => { + it("leaves all Effect RPC reconnects to ConnectionSupervisor", () => { + expect(EFFECT_RPC_RETRY_CONFIG).toEqual({ retryTransientErrors: false, retryCount: 0 }); + }); + + it("distinguishes transport failures from stream-local domain failures", () => { + expect(isConnectionTransportFailure({ _tag: "SocketCloseError", code: 1006 })).toBe(true); + expect( + isConnectionTransportFailure({ + _tag: "RpcClientError", + reason: { _tag: "SocketOpenError", kind: "Timeout" }, + }), + ).toBe(true); + expect(isConnectionTransportFailure(new Error("SocketCloseError in domain text"))).toBe(false); + expect( + isConnectionTransportFailure({ + _tag: "WsRpcError", + message: "Provider request failed", + cause: { _tag: "SocketCloseError", code: 1006 }, + }), + ).toBe(false); + expect( + isConnectionTransportFailure({ + _tag: "SnapshotOutOfDate", + message: "Resubscribe from the latest sequence", + }), + ).toBe(false); + }); + + it("recognizes only structured protocol failures and computes bounded jittered backoff", () => { + expect(isConnectionProtocolFailure({ _tag: "ParseError" })).toBe(true); + expect(isConnectionProtocolFailure(new Error("ParseError in domain text"))).toBe(false); + expect(streamRestartDelayMs(0, () => 0.5)).toBe(250); + expect(streamRestartDelayMs(1, () => 0.5)).toBe(500); + expect(streamRestartDelayMs(99, () => 0.5)).toBe(10_000); + expect(streamRestartDelayMs(99, () => 1)).toBe(10_000); + }); + + it("waits for exact old-stream settlement before installing a replacement", async () => { + const harness = makeStreamHarness(); + harness.start(); + await vi.waitFor(() => expect(harness.runtime.runCallback).toHaveBeenCalledTimes(1)); + + harness.start(); + await vi.waitFor(() => expect(harness.cancels[0]).toHaveBeenCalledTimes(1)); + expect(harness.runtime.runCallback).toHaveBeenCalledTimes(1); + + harness.exits[0]!(Exit.void); + await vi.waitFor(() => expect(harness.runtime.runCallback).toHaveBeenCalledTimes(2)); + + harness.transport.dispose(); + }); + + it("waits for stream settlement across an unsubscribe-resubscribe race", async () => { + const harness = makeStreamHarness(); + harness.start(); + await vi.waitFor(() => expect(harness.runtime.runCallback).toHaveBeenCalledTimes(1)); + + harness.stop(); + harness.start(); + await vi.waitFor(() => expect(harness.cancels[0]).toHaveBeenCalledTimes(1)); + expect(harness.runtime.runCallback).toHaveBeenCalledTimes(1); + + harness.exits[0]!(Exit.void); + await vi.waitFor(() => expect(harness.runtime.runCallback).toHaveBeenCalledTimes(2)); + harness.transport.dispose(); + }); + + it("invalidates instead of starting a same-generation replacement when cancellation never settles", async () => { + vi.useFakeTimers(); + const harness = makeStreamHarness(); + harness.start(); + await vi.advanceTimersByTimeAsync(0); + + harness.start(); + await vi.advanceTimersByTimeAsync(0); + expect(harness.cancels[0]).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(2_000); + + expect(harness.supervisor.invalidate).toHaveBeenCalledWith( + 1, + "stream test.stream did not settle after cancellation", + ); + expect(harness.runtime.runCallback).toHaveBeenCalledTimes(1); + harness.transport.dispose(); + }); + + it("restarts a domain-failed stream without replacing its healthy session", async () => { + vi.useFakeTimers(); + vi.spyOn(Math, "random").mockReturnValue(0.5); + const harness = makeStreamHarness(); + harness.start(); + await vi.advanceTimersByTimeAsync(0); + + harness.exits[0]!(Exit.fail({ _tag: "SnapshotOutOfDate" })); + await vi.advanceTimersByTimeAsync(250); + + expect(harness.supervisor.invalidate).not.toHaveBeenCalled(); + expect(harness.runtime.runCallback).toHaveBeenCalledTimes(2); + harness.transport.dispose(); + }); + + it("restarts a normally completed stream without replacing its healthy session", async () => { + vi.useFakeTimers(); + vi.spyOn(Math, "random").mockReturnValue(0.5); + const harness = makeStreamHarness(); + harness.start(); + await vi.advanceTimersByTimeAsync(0); + + harness.exits[0]!(Exit.void); + await vi.advanceTimersByTimeAsync(250); + + expect(harness.supervisor.invalidate).not.toHaveBeenCalled(); + expect(harness.runtime.runCallback).toHaveBeenCalledTimes(2); + harness.transport.dispose(); + }); + + it("backs off repeated stream-local failures and resets after a sustained stream", async () => { + vi.useFakeTimers(); + vi.spyOn(Math, "random").mockReturnValue(0.5); + const harness = makeStreamHarness(); + harness.start(); + await vi.advanceTimersByTimeAsync(0); + + harness.exits[0]!(Exit.fail({ _tag: "SnapshotOutOfDate" })); + await vi.advanceTimersByTimeAsync(250); + harness.exits[1]!(Exit.fail({ _tag: "SnapshotOutOfDate" })); + await vi.advanceTimersByTimeAsync(499); + expect(harness.runtime.runCallback).toHaveBeenCalledTimes(2); + await vi.advanceTimersByTimeAsync(1); + expect(harness.runtime.runCallback).toHaveBeenCalledTimes(3); + + await vi.advanceTimersByTimeAsync(30_000); + harness.exits[2]!(Exit.fail({ _tag: "SnapshotOutOfDate" })); + await vi.advanceTimersByTimeAsync(249); + expect(harness.runtime.runCallback).toHaveBeenCalledTimes(3); + await vi.advanceTimersByTimeAsync(1); + expect(harness.runtime.runCallback).toHaveBeenCalledTimes(4); + harness.transport.dispose(); + }); + + it("invalidates the session for a structured protocol stream failure", async () => { + const harness = makeStreamHarness(); + harness.start(); + await vi.waitFor(() => expect(harness.runtime.runCallback).toHaveBeenCalledTimes(1)); + + harness.exits[0]!(Exit.fail({ _tag: "ParseError" })); + await vi.waitFor(() => expect(harness.supervisor.invalidate).toHaveBeenCalledTimes(1)); + + expect(harness.supervisor.invalidate).toHaveBeenCalledWith( + 1, + "stream test.stream protocol failed", + ); + harness.transport.dispose(); + }); + + it("invalidates the session for a genuine transport stream failure", async () => { + const harness = makeStreamHarness(); + harness.start(); + await vi.waitFor(() => expect(harness.runtime.runCallback).toHaveBeenCalledTimes(1)); + + harness.exits[0]!(Exit.fail({ _tag: "SocketCloseError", code: 1006 })); + await vi.waitFor(() => expect(harness.supervisor.invalidate).toHaveBeenCalledTimes(1)); + + expect(harness.supervisor.invalidate).toHaveBeenCalledWith( + 1, + "stream test.stream transport failed", + ); + harness.transport.dispose(); + }); + it("keeps the shared lifecycle stream while either lifecycle channel is active", () => { expect(shouldKeepServerLifecycleStream(new Set([WS_CHANNELS.serverWelcome]))).toBe(true); expect(shouldKeepServerLifecycleStream(new Set([WS_CHANNELS.serverMaintenanceUpdated]))).toBe( diff --git a/apps/web/src/wsTransport.ts b/apps/web/src/wsTransport.ts index 01ed74550..8878fd328 100644 --- a/apps/web/src/wsTransport.ts +++ b/apps/web/src/wsTransport.ts @@ -25,10 +25,23 @@ import { type WsPushChannel, type WsPushMessage, } from "@synara/contracts"; -import { Cause, Data, Effect, Exit, Layer, ManagedRuntime, Scope, Stream } from "effect"; -import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; +import { + Cause, + Data, + Effect, + Exit, + Layer, + ManagedRuntime, + Option, + Schedule, + Schema, + Scope, + Stream, +} from "effect"; +import { RpcClient, RpcClientError, RpcSerialization } from "effect/unstable/rpc"; import * as Socket from "effect/unstable/socket/Socket"; +import { ConnectionSupervisor, type ConnectionSupervisorSession } from "./connectionSupervisor"; import type { WsTransportState } from "./wsTransportEvents"; type PushListener = (message: WsPushMessage) => void; @@ -43,8 +56,29 @@ type RpcClientInstance = type SessionHandle = { readonly client: RpcClientInstance; readonly runtime: ManagedRuntime.ManagedRuntime; + readonly clientScope: Scope.Closeable; }; +type ActiveSession = ConnectionSupervisorSession; + +interface StreamCleanup { + readonly generation: number; + readonly identity: symbol; + readonly cancel: () => void; + readonly settled: Promise; + readonly healthyTimer: ReturnType; +} + +interface StreamStartToken { + readonly generation: number; + readonly identity: symbol; +} + +interface StreamRestartTimer { + readonly generation: number; + readonly timer: ReturnType; +} + class WsTransportRpcError extends Data.TaggedError("WsTransportRpcError")<{ readonly message: string; readonly cause?: unknown; @@ -60,6 +94,28 @@ const makeRpcClient = RpcClient.make(WsRpcGroup); // opts out for known long-running calls (git actions, compaction, provider // updates) whose duration is bounded elsewhere. const REQUEST_TIMEOUT_MS = 60_000; +const SESSION_VALIDATION_TIMEOUT_MS = 15_000; +const WAKE_PROBE_TIMEOUT_MS = 5_000; +const STREAM_RESTART_BASE_DELAY_MS = 250; +const STREAM_RESTART_MAX_DELAY_MS = 10_000; +const STREAM_RESTART_JITTER_RATIO = 0.2; +const STREAM_RESTART_RESET_AFTER_MS = 30_000; +const STREAM_SETTLEMENT_TIMEOUT_MS = 2_000; + +export const EFFECT_RPC_RETRY_CONFIG = { + retryTransientErrors: false, + retryCount: 0, +} as const; + +export function streamRestartDelayMs(attempt: number, random = Math.random): number { + const boundedAttempt = Math.max(0, Math.floor(attempt)); + const exponential = Math.min( + STREAM_RESTART_BASE_DELAY_MS * 2 ** boundedAttempt, + STREAM_RESTART_MAX_DELAY_MS, + ); + const jitter = 1 + (random() * 2 - 1) * STREAM_RESTART_JITTER_RATIO; + return Math.min(STREAM_RESTART_MAX_DELAY_MS, Math.max(0, Math.round(exponential * jitter))); +} function resolveRpcUrl(rawUrl: string): string { const url = new URL(rawUrl); @@ -87,9 +143,14 @@ function makeProtocolLayer(url: string) { // JSON keeps the wire format symmetric with any server build: a serialization // mismatch on this single multiplexed socket is a hard connect failure, and the // desktop/dev setup routinely runs web and server on independently-built copies. - return RpcClient.layerProtocolSocket().pipe( - Layer.provide(Layer.mergeAll(socketLayer, RpcSerialization.layerJson)), + const protocolLayer = Layer.effect( + RpcClient.Protocol, + RpcClient.makeProtocolSocket({ + retryTransientErrors: EFFECT_RPC_RETRY_CONFIG.retryTransientErrors, + retryPolicy: Schedule.recurs(EFFECT_RPC_RETRY_CONFIG.retryCount), + }), ); + return protocolLayer.pipe(Layer.provide(Layer.mergeAll(socketLayer, RpcSerialization.layerJson))); } function causeToError(cause: Cause.Cause): Error { @@ -97,6 +158,60 @@ function causeToError(cause: Cause.Cause): Error { return error instanceof Error ? error : new Error(String(error)); } +function connectionErrorSummary(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + const socketCloseCode = message.match(/SocketCloseError:\s*(\d{4})/i)?.[1]; + if (socketCloseCode) return `socket closed (${socketCloseCode})`; + if (/timed?\s*out|timeout/i.test(message)) return "connection timed out"; + if (/schema|decode|encode|protocol/i.test(message)) return "protocol validation failed"; + if (/ECONNREFUSED|connection refused|server unavailable/i.test(message)) { + return "server unavailable"; + } + if (/disposed|interrupt/i.test(message)) return "connection closed"; + return "connection operation failed"; +} + +export function isConnectionTransportFailure(error: unknown): boolean { + const seen = new Set(); + const visit = (value: unknown): boolean => { + if (!value || typeof value !== "object") return false; + if (seen.has(value)) return false; + seen.add(value); + if (Socket.isSocketError(value) || Schema.is(RpcClientError.RpcClientError)(value)) return true; + const record = value as Record; + // A server-declared RPC failure is stream-local even when its diagnostic + // cause resembles a transport error from the server's own dependencies. + if (record._tag === "WsRpcError") return false; + if ( + typeof record._tag === "string" && + /^(?:SocketCloseError|SocketOpenError|SocketError|RpcClientError|RpcClientDefect)$/.test( + record._tag, + ) + ) { + return true; + } + return [record.cause, record.reason, record.error, record.failure].some(visit); + }; + return visit(error); +} + +export function isConnectionProtocolFailure(error: unknown): boolean { + const seen = new Set(); + const visit = (value: unknown): boolean => { + if (!value || typeof value !== "object" || seen.has(value)) return false; + seen.add(value); + const record = value as Record; + if ( + typeof record._tag === "string" && + /^(?:ParseError|SchemaError|DecodeError|EncodeError|ProtocolError)$/.test(record._tag) + ) { + return true; + } + return [record.cause, record.reason, record.error, record.failure].some(visit); + }; + return visit(error); +} + function omitNullUserInputAnswers(input: unknown): unknown { if (!input || typeof input !== "object") { return input; @@ -135,24 +250,44 @@ export class WsTransport { private readonly stateListeners = new Set<(state: WsTransportState) => void>(); private readonly latestPushByChannel = new Map(); private sequence = 0; - private sessionVersion = 0; private state: WsTransportState = "connecting"; private disposed = false; - private runtime: ManagedRuntime.ManagedRuntime; - private clientScope: Scope.Closeable; - private clientPromise: Promise; - private reconnectPromise: Promise | null = null; - private reconnectFailures = 0; - private readonly streamCleanups = new Map void>(); + private readonly supervisor: ConnectionSupervisor; + private readonly streamCleanups = new Map(); + private readonly streamStartTokens = new Map(); + private readonly streamTransitions = new Map>(); + private readonly streamRestartTimers = new Map(); + private readonly streamRestartAttempts = new Map< + string, + { readonly generation: number; readonly attempt: number } + >(); + private readonly wakeCleanups: Array<() => void> = []; private shellSubscribed = false; private readonly threadSubscriptions = new Map(); constructor(url?: string) { this.explicitUrl = url ?? null; - const session = this.createSession(); - this.runtime = session.runtime; - this.clientScope = session.clientScope; - this.clientPromise = session.clientPromise; + this.supervisor = new ConnectionSupervisor({ + connect: (_generation, signal) => this.createValidatedSession(signal), + close: ({ value }) => this.closeSession(value), + probe: ({ value }) => + this.validateSession(value, WAKE_PROBE_TIMEOUT_MS, WS_METHODS.serverGetEnvironment), + onReady: (session) => this.restoreStreams(session), + onInvalidated: (session) => this.stopGenerationStreams(session.generation), + onSnapshot: (snapshot) => { + if (snapshot.phase === "ready") this.setState("open"); + else if (snapshot.phase === "reconnecting") this.setState("reconnecting"); + else if (snapshot.phase === "connecting") this.setState("connecting"); + else this.setState("disposed"); + }, + onError: (error, context) => { + if (!this.disposed) { + console.warn(`[scient-connection] ${context}: ${connectionErrorSummary(error)}`); + } + }, + }); + this.installWakeRecovery(); + this.supervisor.start(); } async request( @@ -161,14 +296,12 @@ export class WsTransport { options?: { readonly timeoutMs?: number | null }, ): Promise { if (this.disposed) throw new Error("Transport disposed"); - const session = await this.getSession(); - - if (method === WS_METHODS.gitRunStackedAction) { - return (await this.runGitActionStream(session, params)) as T; - } + const timeoutMs = options?.timeoutMs === undefined ? REQUEST_TIMEOUT_MS : options.timeoutMs; if (method === ORCHESTRATION_WS_METHODS.subscribeShell) { this.shellSubscribed = true; + const session = await this.getSession(REQUEST_TIMEOUT_MS); + if (!this.shellSubscribed) return undefined as T; this.startShellStream(session); return undefined as T; } @@ -180,6 +313,8 @@ export class WsTransport { if (method === ORCHESTRATION_WS_METHODS.subscribeThread) { const threadId = (params as { threadId: string }).threadId; this.threadSubscriptions.set(threadId, params); + const session = await this.getSession(REQUEST_TIMEOUT_MS); + if (this.threadSubscriptions.get(threadId) !== params) return undefined as T; this.startThreadStream(session, threadId, params as never); return undefined as T; } @@ -190,19 +325,24 @@ export class WsTransport { return undefined as T; } + const session = await this.getSession(REQUEST_TIMEOUT_MS); + + if (method === WS_METHODS.gitRunStackedAction) { + return (await this.runGitActionStream(session, params)) as T; + } + const rpcInput = method === ORCHESTRATION_WS_METHODS.dispatchCommand ? (params as { command: unknown }).command : (params ?? {}); const normalizedRpcInput = omitNullUserInputAnswers(rpcInput); const call = ( - session.client as unknown as Record< + session.value.client as unknown as Record< string, (input: unknown) => Effect.Effect > )[method]; if (!call) throw new WsTransportRpcError({ message: `Unknown RPC method: ${method}` }); - const timeoutMs = options?.timeoutMs === undefined ? REQUEST_TIMEOUT_MS : options.timeoutMs; const rpcEffect = timeoutMs === null ? call(normalizedRpcInput) @@ -215,7 +355,7 @@ export class WsTransport { }), ), }); - return (await session.runtime.runPromise(rpcEffect)) as T; + return (await session.value.runtime.runPromise(rpcEffect)) as T; } subscribe( @@ -273,79 +413,85 @@ export class WsTransport { dispose() { if (this.disposed) return; this.disposed = true; - this.setState("disposed"); - for (const cleanup of this.streamCleanups.values()) cleanup(); - this.streamCleanups.clear(); - // Dispose can race with initial connection or reconnect promises. Mark them - // handled before closing the runtime so test/browser teardown stays quiet. - void this.clientPromise.catch(() => undefined); - void this.reconnectPromise?.catch(() => undefined); - const runtime = this.runtime; - const clientScope = this.clientScope; - void runtime - .runPromise(Scope.close(clientScope, Exit.void)) - .catch(() => undefined) - .finally(() => { - void runtime.dispose().catch(() => undefined); - }); + for (const cleanup of this.wakeCleanups.splice(0)) cleanup(); + for (const key of new Set([ + ...this.streamCleanups.keys(), + ...this.streamStartTokens.keys(), + ...this.streamRestartTimers.keys(), + ])) { + this.stopStream(key); + } + this.supervisor.dispose(); } - private createSession() { - const sessionVersion = ++this.sessionVersion; + private async createValidatedSession(signal: AbortSignal): Promise { const runtime = ManagedRuntime.make(makeProtocolLayer(makeSocketUrl(this.explicitUrl))); const clientScope = runtime.runSync(Scope.make()); - const clientPromise = runtime - .runPromise(Scope.provide(clientScope)(makeRpcClient)) - .then((client): SessionHandle => { - if (!this.disposed && this.sessionVersion === sessionVersion) { - this.setState("open"); - } - return { client, runtime }; - }) - .catch((error) => { - if (!this.disposed && this.sessionVersion === sessionVersion) { - this.setState("closed"); - } - throw error; - }); - return { runtime, clientScope, clientPromise }; - } - - private async getSession(): Promise { try { - const session = await this.clientPromise; - if (this.disposed) { - throw new Error("Transport disposed"); - } + const client = await runtime.runPromise(Scope.provide(clientScope)(makeRpcClient), { + signal, + }); + const session = { client, clientScope, runtime } satisfies SessionHandle; + await this.validateSession( + session, + SESSION_VALIDATION_TIMEOUT_MS, + WS_METHODS.serverGetConfig, + signal, + ); return session; - } catch { - if (this.disposed) throw new Error("Transport disposed"); - return this.reconnect(); + } catch (error) { + await this.closeRuntime(runtime, clientScope); + throw error; } } - private reconnect(): Promise { - if (this.reconnectPromise) return this.reconnectPromise; - - const oldRuntime = this.runtime; - const oldClientScope = this.clientScope; - const staleStreamCleanups = [...this.streamCleanups.values()]; - this.streamCleanups.clear(); - for (const cleanup of staleStreamCleanups) cleanup(); + private validateSession( + session: SessionHandle, + timeoutMs: number, + method: string, + signal?: AbortSignal, + ): Promise { + const validate = ( + session.client as unknown as Record< + string, + (input: unknown) => Effect.Effect + > + )[method]; + if (!validate) + return Promise.reject(new Error(`Connection validation RPC unavailable: ${method}`)); + const validation = validate({}).pipe( + Effect.timeoutOrElse({ + duration: timeoutMs, + onTimeout: () => + Effect.fail( + new WsTransportRpcError({ + message: `Connection validation timed out after ${timeoutMs}ms: ${method}`, + }), + ), + }), + Effect.asVoid, + ); + return session.runtime.runPromise(validation, signal ? { signal } : undefined); + } - this.setState("connecting"); + private closeSession(session: SessionHandle): Promise { + return this.closeRuntime(session.runtime, session.clientScope); + } - void oldRuntime - .runPromise(Scope.close(oldClientScope, Exit.void)) - .catch(() => undefined) - .finally(() => { - void oldRuntime.dispose().catch(() => undefined); - }); + private async closeRuntime( + runtime: ManagedRuntime.ManagedRuntime, + clientScope: Scope.Closeable, + ): Promise { + try { + await runtime.runPromise(Scope.close(clientScope, Exit.void)).catch(() => undefined); + } finally { + await runtime.dispose().catch(() => undefined); + } + } - this.reconnectPromise = this.openReconnectSession().finally(() => { - this.reconnectPromise = null; - }); - return this.reconnectPromise; + private getSession(timeoutMs: number): Promise { + if (this.disposed) return Promise.reject(new Error("Transport disposed")); + return this.supervisor.waitForSession({ timeoutMs }); } private setState(state: WsTransportState): void { @@ -360,31 +506,41 @@ export class WsTransport { } } - private async openReconnectSession(): Promise { - const delayMs = Math.min(500 * 2 ** this.reconnectFailures, 5_000); - this.reconnectFailures += 1; - await new Promise((resolve) => window.setTimeout(resolve, delayMs)); - - const session = this.createSession(); - this.runtime = session.runtime; - this.clientScope = session.clientScope; - this.clientPromise = session.clientPromise; - - const handle = await session.clientPromise; - if (this.disposed) { - throw new Error("Transport disposed"); - } - this.reconnectFailures = 0; + private restoreStreams(session: ActiveSession): void { + if (this.disposed || this.supervisor.currentSession?.generation !== session.generation) return; for (const channel of this.listeners.keys()) { this.startChannelStream(channel as WsPushChannel); } if (this.shellSubscribed) { - this.startShellStream(handle); + this.startShellStream(session); } for (const [threadId, input] of this.threadSubscriptions) { - this.startThreadStream(handle, threadId, input); + this.startThreadStream(session, threadId, input); + } + } + + private installWakeRecovery(): void { + const probe = (reason: string) => { + void this.supervisor.probe(reason); + }; + if (typeof window.addEventListener === "function") { + const handleFocus = () => probe("window focus"); + window.addEventListener("focus", handleFocus); + this.wakeCleanups.push(() => window.removeEventListener("focus", handleFocus)); + } + if (typeof document !== "undefined" && typeof document.addEventListener === "function") { + const handleVisibilityChange = () => { + if (document.visibilityState === "visible") probe("document visible"); + }; + document.addEventListener("visibilitychange", handleVisibilityChange); + this.wakeCleanups.push(() => + document.removeEventListener("visibilitychange", handleVisibilityChange), + ); + } + const onConnectionWake = window.desktopBridge?.onConnectionWake; + if (onConnectionWake) { + this.wakeCleanups.push(onConnectionWake((reason) => probe(`desktop ${reason}`))); } - return handle; } private emit(channel: C, data: WsPushMessage["data"]): void { @@ -407,9 +563,17 @@ export class WsTransport { } private startChannelStream(channel: WsPushChannel): void { - void this.getSession() + void this.supervisor + .waitForSession() .then((session) => { - const { client } = session; + if ( + this.disposed || + !this.listeners.has(channel) || + this.supervisor.currentSession?.generation !== session.generation + ) { + return; + } + const { client } = session.value; if (isServerLifecyclePushChannel(channel)) { this.startLifecycleStream(session); @@ -417,7 +581,7 @@ export class WsTransport { this.startStream( session, "server.config", - client[WS_METHODS.subscribeServerConfig]({}), + () => client[WS_METHODS.subscribeServerConfig]({}), (event: ServerConfigStreamEvent) => { if (event.type === "snapshot") { this.emit(WS_CHANNELS.serverConfigUpdated, { @@ -428,57 +592,65 @@ export class WsTransport { this.emit(WS_CHANNELS.serverConfigUpdated, event.payload); } }, + { isDesired: () => this.listeners.has(channel) }, ); } else if (channel === WS_CHANNELS.serverProviderStatusesUpdated) { this.startStream( session, "server.providers", - client[WS_METHODS.subscribeServerProviderStatuses]({}), + () => client[WS_METHODS.subscribeServerProviderStatuses]({}), (payload: ServerProviderStatusesUpdatedPayload) => this.emit(WS_CHANNELS.serverProviderStatusesUpdated, payload), + { isDesired: () => this.listeners.has(channel) }, ); } else if (channel === WS_CHANNELS.serverSettingsUpdated) { this.startStream( session, "server.settings", - client[WS_METHODS.subscribeServerSettings]({}), + () => client[WS_METHODS.subscribeServerSettings]({}), (payload: ServerSettingsUpdatedPayload) => this.emit(WS_CHANNELS.serverSettingsUpdated, payload), + { isDesired: () => this.listeners.has(channel) }, ); } else if (channel === WS_CHANNELS.terminalEvent) { this.startStream( session, "terminal.events", - client[WS_METHODS.subscribeTerminalEvents]({}), + () => client[WS_METHODS.subscribeTerminalEvents]({}), (event: TerminalEvent) => this.emit(WS_CHANNELS.terminalEvent, event), + { isDesired: () => this.listeners.has(channel) }, ); } else if (channel === WS_CHANNELS.projectDevServerEvent) { this.startStream( session, "project.devServers", - client[WS_METHODS.subscribeProjectDevServerEvents]({}), + () => client[WS_METHODS.subscribeProjectDevServerEvents]({}), (event: ProjectDevServerEvent) => this.emit(WS_CHANNELS.projectDevServerEvent, event), + { isDesired: () => this.listeners.has(channel) }, ); } else if (channel === WS_CHANNELS.automationEvent) { this.startStream( session, "automation.events", - client[WS_METHODS.subscribeAutomationEvents]({}), + () => client[WS_METHODS.subscribeAutomationEvents]({}), (event: AutomationStreamEvent) => this.emit(WS_CHANNELS.automationEvent, event), + { isDesired: () => this.listeners.has(channel) }, ); } else if (channel === ORCHESTRATION_WS_CHANNELS.domainEvent) { this.startStream( session, "orchestration.domain", - client[WS_METHODS.subscribeOrchestrationDomainEvents]({}), + () => client[WS_METHODS.subscribeOrchestrationDomainEvents]({}), (event: OrchestrationEvent) => this.emit(ORCHESTRATION_WS_CHANNELS.domainEvent, event), + { isDesired: () => this.listeners.has(channel) }, ); } }) .catch((error) => { if (!this.disposed && this.listeners.has(channel)) { - console.warn("WebSocket RPC channel failed to start", error); - window.setTimeout(() => this.startChannelStream(channel), 500); + console.warn( + `[scient-connection] channel ${channel} failed to start: ${connectionErrorSummary(error)}`, + ); } }); } @@ -501,11 +673,11 @@ export class WsTransport { return shouldKeepServerLifecycleStream(new Set(this.listeners.keys())); } - private startLifecycleStream(session: SessionHandle): void { + private startLifecycleStream(session: ActiveSession): void { this.startStream( session, "server.lifecycle", - session.client[WS_METHODS.subscribeServerLifecycle]({}), + () => session.value.client[WS_METHODS.subscribeServerLifecycle]({}), (event: ServerLifecycleStreamEvent) => { if (event.type === "welcome") { this.emit(WS_CHANNELS.serverWelcome, event.payload); @@ -513,97 +685,344 @@ export class WsTransport { this.emit(WS_CHANNELS.serverMaintenanceUpdated, event); } }, + { isDesired: () => this.shouldKeepLifecycleStream() }, ); } - private startShellStream(session: SessionHandle): void { + private startShellStream(session: ActiveSession): void { this.startStream( session, "orchestration.shell", - session.client[ORCHESTRATION_WS_METHODS.subscribeShell]({}), + () => session.value.client[ORCHESTRATION_WS_METHODS.subscribeShell]({}), (event: OrchestrationShellStreamItem) => this.emit(ORCHESTRATION_WS_CHANNELS.shellEvent, event), + { isDesired: () => this.shellSubscribed }, ); } - private startThreadStream(session: SessionHandle, threadId: string, input: unknown): void { + private startThreadStream(session: ActiveSession, threadId: string, input: unknown): void { + if (this.supervisor.currentSession?.generation !== session.generation) return; const key = `orchestration.thread:${threadId}`; - this.stopStream(key); this.startStream( session, key, - session.client[ORCHESTRATION_WS_METHODS.subscribeThread](input as never), + () => session.value.client[ORCHESTRATION_WS_METHODS.subscribeThread](input as never), (event: OrchestrationThreadStreamItem) => this.emit(ORCHESTRATION_WS_CHANNELS.threadEvent, event), + { + isDesired: () => this.threadSubscriptions.get(threadId) === input, + replace: true, + }, ); } private startStream( - session: SessionHandle, + session: ActiveSession, key: string, - stream: unknown, + streamFactory: () => unknown, listener: (event: T) => void, + options: { + readonly isDesired: () => boolean; + readonly replace?: boolean; + }, ): void { - if (this.streamCleanups.has(key)) return; - const runnableStream = stream as Stream.Stream; - const cancel = session.runtime.runCallback( - Stream.runForEach(runnableStream, (event) => Effect.sync(() => listener(event))), - { - onExit: (exit) => { - // A replacement or intentional stop removes this exact cleanup from - // the map before cancellation. Ignore that stale stream's later exit; - // otherwise it can reconnect over the replacement and lose events. - if (this.streamCleanups.get(key) !== cancel || this.disposed) { - return; - } - this.streamCleanups.delete(key); - if (Exit.isFailure(exit)) { - const interrupted = Cause.hasInterruptsOnly(exit.cause); - if (!interrupted) { - console.warn("WebSocket RPC stream failed", causeToError(exit.cause)); + if (this.supervisor.currentSession?.generation !== session.generation) return; + const existing = this.streamCleanups.get(key); + const pending = this.streamStartTokens.get(key); + if ( + !options.replace && + (existing?.generation === session.generation || pending?.generation === session.generation) + ) { + return; + } + this.clearStreamRestartTimer(key); + const identity = Symbol(key); + this.streamStartTokens.set(key, { generation: session.generation, identity }); + const previous = this.streamTransitions.get(key) ?? Promise.resolve(); + const transition = previous + .catch(() => undefined) + .then(() => this.replaceStream(session, key, identity, streamFactory, listener, options)) + .finally(() => { + if (this.streamTransitions.get(key) === transition) { + this.streamTransitions.delete(key); + } + }); + this.streamTransitions.set(key, transition); + } + + private async replaceStream( + session: ActiveSession, + key: string, + identity: symbol, + streamFactory: () => unknown, + listener: (event: T) => void, + options: { + readonly isDesired: () => boolean; + readonly replace?: boolean; + }, + ): Promise { + if (this.streamStartTokens.get(key)?.identity !== identity) return; + const existing = this.streamCleanups.get(key); + if (existing) { + this.streamCleanups.delete(key); + clearTimeout(existing.healthyTimer); + existing.cancel(); + const settled = await this.waitForStreamSettlement(existing.settled); + if (!settled) { + if (this.streamStartTokens.get(key)?.identity === identity) { + this.streamStartTokens.delete(key); + } + this.supervisor.invalidate( + existing.generation, + `stream ${key} did not settle after cancellation`, + ); + return; + } + } + if ( + this.disposed || + this.streamStartTokens.get(key)?.identity !== identity || + this.supervisor.currentSession?.generation !== session.generation || + !options.isDesired() + ) { + if (this.streamStartTokens.get(key)?.identity === identity) { + this.streamStartTokens.delete(key); + } + return; + } + + let resolveSettled!: () => void; + const settled = new Promise((resolve) => { + resolveSettled = resolve; + }); + let cleanup!: StreamCleanup; + try { + const runnableStream = streamFactory() as Stream.Stream; + const cancel = session.value.runtime.runCallback( + Stream.runForEach(runnableStream, (event) => + Effect.sync(() => { + if ( + this.supervisor.currentSession?.generation !== session.generation || + this.streamCleanups.get(key) !== cleanup + ) { + return; } - window.setTimeout( - () => { - if (!this.disposed && !this.streamCleanups.has(key)) { - // Reconnecting restores every active channel, shell, and thread - // subscription. Restarting this stream again would replace the - // restored subscription and could drop its first event. - void this.reconnect().catch((error) => { - if (!this.disposed) { - console.warn("WebSocket RPC stream reconnect failed", error); - } - }); - } - }, - interrupted ? 0 : 500, - ); - } + listener(event); + }), + ), + { + onExit: (exit) => { + resolveSettled(); + queueMicrotask(() => { + this.handleStreamExit(session, key, cleanup, exit, streamFactory, listener, options); + }); + }, }, - }, + ); + const healthyTimer = setTimeout(() => { + if (this.streamCleanups.get(key) === cleanup) { + this.streamRestartAttempts.delete(key); + } + }, STREAM_RESTART_RESET_AFTER_MS); + cleanup = { generation: session.generation, identity, cancel, settled, healthyTimer }; + this.streamCleanups.set(key, cleanup); + if (this.streamStartTokens.get(key)?.identity === identity) { + this.streamStartTokens.delete(key); + } + } catch (error) { + resolveSettled(); + if (this.streamStartTokens.get(key)?.identity === identity) { + this.streamStartTokens.delete(key); + } + this.handleStreamFailure(session, key, error, streamFactory, listener, options); + } + } + + private handleStreamExit( + session: ActiveSession, + key: string, + cleanup: StreamCleanup, + exit: Exit.Exit, + streamFactory: () => unknown, + listener: (event: T) => void, + options: { + readonly isDesired: () => boolean; + readonly replace?: boolean; + }, + ): void { + if (this.streamCleanups.get(key) !== cleanup || this.disposed) return; + this.streamCleanups.delete(key); + clearTimeout(cleanup.healthyTimer); + if (Exit.isFailure(exit)) { + const error = causeToError(exit.cause); + const failure = Cause.findErrorOption(exit.cause); + if ( + (Option.isSome(failure) && isConnectionTransportFailure(failure.value)) || + isConnectionTransportFailure(error) + ) { + this.supervisor.invalidate(session.generation, `stream ${key} transport failed`); + return; + } + if ( + (Option.isSome(failure) && isConnectionProtocolFailure(failure.value)) || + isConnectionProtocolFailure(error) + ) { + this.supervisor.invalidate(session.generation, `stream ${key} protocol failed`); + return; + } + if (!Cause.hasInterruptsOnly(exit.cause)) { + console.warn( + `[scient-connection] stream ${key} failed without closing the session: ${connectionErrorSummary(error)}`, + ); + } + } + this.scheduleStreamRestart(session, key, streamFactory, listener, options); + } + + private handleStreamFailure( + session: ActiveSession, + key: string, + error: unknown, + streamFactory: () => unknown, + listener: (event: T) => void, + options: { + readonly isDesired: () => boolean; + readonly replace?: boolean; + }, + ): void { + if (isConnectionTransportFailure(error)) { + this.supervisor.invalidate(session.generation, `stream ${key} transport failed to start`); + return; + } + if (isConnectionProtocolFailure(error)) { + this.supervisor.invalidate(session.generation, `stream ${key} protocol failed to start`); + return; + } + console.warn( + `[scient-connection] stream ${key} failed to start without closing the session: ${connectionErrorSummary(error)}`, ); - this.streamCleanups.set(key, cancel); + this.scheduleStreamRestart(session, key, streamFactory, listener, options); + } + + private scheduleStreamRestart( + session: ActiveSession, + key: string, + streamFactory: () => unknown, + listener: (event: T) => void, + options: { + readonly isDesired: () => boolean; + readonly replace?: boolean; + }, + ): void { + if ( + this.disposed || + this.supervisor.currentSession?.generation !== session.generation || + !options.isDesired() + ) { + return; + } + this.clearStreamRestartTimer(key); + const previousAttempt = this.streamRestartAttempts.get(key); + const attempt = + previousAttempt?.generation === session.generation ? previousAttempt.attempt + 1 : 0; + this.streamRestartAttempts.set(key, { generation: session.generation, attempt }); + const delayMs = streamRestartDelayMs(attempt); + const timer = setTimeout(() => { + if (this.streamRestartTimers.get(key)?.timer !== timer) return; + this.streamRestartTimers.delete(key); + if ( + this.disposed || + this.supervisor.currentSession?.generation !== session.generation || + !options.isDesired() + ) { + return; + } + this.startStream(session, key, streamFactory, listener, options); + }, delayMs); + this.streamRestartTimers.set(key, { generation: session.generation, timer }); } private stopStream(key: string): void { - const cleanup = this.streamCleanups.get(key); - if (!cleanup) return; - this.streamCleanups.delete(key); - cleanup(); + this.streamStartTokens.delete(key); + this.clearStreamRestartTimer(key); + this.streamRestartAttempts.delete(key); + const previous = this.streamTransitions.get(key) ?? Promise.resolve(); + const transition = previous + .catch(() => undefined) + .then(async () => { + const cleanup = this.streamCleanups.get(key); + if (!cleanup) return; + this.streamCleanups.delete(key); + clearTimeout(cleanup.healthyTimer); + cleanup.cancel(); + const settled = await this.waitForStreamSettlement(cleanup.settled); + if (!settled) { + this.supervisor.invalidate( + cleanup.generation, + `stream ${key} did not settle after cancellation`, + ); + } + }) + .finally(() => { + if (this.streamTransitions.get(key) === transition) { + this.streamTransitions.delete(key); + } + }); + this.streamTransitions.set(key, transition); + } + + private async waitForStreamSettlement(settled: Promise): Promise { + let timeout: ReturnType | null = null; + try { + return await Promise.race([ + settled.then(() => true), + new Promise((resolve) => { + timeout = setTimeout(() => resolve(false), STREAM_SETTLEMENT_TIMEOUT_MS); + }), + ]); + } finally { + if (timeout !== null) clearTimeout(timeout); + } + } + + private clearStreamRestartTimer(key: string): void { + const restart = this.streamRestartTimers.get(key); + if (!restart) return; + clearTimeout(restart.timer); + this.streamRestartTimers.delete(key); + } + + private stopGenerationStreams(generation: number): void { + const keys = new Set(); + for (const [key, cleanup] of this.streamCleanups) { + if (cleanup.generation === generation) keys.add(key); + } + for (const [key, pending] of this.streamStartTokens) { + if (pending.generation === generation) keys.add(key); + } + for (const [key, restart] of this.streamRestartTimers) { + if (restart.generation === generation) keys.add(key); + } + for (const key of keys) this.stopStream(key); } private async runGitActionStream( - session: SessionHandle, + session: ActiveSession, params: unknown, ): Promise { let result: GitRunStackedActionResult | null = null; - await session.runtime.runPromise( - Stream.runForEach(session.client[WS_METHODS.gitRunStackedAction](params as never), (event) => - Effect.sync(() => { - this.emit(WS_CHANNELS.gitActionProgress, event as GitActionProgressEvent); - if ((event as GitActionProgressEvent).kind === "action_finished") { - result = (event as Extract).result; - } - }), + await session.value.runtime.runPromise( + Stream.runForEach( + session.value.client[WS_METHODS.gitRunStackedAction](params as never), + (event) => + Effect.sync(() => { + if (this.supervisor.currentSession?.generation !== session.generation) return; + this.emit(WS_CHANNELS.gitActionProgress, event as GitActionProgressEvent); + if ((event as GitActionProgressEvent).kind === "action_finished") { + result = (event as Extract) + .result; + } + }), ), ); if (!result) throw new Error("Git action stream completed without a final result."); diff --git a/apps/web/src/wsTransportEvents.ts b/apps/web/src/wsTransportEvents.ts index 34ea297e8..657f355fa 100644 --- a/apps/web/src/wsTransportEvents.ts +++ b/apps/web/src/wsTransportEvents.ts @@ -3,7 +3,7 @@ // Layer: Web transport utility // Exports: event helpers used by wsNativeApi and terminal runtime recovery. -export type WsTransportState = "connecting" | "open" | "closed" | "disposed"; +export type WsTransportState = "connecting" | "reconnecting" | "open" | "disposed"; export const SYNARA_WS_TRANSPORT_STATE_EVENT = "scient:ws-transport-state"; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 3f18dc657..1016c7145 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -406,6 +406,8 @@ export interface DesktopWindowState { isFullscreen: boolean; } +export type DesktopConnectionWakeReason = "app-activate" | "window-focus" | "system-resume"; + export interface ScientStorageSnapshot { readonly version: 1; readonly exportedAt: string; @@ -414,6 +416,8 @@ export interface ScientStorageSnapshot { export interface DesktopBridge { getWsUrl: () => string | null; + /** Native wake signals that should verify an apparently-open server connection. */ + onConnectionWake?: (listener: (reason: DesktopConnectionWakeReason) => void) => () => void; /** * Absolute filesystem path for a File from drag/drop or file inputs. * Electron only (`webUtils.getPathForFile`). Returns null when unavailable. diff --git a/packages/contracts/src/terminal.test.ts b/packages/contracts/src/terminal.test.ts index f3e14b7f9..a01444e6b 100644 --- a/packages/contracts/src/terminal.test.ts +++ b/packages/contracts/src/terminal.test.ts @@ -205,6 +205,8 @@ describe("TerminalSessionSnapshot", () => { status: "running", pid: 1234, history: "hello\n", + outputEpoch: "epoch-1", + outputSequence: 4, replayPreamble: "\u001b[?2004h\u001b[=7;1u", exitCode: null, exitSignal: null, @@ -223,6 +225,8 @@ describe("TerminalEvent", () => { terminalId: DEFAULT_TERMINAL_ID, createdAt: new Date().toISOString(), data: "line\n", + outputEpoch: "epoch-1", + outputSequence: 1, }), ).toBe(true); }); @@ -235,6 +239,8 @@ describe("TerminalEvent", () => { terminalId: DEFAULT_TERMINAL_ID, createdAt: new Date().toISOString(), data: "line\n", + outputEpoch: "epoch-1", + outputSequence: 2, byteLength: 5, }), ).toBe(true); diff --git a/packages/contracts/src/terminal.ts b/packages/contracts/src/terminal.ts index 78cbb66cf..9aa62dc01 100644 --- a/packages/contracts/src/terminal.ts +++ b/packages/contracts/src/terminal.ts @@ -100,6 +100,10 @@ export const TerminalSessionSnapshot = Schema.Struct({ status: TerminalSessionStatus, pid: Schema.NullOr(Schema.Int.check(Schema.isGreaterThan(0))), history: Schema.String, + /** Identifies the server process that owns this sequence namespace. */ + outputEpoch: Schema.String.check(Schema.isNonEmpty()), + /** Monotonic output-event barrier captured with this authoritative history. */ + outputSequence: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), replayPreamble: Schema.optional(Schema.String.check(Schema.isMaxLength(4_096))), exitCode: Schema.NullOr(Schema.Int), exitSignal: Schema.NullOr(Schema.Int), @@ -123,6 +127,10 @@ const TerminalOutputEvent = Schema.Struct({ ...TerminalEventBaseSchema.fields, type: Schema.Literal("output"), data: Schema.String, + /** Identifies the server process that owns this sequence namespace. */ + outputEpoch: Schema.String.check(Schema.isNonEmpty()), + /** Monotonic sequence within this server-side terminal session. */ + outputSequence: Schema.Int.check(Schema.isGreaterThan(0)), byteLength: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))), }); From 40dfa6c86d002c5829a0942de9814392b36d338f Mon Sep 17 00:00:00 2001 From: yaacovcorcos Date: Tue, 21 Jul 2026 10:54:36 +0300 Subject: [PATCH 09/68] Define safe RPC recovery policies (#51) * Define safe RPC recovery policies * test(web): cover RPC recovery integration * fix(web): recover after uncertain mutation failures * test(web): type RPC recovery harness precisely * fix(web): recover a failed replay generation --- apps/web/src/rpcRecoveryPolicy.test.ts | 176 +++++++++++++++++++ apps/web/src/rpcRecoveryPolicy.ts | 118 +++++++++++++ apps/web/src/wsTransport.test.ts | 231 ++++++++++++++++++++++++- apps/web/src/wsTransport.ts | 73 +++++--- 4 files changed, 576 insertions(+), 22 deletions(-) create mode 100644 apps/web/src/rpcRecoveryPolicy.test.ts create mode 100644 apps/web/src/rpcRecoveryPolicy.ts diff --git a/apps/web/src/rpcRecoveryPolicy.test.ts b/apps/web/src/rpcRecoveryPolicy.test.ts new file mode 100644 index 000000000..55f510300 --- /dev/null +++ b/apps/web/src/rpcRecoveryPolicy.test.ts @@ -0,0 +1,176 @@ +// FILE: rpcRecoveryPolicy.test.ts +// Purpose: Locks conservative RPC replay boundaries and one-retry behavior. +// Layer: Web transport recovery policy tests + +import { ORCHESTRATION_WS_METHODS, WS_METHODS } from "@synara/contracts"; +import { RpcClientError } from "effect/unstable/rpc/RpcClientError"; +import { SocketCloseError, SocketError, SocketOpenError } from "effect/unstable/socket/Socket"; +import { describe, expect, it, vi } from "vitest"; + +import { + isRpcTransportFailure, + rpcRecoveryPolicyFor, + runRpcWithRecovery, +} from "./rpcRecoveryPolicy"; + +describe("rpcRecoveryPolicyFor", () => { + it("allows reviewed read-only methods to replay on a new generation", () => { + expect(rpcRecoveryPolicyFor(WS_METHODS.filesystemBrowse)).toBe("retry-on-new-generation"); + expect(rpcRecoveryPolicyFor(WS_METHODS.gitStatus)).toBe("retry-on-new-generation"); + expect(rpcRecoveryPolicyFor(ORCHESTRATION_WS_METHODS.getSnapshot)).toBe( + "retry-on-new-generation", + ); + }); + + it.each([ + WS_METHODS.projectsAdd, + WS_METHODS.projectsWriteFile, + WS_METHODS.gitPull, + WS_METHODS.gitRunStackedAction, + WS_METHODS.terminalOpen, + WS_METHODS.terminalWrite, + WS_METHODS.serverInstallProvider, + WS_METHODS.serverGenerateThreadRecap, + WS_METHODS.scientProjectInitializationPreview, + WS_METHODS.studioListThreadOutputs, + WS_METHODS.pullRequestsList, + WS_METHODS.pullRequestsDetail, + WS_METHODS.pullRequestsDiff, + WS_METHODS.serverListProviderUsage, + WS_METHODS.providerGetComposerCapabilities, + WS_METHODS.providerListSkills, + WS_METHODS.providerListModels, + WS_METHODS.pullRequestsComment, + WS_METHODS.automationRunNow, + ORCHESTRATION_WS_METHODS.dispatchCommand, + ORCHESTRATION_WS_METHODS.importThread, + ])("never replays a mutating or outcome-ambiguous method: %s", (method) => { + expect(rpcRecoveryPolicyFor(method)).toBe("never-replay"); + }); + + it("defaults unknown future methods to never replay", () => { + expect(rpcRecoveryPolicyFor("future.method")).toBe("never-replay"); + }); +}); + +describe("isRpcTransportFailure", () => { + it("recognizes structured RPC socket failures", () => { + expect( + isRpcTransportFailure(new RpcClientError({ reason: new SocketCloseError({ code: 1006 }) })), + ).toBe(true); + expect( + isRpcTransportFailure( + new RpcClientError({ + reason: new SocketOpenError({ kind: "Timeout", cause: "open timed out" }), + }), + ), + ).toBe(true); + expect( + isRpcTransportFailure(new SocketError({ reason: new SocketCloseError({ code: 1006 }) })), + ).toBe(true); + }); + + it.each([ + new Error("Git command timed out after 60000ms"), + new Error("provider connection is not authenticated"), + new Error("socket validation failed"), + { _tag: "GitCommandError", message: "connection timed out" }, + { _tag: "RpcClientError", reason: { _tag: "HttpClientError" } }, + ])("does not infer transport failure from semantic message text: %o", (error) => { + expect(isRpcTransportFailure(error)).toBe(false); + }); +}); + +describe("runRpcWithRecovery", () => { + it("replays a reviewed read exactly once on a replacement generation", async () => { + const run = vi + .fn<(session: { generation: number }) => Promise>() + .mockRejectedValueOnce(new Error("socket closed")) + .mockResolvedValueOnce("recovered"); + const recover = vi.fn(async () => ({ generation: 2 })); + + await expect( + runRpcWithRecovery({ + method: WS_METHODS.filesystemBrowse, + session: { generation: 1 }, + run, + shouldRecover: () => true, + recover, + }), + ).resolves.toBe("recovered"); + expect(run.mock.calls.map(([session]) => session.generation)).toEqual([1, 2]); + expect(recover).toHaveBeenCalledOnce(); + }); + + it("starts recovery without replaying or replacing a mutation error", async () => { + const failure = new Error("outcome unknown"); + const run = vi.fn(async () => Promise.reject(failure)); + const recover = vi.fn(async () => ({ generation: 2 })); + + await expect( + runRpcWithRecovery({ + method: WS_METHODS.projectsWriteFile, + session: { generation: 1 }, + run, + shouldRecover: () => true, + recover, + }), + ).rejects.toBe(failure); + expect(run).toHaveBeenCalledOnce(); + expect(recover).toHaveBeenCalledOnce(); + }); + + it("does not replay a read without a genuinely new generation", async () => { + const failure = new Error("validation failed"); + const run = vi.fn(async () => Promise.reject(failure)); + + await expect( + runRpcWithRecovery({ + method: WS_METHODS.gitStatus, + session: { generation: 3 }, + run, + shouldRecover: () => true, + recover: async () => ({ generation: 3 }), + }), + ).rejects.toBe(failure); + expect(run).toHaveBeenCalledOnce(); + }); + + it("never attempts a third execution when the replay fails", async () => { + const firstFailure = new Error("first socket closed"); + const replayFailure = new Error("replacement failed"); + const run = vi + .fn<(session: { generation: number }) => Promise>() + .mockRejectedValueOnce(firstFailure) + .mockRejectedValueOnce(replayFailure); + + await expect( + runRpcWithRecovery({ + method: WS_METHODS.serverGetConfig, + session: { generation: 1 }, + run, + shouldRecover: () => true, + recover: async () => ({ generation: 2 }), + }), + ).rejects.toBe(replayFailure); + expect(run).toHaveBeenCalledTimes(2); + }); + + it("preserves semantic errors even if another path already replaced the generation", async () => { + const failure = new Error("Git command timed out after 60000ms"); + const run = vi.fn(async () => Promise.reject(failure)); + const recover = vi.fn(async () => ({ generation: 2 })); + + await expect( + runRpcWithRecovery({ + method: WS_METHODS.filesystemBrowse, + session: { generation: 1 }, + run, + shouldRecover: isRpcTransportFailure, + recover, + }), + ).rejects.toBe(failure); + expect(run).toHaveBeenCalledOnce(); + expect(recover).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/rpcRecoveryPolicy.ts b/apps/web/src/rpcRecoveryPolicy.ts new file mode 100644 index 000000000..874176335 --- /dev/null +++ b/apps/web/src/rpcRecoveryPolicy.ts @@ -0,0 +1,118 @@ +// FILE: rpcRecoveryPolicy.ts +// Purpose: Defines and enforces the narrow set of RPCs that may replay after reconnection. +// Layer: Web transport recovery policy + +import { ORCHESTRATION_WS_METHODS, WS_METHODS } from "@synara/contracts"; + +export type RpcRecoveryPolicy = "retry-on-new-generation" | "never-replay"; + +type KnownRpcMethod = + | (typeof WS_METHODS)[keyof typeof WS_METHODS] + | (typeof ORCHESTRATION_WS_METHODS)[keyof typeof ORCHESTRATION_WS_METHODS]; + +// This allowlist is intentionally conservative. A method belongs here only when +// running it twice cannot create, update, delete, launch user-visible work, +// acknowledge, or spend on the user's behalf. New methods inherit +// `never-replay` until reviewed. +const RETRYABLE_READ_METHODS: ReadonlySet = new Set([ + WS_METHODS.projectsList, + WS_METHODS.projectsDiscoverScripts, + WS_METHODS.projectsListDirectories, + WS_METHODS.projectsSearchEntries, + WS_METHODS.projectsSearchLocalEntries, + WS_METHODS.projectsReadFile, + WS_METHODS.projectsListDevServers, + WS_METHODS.filesystemBrowse, + WS_METHODS.gitGithubRepository, + WS_METHODS.gitStatus, + WS_METHODS.gitReadWorkingTreeDiff, + WS_METHODS.gitListBranches, + WS_METHODS.gitStashInfo, + WS_METHODS.serverGetConfig, + WS_METHODS.serverGetEnvironment, + WS_METHODS.serverGetSettings, + WS_METHODS.serverListWorktrees, + WS_METHODS.serverListLocalServers, + WS_METHODS.serverGetProviderUsageSnapshot, + WS_METHODS.statsGetProfileStats, + WS_METHODS.statsGetProfileTokenStats, + WS_METHODS.serverGetDiagnostics, + WS_METHODS.automationList, + ORCHESTRATION_WS_METHODS.getSnapshot, + ORCHESTRATION_WS_METHODS.getShellSnapshot, + ORCHESTRATION_WS_METHODS.getTurnDiff, + ORCHESTRATION_WS_METHODS.getFullThreadDiff, + ORCHESTRATION_WS_METHODS.replayEvents, +]); + +export function rpcRecoveryPolicyFor(method: string): RpcRecoveryPolicy { + return RETRYABLE_READ_METHODS.has(method as KnownRpcMethod) + ? "retry-on-new-generation" + : "never-replay"; +} + +export interface RpcRecoverySession { + readonly generation: number; +} + +const SOCKET_ERROR_REASONS = new Set([ + "SocketReadError", + "SocketWriteError", + "SocketOpenError", + "SocketCloseError", +]); + +function taggedReason(value: unknown): string | null { + if (!value || typeof value !== "object" || !("reason" in value)) return null; + const reason = value.reason; + if (!reason || typeof reason !== "object" || !("_tag" in reason)) return null; + return typeof reason._tag === "string" ? reason._tag : null; +} + +/** + * Recognizes Effect's structured socket failures without guessing from message + * text. In particular, application and Git timeouts are not transport failures. + */ +export function isRpcTransportFailure(error: unknown): boolean { + if (!error || typeof error !== "object" || !("_tag" in error)) return false; + if (error._tag !== "RpcClientError" && error._tag !== "SocketError") return false; + const reason = taggedReason(error); + return reason !== null && SOCKET_ERROR_REASONS.has(reason); +} + +/** + * Runs one RPC attempt. A structured transport failure always starts connection + * recovery, but only reviewed reads may replay on the replacement generation. + */ +export async function runRpcWithRecovery(input: { + readonly method: string; + readonly session: TSession; + readonly run: (session: TSession) => Promise; + readonly shouldRecover: (error: unknown) => boolean; + readonly recover: (session: TSession, error: unknown) => Promise; +}): Promise { + try { + return await input.run(input.session); + } catch (error) { + if (!input.shouldRecover(error)) throw error; + if (rpcRecoveryPolicyFor(input.method) === "never-replay") { + // The write outcome is uncertain, so preserve the original error and do + // not wait for or execute on a replacement. Recovery still begins now so + // later user actions do not remain pinned to the failed generation. + void input.recover(input.session, error).catch(() => undefined); + throw error; + } + const replacement = await input.recover(input.session, error); + if (!replacement || replacement.generation === input.session.generation) throw error; + try { + return await input.run(replacement); + } catch (replayError) { + if (input.shouldRecover(replayError)) { + // The one reviewed replay is the execution limit, but its failed + // generation must still be invalidated so later calls do not inherit it. + void input.recover(replacement, replayError).catch(() => undefined); + } + throw replayError; + } + } +} diff --git a/apps/web/src/wsTransport.test.ts b/apps/web/src/wsTransport.test.ts index 7c0cdc3a1..0cd3c3352 100644 --- a/apps/web/src/wsTransport.test.ts +++ b/apps/web/src/wsTransport.test.ts @@ -4,9 +4,12 @@ // Depends on: the global WebSocket constructor shim and desktop bridge URL contract. import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { WS_CHANNELS } from "@synara/contracts"; -import { Exit, Stream } from "effect"; +import { WS_CHANNELS, WS_METHODS } from "@synara/contracts"; +import { Effect, Exit, Stream } from "effect"; +import { RpcClientError } from "effect/unstable/rpc/RpcClientError"; +import { SocketCloseError } from "effect/unstable/socket/Socket"; +import { ConnectionSupervisor } from "./connectionSupervisor"; import { EFFECT_RPC_RETRY_CONFIG, isConnectionProtocolFailure, @@ -65,6 +68,94 @@ class MockWebSocket { const originalWebSocket = globalThis.WebSocket; +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 socketClosedFailure() { + return new RpcClientError({ reason: new SocketCloseError({ code: 1006 }) }); +} + +type RpcHarnessFailure = ReturnType; + +interface RpcHarnessInput { + readonly generation: number; + readonly input: unknown; + readonly method: string; +} + +function makeRpcRecoveryHarness(options: { + readonly execute: (input: RpcHarnessInput) => Effect.Effect; + readonly probe: (generation: number) => Promise; + readonly beforeConnect?: (generation: number, signal: AbortSignal) => Promise; +}) { + const attempts: RpcHarnessInput[] = []; + const connectGenerations: number[] = []; + const close = vi.fn(); + const probe = vi.fn((session: { readonly generation: number }) => + options.probe(session.generation), + ); + + const supervisor = new ConnectionSupervisor({ + connect: async (generation, signal) => { + connectGenerations.push(generation); + await options.beforeConnect?.(generation, signal); + const client = new Proxy< + Record Effect.Effect> + >( + {}, + { + get: (_target, method) => (input: unknown) => { + const attempt = { generation, input, method: String(method) }; + attempts.push(attempt); + return options.execute(attempt); + }, + }, + ); + return { + client, + clientScope: {}, + runtime: { + runPromise: (effect: Effect.Effect) => + Effect.runPromise(effect), + }, + }; + }, + close, + probe, + retryBaseDelayMs: 0, + retryMaxDelayMs: 0, + retryJitterRatio: 0, + }); + + const transport = Object.assign(Object.create(WsTransport.prototype) as object, { + disposed: false, + explicitUrl: null, + latestPushByChannel: new Map(), + listeners: new Map(), + sequence: 0, + shellSubscribed: false, + state: "connecting", + stateListeners: new Set(), + streamCleanups: new Map(), + streamRestartTimers: new Map(), + streamStartTokens: new Map(), + streamTransitions: new Map(), + supervisor, + threadSubscriptions: new Map(), + wakeCleanups: [], + }) as unknown as WsTransport; + + supervisor.start(); + return { attempts, close, connectGenerations, probe, supervisor, transport }; +} + function makeStreamHarness() { const transport = new WsTransport("ws://localhost:3020"); transport.dispose(); @@ -377,3 +468,139 @@ describe("WsTransport", () => { expect(listener).not.toHaveBeenCalled(); }); }); + +describe("WsTransport RPC recovery integration", () => { + it("replays a reviewed read once on one replacement generation", async () => { + const failure = socketClosedFailure(); + const harness = makeRpcRecoveryHarness({ + execute: ({ generation }) => + generation === 1 ? Effect.fail(failure) : Effect.succeed("recovered"), + probe: async () => Promise.reject(failure), + }); + + await expect( + harness.transport.request(WS_METHODS.filesystemBrowse, {}, { timeoutMs: null }), + ).resolves.toBe("recovered"); + + expect(harness.connectGenerations).toEqual([1, 2]); + expect(harness.attempts.map(({ generation }) => generation)).toEqual([1, 2]); + expect(harness.probe).toHaveBeenCalledOnce(); + harness.transport.dispose(); + }); + + it("recovers the failed generation without replaying a mutation", async () => { + const failure = socketClosedFailure(); + const harness = makeRpcRecoveryHarness({ + execute: ({ generation, method }) => + generation === 1 && method === WS_METHODS.projectsWriteFile + ? Effect.fail(failure) + : Effect.succeed("next call recovered"), + probe: async () => Promise.reject(failure), + }); + + await expect( + harness.transport.request(WS_METHODS.projectsWriteFile, {}, { timeoutMs: null }), + ).rejects.toBe(failure); + + await vi.waitFor(() => expect(harness.connectGenerations).toEqual([1, 2])); + await expect( + harness.transport.request(WS_METHODS.serverGetConfig, {}, { timeoutMs: null }), + ).resolves.toBe("next call recovered"); + expect( + harness.attempts.filter(({ method }) => method === WS_METHODS.projectsWriteFile), + ).toEqual([{ generation: 1, input: {}, method: WS_METHODS.projectsWriteFile }]); + expect(harness.attempts.map(({ generation }) => generation)).toEqual([1, 2]); + expect(harness.probe).toHaveBeenCalledOnce(); + harness.transport.dispose(); + }); + + it("shares one probe and one replacement generation across concurrent failed reads", async () => { + const failure = socketClosedFailure(); + const probeGate = deferred(); + const harness = makeRpcRecoveryHarness({ + execute: ({ generation, input }) => + generation === 1 + ? Effect.fail(failure) + : Effect.succeed((input as { readonly requestId: string }).requestId), + probe: () => probeGate.promise, + }); + + const left = harness.transport.request( + WS_METHODS.filesystemBrowse, + { requestId: "left" }, + { timeoutMs: null }, + ); + const right = harness.transport.request( + WS_METHODS.filesystemBrowse, + { requestId: "right" }, + { timeoutMs: null }, + ); + await vi.waitFor(() => { + expect(harness.attempts).toHaveLength(2); + expect(harness.probe).toHaveBeenCalledOnce(); + }); + + probeGate.reject(failure); + + await expect(Promise.all([left, right])).resolves.toEqual(["left", "right"]); + expect(harness.connectGenerations).toEqual([1, 2]); + expect(harness.attempts.map(({ generation }) => generation)).toEqual([1, 1, 2, 2]); + expect(harness.probe).toHaveBeenCalledOnce(); + harness.transport.dispose(); + }); + + it("does not replay when the same generation passes its recovery probe", async () => { + const failure = socketClosedFailure(); + const harness = makeRpcRecoveryHarness({ + execute: () => Effect.fail(failure), + probe: async () => undefined, + }); + + await expect( + harness.transport.request(WS_METHODS.gitStatus, {}, { timeoutMs: null }), + ).rejects.toBe(failure); + + expect(harness.connectGenerations).toEqual([1]); + expect(harness.attempts.map(({ generation }) => generation)).toEqual([1]); + expect(harness.probe).toHaveBeenCalledOnce(); + harness.transport.dispose(); + }); + + it("settles a recovering request when transport disposal cancels replacement creation", async () => { + const failure = socketClosedFailure(); + const replacementGate = deferred(); + const harness = makeRpcRecoveryHarness({ + beforeConnect: (generation) => + generation === 2 ? replacementGate.promise : Promise.resolve(), + execute: () => Effect.fail(failure), + probe: async () => Promise.reject(failure), + }); + const request = harness.transport.request(WS_METHODS.serverGetConfig, {}, { timeoutMs: null }); + await vi.waitFor(() => expect(harness.connectGenerations).toEqual([1, 2])); + + harness.transport.dispose(); + + await expect(request).rejects.toThrow("disposed"); + expect(harness.attempts.map(({ generation }) => generation)).toEqual([1]); + replacementGate.resolve(); + await vi.waitFor(() => expect(harness.close).toHaveBeenCalledTimes(2)); + }); + + it("recovers the failed replay generation without executing a third time", async () => { + const firstFailure = socketClosedFailure(); + const replayFailure = socketClosedFailure(); + const harness = makeRpcRecoveryHarness({ + execute: ({ generation }) => Effect.fail(generation === 1 ? firstFailure : replayFailure), + probe: async () => Promise.reject(firstFailure), + }); + + await expect( + harness.transport.request(WS_METHODS.serverGetConfig, {}, { timeoutMs: null }), + ).rejects.toBe(replayFailure); + + await vi.waitFor(() => expect(harness.connectGenerations).toEqual([1, 2, 3])); + expect(harness.attempts.map(({ generation }) => generation)).toEqual([1, 2]); + expect(harness.probe).toHaveBeenCalledTimes(2); + harness.transport.dispose(); + }); +}); diff --git a/apps/web/src/wsTransport.ts b/apps/web/src/wsTransport.ts index 8878fd328..295da72cc 100644 --- a/apps/web/src/wsTransport.ts +++ b/apps/web/src/wsTransport.ts @@ -42,6 +42,7 @@ import { RpcClient, RpcClientError, RpcSerialization } from "effect/unstable/rpc import * as Socket from "effect/unstable/socket/Socket"; import { ConnectionSupervisor, type ConnectionSupervisorSession } from "./connectionSupervisor"; +import { isRpcTransportFailure, runRpcWithRecovery } from "./rpcRecoveryPolicy"; import type { WsTransportState } from "./wsTransportEvents"; type PushListener = (message: WsPushMessage) => void; @@ -336,26 +337,14 @@ export class WsTransport { ? (params as { command: unknown }).command : (params ?? {}); const normalizedRpcInput = omitNullUserInputAnswers(rpcInput); - const call = ( - session.value.client as unknown as Record< - string, - (input: unknown) => Effect.Effect - > - )[method]; - if (!call) throw new WsTransportRpcError({ message: `Unknown RPC method: ${method}` }); - const rpcEffect = - timeoutMs === null - ? call(normalizedRpcInput) - : Effect.timeoutOrElse(call(normalizedRpcInput), { - duration: timeoutMs, - onTimeout: () => - Effect.fail( - new WsTransportRpcError({ - message: `RPC request timed out after ${timeoutMs}ms: ${method}`, - }), - ), - }); - return (await session.value.runtime.runPromise(rpcEffect)) as T; + return runRpcWithRecovery({ + method, + session, + run: (attemptSession) => + this.runRpcCall(attemptSession, method, normalizedRpcInput, timeoutMs), + shouldRecover: isRpcTransportFailure, + recover: (failedSession) => this.recoverReadSession(failedSession, method), + }); } subscribe( @@ -494,6 +483,50 @@ export class WsTransport { return this.supervisor.waitForSession({ timeoutMs }); } + private runRpcCall( + session: ActiveSession, + method: string, + input: unknown, + timeoutMs: number | null, + ): Promise { + const call = ( + session.value.client as unknown as Record< + string, + (input: unknown) => Effect.Effect + > + )[method]; + if (!call) { + return Promise.reject(new WsTransportRpcError({ message: `Unknown RPC method: ${method}` })); + } + const rpcEffect = + timeoutMs === null + ? call(input) + : Effect.timeoutOrElse(call(input), { + duration: timeoutMs, + onTimeout: () => + Effect.fail( + new WsTransportRpcError({ + message: `RPC request timed out after ${timeoutMs}ms: ${method}`, + }), + ), + }); + return session.value.runtime.runPromise(rpcEffect) as Promise; + } + + private async recoverReadSession( + failedSession: ActiveSession, + method: string, + ): Promise { + if (this.disposed) return null; + if (this.supervisor.currentSession?.generation === failedSession.generation) { + await this.supervisor.probe(`RPC ${method} failed`); + } + if (this.disposed || this.supervisor.currentSession?.generation === failedSession.generation) { + return null; + } + return this.getSession(REQUEST_TIMEOUT_MS); + } + private setState(state: WsTransportState): void { if (this.state === state) return; this.state = state; From 211ed85448dddf3cc36366a4847e752b79cde3a6 Mon Sep 17 00:00:00 2001 From: yaacovcorcos Date: Tue, 21 Jul 2026 11:07:09 +0300 Subject: [PATCH 10/68] Surface connection recovery diagnostics (#55) * Surface connection recovery diagnostics * test(web): cover connection recovery experience * style(web): format recovery browser coverage * fix(web): keep recovery diagnostics current --- apps/desktop/src/desktopDiagnostics.test.ts | 21 ++ apps/desktop/src/desktopDiagnostics.ts | 17 ++ apps/desktop/src/main.ts | 6 + apps/desktop/src/preload.ts | 4 + ...onnectionRecoveryNotifications.browser.tsx | 209 ++++++++++++++++++ .../ConnectionRecoveryNotifications.tsx | 126 +++++++++++ apps/web/src/components/ui/toast.tsx | 36 ++- apps/web/src/connectionRecoveryNotice.test.ts | 122 ++++++++++ apps/web/src/connectionRecoveryNotice.ts | 139 ++++++++++++ apps/web/src/routes/__root.tsx | 2 + apps/web/src/wsNativeApi.test.ts | 16 +- apps/web/src/wsNativeApi.ts | 2 +- apps/web/src/wsTransportEvents.test.ts | 78 +++++++ apps/web/src/wsTransportEvents.ts | 9 + packages/contracts/src/ipc.ts | 4 + 15 files changed, 779 insertions(+), 12 deletions(-) create mode 100644 apps/desktop/src/desktopDiagnostics.test.ts create mode 100644 apps/desktop/src/desktopDiagnostics.ts create mode 100644 apps/web/src/components/ConnectionRecoveryNotifications.browser.tsx create mode 100644 apps/web/src/components/ConnectionRecoveryNotifications.tsx create mode 100644 apps/web/src/connectionRecoveryNotice.test.ts create mode 100644 apps/web/src/connectionRecoveryNotice.ts create mode 100644 apps/web/src/wsTransportEvents.test.ts 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/main.ts b/apps/desktop/src/main.ts index d5d954a2b..a78450a61 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -79,6 +79,7 @@ import { 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, @@ -3246,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; diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index eb9a5e55d..dab31a3c9 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -10,6 +10,7 @@ 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"; @@ -75,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/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/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/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 65d398228..4881731d7 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -26,6 +26,7 @@ import { AppSnapCoordinator } from "../components/AppSnapCoordinator"; import { AppSnapWelcomeDialog } from "../components/AppSnapWelcomeDialog"; import { FeedbackDialog } from "../components/FeedbackDialog"; import { ProviderConnectionDialog } from "../components/ProviderConnectionDialog"; +import { ConnectionRecoveryNotifications } from "../components/ConnectionRecoveryNotifications"; import { SETTINGS_TARGETS } from "../settingsNavigation"; import ShortcutsDialog from "../components/ShortcutsDialog"; import WhatsNewDialog from "../components/WhatsNewDialog"; @@ -203,6 +204,7 @@ function RootRouteView() { + diff --git a/apps/web/src/wsNativeApi.test.ts b/apps/web/src/wsNativeApi.test.ts index 40ace20f6..219ccc829 100644 --- a/apps/web/src/wsNativeApi.test.ts +++ b/apps/web/src/wsNativeApi.test.ts @@ -26,6 +26,7 @@ import { import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const requestMock = vi.fn<(...args: Array) => Promise>(); +const onStateChangeMock = vi.fn(() => () => undefined); const showContextMenuFallbackMock = vi.fn< ( @@ -62,9 +63,7 @@ vi.mock("./wsTransport", () => { WsTransport: class MockWsTransport { request = requestMock; subscribe = subscribeMock; - onStateChange() { - return () => undefined; - } + onStateChange = onStateChangeMock; getLatestPush(channel: string) { return latestPushByChannel.get(channel) ?? null; } @@ -116,6 +115,7 @@ const defaultProviders: ReadonlyArray = [ beforeEach(() => { vi.resetModules(); requestMock.mockReset(); + onStateChangeMock.mockClear(); showContextMenuFallbackMock.mockReset(); subscribeMock.mockClear(); channelListeners.clear(); @@ -130,6 +130,16 @@ afterEach(() => { }); describe("wsNativeApi", () => { + it("seeds renderer transport state from the new transport immediately", async () => { + const { createWsNativeApi } = await import("./wsNativeApi"); + + createWsNativeApi(); + + expect(onStateChangeMock).toHaveBeenCalledWith(expect.any(Function), { + replayCurrent: true, + }); + }); + it("delivers and caches valid server.welcome payloads", async () => { const { createWsNativeApi, onServerWelcome } = await import("./wsNativeApi"); diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index 69583a6ad..2c25ee8c7 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -322,7 +322,7 @@ export function createWsNativeApi(): NativeApi { } const transport = new WsTransport(); - transport.onStateChange((state) => emitWsTransportState(state)); + transport.onStateChange((state) => emitWsTransportState(state), { replayCurrent: true }); transport.subscribe(WS_CHANNELS.serverWelcome, (message) => { const payload = message.data; diff --git a/apps/web/src/wsTransportEvents.test.ts b/apps/web/src/wsTransportEvents.test.ts new file mode 100644 index 000000000..3edc096dc --- /dev/null +++ b/apps/web/src/wsTransportEvents.test.ts @@ -0,0 +1,78 @@ +// FILE: wsTransportEvents.test.ts +// Purpose: Verifies transport-state retention and late-subscriber replay. +// Layer: Web transport event tests + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + addWsTransportStateListener, + emitWsTransportState, + getLatestWsTransportState, +} from "./wsTransportEvents"; + +beforeEach(() => { + emitWsTransportState("connecting"); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("wsTransportEvents", () => { + it("retains the latest state even when no browser event target exists", () => { + emitWsTransportState("reconnecting"); + expect(getLatestWsTransportState()).toBe("reconnecting"); + }); + + it("replays the latest state to a late subscriber when requested", () => { + emitWsTransportState("open"); + const listener = vi.fn(); + + const unsubscribe = addWsTransportStateListener(listener, { replayLatest: true }); + + expect(listener).toHaveBeenCalledOnce(); + expect(listener).toHaveBeenCalledWith("open"); + expect(() => unsubscribe()).not.toThrow(); + }); + + it("does not replay unless the caller opts in", () => { + const listener = vi.fn(); + addWsTransportStateListener(listener); + expect(listener).not.toHaveBeenCalled(); + }); + + it("delivers browser events until unsubscribe and ignores events without detail", () => { + const eventListeners = new Set<(event: Event) => void>(); + class TestCustomEvent { + readonly detail: T; + readonly type: string; + + constructor(type: string, init: { detail: T }) { + this.type = type; + this.detail = init.detail; + } + } + vi.stubGlobal("CustomEvent", TestCustomEvent); + vi.stubGlobal("window", { + addEventListener: (_type: string, listener: (event: Event) => void) => + eventListeners.add(listener), + dispatchEvent: (event: Event) => { + for (const listener of eventListeners) listener(event); + return true; + }, + removeEventListener: (_type: string, listener: (event: Event) => void) => + eventListeners.delete(listener), + }); + const listener = vi.fn(); + const unsubscribe = addWsTransportStateListener(listener); + + emitWsTransportState("open"); + for (const eventListener of eventListeners) eventListener({} as Event); + expect(listener).toHaveBeenCalledOnce(); + expect(listener).toHaveBeenCalledWith("open"); + + unsubscribe(); + emitWsTransportState("reconnecting"); + expect(listener).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/web/src/wsTransportEvents.ts b/apps/web/src/wsTransportEvents.ts index 657f355fa..9ebd02f52 100644 --- a/apps/web/src/wsTransportEvents.ts +++ b/apps/web/src/wsTransportEvents.ts @@ -11,8 +11,15 @@ export interface WsTransportStateEventDetail { state: WsTransportState; } +let latestWsTransportState: WsTransportState = "connecting"; + +export function getLatestWsTransportState(): WsTransportState { + return latestWsTransportState; +} + // Emits a browser-local event without leaking transport internals into UI code. export function emitWsTransportState(state: WsTransportState): void { + latestWsTransportState = state; if ( typeof window === "undefined" || typeof window.dispatchEvent !== "function" || @@ -31,7 +38,9 @@ export function emitWsTransportState(state: WsTransportState): void { // Subscribes to the shared transport state event. Returns an idempotent cleanup. export function addWsTransportStateListener( listener: (state: WsTransportState) => void, + options?: { readonly replayLatest?: boolean }, ): () => void { + if (options?.replayLatest) listener(latestWsTransportState); if (typeof window === "undefined" || typeof window.addEventListener !== "function") { return () => undefined; } diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 1016c7145..d17920c13 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -440,6 +440,10 @@ export interface DesktopBridge { shell?: { showInFolder: (path: string) => Promise; }; + diagnostics?: { + /** Opens Scient's local logs without depending on the backend connection. */ + openLogsDirectory: () => Promise; + }; clipboard?: { writeImagePngDataUrl: (dataUrl: string) => Promise; }; From f658bbc503d81f191e97bea357bfa79cf027ff37 Mon Sep 17 00:00:00 2001 From: yaacovcorcos Date: Tue, 21 Jul 2026 11:28:11 +0300 Subject: [PATCH 11/68] Recover Codex sessions after authentication loss (#56) * fix: recover Codex sessions after auth loss * fix(codex): gate auth recovery by provider mode * fix: harden Codex authentication recovery --- .../src/codexAuthenticationError.test.ts | 59 +++ apps/server/src/codexAuthenticationError.ts | 28 ++ .../Layers/ProjectionPipeline.test.ts | 67 ++++ .../Layers/ProjectionPipeline.ts | 2 + .../Layers/ProjectionSnapshotQuery.test.ts | 16 +- .../Layers/ProjectionSnapshotQuery.ts | 6 + .../Layers/ProviderRuntimeIngestion.test.ts | 355 +++++++++++++++++- .../Layers/ProviderRuntimeIngestion.ts | 162 +++++++- .../Layers/ProjectionThreadSessions.ts | 8 + apps/server/src/persistence/Migrations.ts | 2 + ...jectionThreadSessionLastErrorEvent.test.ts | 68 ++++ ...5_ProjectionThreadSessionLastErrorEvent.ts | 26 ++ .../Services/ProjectionThreadSessions.ts | 3 + .../src/provider/Layers/CodexAdapter.test.ts | 183 ++++++++- .../src/provider/Layers/CodexAdapter.ts | 118 +++++- .../Layers/ProviderConnection.test.ts | 112 ++++++ .../src/provider/Layers/ProviderConnection.ts | 18 +- .../provider/Layers/ProviderHealth.test.ts | 126 ++++++- .../src/provider/Layers/ProviderHealth.ts | 92 +++-- apps/web/src/components/ChatView.tsx | 8 + ...odexAuthenticationRecoveryGate.browser.tsx | 90 +++++ .../CodexAuthenticationRecoveryGate.tsx | 38 ++ .../ProviderConnectionDialog.browser.tsx | 252 +++++++++++++ .../components/ProviderConnectionDialog.tsx | 40 +- .../hooks/useProviderAuthRefreshOnFocus.ts | 1 + .../useProviderStatusRefresh.browser.tsx | 57 +++ apps/web/src/lib/codexAuthRecovery.test.ts | 118 ++++++ apps/web/src/lib/codexAuthRecovery.ts | 22 ++ apps/web/src/lib/providerAvailability.test.ts | 20 + apps/web/src/lib/providerAvailability.ts | 12 +- .../providerConnectionPresentation.test.ts | 43 +++ .../src/lib/providerConnectionPresentation.ts | 34 ++ apps/web/src/providerConnectionDialogStore.ts | 3 +- apps/web/src/store.test.ts | 4 + apps/web/src/store.ts | 12 +- apps/web/src/types.ts | 3 + packages/contracts/src/orchestration.ts | 2 + packages/contracts/src/providerRuntime.ts | 1 + packages/contracts/src/server.test.ts | 12 + packages/contracts/src/server.ts | 5 + packages/shared/src/codexConfig.test.ts | 30 ++ packages/shared/src/codexConfig.ts | 12 +- 42 files changed, 2200 insertions(+), 70 deletions(-) create mode 100644 apps/server/src/codexAuthenticationError.test.ts create mode 100644 apps/server/src/codexAuthenticationError.ts create mode 100644 apps/server/src/persistence/Migrations/055_ProjectionThreadSessionLastErrorEvent.test.ts create mode 100644 apps/server/src/persistence/Migrations/055_ProjectionThreadSessionLastErrorEvent.ts create mode 100644 apps/web/src/components/CodexAuthenticationRecoveryGate.browser.tsx create mode 100644 apps/web/src/components/CodexAuthenticationRecoveryGate.tsx create mode 100644 apps/web/src/lib/codexAuthRecovery.test.ts create mode 100644 apps/web/src/lib/codexAuthRecovery.ts 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/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/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/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/ProviderConnection.test.ts b/apps/server/src/provider/Layers/ProviderConnection.test.ts index f8d007501..82cfbaa90 100644 --- a/apps/server/src/provider/Layers/ProviderConnection.test.ts +++ b/apps/server/src/provider/Layers/ProviderConnection.test.ts @@ -106,6 +106,7 @@ function makeConnectionTestLayer(input?: { readonly droidAuthenticationProbe?: typeof probeDroidAcpAuthentication; readonly modelsAvailable?: boolean; readonly initiallyAuthenticated?: boolean; + readonly requiresProviderAccount?: boolean | null; readonly onListModels?: (input: { readonly provider: ProviderKind; readonly binaryPath?: string; @@ -119,6 +120,13 @@ function makeConnectionTestLayer(input?: { 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 } : {}), }); @@ -640,6 +648,110 @@ describe("ProviderConnectionLive", () => { 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 }); diff --git a/apps/server/src/provider/Layers/ProviderConnection.ts b/apps/server/src/provider/Layers/ProviderConnection.ts index ac2530b82..7bc067c04 100644 --- a/apps/server/src/provider/Layers/ProviderConnection.ts +++ b/apps/server/src/provider/Layers/ProviderConnection.ts @@ -500,7 +500,23 @@ export function makeProviderConnectionLive(options?: { const command = commandResult.success; const refreshedBeforeStart = yield* providerHealth.refresh; const currentStatus = refreshedBeforeStart.find((status) => status.provider === provider); - if (currentStatus?.available && currentStatus.authStatus === "authenticated") { + 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 }; } diff --git a/apps/server/src/provider/Layers/ProviderHealth.test.ts b/apps/server/src/provider/Layers/ProviderHealth.test.ts index ee3bb0b76..ff7949dac 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.test.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.test.ts @@ -684,6 +684,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 +775,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 +827,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 +1083,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 +1095,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 +1193,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 +1290,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'); diff --git a/apps/server/src/provider/Layers/ProviderHealth.ts b/apps/server/src/provider/Layers/ProviderHealth.ts index 76b171e79..80f304131 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.ts @@ -19,7 +19,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 { @@ -535,15 +538,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. * @@ -552,7 +546,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; @@ -560,13 +555,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); }); @@ -578,7 +570,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 ───────────────────────────────── @@ -788,22 +780,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, @@ -815,7 +798,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( @@ -881,12 +879,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.", @@ -946,6 +961,7 @@ export const makeCheckCodexProviderStatus = ( status: parsed.status, available: true, authStatus: parsed.authStatus, + requiresProviderAccount: true, version: parsedVersion, ...(codexAuthType ? { authType: codexAuthType } : {}), ...(codexLabel ? { authLabel: codexLabel } : {}), @@ -1890,6 +1906,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 && @@ -1937,11 +1954,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 } + : {}), + }; }); } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 5613fbd4d..2965d083d 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, @@ -10843,6 +10844,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/ProviderConnectionDialog.browser.tsx b/apps/web/src/components/ProviderConnectionDialog.browser.tsx index fadff29ce..055f410d4 100644 --- a/apps/web/src/components/ProviderConnectionDialog.browser.tsx +++ b/apps/web/src/components/ProviderConnectionDialog.browser.tsx @@ -164,6 +164,258 @@ describe("ProviderConnectionDialog", () => { } }); + 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", diff --git a/apps/web/src/components/ProviderConnectionDialog.tsx b/apps/web/src/components/ProviderConnectionDialog.tsx index ca9b28ff1..6b358ed5a 100644 --- a/apps/web/src/components/ProviderConnectionDialog.tsx +++ b/apps/web/src/components/ProviderConnectionDialog.tsx @@ -40,18 +40,31 @@ function formatRemainingTime(startedAt: string, nowMs: number): string { } 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 [clockMs, setClockMs] = useState(() => Date.now()); - + const [runtimeReconnectBaselineOperationId, setRuntimeReconnectBaselineOperationId] = useState< + string | null | undefined + >(undefined); 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 presentation = provider + ? describeProviderConnection(provider, status, { + forceReconnect: runtimeReconnectRequired, + }) + : null; const Icon = provider ? PROVIDER_ICON_COMPONENT_BY_PROVIDER[provider] : null; const activeConnection = status?.connectionState && @@ -59,6 +72,10 @@ export function ProviderConnectionDialog() { ? status.connectionState : null; + useEffect(() => { + setRuntimeReconnectBaselineOperationId(undefined); + }, [isOpen, provider, source]); + useEffect(() => { setActionPending(false); setActionError(null); @@ -123,8 +140,21 @@ export function ProviderConnectionDialog() { (previousMethod !== "claude_subscription" ? previousMethod : undefined) ?? 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 }); - applyProviderStatusesToCache(queryClient, result.providers); + 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) => diff --git a/apps/web/src/hooks/useProviderAuthRefreshOnFocus.ts b/apps/web/src/hooks/useProviderAuthRefreshOnFocus.ts index 9eb61fa62..ec84926b0 100644 --- a/apps/web/src/hooks/useProviderAuthRefreshOnFocus.ts +++ b/apps/web/src/hooks/useProviderAuthRefreshOnFocus.ts @@ -14,6 +14,7 @@ const PROVIDER_AUTH_REFRESH_INTERVAL_MS = 5 * 60_000; export function useProviderAuthRefreshOnFocus(): void { useProviderStatusRefresh({ + initialDelayMs: 0, intervalMs: PROVIDER_AUTH_REFRESH_INTERVAL_MS, 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 index 04f5bb10d..28ebd684f 100644 --- a/apps/web/src/hooks/useProviderStatusRefresh.browser.tsx +++ b/apps/web/src/hooks/useProviderStatusRefresh.browser.tsx @@ -5,6 +5,7 @@ import { render } from "vitest-browser-react"; import { serverQueryKeys } from "../lib/serverReactQuery"; import { readNativeApi } from "../nativeApi"; +import { useProviderAuthRefreshOnFocus } from "./useProviderAuthRefreshOnFocus"; import { useProviderStatusRefresh } from "./useProviderStatusRefresh"; const runtime = { @@ -45,11 +46,67 @@ function RefreshHarness() { return null; } +function AuthRefreshHarness() { + useProviderAuthRefreshOnFocus(); + return null; +} + afterEach(() => { vi.restoreAllMocks(); }); describe("useProviderStatusRefresh", () => { + it("immediately refreshes durable provider-account ownership on first launch", async () => { + const queryClient = new QueryClient(); + const cachedCodex = { + provider: "codex", + status: "ready", + available: true, + authStatus: "authenticated", + requiresProviderAccount: false, + checkedAt: "2026-07-20T16:00:00.000Z", + runtime: { ...runtime, source: "system" as const, canInstall: false, message: null }, + } satisfies ServerProviderStatus; + const refreshedCodex = { + ...cachedCodex, + requiresProviderAccount: true, + checkedAt: "2026-07-20T16:05:00.000Z", + } satisfies ServerProviderStatus; + queryClient.setQueryData(serverQueryKeys.config(), config(cachedCodex)); + const refreshProviders = vi.fn().mockResolvedValue({ providers: [refreshedCodex] }); + 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] + ?.requiresProviderAccount, + ).toBe(true); + } finally { + await screen.unmount(); + queryClient.clear(); + Object.defineProperty(window, "nativeApi", { + configurable: true, + value: previousNativeApi, + }); + } + }); + it("preserves managed-install capability after a window-focus refresh", async () => { const queryClient = new QueryClient(); queryClient.setQueryData( 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/providerAvailability.test.ts b/apps/web/src/lib/providerAvailability.test.ts index 0825391d4..5121be9b5 100644 --- a/apps/web/src/lib/providerAvailability.test.ts +++ b/apps/web/src/lib/providerAvailability.test.ts @@ -143,6 +143,26 @@ describe("isProviderUsable", () => { 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, diff --git a/apps/web/src/lib/providerAvailability.ts b/apps/web/src/lib/providerAvailability.ts index 6e5d39758..965505424 100644 --- a/apps/web/src/lib/providerAvailability.ts +++ b/apps/web/src/lib/providerAvailability.ts @@ -28,6 +28,13 @@ 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; @@ -64,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 @@ -85,7 +95,7 @@ export function isProviderUsable(status: ServerProviderStatus | null | undefined // Missing status means the health check has not confirmed an installed provider yet. return false; } - const authUsable = providerRequiresVerifiedAuth(status.provider) + const authUsable = providerStatusRequiresVerifiedAuth(status) ? status.authStatus === "authenticated" : status.authStatus !== "unauthenticated"; return status.available && status.status === "ready" && authUsable; diff --git a/apps/web/src/lib/providerConnectionPresentation.test.ts b/apps/web/src/lib/providerConnectionPresentation.test.ts index ac6139d43..01d391913 100644 --- a/apps/web/src/lib/providerConnectionPresentation.test.ts +++ b/apps/web/src/lib/providerConnectionPresentation.test.ts @@ -127,6 +127,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, diff --git a/apps/web/src/lib/providerConnectionPresentation.ts b/apps/web/src/lib/providerConnectionPresentation.ts index 3b867d3f4..372fa75e3 100644 --- a/apps/web/src/lib/providerConnectionPresentation.ts +++ b/apps/web/src/lib/providerConnectionPresentation.ts @@ -87,6 +87,7 @@ export interface ProviderConnectionPresentation { 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; @@ -124,6 +125,39 @@ export function describeProviderConnection( }; } + 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 { diff --git a/apps/web/src/providerConnectionDialogStore.ts b/apps/web/src/providerConnectionDialogStore.ts index dcde20340..609bc5730 100644 --- a/apps/web/src/providerConnectionDialogStore.ts +++ b/apps/web/src/providerConnectionDialogStore.ts @@ -11,7 +11,8 @@ export type ProviderConnectionSource = | "health_banner" | "settings" | "empty_state" - | "runtime_error"; + | "runtime_error" + | "runtime_authentication_error"; interface ProviderConnectionDialogStore { isOpen: boolean; diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index fd547fefb..47e4922ee 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -517,12 +517,16 @@ describe("store pure functions", () => { runtimeMode: "full-access", activeTurnId: null, lastError: "provider crashed", + lastErrorEventId: EventId.makeUnsafe("runtime-error-event"), + lastErrorClass: "authentication_error", updatedAt: "2026-02-27T00:02:00.000Z", }, }), ]); expect(next.threads[0]?.error).toBe("provider crashed"); + expect(next.threads[0]?.session?.lastErrorEventId).toBe("runtime-error-event"); + expect(next.threads[0]?.session?.lastErrorClass).toBe("authentication_error"); expect(next.threads[0]?.latestTurn).toMatchObject({ turnId: TurnId.makeUnsafe("turn-running"), state: "error", diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 0c026ed38..556ca9278 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -315,7 +315,9 @@ function threadSessionsEqual( left.activeTurnId === right.activeTurnId && left.createdAt === right.createdAt && left.updatedAt === right.updatedAt && - left.lastError === right.lastError + left.lastError === right.lastError && + left.lastErrorEventId === right.lastErrorEventId && + left.lastErrorClass === right.lastErrorClass ); } @@ -1126,6 +1128,8 @@ function readModelSessionFromThreadSession( runtimeMode: previousThread?.runtimeMode ?? incomingSession?.runtimeMode ?? "full-access", activeTurnId: previousSession.activeTurnId ?? null, lastError: previousSession.lastError ?? null, + lastErrorEventId: previousSession.lastErrorEventId ?? null, + lastErrorClass: previousSession.lastErrorClass ?? null, updatedAt: previousSession.updatedAt, }; } @@ -1577,6 +1581,8 @@ function normalizeThreadSession( createdAt: incoming.updatedAt, updatedAt: incoming.updatedAt, ...(nextLastError ? { lastError: nextLastError } : {}), + ...(incoming.lastErrorEventId ? { lastErrorEventId: incoming.lastErrorEventId } : {}), + ...(incoming.lastErrorClass ? { lastErrorClass: incoming.lastErrorClass } : {}), } satisfies NonNullable; if ( previous && @@ -1586,7 +1592,9 @@ function normalizeThreadSession( previous.activeTurnId === nextSession.activeTurnId && previous.createdAt === nextSession.createdAt && previous.updatedAt === nextSession.updatedAt && - previous.lastError === nextSession.lastError + previous.lastError === nextSession.lastError && + previous.lastErrorEventId === nextSession.lastErrorEventId && + previous.lastErrorClass === nextSession.lastErrorClass ) { return previous; } diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index 5113639bf..bd77646ed 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -18,6 +18,7 @@ import type { ProjectScript as ContractProjectScript, ThreadId, ProjectId, + EventId, TurnId, MessageId, ProviderMentionReference, @@ -317,5 +318,7 @@ export interface ThreadSession { createdAt: string; updatedAt: string; lastError?: string; + lastErrorEventId?: EventId; + lastErrorClass?: string; orchestrationStatus: OrchestrationSessionStatus; } diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 56aa5ab1b..169ae6f67 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -490,6 +490,8 @@ export const OrchestrationSession = Schema.Struct({ runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)), activeTurnId: Schema.NullOr(TurnId), lastError: Schema.NullOr(TrimmedNonEmptyString), + lastErrorEventId: Schema.optional(Schema.NullOr(EventId)), + lastErrorClass: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), updatedAt: IsoDateTime, }); export type OrchestrationSession = typeof OrchestrationSession.Type; diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index cfcfefc15..610c91a83 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -99,6 +99,7 @@ export type RuntimeSessionExitKind = typeof RuntimeSessionExitKind.Type; const RuntimeErrorClass = Schema.Literals([ "provider_error", + "authentication_error", "transport_error", "permission_error", "validation_error", diff --git a/packages/contracts/src/server.test.ts b/packages/contracts/src/server.test.ts index ea1c72070..6a422578b 100644 --- a/packages/contracts/src/server.test.ts +++ b/packages/contracts/src/server.test.ts @@ -14,6 +14,11 @@ describe("provider connection contracts", () => { provider: "codex", method: "codex_browser", }); + expect(decode({ provider: "codex", method: "codex_browser", mode: "reauthenticate" })).toEqual({ + provider: "codex", + method: "codex_browser", + mode: "reauthenticate", + }); expect(decode({ provider: "claudeAgent", method: "claude_account" })).toEqual({ provider: "claudeAgent", method: "claude_account", @@ -39,6 +44,13 @@ describe("provider connection contracts", () => { method: "password", }), ).toThrow(); + expect(() => + Schema.decodeUnknownSync(ServerProviderConnectionStartInput)({ + provider: "codex", + method: "codex_browser", + mode: "force", + }), + ).toThrow(); expect(() => Schema.decodeUnknownSync(ServerProviderConnectionCancelInput)({ provider: "codex", diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index caa1e3423..c099290cd 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -130,6 +130,10 @@ export const ServerProviderStatus = Schema.Struct({ status: ServerProviderStatusState, available: Schema.Boolean, authStatus: ServerProviderAuthStatus, + // `false` means this runtime owns authentication outside the provider's + // account flow (for example a custom Codex model provider). Older servers + // omit the field, so clients retain their provider-specific default. + requiresProviderAccount: Schema.optional(Schema.Boolean), authType: Schema.optional(TrimmedNonEmptyString), authLabel: Schema.optional(TrimmedNonEmptyString), voiceTranscriptionAvailable: Schema.optional(Schema.Boolean), @@ -594,6 +598,7 @@ export type ServerProviderInstallationResult = typeof ServerProviderInstallation export const ServerProviderConnectionStartInput = Schema.Struct({ provider: ProviderKind, method: ServerProviderConnectionMethod, + mode: Schema.optional(Schema.Literals(["connect", "reauthenticate"])), }); export type ServerProviderConnectionStartInput = typeof ServerProviderConnectionStartInput.Type; diff --git a/packages/shared/src/codexConfig.test.ts b/packages/shared/src/codexConfig.test.ts index b750e29da..dfcee81ab 100644 --- a/packages/shared/src/codexConfig.test.ts +++ b/packages/shared/src/codexConfig.test.ts @@ -4,6 +4,7 @@ import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { + codexModelProviderRequiresOpenAIAccount, parseCodexConfigActiveProviderEnvKey, parseCodexConfigModelProvider, parseCodexConfigProviderEnvKey, @@ -36,6 +37,13 @@ describe("parseCodexConfigModelProvider", () => { ).toBe("azure"); }); + it.each(['"model_provider"', "'model_provider'"])( + "reads a quoted top-level %s key", + (quotedKey) => { + expect(parseCodexConfigModelProvider(`${quotedKey} = "azure"\n`)).toBe("azure"); + }, + ); + it("ignores model_provider declarations inside nested sections", () => { expect( parseCodexConfigModelProvider( @@ -45,6 +53,15 @@ describe("parseCodexConfigModelProvider", () => { }); }); +describe("codexModelProviderRequiresOpenAIAccount", () => { + it("recognizes only the default and exact openai provider as account-backed", () => { + expect(codexModelProviderRequiresOpenAIAccount(undefined)).toBe(true); + expect(codexModelProviderRequiresOpenAIAccount("openai")).toBe(true); + expect(codexModelProviderRequiresOpenAIAccount("OpenAI")).toBe(false); + expect(codexModelProviderRequiresOpenAIAccount("my-company-proxy")).toBe(false); + }); +}); + describe("parseCodexConfigProviderEnvKey", () => { it("reads env_key from the matching model provider section", () => { expect( @@ -92,6 +109,19 @@ describe("parseCodexConfigActiveProviderEnvKey", () => { it("returns undefined for the default openai provider", () => { expect(parseCodexConfigActiveProviderEnvKey('model_provider = "openai"\n')).toBeUndefined(); }); + + it("treats a mixed-case OpenAI identifier as a custom provider", () => { + expect( + parseCodexConfigActiveProviderEnvKey( + [ + 'model_provider = "OpenAI"', + "", + "[model_providers.OpenAI]", + 'env_key = "CUSTOM_OPENAI_API_KEY"', + ].join("\n"), + ), + ).toBe("CUSTOM_OPENAI_API_KEY"); + }); }); describe("readActiveCodexProviderEnvKey", () => { diff --git a/packages/shared/src/codexConfig.ts b/packages/shared/src/codexConfig.ts index a7ec69821..a1df1d1d2 100644 --- a/packages/shared/src/codexConfig.ts +++ b/packages/shared/src/codexConfig.ts @@ -9,7 +9,9 @@ import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; function readQuotedAssignmentValue(trimmedLine: string, key: string): string | undefined { - const match = trimmedLine.match(new RegExp(`^${key}\\s*=\\s*(?:"([^"]+)"|'([^']+)')`)); + const match = trimmedLine.match( + new RegExp(`^(?:${key}|"${key}"|'${key}')\\s*=\\s*(?:"([^"]+)"|'([^']+)')`), + ); return match?.[1] ?? match?.[2]; } @@ -38,6 +40,12 @@ export function parseCodexConfigModelProvider(content: string): string | undefin return undefined; } +export function codexModelProviderRequiresOpenAIAccount( + provider: string | undefined, +): provider is undefined | "openai" { + return provider === undefined || provider === "openai"; +} + export function parseCodexConfigProviderEnvKey( content: string, provider: string, @@ -64,7 +72,7 @@ export function parseCodexConfigProviderEnvKey( export function parseCodexConfigActiveProviderEnvKey(content: string): string | undefined { const provider = parseCodexConfigModelProvider(content); - if (!provider || provider === "openai") { + if (codexModelProviderRequiresOpenAIAccount(provider)) { return undefined; } From 88ac61d34b8257c2d7f3663853d34eb15fd78819 Mon Sep 17 00:00:00 2001 From: yaacovcorcos Date: Tue, 21 Jul 2026 11:48:25 +0300 Subject: [PATCH 12/68] Support Linux desktop development launch (#57) * Secure Scient state initialization * Allow legacy migration state test * Supervise the desktop backend lifecycle * Supervise desktop connection recovery * Define safe RPC recovery policies * Surface connection recovery diagnostics * Support Linux desktop development launch * Isolate provider dialog browser fixtures * fix(desktop): fail closed on unsafe Linux sandbox * style(desktop): format Linux launcher hardening * fix(release): preserve AppImage sandbox * revert(release): keep AppImage migration out of launcher hardening * fix(desktop): honor Linux user namespace sandbox --- apps/desktop/scripts/dev-electron.mjs | 22 ++-- apps/desktop/scripts/electron-launcher.mjs | 85 +++++++++++++ .../scripts/electron-launcher.test.mjs | 113 ++++++++++++++++++ apps/desktop/scripts/smoke-test.mjs | 6 +- apps/desktop/scripts/start-electron.mjs | 7 +- 5 files changed, 218 insertions(+), 15 deletions(-) create mode 100644 apps/desktop/scripts/electron-launcher.test.mjs 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, From 3c04060cb946ffe1d0d87bea7ca6a31b9a4ad49e Mon Sep 17 00:00:00 2001 From: yaacovcorcos Date: Tue, 21 Jul 2026 12:47:47 +0300 Subject: [PATCH 13/68] Fix Antigravity browser authentication (#59) * Fix Antigravity browser authentication * Harden Antigravity authentication lifecycle * Close Antigravity code window deterministically * Keep Antigravity code submission cancellable --- .../Layers/ProviderConnection.test.ts | 824 ++++++++++++++++-- .../src/provider/Layers/ProviderConnection.ts | 451 ++++++++-- .../provider/Layers/ProviderHealth.test.ts | 72 +- .../src/provider/Layers/ProviderHealth.ts | 58 +- .../provider/Services/ProviderConnection.ts | 4 + apps/server/src/wsRpc.ts | 5 + .../ProviderConnectionDialog.browser.tsx | 191 ++++ .../components/ProviderConnectionDialog.tsx | 164 +++- apps/web/src/wsNativeApi.test.ts | 15 +- apps/web/src/wsNativeApi.ts | 2 + docs/plans/managed-provider-installation.md | 24 +- packages/contracts/src/ipc.ts | 4 + packages/contracts/src/rpc.ts | 11 + packages/contracts/src/server.test.ts | 30 + packages/contracts/src/server.ts | 19 + packages/contracts/src/ws.test.ts | 16 +- packages/contracts/src/ws.ts | 7 + 17 files changed, 1713 insertions(+), 184 deletions(-) diff --git a/apps/server/src/provider/Layers/ProviderConnection.test.ts b/apps/server/src/provider/Layers/ProviderConnection.test.ts index 82cfbaa90..144d473ca 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,14 +29,25 @@ import { } from "../Services/ProviderRuntimeManager"; import { + antigravityAuthenticationCommandArgs, expectedMethodForProvider, makeProviderConnectionLive, + parseAntigravityOAuthAuthorizationUrl, parseGrokOAuthAuthorizationUrl, providerConnectionCommandArgs, } from "./ProviderConnection"; 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, @@ -67,15 +83,21 @@ const TEST_CONFIG: ServerConfigShape = { 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, @@ -83,7 +105,11 @@ function makeHandle(input: { ? input.stdout ? Stream.concat(Stream.make(encoder.encode(input.stdout)), Stream.never) : Stream.never - : Stream.make(encoder.encode(input.stdout ?? "browser opened")), + : 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, @@ -99,20 +125,41 @@ function makeConnectionTestLayer(input?: { 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 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; + const connectionStateWaiters = new Set< + (state: ServerProviderConnectionState | undefined) => void + >(); let authenticated = input?.initiallyAuthenticated ?? false; let refreshCalls = 0; const status = (): ServerProviderStatus => ({ @@ -143,6 +190,7 @@ function makeConnectionTestLayer(input?: { setConnectionState: (_provider, state) => Effect.sync(() => { connectionState = state ?? undefined; + for (const waiter of [...connectionStateWaiters]) waiter(connectionState); return [status()]; }), streamChanges: Stream.empty, @@ -153,35 +201,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?.processExitCode !== undefined ? { code: input.processExitCode } : {}), - ...(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"), @@ -220,25 +357,26 @@ 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?.beforeAntigravityOutputPublication + ? { beforeAntigravityOutputPublication: input.beforeAntigravityOutputPublication } + : {}), + ...(input?.afterAntigravityCodeWindowInputClosed + ? { afterAntigravityCodeWindowInputClosed: input.afterAntigravityCodeWindowInputClosed } + : {}), ...(input?.droidAuthenticationProbe ? { droidAuthenticationProbe: input.droidAuthenticationProbe } : {}), @@ -251,7 +389,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", () => { @@ -283,7 +434,7 @@ 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([]); }); @@ -335,16 +486,72 @@ describe("Grok OAuth authorization URL parsing", () => { }); }); +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", + "60s", + "--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( @@ -354,19 +561,513 @@ 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_CONFIG.stateDir }, + }); + 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_CONFIG.stateDir }), + }), + ]), + ); + expect(onAuthenticationKill).toHaveBeenCalledTimes(1); + expect(onListModels).toHaveBeenCalledWith({ + provider: "antigravity", + binaryPath: "agy", + cwd: TEST_CONFIG.stateDir, + }); + }); + + 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: "antigravity", + hanging: true, + processStdout: `Authentication required:\n${authorizationUrl}\n`, + }); + + await Effect.runPromise( + Effect.gen(function* () { + const connection = yield* ProviderConnection; + 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)), + ); + }); + + 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: "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; + 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)), + ); + }); + + 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: "antigravity", + method: "antigravity_browser", + }); + 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)), + ); + }); + + 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; + }); + 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; + }, + }); + + await Effect.runPromise( + Effect.gen(function* () { + const connection = yield* ProviderConnection; + const cancelledPublished = fixture.waitForConnectionState( + (state) => state?.status === "cancelled", + ); + const started = yield* connection.start({ + provider: "antigravity", + method: "antigravity_browser", + }); + 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("closes Antigravity code entry on schedule even when the PTY hangs", async () => { + const onKill = vi.fn(); + 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: "antigravity", + method: "antigravity_browser", + }); + const operationId = started.providers[0]?.connectionState?.operationId; + 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("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); + }, + }); + + await Effect.runPromise( + Effect.gen(function* () { + const connection = yield* ProviderConnection; + 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(Buffer.concat(submittedChunks.map((chunk) => Buffer.from(chunk))).toString("utf8")).toBe( + "4/test-code-before-deadline\n", + ); + }); + + 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: "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.submitAuthorizationCode({ + provider: "antigravity", + operationId: operationId!, + authorizationCode: "4/test-code-456", + }), + ); + expect(duplicate._tag).toBe("Failure"); + if (duplicate._tag === "Failure") { + expect(duplicate.failure.reason).toBe("authorization_code_already_submitted"); + } + 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)), ); - expect(onPtyKill).toHaveBeenCalledTimes(1); }); it("starts Grok's browser login with the resolved executable", async () => { @@ -546,6 +1247,7 @@ describe("ProviderConnectionLive", () => { expect(onListModels).toHaveBeenCalledWith({ provider: "claudeAgent", binaryPath: "claude", + cwd: TEST_CONFIG.cwd, }); }); diff --git a/apps/server/src/provider/Layers/ProviderConnection.ts b/apps/server/src/provider/Layers/ProviderConnection.ts index 7bc067c04..708b12098 100644 --- a/apps/server/src/provider/Layers/ProviderConnection.ts +++ b/apps/server/src/provider/Layers/ProviderConnection.ts @@ -24,6 +24,7 @@ import { Fiber, Layer, Option, + Queue, Ref, Result, Scope, @@ -36,6 +37,7 @@ import { buildCodexProcessEnv } from "../../codexProcessEnv"; import { resolveBaseCodexHomePath } from "../../codexHomePaths"; import { ServerSettingsService } from "../../serverSettings"; import { collectUint8StreamText } from "../../stream/collectUint8StreamText"; +import { PtyAdapter } from "../../terminal/Services/PTY"; import { buildClaudeProcessEnv } from "../claudeProcessEnv"; import { buildCursorAgentCommand } from "../acp/CursorAcpCommand"; import { probeDroidAcpAuthentication } from "../acp/DroidAcpSupport"; @@ -43,37 +45,62 @@ 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"; const CONNECTION_TIMEOUT = Duration.minutes(10); +const ANTIGRAVITY_CODE_WINDOW_TIMEOUT = Duration.seconds(60); +const ANTIGRAVITY_AUTHENTICATION_PROBE_INTERVAL = Duration.millis(500); +// Antigravity keeps the provider-owned code window at 60 seconds. The local +// supervisor gets a small hidden grace period so a CLI exit at that boundary +// can still complete one bounded authentication probe. +const ANTIGRAVITY_CONNECTION_TIMEOUT = Duration.seconds(65); 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 strategy?: "antigravity-pty"; + readonly strategy?: "antigravity-browser"; } interface ConnectionOutputObserver { - readonly onOutputChunk?: (chunk: Uint8Array) => Effect.Effect; + readonly onOutputChunk?: (chunk: Uint8Array) => Effect.Effect | undefined; } const GROK_OAUTH_AUTHORIZATION_ORIGIN = "https://auth.x.ai"; const GROK_OAUTH_AUTHORIZATION_PATH = "/oauth2/authorize"; -const GROK_OAUTH_OUTPUT_BUFFER_MAX_CHARS = 16 * 1024; +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 { - const candidates = - output.match(/https:\/\/auth\.x\.ai\/oauth2\/authorize\?[^\s\u0000-\u001f\u007f<>"']+/gu) ?? []; - for (const candidate of candidates) { + for (const candidate of outputUrlCandidates(output)) { if (candidate.length > 8_192) continue; try { const url = new URL(candidate); @@ -112,6 +139,66 @@ export function parseGrokOAuthAuthorizationUrl(output: string): string | null { 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 has no login subcommand, and its 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", + "60s", + "--print", + ANTIGRAVITY_AUTH_PROMPT, + ]; +} + export function expectedMethodForProvider( provider: ProviderKind, ): ServerProviderConnectionMethod | null { @@ -166,9 +253,20 @@ 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 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; return Layer.effect( ProviderConnection, @@ -329,8 +427,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; } @@ -406,18 +505,22 @@ export function makeProviderConnectionLive(options?: { ) { 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(observer.onOutputChunk)) : stream; + observer?.onOutputChunk + ? stream.pipe(Stream.tap((chunk) => observer.onOutputChunk?.(chunk) ?? Effect.void)) + : stream; const [stdout, stderr, exitCode] = yield* Effect.all( [ collectUint8StreamText({ @@ -438,48 +541,152 @@ export function makeProviderConnectionLive(options?: { 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 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; + } + 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) { @@ -521,6 +728,12 @@ export function makeProviderConnectionLive(options?: { 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"]; @@ -547,30 +760,32 @@ export function makeProviderConnectionLive(options?: { provider, state({ status: "waiting_for_browser", message: command.waitingMessage }), ); - let grokOutputBuffer = ""; - let publishedGrokAuthorizationUrl: string | null = null; - const grokOutputObserver: ConnectionOutputObserver | undefined = - provider === "grok" + let oauthOutputBuffer = ""; + let publishedAuthorizationUrl: string | null = null; + const oauthOutputObserver: ConnectionOutputObserver | undefined = + provider === "grok" || provider === "antigravity" ? { - onOutputChunk: (chunk) => - Effect.gen(function* () { - grokOutputBuffer = - `${grokOutputBuffer}${Buffer.from(chunk).toString("utf8")}`.slice( - -GROK_OAUTH_OUTPUT_BUFFER_MAX_CHARS, - ); - const authorizationUrl = parseGrokOAuthAuthorizationUrl(grokOutputBuffer); - if (!authorizationUrl || authorizationUrl === publishedGrokAuthorizationUrl) - return; - publishedGrokAuthorizationUrl = authorizationUrl; - yield* publishState( - provider, - state({ - status: "waiting_for_browser", - message: command.waitingMessage, - authorizationUrl, - }), + 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 = @@ -580,11 +795,30 @@ export function makeProviderConnectionLive(options?: { childProcessSpawner: spawner, cwd: serverConfig.cwd, }).pipe(Effect.as(0)) - : command.strategy === "antigravity-pty" - ? runAntigravityConnection(command) - : runCommand(command, grokOutputObserver).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, ); @@ -651,7 +885,7 @@ export function makeProviderConnectionLive(options?: { .listModels({ provider, binaryPath: command.executable, - cwd: serverConfig.cwd, + cwd: command.cwd ?? serverConfig.cwd, }) .pipe(Effect.timeoutOption(Duration.seconds(30)), Effect.result); if ( @@ -713,7 +947,14 @@ export function makeProviderConnectionLive(options?: { ); 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); @@ -742,7 +983,73 @@ export function makeProviderConnectionLive(options?: { }, ); - 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 ff7949dac..3e635c3fb 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.test.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.test.ts @@ -2,7 +2,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; 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"; @@ -73,6 +73,7 @@ function mockSpawnerLayer( options: | { readonly env?: NodeJS.ProcessEnv; + readonly cwd?: string; readonly windowsVerbatimArguments?: boolean; } | undefined, @@ -90,6 +91,7 @@ function mockSpawnerLayer( args: ReadonlyArray; options?: { env?: NodeJS.ProcessEnv; + cwd?: string; windowsVerbatimArguments?: boolean; }; }; @@ -2000,22 +2002,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.version, "1.0.11"); + 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, - "Antigravity CLI 1.0.11 is too old for Scient. Upgrade to 1.0.12 or newer.", + "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.1.3"); + assert.strictEqual( + status.message, + "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}`); }), @@ -2030,14 +2078,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 { @@ -2112,14 +2160,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 80f304131..6c016273b 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.ts @@ -123,7 +123,7 @@ 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 MINIMUM_ANTIGRAVITY_CLI_VERSION = "1.1.4"; const PROVIDERS = [ CODEX_PROVIDER, @@ -586,14 +586,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", @@ -752,11 +757,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`)) @@ -1588,11 +1598,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, ); @@ -1611,11 +1622,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; @@ -1630,10 +1642,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", @@ -1644,7 +1664,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, ); @@ -2400,7 +2420,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, 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/wsRpc.ts b/apps/server/src/wsRpc.ts index c3d908bb3..ce57540ec 100644 --- a/apps/server/src/wsRpc.ts +++ b/apps/server/src/wsRpc.ts @@ -1049,6 +1049,11 @@ export const makeWsRpcLayer = () => 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) => diff --git a/apps/web/src/components/ProviderConnectionDialog.browser.tsx b/apps/web/src/components/ProviderConnectionDialog.browser.tsx index 055f410d4..e677c78fc 100644 --- a/apps/web/src/components/ProviderConnectionDialog.browser.tsx +++ b/apps/web/src/components/ProviderConnectionDialog.browser.tsx @@ -54,6 +54,7 @@ function installNativeApi(overrides: { refreshProviders?: ReturnType; startProviderConnection?: ReturnType; cancelProviderConnection?: ReturnType; + submitProviderConnectionAuthorizationCode?: ReturnType; prepareProviderInstall?: ReturnType; installProvider?: ReturnType; openExternal?: ReturnType; @@ -75,6 +76,12 @@ function installNativeApi(overrides: { ...(overrides.cancelProviderConnection ? { cancelProviderConnection: overrides.cancelProviderConnection } : {}), + ...(overrides.submitProviderConnectionAuthorizationCode + ? { + submitProviderConnectionAuthorizationCode: + overrides.submitProviderConnectionAuthorizationCode, + } + : {}), ...(overrides.prepareProviderInstall ? { prepareProviderInstall: overrides.prepareProviderInstall } : {}), @@ -741,6 +748,9 @@ describe("ProviderConnectionDialog", () => { try { await page.getByRole("button", { name: "Open xAI sign-in again" }).click(); await vi.waitFor(() => expect(openExternal).toHaveBeenCalledWith(authorizationUrl)); + await expect + .element(page.getByPlaceholder("Paste authorization code")) + .not.toBeInTheDocument(); } finally { await screen.unmount(); queryClient.clear(); @@ -748,6 +758,187 @@ describe("ProviderConnectionDialog", () => { } }); + 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 Google sign-in again" }).click(); + await vi.waitFor(() => expect(openExternal).toHaveBeenCalledWith(authorizationUrl)); + await expect.element(page.getByText(/Automatic timeout in (?:1:00|0: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", diff --git a/apps/web/src/components/ProviderConnectionDialog.tsx b/apps/web/src/components/ProviderConnectionDialog.tsx index 6b358ed5a..725b359ca 100644 --- a/apps/web/src/components/ProviderConnectionDialog.tsx +++ b/apps/web/src/components/ProviderConnectionDialog.tsx @@ -4,7 +4,7 @@ import type { ServerProviderConnectionMethod, ServerProviderInstallPlan } from "@synara/contracts"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { CLAUDE_CONNECTION_METHOD_OPTIONS, @@ -27,13 +27,15 @@ import { DialogPopup, DialogTitle, } from "./ui/dialog"; +import { Input } from "./ui/input"; import { Spinner } from "./ui/spinner"; const CONNECTION_TIMEOUT_MS = 10 * 60 * 1_000; +const ANTIGRAVITY_CONNECTION_TIMEOUT_MS = 60 * 1_000; -function formatRemainingTime(startedAt: string, nowMs: number): string { +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((CONNECTION_TIMEOUT_MS - elapsedMs) / 1_000)); + 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")}`; @@ -50,6 +52,13 @@ export function ProviderConnectionDialog() { 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; @@ -71,6 +80,7 @@ export function ProviderConnectionDialog() { ["starting", "waiting_for_browser", "verifying"].includes(status.connectionState.status) ? status.connectionState : null; + activeConnectionOperationIdRef.current = activeConnection?.operationId ?? null; useEffect(() => { setRuntimeReconnectBaselineOperationId(undefined); @@ -111,6 +121,25 @@ export function ProviderConnectionDialog() { 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 = status?.available === true && providerConnectionMethod(provider) !== null; @@ -160,9 +189,20 @@ export function ProviderConnectionDialog() { 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, @@ -175,6 +215,7 @@ export function ProviderConnectionDialog() { const restartSignIn = () => { const operation = status?.connectionState; if (!operation) return Promise.resolve(); + invalidateAuthorizationCodeSubmission(operation.operationId); return runAction(async () => { const cancelled = await ensureNativeApi().server.cancelProviderConnection({ provider, @@ -191,6 +232,54 @@ export function ProviderConnectionDialog() { 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) { @@ -270,15 +359,23 @@ export function ProviderConnectionDialog() { : "Checking the current provider state."}
- {activeConnection ? ( + {activeConnection && activeConnection.status !== "verifying" ? (

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

) : null}
) : null} - {provider === "grok" && activeConnection?.authorizationUrl ? ( + {(provider === "grok" || provider === "antigravity") && + activeConnection?.authorizationUrl ? ( ) : 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

@@ -335,9 +477,11 @@ 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."} + {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."}

diff --git a/apps/web/src/wsNativeApi.test.ts b/apps/web/src/wsNativeApi.test.ts index 219ccc829..37735cc40 100644 --- a/apps/web/src/wsNativeApi.test.ts +++ b/apps/web/src/wsNativeApi.test.ts @@ -610,7 +610,7 @@ describe("wsNativeApi", () => { expect(requestMock).toHaveBeenCalledWith(WS_METHODS.serverGetEnvironment); }); - it("forwards provider connection start and cancel requests", async () => { + it("forwards provider connection start, code submission, and cancel requests", async () => { requestMock.mockResolvedValue({ providers: defaultProviders }); const { createWsNativeApi } = await import("./wsNativeApi"); const api = createWsNativeApi(); @@ -623,6 +623,11 @@ describe("wsNativeApi", () => { provider: "codex", operationId: "operation-1", }); + await api.server.submitProviderConnectionAuthorizationCode({ + provider: "antigravity", + operationId: "operation-2", + authorizationCode: "4/test-code-123", + }); expect(requestMock).toHaveBeenCalledWith(WS_METHODS.serverStartProviderConnection, { provider: "codex", @@ -632,6 +637,14 @@ describe("wsNativeApi", () => { provider: "codex", operationId: "operation-1", }); + expect(requestMock).toHaveBeenCalledWith( + WS_METHODS.serverSubmitProviderConnectionAuthorizationCode, + { + provider: "antigravity", + operationId: "operation-2", + authorizationCode: "4/test-code-123", + }, + ); }); it("fetches auth session state over HTTP", async () => { diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index 2c25ee8c7..8036559d8 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -647,6 +647,8 @@ export function createWsNativeApi(): NativeApi { transport.request(WS_METHODS.serverStartProviderConnection, input), cancelProviderConnection: (input) => transport.request(WS_METHODS.serverCancelProviderConnection, input), + submitProviderConnectionAuthorizationCode: (input) => + transport.request(WS_METHODS.serverSubmitProviderConnectionAuthorizationCode, input), prepareProviderInstall: (input) => transport.request(WS_METHODS.serverPrepareProviderInstall, input), installProvider: (input) => transport.request(WS_METHODS.serverInstallProvider, input), diff --git a/docs/plans/managed-provider-installation.md b/docs/plans/managed-provider-installation.md index 884c30f8e..3fe7fe393 100644 --- a/docs/plans/managed-provider-installation.md +++ b/docs/plans/managed-provider-installation.md @@ -12,17 +12,17 @@ The runtime-installation half of this design is now implemented for every provid ## Current implementation matrix -| Provider | Automatic runtime on current macOS arm64 target | In-app connection | Remaining release gate | -| ----------- | ------------------------------------------------ | -------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | -| Codex | Yes, pinned official standalone release | Official browser login and status verification | Clean-machine proof on every claimed OS/architecture | -| Claude | Yes, pinned native release and manifest checksum | Authorized Claude Console browser login and status verification | Fresh Console account and billing-onboarding proof; subscription login stays disabled without authorization | -| Antigravity | Yes, pinned manifest release | Bare `agy` in a private PTY opens Google sign-in; `agy models` verifies it | Clean-account authentication proof; other targets need packaged proof | -| Cursor | Yes, pinned macOS arm64 archive | Official browser login and health verification | Managed installation is intentionally gated off on other targets until reviewed | -| Grok | Yes, pinned macOS arm64 binary | Official xAI browser login plus explicit model/auth verification | Fresh-account packaged proof; review other targets | -| Droid | Yes, pinned native binary and vendor SHA-256 | Authentication-only ACP device pairing plus non-inference verification | Fresh-account packaged proof on macOS, Windows, and Linux | -| OpenCode | Yes, pinned official GitHub release | Provider guidance | Design a provider-and-method chooser; clean-machine proof on claimed targets | -| Kilo | Yes, pinned official GitHub release | Provider guidance | Design Gateway/provider chooser; clean-machine proof on claimed targets | -| Pi | Bundled; no external CLI is required | Existing embedded provider configuration | Design the model-provider/OAuth chooser and verified credential presentation | +| Provider | Automatic runtime on current macOS arm64 target | In-app connection | Remaining release gate | +| ----------- | ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | +| Codex | Yes, pinned official standalone release | Official browser login and status verification | Clean-machine proof on every claimed OS/architecture | +| Claude | Yes, pinned native release and manifest checksum | Authorized Claude Console browser login and status verification | Fresh Console account and billing-onboarding proof; subscription login stays disabled without authorization | +| Antigravity | Yes, pinned manifest release | Sandboxed `agy --print` runs in a private PTY from neutral state, accepts the transient Google code in-app, and `agy models` verifies it | Clean-account authentication proof; other targets need packaged proof | +| Cursor | Yes, pinned macOS arm64 archive | Official browser login and health verification | Managed installation is intentionally gated off on other targets until reviewed | +| Grok | Yes, pinned macOS arm64 binary | Official xAI browser login plus explicit model/auth verification | Fresh-account packaged proof; review other targets | +| Droid | Yes, pinned native binary and vendor SHA-256 | Authentication-only ACP device pairing plus non-inference verification | Fresh-account packaged proof on macOS, Windows, and Linux | +| OpenCode | Yes, pinned official GitHub release | Provider guidance | Design a provider-and-method chooser; clean-machine proof on claimed targets | +| Kilo | Yes, pinned official GitHub release | Provider guidance | Design Gateway/provider chooser; clean-machine proof on claimed targets | +| Pi | Bundled; no external CLI is required | Existing embedded provider configuration | Design the model-provider/OAuth chooser and verified credential presentation | ## Product requirements @@ -134,7 +134,7 @@ The installation service must: - Source: official platform manifest and native artifact. - Verification now: pinned manifest version and manifest SHA-512. - Smoke test: `agy --version`. -- Authentication now: bare `agy` runs in a private PTY and opens provider-owned Google sign-in. Scient discards raw PTY output and polls the separate model verifier. +- Authentication now: Antigravity 1.1.4 or newer runs sandboxed `agy --print` in a private PTY from Scient's neutral state directory and opens provider-owned Google sign-in. Scient validates the Google URL, forwards one redacted one-time code to that process, discards raw output, polls the separate model verifier, keeps Antigravity's 60-second OAuth window, and allows only a hidden five-second grace for the final local verification probe. - Auth verification: `agy models` returns models. - Managed invocations disable AGY's native self-updater so updates stay inside Scient's reviewed runtime lifecycle. diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index d17920c13..fa9bc95bf 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -132,6 +132,7 @@ import type { ServerProviderConnectionCancelInput, ServerProviderConnectionResult, ServerProviderConnectionStartInput, + ServerProviderConnectionSubmitAuthorizationCodeInput, ServerProviderInstallCancelInput, ServerProviderInstallInput, ServerProviderInstallPlanInput, @@ -653,6 +654,9 @@ export interface NativeApi { cancelProviderConnection: ( input: ServerProviderConnectionCancelInput, ) => Promise; + submitProviderConnectionAuthorizationCode: ( + input: ServerProviderConnectionSubmitAuthorizationCodeInput, + ) => Promise; prepareProviderInstall: ( input: ServerProviderInstallPlanInput, ) => Promise; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index b599e75f4..aba718dcf 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -157,6 +157,7 @@ import { ServerProviderConnectionError, ServerProviderConnectionResult, ServerProviderConnectionStartInput, + ServerProviderConnectionSubmitAuthorizationCodeInput, ServerProviderInstallCancelInput, ServerProviderInstallInput, ServerProviderInstallPlanInput, @@ -714,6 +715,15 @@ export const WsServerCancelProviderConnectionRpc = Rpc.make( }, ); +export const WsServerSubmitProviderConnectionAuthorizationCodeRpc = Rpc.make( + WS_METHODS.serverSubmitProviderConnectionAuthorizationCode, + { + payload: ServerProviderConnectionSubmitAuthorizationCodeInput, + success: ServerProviderConnectionResult, + error: ServerProviderConnectionError, + }, +); + export const WsServerInstallProviderRpc = Rpc.make(WS_METHODS.serverInstallProvider, { payload: ServerProviderInstallInput, success: ServerProviderInstallationResult, @@ -1054,6 +1064,7 @@ export const WsRpcGroup = RpcGroup.make( WsServerRefreshProvidersRpc, WsServerStartProviderConnectionRpc, WsServerCancelProviderConnectionRpc, + WsServerSubmitProviderConnectionAuthorizationCodeRpc, WsServerPrepareProviderInstallRpc, WsServerInstallProviderRpc, WsServerCancelProviderInstallRpc, diff --git a/packages/contracts/src/server.test.ts b/packages/contracts/src/server.test.ts index 6a422578b..49b60745d 100644 --- a/packages/contracts/src/server.test.ts +++ b/packages/contracts/src/server.test.ts @@ -4,6 +4,7 @@ import { Schema } from "effect"; import { ServerProviderConnectionCancelInput, ServerProviderConnectionStartInput, + ServerProviderConnectionSubmitAuthorizationCodeInput, ServerProviderStatus, } from "./server"; @@ -59,6 +60,35 @@ describe("provider connection contracts", () => { ).toThrow(); }); + it("accepts a bounded one-time authorization code and rejects control characters", () => { + const decode = Schema.decodeUnknownSync(ServerProviderConnectionSubmitAuthorizationCodeInput); + expect( + decode({ + provider: "antigravity", + operationId: "operation-1", + authorizationCode: " 4/test_code-123 ", + }), + ).toEqual({ + provider: "antigravity", + operationId: "operation-1", + authorizationCode: "4/test_code-123", + }); + const rejectedCode = "4/test-code\nsecond-line"; + let diagnostic = ""; + try { + decode({ + provider: "antigravity", + operationId: "operation-1", + authorizationCode: rejectedCode, + }); + } catch (error) { + diagnostic = String(error); + } + expect(diagnostic).not.toBe(""); + expect(diagnostic).not.toContain(rejectedCode); + expect(diagnostic).not.toContain("test-code"); + }); + it("keeps connection progress optional for old provider snapshots", () => { const decoded = Schema.decodeUnknownSync(ServerProviderStatus)({ provider: "codex", diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index c099290cd..266eadb5d 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -608,6 +608,22 @@ export const ServerProviderConnectionCancelInput = Schema.Struct({ }); export type ServerProviderConnectionCancelInput = typeof ServerProviderConnectionCancelInput.Type; +export const ServerProviderConnectionAuthorizationCode = Schema.redact( + TrimmedNonEmptyString.check( + Schema.isMinLength(8), + Schema.isMaxLength(2_048), + Schema.isPattern(/^[A-Za-z0-9._~+/=-]+$/), + ), +); + +export const ServerProviderConnectionSubmitAuthorizationCodeInput = Schema.Struct({ + provider: ProviderKind, + operationId: TrimmedNonEmptyString, + authorizationCode: ServerProviderConnectionAuthorizationCode, +}); +export type ServerProviderConnectionSubmitAuthorizationCodeInput = + typeof ServerProviderConnectionSubmitAuthorizationCodeInput.Type; + export class ServerProviderConnectionError extends Schema.TaggedErrorClass()( "ServerProviderConnectionError", { @@ -618,6 +634,9 @@ export class ServerProviderConnectionError extends Schema.TaggedErrorClass }), ); -it.effect("accepts provider connection start and cancel requests", () => +it.effect("accepts provider connection start, code submission, and cancel requests", () => Effect.gen(function* () { const start = yield* decode(WebSocketRequest, { id: "req-provider-connect-1", @@ -106,6 +106,20 @@ it.effect("accepts provider connection start and cancel requests", () => }); assert.strictEqual(start.body._tag, WS_METHODS.serverStartProviderConnection); + const submitCode = yield* decode(WebSocketRequest, { + id: "req-provider-connect-code-1", + body: { + _tag: WS_METHODS.serverSubmitProviderConnectionAuthorizationCode, + provider: "antigravity", + operationId: "operation-1", + authorizationCode: "4/test-code-123", + }, + }); + assert.strictEqual( + submitCode.body._tag, + WS_METHODS.serverSubmitProviderConnectionAuthorizationCode, + ); + const cancel = yield* decode(WebSocketRequest, { id: "req-provider-connect-2", body: { diff --git a/packages/contracts/src/ws.ts b/packages/contracts/src/ws.ts index bed7f30ad..c09a17a4d 100644 --- a/packages/contracts/src/ws.ts +++ b/packages/contracts/src/ws.ts @@ -94,6 +94,7 @@ import { ServerLifecycleStreamEvent, ServerProviderConnectionCancelInput, ServerProviderConnectionStartInput, + ServerProviderConnectionSubmitAuthorizationCodeInput, ServerProviderInstallCancelInput, ServerProviderInstallInput, ServerProviderInstallPlanInput, @@ -213,6 +214,8 @@ export const WS_METHODS = { serverRefreshProviders: "server.refreshProviders", serverStartProviderConnection: "server.startProviderConnection", serverCancelProviderConnection: "server.cancelProviderConnection", + serverSubmitProviderConnectionAuthorizationCode: + "server.submitProviderConnectionAuthorizationCode", serverPrepareProviderInstall: "server.prepareProviderInstall", serverInstallProvider: "server.installProvider", serverCancelProviderInstall: "server.cancelProviderInstall", @@ -401,6 +404,10 @@ const WebSocketRequestBody = Schema.Union([ tagRequestBody(WS_METHODS.serverRefreshProviders, Schema.Struct({})), tagRequestBody(WS_METHODS.serverStartProviderConnection, ServerProviderConnectionStartInput), tagRequestBody(WS_METHODS.serverCancelProviderConnection, ServerProviderConnectionCancelInput), + tagRequestBody( + WS_METHODS.serverSubmitProviderConnectionAuthorizationCode, + ServerProviderConnectionSubmitAuthorizationCodeInput, + ), tagRequestBody(WS_METHODS.serverPrepareProviderInstall, ServerProviderInstallPlanInput), tagRequestBody(WS_METHODS.serverInstallProvider, ServerProviderInstallInput), tagRequestBody(WS_METHODS.serverCancelProviderInstall, ServerProviderInstallCancelInput), From 71da4e46db150a02192f7e1cb1c995f65281cc0e Mon Sep 17 00:00:00 2001 From: yaacovcorcos Date: Tue, 21 Jul 2026 13:50:49 +0300 Subject: [PATCH 14/68] Scope desktop signing credentials by platform (#60) --- .github/workflows/release.yml | 149 ++++-------------- scripts/build-desktop-artifact.ts | 31 ++-- scripts/build-release-desktop-artifact.sh | 122 ++++++++++++++ .../lib/desktop-signing-environment.test.ts | 65 ++++++++ scripts/lib/desktop-signing-environment.ts | 68 ++++++++ scripts/release-smoke.ts | 110 ++++++++++++- 6 files changed, 414 insertions(+), 131 deletions(-) create mode 100644 scripts/build-release-desktop-artifact.sh create mode 100644 scripts/lib/desktop-signing-environment.test.ts create mode 100644 scripts/lib/desktop-signing-environment.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 72ffa4c6b..96a3bdbd3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -253,7 +253,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 +262,34 @@ 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: 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 +303,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 diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 9cc7a444c..236c7d420 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -19,6 +19,7 @@ import { MAC_APPSNAP_HELPER_STAGE_PATH, validateDesktopNativeBuildHost, } from "./lib/desktop-platform-build-config.ts"; +import { isolateDesktopSigningEnvironment } from "./lib/desktop-signing-environment.ts"; import { SCIENT_PRODUCTION_BUNDLE_ID } from "@synara/shared/desktopIdentity"; import { parseBooleanEnvValue } from "./lib/env-bool.ts"; import { verifySingleMacDmgSignature } from "./lib/mac-artifact-signature.ts"; @@ -842,6 +843,19 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( } const electronVersion = desktopPackageJson.dependencies.electron; + const platformBuildConfig = yield* createBuildConfig( + options.platform, + options.target, + desktopPackageJson.productName ?? "Scient", + options.signed, + options.mockUpdates, + options.mockUpdateServerPort, + ); + const signingEnvironment = isolateDesktopSigningEnvironment( + process.env, + options.platform, + options.signed, + ); const serverDependencies = serverPackageJson.dependencies; if (!serverDependencies || Object.keys(serverDependencies).length === 0) { @@ -971,14 +985,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( description: "Scient desktop build", author: "Yaacov Corcos", main: "apps/desktop/dist-electron/main.js", - build: yield* createBuildConfig( - options.platform, - options.target, - desktopPackageJson.productName ?? "Scient", - options.signed, - options.mockUpdates, - options.mockUpdateServerPort, - ), + build: platformBuildConfig, dependencies: { ...resolvedServerDependencies, ...resolvedDesktopRuntimeDependencies, @@ -1003,6 +1010,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( const buildEnv: NodeJS.ProcessEnv = { ...process.env, + ...signingEnvironment, }; for (const [key, value] of Object.entries(buildEnv)) { if (value === "") { @@ -1011,13 +1019,6 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( } if (!options.signed) { buildEnv.CSC_IDENTITY_AUTO_DISCOVERY = "false"; - delete buildEnv.CSC_LINK; - delete buildEnv.CSC_KEY_PASSWORD; - delete buildEnv.WIN_CSC_LINK; - delete buildEnv.WIN_CSC_KEY_PASSWORD; - delete buildEnv.APPLE_API_KEY; - delete buildEnv.APPLE_API_KEY_ID; - delete buildEnv.APPLE_API_ISSUER; } if (process.platform === "win32") { diff --git a/scripts/build-release-desktop-artifact.sh b/scripts/build-release-desktop-artifact.sh new file mode 100644 index 000000000..022ed4aba --- /dev/null +++ b/scripts/build-release-desktop-artifact.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash + +set -euo pipefail + +platform="$1" +target="$2" +arch="$3" +build_version="$4" + +args=( + --platform "$platform" + --target "$target" + --arch "$arch" + --build-version "$build_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 [[ "$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 [[ "$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 $platform." +fi + +if [[ "$platform" == "mac" && "$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 diff --git a/scripts/lib/desktop-signing-environment.test.ts b/scripts/lib/desktop-signing-environment.test.ts new file mode 100644 index 000000000..43fe49ef2 --- /dev/null +++ b/scripts/lib/desktop-signing-environment.test.ts @@ -0,0 +1,65 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { + APPLE_SIGNING_ENV_NAMES, + DESKTOP_SIGNING_ENV_NAMES, + isolateDesktopSigningEnvironment, + WINDOWS_SIGNING_ENV_NAMES, +} from "./desktop-signing-environment.ts"; + +const signingFixture = Object.fromEntries( + DESKTOP_SIGNING_ENV_NAMES.map((name) => [name, `${name}-value`]), +); + +function assertSigningVariablesRemoved(environment: Record): void { + for (const name of DESKTOP_SIGNING_ENV_NAMES) { + assert.notProperty(environment, name); + } +} + +describe("isolateDesktopSigningEnvironment", () => { + it("retains only Apple credentials for a signed macOS packager", () => { + const environment = { ...signingFixture, SAFE_VALUE: "preserved" }; + + const signingEnvironment = isolateDesktopSigningEnvironment(environment, "mac", true); + + assert.deepEqual( + Object.keys(signingEnvironment).toSorted(), + [...APPLE_SIGNING_ENV_NAMES].toSorted(), + ); + assertSigningVariablesRemoved(environment); + assert.equal(environment.SAFE_VALUE, "preserved"); + }); + + it("retains only Windows credentials for a signed Windows packager", () => { + const environment = { ...signingFixture }; + + const signingEnvironment = isolateDesktopSigningEnvironment(environment, "win", true); + + assert.deepEqual( + Object.keys(signingEnvironment).toSorted(), + [...WINDOWS_SIGNING_ENV_NAMES].toSorted(), + ); + assertSigningVariablesRemoved(environment); + }); + + it("provides no signing credentials to Linux or unsigned packagers", () => { + for (const [platform, signed] of [ + ["linux", true], + ["mac", false], + ["win", false], + ] as const) { + const environment = { ...signingFixture }; + + assert.deepEqual(isolateDesktopSigningEnvironment(environment, platform, signed), {}); + assertSigningVariablesRemoved(environment); + } + }); + + it("does not forward empty signing values", () => { + const environment = Object.fromEntries(APPLE_SIGNING_ENV_NAMES.map((name) => [name, ""])); + + assert.deepEqual(isolateDesktopSigningEnvironment(environment, "mac", true), {}); + assertSigningVariablesRemoved(environment); + }); +}); diff --git a/scripts/lib/desktop-signing-environment.ts b/scripts/lib/desktop-signing-environment.ts new file mode 100644 index 000000000..c3108c668 --- /dev/null +++ b/scripts/lib/desktop-signing-environment.ts @@ -0,0 +1,68 @@ +// FILE: desktop-signing-environment.ts +// Purpose: Isolate desktop signing credentials from non-signing build subprocesses. +// Layer: Release/build helper +// Depends on: Platform-specific signing environment variable names. + +import { + WINDOWS_AZURE_SIGNING_ENV_NAMES, + WINDOWS_CERTIFICATE_SIGNING_ENV_NAMES, +} from "./windows-signing.ts"; + +export const APPLE_SIGNING_ENV_NAMES = [ + "CSC_LINK", + "CSC_KEY_PASSWORD", + "APPLE_API_KEY", + "APPLE_API_KEY_ID", + "APPLE_API_ISSUER", +] as const; + +export const WINDOWS_SIGNING_ENV_NAMES = [ + ...WINDOWS_CERTIFICATE_SIGNING_ENV_NAMES, + ...WINDOWS_AZURE_SIGNING_ENV_NAMES, + "AZURE_TRUSTED_SIGNING_FILE_DIGEST", + "AZURE_TRUSTED_SIGNING_TIMESTAMP_DIGEST", + "AZURE_TRUSTED_SIGNING_TIMESTAMP_RFC3161", +] as const; + +export const DESKTOP_SIGNING_ENV_NAMES = [ + ...APPLE_SIGNING_ENV_NAMES, + ...WINDOWS_SIGNING_ENV_NAMES, +] as const; + +type DesktopBuildPlatform = "linux" | "mac" | "win"; +type MutableEnvironment = Record; + +function retainedSigningNames( + platform: DesktopBuildPlatform, + signed: boolean, +): ReadonlySet { + if (!signed) return new Set(); + if (platform === "mac") return new Set(APPLE_SIGNING_ENV_NAMES); + if (platform === "win") return new Set(WINDOWS_SIGNING_ENV_NAMES); + return new Set(); +} + +/** + * Removes every desktop signing variable from the parent environment and returns + * only the credentials required by the selected signed platform. Call this before + * spawning build or staging subprocesses, then pass the returned environment only + * to electron-builder. + */ +export function isolateDesktopSigningEnvironment( + environment: MutableEnvironment, + platform: DesktopBuildPlatform, + signed: boolean, +): NodeJS.ProcessEnv { + const retainedNames = retainedSigningNames(platform, signed); + const signingEnvironment: NodeJS.ProcessEnv = {}; + + for (const name of DESKTOP_SIGNING_ENV_NAMES) { + const value = environment[name]; + delete environment[name]; + if (retainedNames.has(name) && value?.trim()) { + signingEnvironment[name] = value; + } + } + + return signingEnvironment; +} diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index 11c0cc2ec..e606309c1 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -8,6 +8,7 @@ import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } f import { tmpdir } from "node:os"; import { delimiter, dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { parse as parseYaml } from "yaml"; import { SCIENT_DESKTOP_UPDATES_ENABLED, @@ -96,6 +97,30 @@ function assertNotContains(haystack: string, needle: string, message: string): v } } +interface ReleaseWorkflowStep { + readonly env?: Record; + readonly if?: string; + readonly name?: string; +} + +function assertScopedSigningEnvironment( + step: ReleaseWorkflowStep, + expectedNames: ReadonlyArray, + forbiddenNames: ReadonlyArray, +): void { + const environment = step.env ?? {}; + for (const name of expectedNames) { + if (!(name in environment)) { + throw new Error(`Expected ${step.name} to receive ${name}.`); + } + } + for (const name of forbiddenNames) { + if (name in environment) { + throw new Error(`${step.name} must not receive ${name}.`); + } + } +} + function verifyCanonicalIdentity(): void { const serverPackage = JSON.parse( readFileSync(resolve(repoRoot, "apps/server/package.json"), "utf8"), @@ -150,6 +175,71 @@ function verifyReleaseWorkflowSafety(): void { resolve(repoRoot, ".github/workflows/release.yml"), "utf8", ).replaceAll("\r\n", "\n"); + const releaseBuildScript = readFileSync( + resolve(repoRoot, "scripts/build-release-desktop-artifact.sh"), + "utf8", + ).replaceAll("\r\n", "\n"); + const parsedWorkflow = parseYaml(workflow) as { + jobs?: { + build?: { steps?: Array }; + }; + }; + const buildSteps = parsedWorkflow.jobs?.build?.steps ?? []; + const requireBuildStep = (name: string) => { + const step = buildSteps.find((candidate) => candidate.name === name); + if (!step) { + throw new Error(`Expected release workflow build step: ${name}.`); + } + return step; + }; + const macBuildStep = requireBuildStep("Build macOS desktop artifact"); + const linuxBuildStep = requireBuildStep("Build Linux desktop artifact"); + const windowsBuildStep = requireBuildStep("Build Windows desktop artifact"); + const appleSigningNames = [ + "CSC_LINK", + "CSC_KEY_PASSWORD", + "APPLE_API_KEY", + "APPLE_API_KEY_ID", + "APPLE_API_ISSUER", + ]; + const windowsSigningNames = [ + "WIN_CSC_LINK", + "WIN_CSC_KEY_PASSWORD", + "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 (macBuildStep.if !== "matrix.platform == 'mac'") { + throw new Error("Expected macOS signing credentials to be gated to macOS builders."); + } + if (linuxBuildStep.if !== "matrix.platform == 'linux'") { + throw new Error("Expected the unsigned Linux build to be gated to Linux builders."); + } + if (windowsBuildStep.if !== "matrix.platform == 'win'") { + throw new Error("Expected Windows signing credentials to be gated to Windows builders."); + } + assertScopedSigningEnvironment(macBuildStep, appleSigningNames, windowsSigningNames); + assertScopedSigningEnvironment(windowsBuildStep, windowsSigningNames, appleSigningNames); + assertScopedSigningEnvironment( + linuxBuildStep, + [], + [...appleSigningNames, ...windowsSigningNames], + ); + const expectedSigningStep = new Map([ + ...appleSigningNames.map((name) => [name, macBuildStep.name ?? ""] as const), + ...windowsSigningNames.map((name) => [name, windowsBuildStep.name ?? ""] as const), + ]); + for (const step of buildSteps) { + for (const name of [...appleSigningNames, ...windowsSigningNames]) { + if (name in (step.env ?? {}) && step.name !== expectedSigningStep.get(name)) { + throw new Error(`${step.name ?? "Unnamed build step"} must not receive ${name}.`); + } + } + } assertContains( workflow, "if: ${{ vars.SCIENT_DESKTOP_RELEASES_ENABLED == 'true' }}", @@ -206,7 +296,7 @@ function verifyReleaseWorkflowSafety(): void { "Expected publication to require the exact release/stable head.", ); assertContains( - workflow, + releaseBuildScript, "Public macOS releases require all Apple signing and notarization secrets.", "Expected public macOS releases to fail closed without signing and notarization.", ); @@ -216,7 +306,7 @@ function verifyReleaseWorkflowSafety(): void { "Expected the release workflow to accept a standard Windows signing certificate.", ); assertContains( - workflow, + releaseBuildScript, "Public Windows releases require a standard Authenticode certificate or Azure Trusted Signing.", "Expected public Windows releases to fail closed without signing.", ); @@ -292,6 +382,22 @@ function verifyDesktopStageLockAuthority(): void { ")`bun run install`,", "Expected node-pty staging to run its pinned install and postinstall lifecycle.", ); + const signingIsolationIndex = buildScript.indexOf("isolateDesktopSigningEnvironment("); + const firstBuildSubprocessIndex = buildScript.indexOf(")`bun run build:desktop`,"); + if ( + signingIsolationIndex === -1 || + firstBuildSubprocessIndex === -1 || + signingIsolationIndex > firstBuildSubprocessIndex + ) { + throw new Error( + "Expected desktop signing credentials to be removed before the first build subprocess.", + ); + } + assertContains( + buildScript, + "...signingEnvironment", + "Expected signing credentials to be restored only for the electron-builder environment.", + ); const rootPackage = readFileSync(resolve(repoRoot, "package.json"), "utf8"); assertContains( rootPackage, From 4813299680d9639a9271dc4c2575708cff9ced6a Mon Sep 17 00:00:00 2001 From: yaacovcorcos Date: Tue, 21 Jul 2026 18:03:46 +0300 Subject: [PATCH 15/68] Fix managed Antigravity updates, authentication, and dialog UX (#62) * Fix trusted Antigravity install and update routing * Fix Antigravity browser authentication and models * Refine provider connection dialog actions * Harden managed provider update recovery --- .../Layers/AntigravityAdapter.test.ts | 56 +++++ .../src/provider/Layers/AntigravityAdapter.ts | 60 +++++- .../Layers/ProviderConnection.test.ts | 50 ++++- .../src/provider/Layers/ProviderConnection.ts | 34 ++- .../provider/Layers/ProviderHealth.test.ts | 33 ++- .../src/provider/Layers/ProviderHealth.ts | 32 ++- .../Layers/ProviderRuntimeManager.test.ts | 20 +- .../provider/Layers/ProviderRuntimeManager.ts | 75 ++++++- .../src/provider/antigravityReleaseChannel.ts | 47 ++++ .../src/provider/providerMaintenance.test.ts | 56 ++++- .../src/provider/providerMaintenance.ts | 123 ++--------- .../provider/providerRuntimeRecipes.test.ts | 101 +++++++++ .../src/provider/providerRuntimeRecipes.ts | 52 +++-- .../providerUpdateRuntimePolicy.test.ts | 46 ++++ .../provider/providerUpdateRuntimePolicy.ts | 20 ++ apps/server/src/wsRpc.ts | 13 +- .../ProviderConnectionDialog.browser.tsx | 200 ++++++++++++++++-- .../components/ProviderConnectionDialog.tsx | 114 ++++++---- .../providerConnectionPresentation.test.ts | 96 +++++++++ .../src/lib/providerConnectionPresentation.ts | 82 +++++++ apps/web/src/providerConnectionDialogStore.ts | 3 +- apps/web/src/providerUpdates.test.ts | 76 +++++++ apps/web/src/providerUpdates.ts | 5 + apps/web/src/routes/_chat.settings.tsx | 17 +- docs/plans/managed-provider-installation.md | 22 +- packages/shared/package.json | 4 + packages/shared/src/providerVersions.test.ts | 23 ++ packages/shared/src/providerVersions.ts | 92 ++++++++ 28 files changed, 1328 insertions(+), 224 deletions(-) create mode 100644 apps/server/src/provider/antigravityReleaseChannel.ts create mode 100644 apps/server/src/provider/providerRuntimeRecipes.test.ts create mode 100644 apps/server/src/provider/providerUpdateRuntimePolicy.test.ts create mode 100644 apps/server/src/provider/providerUpdateRuntimePolicy.ts create mode 100644 packages/shared/src/providerVersions.test.ts create mode 100644 packages/shared/src/providerVersions.ts 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/ProviderConnection.test.ts b/apps/server/src/provider/Layers/ProviderConnection.test.ts index 144d473ca..193b59089 100644 --- a/apps/server/src/provider/Layers/ProviderConnection.test.ts +++ b/apps/server/src/provider/Layers/ProviderConnection.test.ts @@ -129,6 +129,7 @@ function makeConnectionTestLayer(input?: { 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; @@ -371,6 +372,11 @@ function makeConnectionTestLayer(input?: { antigravityAuthenticationProbeInterval: input.antigravityAuthenticationProbeInterval, } : {}), + ...(input?.antigravityAuthenticationSettleTimeout + ? { + antigravityAuthenticationSettleTimeout: input.antigravityAuthenticationSettleTimeout, + } + : {}), ...(input?.beforeAntigravityOutputPublication ? { beforeAntigravityOutputPublication: input.beforeAntigravityOutputPublication } : {}), @@ -525,7 +531,7 @@ describe("Antigravity OAuth authorization URL parsing", () => { "--model", "__scient_auth_only_operation-1", "--print-timeout", - "60s", + "600s", "--print", "Authenticate this Antigravity CLI only. Do not inspect or modify files and do not perform a task.", ]); @@ -893,6 +899,48 @@ describe("ProviderConnectionLive", () => { ); }); + 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"; diff --git a/apps/server/src/provider/Layers/ProviderConnection.ts b/apps/server/src/provider/Layers/ProviderConnection.ts index 708b12098..d5b6c470f 100644 --- a/apps/server/src/provider/Layers/ProviderConnection.ts +++ b/apps/server/src/provider/Layers/ProviderConnection.ts @@ -48,12 +48,16 @@ import { ProviderRuntimeManager } from "../Services/ProviderRuntimeManager"; import { parseAntigravityModelsAuthStatus } from "./ProviderHealth"; const CONNECTION_TIMEOUT = Duration.minutes(10); -const ANTIGRAVITY_CODE_WINDOW_TIMEOUT = Duration.seconds(60); +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); -// Antigravity keeps the provider-owned code window at 60 seconds. The local -// supervisor gets a small hidden grace period so a CLI exit at that boundary -// can still complete one bounded authentication probe. -const ANTIGRAVITY_CONNECTION_TIMEOUT = Duration.seconds(65); +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 { @@ -180,7 +184,7 @@ export function parseAntigravityOAuthAuthorizationUrl(output: string): string | } /** - * Antigravity 1.1.4 has no login subcommand, and its hidden bare TUI does not + * 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. @@ -193,7 +197,7 @@ export function antigravityAuthenticationCommandArgs(operationId: string): Reado "--model", `__scient_auth_only_${operationId}`, "--print-timeout", - "60s", + `${ANTIGRAVITY_AUTHORIZATION_WINDOW_SECONDS}s`, "--print", ANTIGRAVITY_AUTH_PROMPT, ]; @@ -257,6 +261,7 @@ export function makeProviderConnectionLive(options?: { 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; @@ -267,6 +272,8 @@ export function makeProviderConnectionLive(options?: { 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, @@ -671,6 +678,19 @@ export function makeProviderConnectionLive(options?: { 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; }), ), diff --git a/apps/server/src/provider/Layers/ProviderHealth.test.ts b/apps/server/src/provider/Layers/ProviderHealth.test.ts index 3e635c3fb..fee0d2802 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.test.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.test.ts @@ -261,8 +261,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", @@ -2100,6 +2100,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(); diff --git a/apps/server/src/provider/Layers/ProviderHealth.ts b/apps/server/src/provider/Layers/ProviderHealth.ts index 6c016273b..b73c9ad28 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.ts @@ -73,6 +73,7 @@ import { type ClaudeAccountCapabilities, } from "../claudeCapabilities"; import { buildClaudeProcessEnv } from "../claudeProcessEnv"; +import { MINIMUM_ANTIGRAVITY_CLI_VERSION } from "../antigravityReleaseChannel"; import { detailFromResult, extractAuthBoolean, @@ -92,6 +93,7 @@ import { writeProviderStatusCache, } from "../providerStatusCache"; import { makeProviderMaintenanceCommandCoordinator } from "../providerMaintenanceCommandCoordinator"; +import { providerExternalUpdateBlockReason } from "../providerUpdateRuntimePolicy"; import { enrichProviderStatusWithVersionAdvisory, compareSemverVersions, @@ -123,8 +125,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.1.4"; - const PROVIDERS = [ CODEX_PROVIDER, CLAUDE_AGENT_PROVIDER, @@ -2231,8 +2231,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)); @@ -2648,6 +2662,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 08751e3b8..412ce1e29 100644 --- a/apps/server/src/provider/Layers/ProviderRuntimeManager.test.ts +++ b/apps/server/src/provider/Layers/ProviderRuntimeManager.test.ts @@ -10,7 +10,10 @@ 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"); @@ -31,6 +34,21 @@ function resolveAntigravity(baseDir: string, configuredExecutable?: string) { } 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 { diff --git a/apps/server/src/provider/Layers/ProviderRuntimeManager.ts b/apps/server/src/provider/Layers/ProviderRuntimeManager.ts index eb6585b7d..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", @@ -616,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) { @@ -623,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, @@ -721,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, @@ -752,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", @@ -774,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", 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/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/wsRpc.ts b/apps/server/src/wsRpc.ts index ce57540ec..66e216d58 100644 --- a/apps/server/src/wsRpc.ts +++ b/apps/server/src/wsRpc.ts @@ -58,6 +58,7 @@ import { ProviderAdapterRegistry } from "./provider/Services/ProviderAdapterRegi 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"; @@ -1096,20 +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).pipe( Effect.andThen(providerClientStatusProjection.getStatuses), Effect.map((providers) => ({ providers })), - ), - ), + ); + }), ), [WS_METHODS.serverListWorktrees]: () => Effect.succeed({ worktrees: [] }), [WS_METHODS.serverListLocalServers]: () => diff --git a/apps/web/src/components/ProviderConnectionDialog.browser.tsx b/apps/web/src/components/ProviderConnectionDialog.browser.tsx index e677c78fc..14df1e47b 100644 --- a/apps/web/src/components/ProviderConnectionDialog.browser.tsx +++ b/apps/web/src/components/ProviderConnectionDialog.browser.tsx @@ -105,6 +105,7 @@ function installNativeApi(overrides: { describe("ProviderConnectionDialog", () => { afterEach(() => { useProviderConnectionDialogStore.getState().setOpen(false); + document.documentElement.style.removeProperty("--app-font-size-ui"); document.body.innerHTML = ""; vi.restoreAllMocks(); }); @@ -162,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(); @@ -171,6 +172,58 @@ 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", @@ -690,7 +743,7 @@ describe("ProviderConnectionDialog", () => { ); try { - 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.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(); @@ -733,9 +786,27 @@ describe("ProviderConnectionDialog", () => { 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 restoreNativeApi = installNativeApi({ openExternal }); + 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"); @@ -746,11 +817,22 @@ describe("ProviderConnectionDialog", () => { ); try { - await page.getByRole("button", { name: "Open xAI sign-in again" }).click(); + 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(); @@ -790,9 +872,9 @@ describe("ProviderConnectionDialog", () => { ); try { - await page.getByRole("button", { name: "Open Google sign-in again" }).click(); + await page.getByRole("button", { name: "Open browser again" }).click(); await vi.waitFor(() => expect(openExternal).toHaveBeenCalledWith(authorizationUrl)); - await expect.element(page.getByText(/Automatic timeout in (?:1:00|0:5\d)/u)).toBeVisible(); + await expect.element(page.getByText(/Automatic timeout in (?:10:00|9:5\d)/u)).toBeVisible(); } finally { await screen.unmount(); queryClient.clear(); @@ -904,7 +986,7 @@ describe("ProviderConnectionDialog", () => { await expect .element(page.getByPlaceholder("Paste authorization code")) .not.toBeInTheDocument(); - const cancelButton = page.getByRole("button", { name: "Cancel sign in" }); + 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(); @@ -992,7 +1074,7 @@ describe("ProviderConnectionDialog", () => { } }); - it("requires reviewed consent before starting a managed installation", async () => { + it("requires explicit consent before installing the trusted latest release", async () => { const initialProvider = { provider: "antigravity", status: "error", @@ -1030,16 +1112,16 @@ describe("ProviderConnectionDialog", () => { status: "downloading", startedAt: checkedAt, finishedAt: null, - message: "Downloading Antigravity 1.1.4.", - version: "1.1.4", + 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: "reviewed-plan-1", - version: "1.1.4", + planToken: "trusted-plan-1", + version: "1.1.5", target: "darwin-arm64", sourceHost: "storage.googleapis.com", downloadBytes: 46_664_998, @@ -1063,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(); diff --git a/apps/web/src/components/ProviderConnectionDialog.tsx b/apps/web/src/components/ProviderConnectionDialog.tsx index 725b359ca..711a8a6d3 100644 --- a/apps/web/src/components/ProviderConnectionDialog.tsx +++ b/apps/web/src/components/ProviderConnectionDialog.tsx @@ -3,17 +3,20 @@ // Layer: Shared UI component 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 } 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"; @@ -31,7 +34,7 @@ import { Input } from "./ui/input"; import { Spinner } from "./ui/spinner"; const CONNECTION_TIMEOUT_MS = 10 * 60 * 1_000; -const ANTIGRAVITY_CONNECTION_TIMEOUT_MS = 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)); @@ -48,6 +51,7 @@ export function ProviderConnectionDialog() { 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 @@ -69,11 +73,22 @@ export function ProviderConnectionDialog() { status?.connectionState?.status === "connected" && status.connectionState.operationId !== runtimeReconnectBaselineOperationId; const runtimeReconnectRequired = runtimeReauthenticationFlow && !runtimeReconnectCompleted; - const presentation = provider + 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 && @@ -84,6 +99,7 @@ export function ProviderConnectionDialog() { useEffect(() => { setRuntimeReconnectBaselineOperationId(undefined); + setManagedUpdateStarted(false); }, [isOpen, provider, source]); useEffect(() => { @@ -291,6 +307,7 @@ export function ProviderConnectionDialog() { provider, planToken: installPlan.planToken, }); + if (managedUpdateFlow) setManagedUpdateStarted(true); setInstallPlan(null); applyProviderStatusesToCache(queryClient, result.providers); }); @@ -344,7 +361,7 @@ export function ProviderConnectionDialog() {
- +

{presentation.description}

@@ -371,22 +388,38 @@ export function ProviderConnectionDialog() { )}

) : null} + {presentation.busy && activeConnection ? ( +
+ {(provider === "grok" || provider === "antigravity") && + activeConnection.authorizationUrl ? ( + + ) : null} + {presentation.canRestart ? ( + + ) : null} +
+ ) : null} ) : null} - {(provider === "grok" || provider === "antigravity") && - activeConnection?.authorizationUrl ? ( - - ) : null} - {provider === "antigravity" && activeConnection?.status === "waiting_for_browser" ? (
-

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}` @@ -477,39 +516,42 @@ export function ProviderConnectionDialog() { ) : null}

- {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."} + {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.canRestart ? ( + + {presentation.canCancel ? ( ) : null} - {presentation.canCancel ? ( + {presentation.primaryAction !== "none" ? ( - ) : null} - {presentation.primaryAction !== "none" ? ( - ) : null} diff --git a/apps/web/src/lib/providerConnectionPresentation.test.ts b/apps/web/src/lib/providerConnectionPresentation.test.ts index 01d391913..a1091b8a2 100644 --- a/apps/web/src/lib/providerConnectionPresentation.test.ts +++ b/apps/web/src/lib/providerConnectionPresentation.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest"; import { CLAUDE_CONNECTION_METHOD_OPTIONS, describeProviderConnection, + describeManagedProviderUpdate, providerConnectionMethod, providerInstallUrl, } from "./providerConnectionPresentation"; @@ -17,6 +18,101 @@ 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_account"); diff --git a/apps/web/src/lib/providerConnectionPresentation.ts b/apps/web/src/lib/providerConnectionPresentation.ts index 372fa75e3..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", @@ -84,6 +86,86 @@ export interface ProviderConnectionPresentation { 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, diff --git a/apps/web/src/providerConnectionDialogStore.ts b/apps/web/src/providerConnectionDialogStore.ts index 609bc5730..1bb6f804e 100644 --- a/apps/web/src/providerConnectionDialogStore.ts +++ b/apps/web/src/providerConnectionDialogStore.ts @@ -12,7 +12,8 @@ export type ProviderConnectionSource = | "settings" | "empty_state" | "runtime_error" - | "runtime_authentication_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 56e6321c8..ca67d107c 100644 --- a/apps/web/src/providerUpdates.test.ts +++ b/apps/web/src/providerUpdates.test.ts @@ -269,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/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index 9f98f8358..d4da3a2f3 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -1231,10 +1231,15 @@ function SettingsRouteView() { ); const runProviderUpdate = useCallback( - async (provider: ProviderKind) => { + async (providerStatus: ServerProviderStatus) => { + const provider = providerStatus.provider; if (updatingProviders.has(provider)) { return; } + if (providerStatus.runtime?.source === "managed") { + useProviderConnectionDialogStore.getState().openDialog(provider, "managed_update"); + return; + } setUpdatingProviders((current) => new Set(current).add(provider)); try { const result = await withProviderUpdateTimeout({ @@ -3082,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 ( @@ -3091,18 +3096,18 @@ function SettingsRouteView() { title={PROVIDER_DISPLAY_NAMES[providerStatus.provider]} description={updateLabel || undefined} actions={ - updateAdvisory?.canUpdate ? ( + shouldOfferProviderUpdateAction(providerStatus) ? (
JavaScript initialized inside the preview origin.
@@ -118,7 +118,7 @@

Interactive preview passed

reset.addEventListener("click", () => { value = 0; render(); - status.textContent = "Reset worked. The isolated page remains interactive."; + status.textContent = "Reset worked. The local page remains interactive."; }); window.addEventListener("pagehide", () => window.clearInterval(timer), { once: true }); diff --git a/docs/manual-testing/html-preview/static-preview.html b/docs/manual-testing/html-preview/static-preview.html index 87a1047b5..315085f01 100644 --- a/docs/manual-testing/html-preview/static-preview.html +++ b/docs/manual-testing/html-preview/static-preview.html @@ -99,8 +99,8 @@
Scient · Windows smoke test

Static preview passed

- If this card is visible inside Scient instead of a black page, the inert HTML preview origin - loaded successfully. + If this card is visible inside Scient instead of a black page, the local HTML browser loaded + successfully.