From 80fa2cc340c02fe5676796221da94b1b6a384a3d Mon Sep 17 00:00:00 2001
From: yaacovcorcos
Date: Mon, 20 Jul 2026 19:23:16 +0300
Subject: [PATCH 01/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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