diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index bd34a1c2..2d479f31 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -186,6 +186,9 @@ jobs:
- name: Test Effect Windows process spawn
run: bun run --cwd apps/server test src/windowsProcessEffect.test.ts
+ - name: Test Windows provider runtime discovery
+ run: bun run --cwd apps/server test src/provider/Layers/ProviderRuntimeManager.test.ts
+
- name: Test Windows private state initialization
run: |
bun run --cwd apps/server test src/serverPrivateDirectories.test.ts
diff --git a/apps/server/src/provider/Layers/ProviderConnection.test.ts b/apps/server/src/provider/Layers/ProviderConnection.test.ts
index 580bd996..dc9fb0a1 100644
--- a/apps/server/src/provider/Layers/ProviderConnection.test.ts
+++ b/apps/server/src/provider/Layers/ProviderConnection.test.ts
@@ -1,6 +1,7 @@
import type {
ProviderKind,
ServerProviderConnectionState,
+ ServerProviderInstallationState,
ServerProviderRuntimeSource,
ServerProviderStatus,
} from "@synara/contracts";
@@ -30,11 +31,15 @@ import {
import {
antigravityAuthenticationCommandArgs,
+ CODEX_DEVICE_CODE_CONNECTION_TIMEOUT,
expectedMethodForProvider,
makeProviderConnectionLive,
parseAntigravityOAuthAuthorizationUrl,
+ parseCodexDeviceAuthorization,
+ parseCodexOAuthAuthorizationUrl,
parseGrokOAuthAuthorizationUrl,
providerConnectionCommandArgs,
+ resolveProviderConnectionTimeout,
} from "./ProviderConnection";
import { resolveProviderProbeCwd } from "./ProviderHealth";
@@ -127,6 +132,7 @@ function makeConnectionTestLayer(input?: {
readonly provider?: ProviderKind;
readonly runtimeSource?: ServerProviderRuntimeSource;
readonly timeout?: Duration.Duration;
+ readonly codexDeviceCodeTimeout?: Duration.Duration;
readonly antigravityCodeWindowTimeout?: Duration.Duration;
readonly antigravityCodeWindowCloseSignal?: Effect.Effect;
readonly antigravityTimeout?: Duration.Duration;
@@ -153,6 +159,9 @@ function makeConnectionTestLayer(input?: {
readonly listModelsHanging?: boolean;
readonly initiallyAuthenticated?: boolean;
readonly requiresProviderAccount?: boolean | null;
+ readonly installationState?:
+ | ServerProviderInstallationState
+ | (() => ServerProviderInstallationState | null);
readonly onListModels?: (input: {
readonly provider: ProviderKind;
readonly binaryPath?: string;
@@ -337,7 +346,10 @@ function makeConnectionTestLayer(input?: {
previousReleaseAvailable: false,
bundled: false,
canInstall: false,
- installationState: null,
+ installationState:
+ typeof input?.installationState === "function"
+ ? input.installationState()
+ : (input?.installationState ?? null),
}),
resolve: (provider, configured) =>
Effect.succeed({
@@ -362,6 +374,9 @@ function makeConnectionTestLayer(input?: {
} satisfies ProviderRuntimeManagerShape);
const layer = makeProviderConnectionLive({
...(input?.timeout ? { timeout: input.timeout } : {}),
+ ...(input?.codexDeviceCodeTimeout
+ ? { codexDeviceCodeTimeout: input.codexDeviceCodeTimeout }
+ : {}),
...(input?.antigravityCodeWindowTimeout
? { antigravityCodeWindowTimeout: input.antigravityCodeWindowTimeout }
: {}),
@@ -417,6 +432,10 @@ describe("provider connection command allowlist", () => {
it("uses Codex browser login with fixed argv", () => {
expect(expectedMethodForProvider("codex")).toBe("codex_browser");
expect(providerConnectionCommandArgs("codex", "codex_browser")).toEqual(["login"]);
+ expect(providerConnectionCommandArgs("codex", "codex_device_code")).toEqual([
+ "login",
+ "--device-auth",
+ ]);
});
it("uses normal Claude account login by default and keeps explicit alternatives", () => {
@@ -469,6 +488,49 @@ describe("provider connection command allowlist", () => {
});
});
+describe("Codex authorization output parsing", () => {
+ const authorizationUrl =
+ "https://auth.openai.com/oauth/authorize?response_type=code&client_id=test-client&redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback&state=test-state&code_challenge=test-challenge&code_challenge_method=S256";
+
+ it("accepts only an official PKCE browser authorization URL", () => {
+ expect(parseCodexOAuthAuthorizationUrl(`Open this URL:\n${authorizationUrl}\n`)).toBe(
+ authorizationUrl,
+ );
+ expect(
+ parseCodexOAuthAuthorizationUrl(
+ authorizationUrl.replace("auth.openai.com", "auth.openai.com.example.com"),
+ ),
+ ).toBeNull();
+ expect(
+ parseCodexOAuthAuthorizationUrl(authorizationUrl.replace("code_challenge", "ignored")),
+ ).toBeNull();
+ expect(
+ parseCodexOAuthAuthorizationUrl(
+ authorizationUrl.replace(
+ "http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback",
+ "https%3A%2F%2Fexample.com%2Fcallback",
+ ),
+ ),
+ ).toBeNull();
+ });
+
+ it("extracts the official device page and bounded one-time code", () => {
+ expect(
+ parseCodexDeviceAuthorization(
+ "1. Open this link\n\u001B[34mhttps://auth.openai.com/codex/device\u001B[0m\n2. Enter this one-time code\n\u001B[34mABCD-EFGH\u001B[0m\n",
+ ),
+ ).toEqual({
+ authorizationUrl: "https://auth.openai.com/codex/device",
+ userCode: "ABCD-EFGH",
+ });
+ expect(
+ parseCodexDeviceAuthorization(
+ "https://auth.openai.com.example.com/codex/device\nnot-a-device-code",
+ ),
+ ).toEqual({});
+ });
+});
+
describe("Grok OAuth authorization URL parsing", () => {
const authorizationUrl =
"https://auth.x.ai/oauth2/authorize?response_type=code&redirect_uri=http%3A%2F%2F127.0.0.1%3A50418%2Fcallback&state=test-state&code_challenge=test-challenge";
@@ -541,6 +603,202 @@ describe("Antigravity OAuth authorization URL parsing", () => {
});
describe("ProviderConnectionLive", () => {
+ it("waits for the exact installation operation before starting sign-in", async () => {
+ const onSpawn = vi.fn();
+ let installationState: ServerProviderInstallationState = {
+ operationId: "trusted-plan-transition",
+ operation: "install",
+ status: "downloading",
+ startedAt: "2026-07-23T10:00:00.000Z",
+ finishedAt: null,
+ message: "Downloading Codex.",
+ };
+ const fixture = makeConnectionTestLayer({
+ provider: "codex",
+ installationState: () => installationState,
+ onSpawn,
+ });
+
+ await Effect.runPromise(
+ Effect.gen(function* () {
+ const connection = yield* ProviderConnection;
+ yield* connection.startAfterInstallation({
+ provider: "codex",
+ method: "codex_browser",
+ installationOperationId: "trusted-plan-transition",
+ });
+ yield* Effect.sleep(Duration.millis(20));
+ expect(onSpawn).not.toHaveBeenCalled();
+ installationState = {
+ ...installationState,
+ status: "installed",
+ finishedAt: "2026-07-23T10:00:02.000Z",
+ message: "Codex is installed and verified.",
+ };
+ const connected = fixture.waitForConnectionState((state) => state?.status === "connected");
+ yield* Effect.promise(() => connected);
+ }).pipe(Effect.provide(fixture.layer)),
+ );
+
+ expect(onSpawn).toHaveBeenCalledWith(
+ expect.objectContaining({ command: "codex", args: ["login"] }),
+ );
+ });
+
+ it.each(["failed", "cancelled"] as const)(
+ "does not start sign-in after the exact installation operation is %s",
+ async (status) => {
+ const onSpawn = vi.fn();
+ const fixture = makeConnectionTestLayer({
+ provider: "codex",
+ installationState: {
+ operationId: `trusted-plan-${status}`,
+ operation: "install",
+ status,
+ startedAt: "2026-07-23T10:00:00.000Z",
+ finishedAt: "2026-07-23T10:00:02.000Z",
+ message: `Installation ${status}.`,
+ },
+ onSpawn,
+ });
+
+ await Effect.runPromise(
+ Effect.gen(function* () {
+ const connection = yield* ProviderConnection;
+ yield* connection.startAfterInstallation({
+ provider: "codex",
+ method: "codex_browser",
+ installationOperationId: `trusted-plan-${status}`,
+ });
+ yield* Effect.sleep(Duration.millis(40));
+ }).pipe(Effect.provide(fixture.layer)),
+ );
+
+ expect(onSpawn).not.toHaveBeenCalled();
+ },
+ );
+
+ it("starts sign-in only after the exact requested installation succeeds", async () => {
+ const onSpawn = vi.fn();
+ const installationState = {
+ operationId: "trusted-plan-1",
+ operation: "install",
+ status: "installed",
+ startedAt: "2026-07-23T10:00:00.000Z",
+ finishedAt: "2026-07-23T10:00:02.000Z",
+ message: "Codex is installed and verified.",
+ } satisfies ServerProviderInstallationState;
+ const fixture = makeConnectionTestLayer({
+ provider: "codex",
+ installationState,
+ onSpawn,
+ });
+
+ await Effect.runPromise(
+ Effect.gen(function* () {
+ const connection = yield* ProviderConnection;
+ yield* connection.startAfterInstallation({
+ provider: "codex",
+ method: "codex_browser",
+ installationOperationId: "different-plan",
+ });
+ yield* Effect.sleep(Duration.millis(20));
+ expect(onSpawn).not.toHaveBeenCalled();
+ yield* connection.startAfterInstallation({
+ provider: "codex",
+ method: "codex_browser",
+ installationOperationId: "trusted-plan-1",
+ });
+ const connected = fixture.waitForConnectionState((state) => state?.status === "connected");
+ yield* Effect.promise(() => connected);
+ }).pipe(Effect.provide(fixture.layer)),
+ );
+
+ expect(onSpawn).toHaveBeenCalledWith(
+ expect.objectContaining({ command: "codex", args: ["login"] }),
+ );
+ });
+
+ it("publishes Codex's official device page and one-time code", async () => {
+ const fixture = makeConnectionTestLayer({
+ provider: "codex",
+ hanging: true,
+ processStdout:
+ "1. Open this link\nhttps://auth.openai.com/codex/device\n2. Enter this one-time code\nABCD-EFGH\n",
+ });
+
+ await Effect.runPromise(
+ Effect.gen(function* () {
+ const connection = yield* ProviderConnection;
+ const codePublished = fixture.waitForConnectionState(
+ (state) => state?.userCode === "ABCD-EFGH",
+ );
+ const started = yield* connection.start({
+ provider: "codex",
+ method: "codex_device_code",
+ });
+ const operationId = started.providers[0]?.connectionState?.operationId;
+ yield* Effect.promise(() => codePublished);
+ expect(fixture.getConnectionState()).toMatchObject({
+ authorizationUrl: "https://auth.openai.com/codex/device",
+ userCode: "ABCD-EFGH",
+ });
+ yield* connection.cancel({ provider: "codex", operationId: operationId! });
+ }).pipe(Effect.provide(fixture.layer)),
+ );
+ });
+
+ it("gives Codex device authorization its full provider window and respects explicit overrides", () => {
+ const defaultTimeout = Duration.minutes(10);
+ const antigravityTimeout = Duration.minutes(11);
+ expect(Duration.toMillis(CODEX_DEVICE_CODE_CONNECTION_TIMEOUT)).toBe(16 * 60 * 1_000);
+ expect(
+ Duration.toMillis(
+ resolveProviderConnectionTimeout({
+ provider: "codex",
+ method: "codex_device_code",
+ defaultTimeout,
+ codexDeviceCodeTimeout: CODEX_DEVICE_CODE_CONNECTION_TIMEOUT,
+ antigravityTimeout,
+ }),
+ ),
+ ).toBe(16 * 60 * 1_000);
+ expect(
+ Duration.toMillis(
+ resolveProviderConnectionTimeout({
+ provider: "codex",
+ method: "codex_device_code",
+ explicitTimeout: Duration.millis(5),
+ defaultTimeout,
+ codexDeviceCodeTimeout: CODEX_DEVICE_CODE_CONNECTION_TIMEOUT,
+ antigravityTimeout,
+ }),
+ ),
+ ).toBe(5);
+ });
+
+ it("times out and cleans up a Codex device authorization using its dedicated policy", async () => {
+ const onKill = vi.fn();
+ const fixture = makeConnectionTestLayer({
+ provider: "codex",
+ hanging: true,
+ codexDeviceCodeTimeout: Duration.millis(5),
+ onKill,
+ });
+
+ await Effect.runPromise(
+ Effect.gen(function* () {
+ const connection = yield* ProviderConnection;
+ yield* connection.start({ provider: "codex", method: "codex_device_code" });
+ yield* Effect.sleep(Duration.millis(20));
+ expect(fixture.getConnectionState()?.status).toBe("failed");
+ expect(fixture.getConnectionState()?.message).toContain("timed out");
+ }).pipe(Effect.provide(fixture.layer)),
+ );
+
+ expect(onKill).toHaveBeenCalledTimes(1);
+ });
+
it("runs managed Antigravity's browser-auth bootstrap and verifies models", async () => {
const onSpawn = vi.fn();
const onAuthenticationKill = vi.fn();
diff --git a/apps/server/src/provider/Layers/ProviderConnection.ts b/apps/server/src/provider/Layers/ProviderConnection.ts
index e1aac571..f3b3da09 100644
--- a/apps/server/src/provider/Layers/ProviderConnection.ts
+++ b/apps/server/src/provider/Layers/ProviderConnection.ts
@@ -49,6 +49,9 @@ import { ProviderRuntimeManager } from "../Services/ProviderRuntimeManager";
import { parseAntigravityModelsAuthStatus, resolveProviderProbeCwd } from "./ProviderHealth";
const CONNECTION_TIMEOUT = Duration.minutes(10);
+export const CODEX_DEVICE_CODE_CONNECTION_TIMEOUT = Duration.minutes(16);
+const INSTALLATION_HANDOFF_TIMEOUT = Duration.minutes(30);
+const INSTALLATION_HANDOFF_POLL_INTERVAL = Duration.millis(250);
const ANTIGRAVITY_AUTHORIZATION_WINDOW_SECONDS = 10 * 60;
const ANTIGRAVITY_CODE_WINDOW_TIMEOUT = Duration.seconds(ANTIGRAVITY_AUTHORIZATION_WINDOW_SECONDS);
const ANTIGRAVITY_AUTHENTICATION_PROBE_INTERVAL = Duration.millis(500);
@@ -89,10 +92,19 @@ const GOOGLE_OAUTH_AUTHORIZATION_ORIGIN = "https://accounts.google.com";
const GOOGLE_OAUTH_AUTHORIZATION_PATHS = new Set(["/o/oauth2/auth", "/o/oauth2/v2/auth"]);
const ANTIGRAVITY_OAUTH_CALLBACK_ORIGIN = "https://antigravity.google";
const ANTIGRAVITY_OAUTH_CALLBACK_PATH = "/oauth-callback";
+const CODEX_OAUTH_AUTHORIZATION_ORIGINS = new Set([
+ "https://auth.openai.com",
+ "https://chatgpt.com",
+]);
+const CODEX_DEVICE_PATH = "/codex/device";
const OAUTH_OUTPUT_BUFFER_MAX_CHARS = 16 * 1024;
const ANTIGRAVITY_AUTH_PROMPT =
"Authenticate this Antigravity CLI only. Do not inspect or modify files and do not perform a task.";
const authorizationCodeEncoder = new TextEncoder();
+const ANSI_SEQUENCE_PATTERN = new RegExp(
+ String.raw`\u001B(?:\[[0-?]*[ -/]*[@-~]|\][^\u0007]*(?:\u0007|\u001B\\))`,
+ "gu",
+);
function outputUrlCandidates(output: string): ReadonlyArray {
return (output.match(/https:\/\/[^\s<>"']+/gu) ?? []).filter(
@@ -104,6 +116,85 @@ function outputUrlCandidates(output: string): ReadonlyArray {
);
}
+function stripAnsi(output: string): string {
+ return output.replace(ANSI_SEQUENCE_PATTERN, "");
+}
+
+function isSafeCodexUrl(candidate: string): URL | null {
+ if (candidate.length > 8_192) return null;
+ try {
+ const url = new URL(candidate);
+ return url.protocol === "https:" &&
+ CODEX_OAUTH_AUTHORIZATION_ORIGINS.has(url.origin) &&
+ !url.hash &&
+ !url.username &&
+ !url.password
+ ? url
+ : null;
+ } catch {
+ return null;
+ }
+}
+
+export function parseCodexOAuthAuthorizationUrl(output: string): string | null {
+ for (const candidate of outputUrlCandidates(stripAnsi(output))) {
+ const url = isSafeCodexUrl(candidate);
+ if (
+ url &&
+ url.pathname === "/oauth/authorize" &&
+ url.searchParams.get("response_type") === "code" &&
+ url.searchParams.get("client_id") &&
+ url.searchParams.get("state") &&
+ url.searchParams.get("code_challenge") &&
+ url.searchParams.get("code_challenge_method") === "S256"
+ ) {
+ const redirectValue = url.searchParams.get("redirect_uri");
+ if (!redirectValue) continue;
+ try {
+ const redirectUrl = new URL(redirectValue);
+ if (
+ redirectUrl.protocol === "http:" &&
+ (redirectUrl.hostname === "localhost" || redirectUrl.hostname === "127.0.0.1") &&
+ redirectUrl.port &&
+ redirectUrl.pathname === "/auth/callback" &&
+ !redirectUrl.search &&
+ !redirectUrl.hash &&
+ !redirectUrl.username &&
+ !redirectUrl.password
+ ) {
+ return url.toString();
+ }
+ } catch {
+ // Ignore malformed or incomplete output while the CLI is still streaming.
+ }
+ }
+ }
+ return null;
+}
+
+export function parseCodexDeviceAuthorization(output: string): {
+ readonly authorizationUrl?: string;
+ readonly userCode?: string;
+} {
+ const cleaned = stripAnsi(output);
+ let authorizationUrl: string | undefined;
+ for (const candidate of outputUrlCandidates(cleaned)) {
+ const url = isSafeCodexUrl(candidate);
+ if (url?.pathname === CODEX_DEVICE_PATH && !url.search) {
+ authorizationUrl = url.toString();
+ break;
+ }
+ }
+ const userCode = cleaned
+ .split(/\r?\n/gu)
+ .map((line) => line.trim())
+ .find((line) => /^[A-Z0-9]{4,}(?:-[A-Z0-9]{2,})+$/u.test(line) && line.length <= 64);
+ return {
+ ...(authorizationUrl ? { authorizationUrl } : {}),
+ ...(userCode ? { userCode } : {}),
+ };
+}
+
export function parseGrokOAuthAuthorizationUrl(output: string): string | null {
for (const candidate of outputUrlCandidates(output)) {
if (candidate.length > 8_192) continue;
@@ -230,6 +321,9 @@ export function providerConnectionCommandArgs(
method: ServerProviderConnectionMethod,
): ReadonlyArray | null {
if (provider === "codex" && method === "codex_browser") return ["login"];
+ if (provider === "codex" && method === "codex_device_code") {
+ return ["login", "--device-auth"];
+ }
if (provider === "claudeAgent" && method === "claude_account") {
return ["auth", "login"];
}
@@ -256,8 +350,25 @@ function makeConnectionError(input: {
return new ServerProviderConnectionError(input);
}
+export function resolveProviderConnectionTimeout(input: {
+ readonly provider: ProviderKind;
+ readonly method: ServerProviderConnectionMethod;
+ readonly explicitTimeout?: Duration.Duration;
+ readonly defaultTimeout: Duration.Duration;
+ readonly codexDeviceCodeTimeout: Duration.Duration;
+ readonly antigravityTimeout: Duration.Duration;
+}): Duration.Duration {
+ if (input.explicitTimeout !== undefined) return input.explicitTimeout;
+ if (input.provider === "codex" && input.method === "codex_device_code") {
+ return input.codexDeviceCodeTimeout;
+ }
+ if (input.provider === "antigravity") return input.antigravityTimeout;
+ return input.defaultTimeout;
+}
+
export function makeProviderConnectionLive(options?: {
readonly timeout?: Duration.Duration;
+ readonly codexDeviceCodeTimeout?: Duration.Duration;
readonly antigravityCodeWindowTimeout?: Duration.Duration;
readonly antigravityCodeWindowCloseSignal?: Effect.Effect;
readonly antigravityTimeout?: Duration.Duration;
@@ -268,6 +379,8 @@ export function makeProviderConnectionLive(options?: {
readonly droidAuthenticationProbe?: typeof probeDroidAcpAuthentication;
}) {
const timeout = options?.timeout ?? CONNECTION_TIMEOUT;
+ const codexDeviceCodeTimeout =
+ options?.codexDeviceCodeTimeout ?? CODEX_DEVICE_CODE_CONNECTION_TIMEOUT;
const antigravityCodeWindowTimeout =
options?.antigravityCodeWindowTimeout ?? ANTIGRAVITY_CODE_WINDOW_TIMEOUT;
const antigravityTimeout = options?.antigravityTimeout ?? ANTIGRAVITY_CONNECTION_TIMEOUT;
@@ -394,7 +507,10 @@ export function makeProviderConnectionLive(options?: {
...runtimeEnv,
CODEX_HOME: resolveBaseCodexHomePath(process.env, homePath),
},
- waitingMessage: "Finish signing in to ChatGPT in the browser window.",
+ waitingMessage:
+ method === "codex_device_code"
+ ? "Open the secure OpenAI page and enter the one-time code shown here."
+ : "Finish signing in to ChatGPT in the browser window.",
} satisfies ConnectionCommand;
}
@@ -766,6 +882,7 @@ export function makeProviderConnectionLive(options?: {
readonly message: string;
readonly finished?: boolean;
readonly authorizationUrl?: string;
+ readonly userCode?: string;
}): ServerProviderConnectionState => ({
operationId,
method,
@@ -774,11 +891,12 @@ export function makeProviderConnectionLive(options?: {
finishedAt: input.finished ? new Date().toISOString() : null,
message: input.message,
...(input.authorizationUrl ? { authorizationUrl: input.authorizationUrl } : {}),
+ ...(input.userCode ? { userCode: input.userCode } : {}),
});
yield* publishState(
provider,
- state({ status: "starting", message: "Starting secure browser sign in." }),
+ state({ status: "starting", message: "Starting secure provider sign in." }),
);
const operation = Effect.gen(function* () {
@@ -788,27 +906,46 @@ export function makeProviderConnectionLive(options?: {
);
let oauthOutputBuffer = "";
let publishedAuthorizationUrl: string | null = null;
+ let publishedUserCode: string | null = null;
const oauthOutputObserver: ConnectionOutputObserver | undefined =
- provider === "grok" || provider === "antigravity"
+ provider === "grok" || provider === "antigravity" || provider === "codex"
? {
onOutputChunk: (chunk) => {
- if (publishedAuthorizationUrl) return undefined;
oauthOutputBuffer =
`${oauthOutputBuffer}${Buffer.from(chunk).toString("utf8")}`.slice(
-OAUTH_OUTPUT_BUFFER_MAX_CHARS,
);
- const authorizationUrl =
- provider === "grok"
- ? parseGrokOAuthAuthorizationUrl(oauthOutputBuffer)
- : parseAntigravityOAuthAuthorizationUrl(oauthOutputBuffer);
- if (!authorizationUrl) return undefined;
- publishedAuthorizationUrl = authorizationUrl;
+ const codexDevice =
+ provider === "codex" && method === "codex_device_code"
+ ? parseCodexDeviceAuthorization(oauthOutputBuffer)
+ : null;
+ const authorizationUrl = codexDevice
+ ? (codexDevice.authorizationUrl ?? null)
+ : provider === "codex"
+ ? parseCodexOAuthAuthorizationUrl(oauthOutputBuffer)
+ : provider === "grok"
+ ? parseGrokOAuthAuthorizationUrl(oauthOutputBuffer)
+ : parseAntigravityOAuthAuthorizationUrl(oauthOutputBuffer);
+ const userCode = codexDevice?.userCode ?? null;
+ const nextAuthorizationUrl = authorizationUrl ?? publishedAuthorizationUrl;
+ const nextUserCode = userCode ?? publishedUserCode;
+ if (
+ nextAuthorizationUrl === publishedAuthorizationUrl &&
+ nextUserCode === publishedUserCode
+ ) {
+ return undefined;
+ }
+ publishedAuthorizationUrl = nextAuthorizationUrl;
+ publishedUserCode = nextUserCode;
return publishState(
provider,
state({
status: "waiting_for_browser",
message: command.waitingMessage,
- authorizationUrl,
+ ...(nextAuthorizationUrl
+ ? { authorizationUrl: nextAuthorizationUrl }
+ : {}),
+ ...(nextUserCode ? { userCode: nextUserCode } : {}),
}),
).pipe(Effect.asVoid);
},
@@ -839,10 +976,14 @@ export function makeProviderConnectionLive(options?: {
oauthOutputObserver,
)
: runCommand(command, oauthOutputObserver).pipe(Effect.scoped);
- const operationTimeout =
- provider === "antigravity" && options?.timeout === undefined
- ? antigravityTimeout
- : timeout;
+ const operationTimeout = resolveProviderConnectionTimeout({
+ provider,
+ method,
+ ...(options?.timeout !== undefined ? { explicitTimeout: options.timeout } : {}),
+ defaultTimeout: timeout,
+ codexDeviceCodeTimeout,
+ antigravityTimeout,
+ });
const exitCodeResult = yield* connectionProcess.pipe(
Effect.timeoutOption(operationTimeout),
Effect.result,
@@ -989,6 +1130,60 @@ export function makeProviderConnectionLive(options?: {
},
);
+ const startAfterInstallation: ProviderConnectionShape["startAfterInstallation"] = Effect.fn(
+ "ProviderConnection.startAfterInstallation",
+ )(function* (input) {
+ if (
+ providerConnectionCommandArgs(input.provider, input.method) === null ||
+ (input.provider !== "codex" && expectedMethodForProvider(input.provider) !== input.method)
+ ) {
+ yield* Effect.logWarning("Ignoring an invalid provider installation handoff.", {
+ provider: input.provider,
+ method: input.method,
+ });
+ return;
+ }
+
+ const monitor = Effect.gen(function* () {
+ const initial = yield* providerRuntimeManager.getSnapshot(input.provider);
+ if (initial.installationState?.operationId !== input.installationOperationId) {
+ yield* Effect.logWarning("Ignoring a stale provider installation handoff.", {
+ provider: input.provider,
+ installationOperationId: input.installationOperationId,
+ });
+ return;
+ }
+ while (true) {
+ const snapshot = yield* providerRuntimeManager.getSnapshot(input.provider);
+ const installation = snapshot.installationState;
+ if (installation?.operationId === input.installationOperationId) {
+ if (installation.status === "installed") {
+ const result = yield* Effect.result(
+ start({ provider: input.provider, method: input.method }),
+ );
+ if (Result.isFailure(result)) {
+ const now = new Date().toISOString();
+ yield* publishState(input.provider, {
+ operationId: `handoff-${input.installationOperationId}`,
+ method: input.method,
+ status: "failed",
+ startedAt: now,
+ finishedAt: now,
+ message: `Installation succeeded, but sign in could not start: ${result.failure.message}`,
+ });
+ }
+ return;
+ }
+ if (installation.status === "failed" || installation.status === "cancelled") {
+ return;
+ }
+ }
+ yield* Effect.sleep(INSTALLATION_HANDOFF_POLL_INTERVAL);
+ }
+ }).pipe(Effect.timeoutOption(INSTALLATION_HANDOFF_TIMEOUT), Effect.asVoid);
+ yield* monitor.pipe(Effect.forkIn(operationScope));
+ });
+
const cancel: ProviderConnectionShape["cancel"] = Effect.fn("ProviderConnection.cancel")(
function* (input) {
const active = (yield* Ref.get(activeConnectionsRef)).get(input.provider);
@@ -1075,7 +1270,12 @@ export function makeProviderConnectionLive(options?: {
return { providers: yield* providerHealth.getStatuses };
});
- return { start, cancel, submitAuthorizationCode } satisfies ProviderConnectionShape;
+ return {
+ start,
+ cancel,
+ submitAuthorizationCode,
+ startAfterInstallation,
+ } satisfies ProviderConnectionShape;
}),
);
}
diff --git a/apps/server/src/provider/Layers/ProviderRuntimeManager.test.ts b/apps/server/src/provider/Layers/ProviderRuntimeManager.test.ts
index 412ce1e2..bd038303 100644
--- a/apps/server/src/provider/Layers/ProviderRuntimeManager.test.ts
+++ b/apps/server/src/provider/Layers/ProviderRuntimeManager.test.ts
@@ -1,18 +1,41 @@
import { createHash } from "node:crypto";
-import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+import {
+ chmodSync,
+ mkdirSync,
+ mkdtempSync,
+ readFileSync,
+ realpathSync,
+ rmSync,
+ writeFileSync,
+} from "node:fs";
import os from "node:os";
import path from "node:path";
import * as NodeServices from "@effect/platform-node/NodeServices";
import { Effect, Layer } from "effect";
-import { describe, expect, it } from "vitest";
+import { describe, expect, it, vi } from "vitest";
+
+const shellMocks = vi.hoisted(() => ({
+ readWindowsPersistentEnvironmentAsync: vi.fn(
+ async (): Promise>> => ({ ...process.env }),
+ ),
+}));
+
+vi.mock("@synara/shared/shell", async (importOriginal) => ({
+ ...(await importOriginal()),
+ readWindowsPersistentEnvironmentAsync: shellMocks.readWindowsPersistentEnvironmentAsync,
+}));
import { ServerConfig } from "../../config";
import { ProviderRuntimeManager } from "../Services/ProviderRuntimeManager";
import type { ProviderRuntimeCurrentRecord } from "../providerRuntimeTypes";
import {
+ cancelAndAwaitProviderRuntimeOperations,
canActivateManagedRuntimeVersion,
+ findExecutableOnPath,
+ makeProviderRuntimeOperationGate,
ProviderRuntimeManagerLive,
+ windowsKnownProviderExecutableCandidates,
} from "./ProviderRuntimeManager";
function sha256(filePath: string): string {
@@ -33,7 +56,52 @@ function resolveAntigravity(baseDir: string, configuredExecutable?: string) {
}).pipe(Effect.provide(layer), Effect.scoped);
}
+function getProviderSnapshot(baseDir: string, provider: "codex" | "antigravity") {
+ const configLayer = ServerConfig.layerTest(baseDir, baseDir).pipe(
+ Layer.provide(NodeServices.layer),
+ );
+ const layer = Layer.mergeAll(
+ configLayer,
+ ProviderRuntimeManagerLive.pipe(Layer.provide(configLayer)),
+ ).pipe(Layer.provide(NodeServices.layer));
+ return Effect.gen(function* () {
+ const manager = yield* ProviderRuntimeManager;
+ return yield* manager.getSnapshot(provider);
+ }).pipe(Effect.provide(layer), Effect.scoped);
+}
+
describe("ProviderRuntimeManager managed integrity", () => {
+ it("makes cancellation and activation mutually exclusive", () => {
+ const cancelled = makeProviderRuntimeOperationGate();
+ expect(cancelled.cancel()).toBe(true);
+ expect(cancelled.signal.aborted).toBe(true);
+ expect(cancelled.beginCommit()).toBe(false);
+
+ const committing = makeProviderRuntimeOperationGate();
+ expect(committing.beginCommit()).toBe(true);
+ expect(committing.cancel()).toBe(false);
+ expect(committing.signal.aborted).toBe(false);
+ });
+
+ it("waits for active runtime operations to finish during shutdown", async () => {
+ const gate = makeProviderRuntimeOperationGate();
+ let finishOperation: (() => void) | undefined;
+ let operationFinished = false;
+ const completion = new Promise((resolve) => {
+ finishOperation = () => {
+ operationFinished = true;
+ resolve();
+ };
+ });
+
+ const shutdown = cancelAndAwaitProviderRuntimeOperations([{ gate, completion }]);
+ expect(gate.signal.aborted).toBe(true);
+ expect(operationFinished).toBe(false);
+ finishOperation?.();
+ await shutdown;
+ expect(operationFinished).toBe(true);
+ });
+
it("allows install or repair at the current version but never downgrades", () => {
expect(
canActivateManagedRuntimeVersion({ currentVersion: null, candidateVersion: "1.1.5" }),
@@ -67,6 +135,142 @@ describe("ProviderRuntimeManager managed integrity", () => {
}
});
+ it("loads the last terminal installation failure after restart", async () => {
+ const baseDir = mkdtempSync(path.join(os.tmpdir(), "scient-runtime-state-"));
+ try {
+ const stateDir = path.join(baseDir, "userdata", "provider-installations");
+ mkdirSync(stateDir, { recursive: true });
+ const failure = {
+ operationId: "install-failed-1",
+ operation: "install",
+ status: "failed",
+ startedAt: "2026-07-23T10:00:00.000Z",
+ finishedAt: "2026-07-23T10:00:01.000Z",
+ message: "Installation failed while downloading the provider: connection reset.",
+ };
+ writeFileSync(path.join(stateDir, "codex.json"), JSON.stringify(failure));
+
+ const snapshot = await Effect.runPromise(getProviderSnapshot(baseDir, "codex"));
+ expect(snapshot.installationState).toEqual(failure);
+ } finally {
+ rmSync(baseDir, { recursive: true, force: true });
+ }
+ });
+
+ it("does not restore a stale successful installation after restart", async () => {
+ const baseDir = mkdtempSync(path.join(os.tmpdir(), "scient-runtime-state-"));
+ try {
+ const stateDir = path.join(baseDir, "userdata", "provider-installations");
+ mkdirSync(stateDir, { recursive: true });
+ writeFileSync(
+ path.join(stateDir, "codex.json"),
+ JSON.stringify({
+ operationId: "install-succeeded-1",
+ operation: "install",
+ status: "installed",
+ startedAt: "2026-07-23T10:00:00.000Z",
+ finishedAt: "2026-07-23T10:00:01.000Z",
+ message: "Codex is installed and verified.",
+ }),
+ );
+
+ const snapshot = await Effect.runPromise(getProviderSnapshot(baseDir, "codex"));
+ expect(snapshot.installationState).toBeNull();
+ } finally {
+ rmSync(baseDir, { recursive: true, force: true });
+ }
+ });
+
+ it("recognizes the official standalone Codex install location on Windows", () => {
+ expect(
+ windowsKnownProviderExecutableCandidates({
+ provider: "codex",
+ environment: { LOCALAPPDATA: "C:\\Users\\Levin\\AppData\\Local" },
+ }),
+ ).toEqual(["C:\\Users\\Levin\\AppData\\Local\\Programs\\OpenAI\\Codex\\bin\\codex.exe"]);
+ expect(
+ windowsKnownProviderExecutableCandidates({
+ provider: "antigravity",
+ environment: { LOCALAPPDATA: "C:\\Users\\Levin\\AppData\\Local" },
+ }),
+ ).toEqual([]);
+ });
+
+ it.runIf(process.platform === "win32")(
+ "finds a provider installed into a newly persisted Windows PATH entry",
+ async () => {
+ const baseDir = mkdtempSync(path.join(os.tmpdir(), "scient-windows-path-"));
+ try {
+ const executable = path.join(baseDir, "codex.exe");
+ writeFileSync(executable, "test");
+ const discovered = await findExecutableOnPath({
+ command: "codex",
+ pathValue: baseDir,
+ platform: "win32",
+ });
+ expect(discovered).not.toBeNull();
+ expect(readFileSync(discovered!, "utf8")).toBe("test");
+ } finally {
+ rmSync(baseDir, { recursive: true, force: true });
+ }
+ },
+ );
+
+ it.runIf(process.platform === "win32")(
+ "refreshes the manager's persisted Windows PATH after an external provider install",
+ async () => {
+ const baseDir = mkdtempSync(path.join(os.tmpdir(), "scient-windows-manager-path-"));
+ const previousPath = process.env.PATH;
+ const emptyPath = path.join(baseDir, "empty");
+ let persistedPath = emptyPath;
+ let now = 1_000_000;
+ const nowSpy = vi.spyOn(Date, "now").mockImplementation(() => now);
+ shellMocks.readWindowsPersistentEnvironmentAsync.mockClear();
+ shellMocks.readWindowsPersistentEnvironmentAsync.mockImplementation(async () => ({
+ PATH: persistedPath,
+ }));
+ try {
+ mkdirSync(emptyPath);
+ writeFileSync(path.join(baseDir, "codex.exe"), "test");
+ process.env.PATH = emptyPath;
+ const configLayer = ServerConfig.layerTest(baseDir, baseDir).pipe(
+ Layer.provide(NodeServices.layer),
+ );
+ const runtimeLayer = ProviderRuntimeManagerLive.pipe(Layer.provide(configLayer));
+ const layer = Layer.mergeAll(configLayer, runtimeLayer).pipe(
+ Layer.provide(NodeServices.layer),
+ );
+
+ const result = await Effect.runPromise(
+ Effect.gen(function* () {
+ const manager = yield* ProviderRuntimeManager;
+ const beforeInstall = yield* manager.resolve("codex");
+ persistedPath = baseDir;
+ now += 5_001;
+ const afterInstall = yield* manager.resolve("codex");
+ return { beforeInstall, afterInstall };
+ }).pipe(Effect.provide(layer), Effect.scoped),
+ );
+
+ expect(result.beforeInstall.source).toBe("missing");
+ expect(result.afterInstall.source).toBe("system");
+ expect(result.afterInstall.executable).not.toBeNull();
+ expect(realpathSync.native(result.afterInstall.executable!)).toBe(
+ realpathSync.native(path.join(baseDir, "codex.exe")),
+ );
+ expect(shellMocks.readWindowsPersistentEnvironmentAsync).toHaveBeenCalledTimes(2);
+ } finally {
+ nowSpy.mockRestore();
+ shellMocks.readWindowsPersistentEnvironmentAsync.mockImplementation(
+ async (): Promise>> => ({ ...process.env }),
+ );
+ if (previousPath === undefined) delete process.env.PATH;
+ else process.env.PATH = previousPath;
+ 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;
@@ -80,9 +284,18 @@ describe("ProviderRuntimeManager managed integrity", () => {
"releases",
releaseId,
);
- const executablePath = path.join(releaseDir, "bin", "agy");
+ const executableRelativePath = path.join(
+ "bin",
+ process.platform === "win32" ? "agy.cmd" : "agy",
+ );
+ const executablePath = path.join(releaseDir, executableRelativePath);
mkdirSync(path.dirname(executablePath), { recursive: true });
- writeFileSync(executablePath, "#!/bin/sh\necho 'Antigravity CLI 1.1.4'\n");
+ writeFileSync(
+ executablePath,
+ process.platform === "win32"
+ ? "@echo off\r\necho Antigravity CLI 1.1.4\r\n"
+ : "#!/bin/sh\necho 'Antigravity CLI 1.1.4'\n",
+ );
chmodSync(executablePath, 0o700);
const record: ProviderRuntimeCurrentRecord = {
version: 1,
@@ -90,7 +303,7 @@ describe("ProviderRuntimeManager managed integrity", () => {
releaseId,
previousReleaseId: null,
runtimeVersion: "1.1.4",
- executableRelativePath: path.join("bin", "agy"),
+ executableRelativePath,
executablePath,
smokeArgs: ["--version"],
digestAlgorithm: "sha256",
@@ -114,12 +327,29 @@ describe("ProviderRuntimeManager managed integrity", () => {
expect(first.source).toBe("managed");
expect(first.executable).toBe(executablePath);
- writeFileSync(executablePath, "#!/bin/sh\necho 'tampered'\n");
+ writeFileSync(
+ executablePath,
+ process.platform === "win32"
+ ? "@echo off\r\necho tampered\r\n"
+ : "#!/bin/sh\necho 'tampered'\n",
+ );
chmodSync(executablePath, 0o700);
const afterRestart = await Effect.runPromise(resolveAntigravity(baseDir));
expect(afterRestart.source).toBe("missing");
expect(afterRestart.executable).toBeNull();
expect(afterRestart.canRepair).toBe(true);
+ expect(
+ JSON.parse(
+ readFileSync(
+ path.join(baseDir, "userdata", "provider-installations", "antigravity.json"),
+ "utf8",
+ ),
+ ),
+ ).toMatchObject({
+ operation: "repair",
+ status: "failed",
+ message: expect.stringContaining("damaged and must be repaired"),
+ });
} finally {
if (previousPath === undefined) delete process.env.PATH;
else process.env.PATH = previousPath;
diff --git a/apps/server/src/provider/Layers/ProviderRuntimeManager.ts b/apps/server/src/provider/Layers/ProviderRuntimeManager.ts
index a10e7058..8f5fb198 100644
--- a/apps/server/src/provider/Layers/ProviderRuntimeManager.ts
+++ b/apps/server/src/provider/Layers/ProviderRuntimeManager.ts
@@ -2,17 +2,18 @@ import { createHash, randomUUID } from "node:crypto";
import { constants as FS_CONSTANTS, createReadStream } from "node:fs";
import FS from "node:fs/promises";
import Path from "node:path";
-import { delimiter as pathDelimiter } from "node:path";
import {
PROVIDER_DISPLAY_NAMES,
+ ServerProviderInstallationState as ServerProviderInstallationStateSchema,
type ProviderKind,
ServerProviderInstallationError,
type ServerProviderInstallationState,
type ServerProviderRuntimeSource,
} from "@synara/contracts";
import { compareSemverVersions } from "@synara/shared/providerVersions";
-import { Effect, Layer, PubSub, Stream } from "effect";
+import { mergePathEntries, readWindowsPersistentEnvironmentAsync } from "@synara/shared/shell";
+import { Effect, Layer, PubSub, Schema, Stream } from "effect";
import { ServerConfig } from "../../config";
import { writeFileStringAtomically } from "../../atomicWrite";
@@ -84,6 +85,17 @@ const PLAN_TTL_MS = 10 * 60 * 1000;
const SMOKE_TIMEOUT_MS = 15_000;
const SMOKE_OUTPUT_LIMIT = 64 * 1024;
const MINIMUM_INSTALL_FREE_BYTES = 256 * 1024 * 1024;
+const WINDOWS_ENVIRONMENT_CACHE_MS = 5_000;
+const TERMINAL_INSTALLATION_STATUSES = new Set([
+ "installed",
+ "succeeded",
+ "failed",
+ "cancelled",
+]);
+const RESTORABLE_INSTALLATION_STATUSES = new Set([
+ "failed",
+ "cancelled",
+]);
type RuntimeOperation = ServerProviderInstallationState["operation"];
@@ -96,7 +108,43 @@ interface PreparedInstall {
interface ActiveOperation {
readonly operationId: string;
readonly operation: RuntimeOperation;
- readonly controller: AbortController;
+ readonly gate: ProviderRuntimeOperationGate;
+ completion: Promise;
+}
+
+export interface ProviderRuntimeOperationGate {
+ readonly signal: AbortSignal;
+ readonly cancel: () => boolean;
+ readonly beginCommit: () => boolean;
+}
+
+export function makeProviderRuntimeOperationGate(): ProviderRuntimeOperationGate {
+ const controller = new AbortController();
+ let phase: "running" | "cancelled" | "committing" = "running";
+ return {
+ signal: controller.signal,
+ cancel: () => {
+ if (phase !== "running") return false;
+ phase = "cancelled";
+ controller.abort();
+ return true;
+ },
+ beginCommit: () => {
+ if (phase !== "running" || controller.signal.aborted) return false;
+ phase = "committing";
+ return true;
+ },
+ };
+}
+
+export async function cancelAndAwaitProviderRuntimeOperations(
+ operations: ReadonlyArray<{
+ readonly gate: ProviderRuntimeOperationGate;
+ readonly completion: Promise;
+ }>,
+): Promise {
+ for (const operation of operations) operation.gate.cancel();
+ await Promise.allSettled(operations.map((operation) => operation.completion));
}
function installationError(input: {
@@ -119,6 +167,39 @@ function currentRecordPath(stateDir: string, provider: ProviderKind): string {
return Path.join(providerRoot(stateDir, provider), "current.json");
}
+function installationStatePath(stateDir: string, provider: ProviderKind): string {
+ return Path.join(stateDir, "provider-installations", `${provider}.json`);
+}
+
+const decodeInstallationState = Schema.decodeUnknownSync(ServerProviderInstallationStateSchema);
+
+async function readPersistedInstallationState(
+ stateDir: string,
+ provider: ProviderKind,
+): Promise {
+ try {
+ const state = decodeInstallationState(
+ JSON.parse(await FS.readFile(installationStatePath(stateDir, provider), "utf8")),
+ );
+ return RESTORABLE_INSTALLATION_STATUSES.has(state.status) ? state : null;
+ } catch {
+ return null;
+ }
+}
+
+async function writePersistedInstallationState(
+ stateDir: string,
+ provider: ProviderKind,
+ state: ServerProviderInstallationState,
+): Promise {
+ await Effect.runPromise(
+ writeFileStringAtomically({
+ filePath: installationStatePath(stateDir, provider),
+ contents: `${JSON.stringify(state, null, 2)}\n`,
+ }),
+ );
+}
+
function releaseRoot(stateDir: string, provider: ProviderKind, releaseId: string): string {
return Path.join(providerRoot(stateDir, provider), "releases", releaseId);
}
@@ -237,21 +318,23 @@ async function removeCurrentRecord(stateDir: string, provider: ProviderKind): Pr
await FS.rm(currentRecordPath(stateDir, provider), { force: true });
}
-function findExecutableOnPath(input: {
+export function findExecutableOnPath(input: {
readonly command: string;
readonly pathValue: string;
readonly platform?: NodeJS.Platform;
}): Promise {
const platform = input.platform ?? process.platform;
+ const pathApi = platform === "win32" ? Path.win32 : Path.posix;
+ const delimiter = platform === "win32" ? ";" : ":";
const extensions =
platform === "win32"
? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";").map((part) => part.toLowerCase())
: [""];
return (async () => {
- for (const directory of input.pathValue.split(pathDelimiter).filter(Boolean)) {
+ for (const directory of input.pathValue.split(delimiter).filter(Boolean)) {
for (const extension of extensions) {
- const hasExtension = Path.extname(input.command).length > 0;
- const candidate = Path.join(
+ const hasExtension = pathApi.extname(input.command).length > 0;
+ const candidate = pathApi.join(
directory,
hasExtension ? input.command : `${input.command}${extension}`,
);
@@ -271,21 +354,45 @@ function findExecutableOnPath(input: {
})();
}
+export function windowsKnownProviderExecutableCandidates(input: {
+ readonly provider: ProviderKind;
+ readonly environment: NodeJS.ProcessEnv | Partial>;
+}): ReadonlyArray {
+ if (input.provider !== "codex") return [];
+ const localAppData = Object.entries(input.environment)
+ .find(([name]) => name.toUpperCase() === "LOCALAPPDATA")?.[1]
+ ?.trim();
+ return localAppData
+ ? [Path.win32.join(localAppData, "Programs", "OpenAI", "Codex", "bin", "codex.exe")]
+ : [];
+}
+
+async function firstExistingFile(candidates: ReadonlyArray): Promise {
+ for (const candidate of candidates) {
+ const stat = await FS.stat(candidate).catch(() => null);
+ if (stat?.isFile()) return FS.realpath(candidate).catch(() => candidate);
+ }
+ return null;
+}
+
async function resolveConfiguredExecutable(input: {
readonly command: string;
readonly pathValue: string;
+ readonly platform?: NodeJS.Platform;
}): Promise {
+ const platform = input.platform ?? process.platform;
+ const pathApi = platform === "win32" ? Path.win32 : Path.posix;
const hasPathSeparator =
- Path.isAbsolute(input.command) ||
- input.command.includes(Path.sep) ||
- (process.platform === "win32" && input.command.includes("/"));
+ pathApi.isAbsolute(input.command) ||
+ input.command.includes(pathApi.sep) ||
+ (platform === "win32" && input.command.includes("/"));
if (!hasPathSeparator) {
- return findExecutableOnPath(input);
+ return findExecutableOnPath({ ...input, platform });
}
const stat = await FS.stat(input.command).catch(() => null);
if (!stat?.isFile()) return null;
- if (process.platform !== "win32") {
+ if (platform !== "win32") {
try {
await FS.access(input.command, FS_CONSTANTS.X_OK);
} catch {
@@ -330,16 +437,25 @@ export const ProviderRuntimeManagerLive = Layer.effect(
const changes = yield* PubSub.unbounded>();
const records = new Map();
const installationStates = new Map();
+ const installationStateWrites = new Map>();
const plans = new Map();
const active = new Map();
const verifiedManagedReleases = new Set();
const managedVerificationPromises = new Map>();
const basePath = process.env.PATH ?? "";
+ let windowsEnvironmentCache: Partial> | null = null;
+ let windowsEnvironmentReadAt = 0;
const managedDirectoriesAdded = new Set();
let lastAssignedPath = basePath;
let disposed = false;
for (const provider of PROVIDERS) {
+ const persistedInstallationState = yield* Effect.promise(() =>
+ readPersistedInstallationState(config.stateDir, provider),
+ );
+ if (persistedInstallationState) {
+ installationStates.set(provider, persistedInstallationState);
+ }
const record = yield* Effect.promise(() => readCurrentRecord(config.stateDir, provider));
if (record) {
records.set(provider, record);
@@ -353,9 +469,10 @@ export const ProviderRuntimeManagerLive = Layer.effect(
// A prior remove only deactivates the runtime immediately so an already-running
// provider process is never invalidated. The next app start is the safe GC point.
yield* Effect.promise(() =>
- FS.rm(providerRoot(config.stateDir, provider), { recursive: true, force: true }).catch(
- () => undefined,
- ),
+ FS.rm(providerRoot(config.stateDir, provider), {
+ recursive: true,
+ force: true,
+ }).catch(() => undefined),
);
} else {
yield* Effect.logWarning(
@@ -365,14 +482,42 @@ export const ProviderRuntimeManagerLive = Layer.effect(
}
}
+ const currentSystemEnvironment = async (): Promise>> => {
+ if (process.platform !== "win32") return process.env;
+ if (
+ windowsEnvironmentCache &&
+ Date.now() - windowsEnvironmentReadAt < WINDOWS_ENVIRONMENT_CACHE_MS
+ ) {
+ return windowsEnvironmentCache;
+ }
+ try {
+ windowsEnvironmentCache = await readWindowsPersistentEnvironmentAsync();
+ windowsEnvironmentReadAt = Date.now();
+ } catch {
+ windowsEnvironmentCache = {};
+ windowsEnvironmentReadAt = Date.now();
+ }
+ return windowsEnvironmentCache;
+ };
+
+ const currentSystemPath = async (): Promise<{
+ readonly pathValue: string;
+ readonly environment: Partial>;
+ }> => {
+ const environment = await currentSystemEnvironment();
+ return {
+ pathValue: mergePathEntries(environment.PATH, basePath, process.platform) ?? basePath,
+ environment,
+ };
+ };
+
const refreshProcessPath = () => {
const managedDirectories = Array.from(records.values())
.filter((record) => verifiedManagedReleases.has(`${record.provider}:${record.releaseId}`))
.map((record) => Path.dirname(record.executablePath));
for (const directory of managedDirectories) managedDirectoriesAdded.add(directory);
- lastAssignedPath = [basePath, ...new Set(managedDirectories)]
- .filter(Boolean)
- .join(pathDelimiter);
+ const delimiter = process.platform === "win32" ? ";" : ":";
+ lastAssignedPath = [basePath, ...new Set(managedDirectories)].filter(Boolean).join(delimiter);
process.env.PATH = lastAssignedPath;
};
refreshProcessPath();
@@ -412,7 +557,7 @@ export const ProviderRuntimeManagerLive = Layer.effect(
readonly totalBytes?: number | null;
},
) => {
- installationStates.set(provider, {
+ const state: ServerProviderInstallationState = {
operationId: input.operationId,
operation: input.operation,
status: input.status,
@@ -429,7 +574,23 @@ export const ProviderRuntimeManagerLive = Layer.effect(
input.totalBytes === null ? null : Math.max(0, Math.trunc(input.totalBytes)),
}
: {}),
- });
+ };
+ installationStates.set(provider, state);
+ if (TERMINAL_INSTALLATION_STATUSES.has(state.status)) {
+ const previousWrite = installationStateWrites.get(provider) ?? Promise.resolve();
+ const nextWrite = previousWrite
+ .catch(() => undefined)
+ .then(() => writePersistedInstallationState(config.stateDir, provider, state))
+ .catch((cause) =>
+ Effect.runPromise(
+ Effect.logWarning("Failed to persist provider installation diagnostics.", {
+ provider,
+ cause,
+ }),
+ ),
+ );
+ installationStateWrites.set(provider, nextWrite);
+ }
publish();
};
@@ -470,10 +631,11 @@ export const ProviderRuntimeManagerLive = Layer.effect(
const startOperation = (input: {
readonly provider: ProviderKind;
readonly operation: RuntimeOperation;
+ readonly operationId?: string;
readonly run: (context: {
readonly operationId: string;
readonly startedAt: string;
- readonly controller: AbortController;
+ readonly gate: ProviderRuntimeOperationGate;
}) => Promise;
}): Effect.Effect =>
Effect.gen(function* () {
@@ -484,10 +646,16 @@ export const ProviderRuntimeManagerLive = Layer.effect(
message: "A provider runtime operation is already running.",
});
}
- const operationId = randomUUID();
+ const operationId = input.operationId ?? randomUUID();
const startedAt = new Date().toISOString();
- const controller = new AbortController();
- active.set(input.provider, { operationId, operation: input.operation, controller });
+ const gate = makeProviderRuntimeOperationGate();
+ const activeOperation: ActiveOperation = {
+ operationId,
+ operation: input.operation,
+ gate,
+ completion: Promise.resolve(),
+ };
+ active.set(input.provider, activeOperation);
setInstallationState(input.provider, {
operationId,
operation: input.operation,
@@ -506,10 +674,34 @@ export const ProviderRuntimeManagerLive = Layer.effect(
: "Removing the Scient-managed provider runtime.",
});
- void input
- .run({ operationId, startedAt, controller })
+ const completion = Promise.resolve()
+ .then(() => input.run({ operationId, startedAt, gate }))
.catch((cause) => {
- const cancelled = controller.signal.aborted;
+ const cancelled = gate.signal.aborted;
+ const previousState = installationStates.get(input.provider);
+ const failedFromStatus = previousState?.status;
+ const failedAt =
+ input.operation === "rollback"
+ ? "restoring the previous provider"
+ : input.operation === "remove"
+ ? "removing the managed provider"
+ : failedFromStatus === "resolving"
+ ? "resolving the trusted release"
+ : failedFromStatus === "downloading"
+ ? "downloading the provider"
+ : failedFromStatus === "verifying"
+ ? "verifying the download"
+ : failedFromStatus === "smoke_testing"
+ ? "checking the installed provider"
+ : "installing the provider";
+ const operationLabel =
+ input.operation === "repair"
+ ? "Repair"
+ : input.operation === "rollback"
+ ? "Rollback"
+ : input.operation === "remove"
+ ? "Removal"
+ : "Installation";
setInstallationState(input.provider, {
operationId,
operation: input.operation,
@@ -518,13 +710,15 @@ export const ProviderRuntimeManagerLive = Layer.effect(
finishedAt: new Date().toISOString(),
message: cancelled
? "Provider runtime operation was cancelled."
- : errorMessage(cause),
+ : `${operationLabel} failed while ${failedAt}: ${errorMessage(cause)}`,
+ ...(previousState?.version ? { version: previousState.version } : {}),
});
})
.finally(() => {
if (active.get(input.provider)?.operationId === operationId)
active.delete(input.provider);
});
+ activeOperation.completion = completion;
});
const runInstall = async (input: {
@@ -533,9 +727,9 @@ export const ProviderRuntimeManagerLive = Layer.effect(
readonly operationId: string;
readonly operation: "install" | "repair";
readonly startedAt: string;
- readonly controller: AbortController;
+ readonly gate: ProviderRuntimeOperationGate;
}): Promise => {
- const { provider, artifact, operationId, operation, startedAt, controller } = input;
+ const { provider, artifact, operationId, operation, startedAt, gate } = input;
const root = providerRoot(config.stateDir, provider);
const downloads = Path.join(root, "downloads");
await FS.mkdir(downloads, { recursive: true });
@@ -572,7 +766,7 @@ export const ProviderRuntimeManagerLive = Layer.effect(
url: artifact.url,
destination: archivePath,
allowedHosts: artifact.allowedHosts,
- signal: controller.signal,
+ signal: gate.signal,
...(artifact.size ? { expectedSize: artifact.size } : {}),
onProgress: (bytesDownloaded, totalBytes) =>
setInstallationState(provider, {
@@ -599,8 +793,7 @@ export const ProviderRuntimeManagerLive = Layer.effect(
algorithm: artifact.digestAlgorithm,
expectedDigest: artifact.digest,
});
- if (controller.signal.aborted)
- throw new DOMException("Installation cancelled.", "AbortError");
+ if (gate.signal.aborted) throw new DOMException("Installation cancelled.", "AbortError");
setInstallationState(provider, {
operationId,
@@ -615,7 +808,7 @@ export const ProviderRuntimeManagerLive = Layer.effect(
destination: stagedRelease,
format: artifact.archiveFormat,
executablePath: artifact.executablePath,
- signal: controller.signal,
+ signal: gate.signal,
});
const recipe = getProviderRuntimeRecipe(provider);
const managedExecutableRelativePath = Path.join(
@@ -643,7 +836,7 @@ export const ProviderRuntimeManagerLive = Layer.effect(
await smokeTestExecutable({
executable,
args: artifact.smokeArgs,
- signal: controller.signal,
+ signal: gate.signal,
});
const current = records.get(provider) ?? null;
@@ -684,10 +877,12 @@ export const ProviderRuntimeManagerLive = Layer.effect(
catalogRevision: artifact.catalogRevision,
installedAt: new Date().toISOString(),
};
+ if (gate.signal.aborted) throw new DOMException("Installation cancelled.", "AbortError");
await writeFileStringAtomically({
filePath: releaseRecordPath(config.stateDir, provider, releaseId),
contents: `${JSON.stringify({ ...record, previousReleaseId: null }, null, 2)}\n`,
}).pipe(Effect.runPromise);
+ if (!gate.beginCommit()) throw new DOMException("Installation cancelled.", "AbortError");
await writeCurrentRecord(config.stateDir, provider, record);
records.set(provider, record);
verifiedManagedReleases.add(`${provider}:${record.releaseId}`);
@@ -782,14 +977,15 @@ export const ProviderRuntimeManagerLive = Layer.effect(
yield* startOperation({
provider: input.provider,
operation: "install",
- run: ({ operationId, startedAt, controller }) =>
+ operationId: input.planToken,
+ run: ({ operationId, startedAt, gate }) =>
runInstall({
provider: input.provider,
artifact: plan.artifact,
operationId,
operation: "install",
startedAt,
- controller,
+ gate,
}),
});
});
@@ -804,7 +1000,13 @@ export const ProviderRuntimeManagerLive = Layer.effect(
message: "This provider runtime operation is no longer running.",
});
}
- operation.controller.abort();
+ if (!operation.gate.cancel()) {
+ return yield* installationError({
+ provider: input.provider,
+ reason: "operation_not_found",
+ message: "This provider runtime operation is already finishing.",
+ });
+ }
});
const repair: ProviderRuntimeManagerShape["repair"] = (input) =>
@@ -848,14 +1050,14 @@ export const ProviderRuntimeManagerLive = Layer.effect(
yield* startOperation({
provider: input.provider,
operation: "repair",
- run: ({ operationId, startedAt, controller }) =>
+ run: ({ operationId, startedAt, gate }) =>
runInstall({
provider: input.provider,
artifact,
operationId,
operation: "repair",
startedAt,
- controller,
+ gate,
}),
});
});
@@ -873,7 +1075,7 @@ export const ProviderRuntimeManagerLive = Layer.effect(
yield* startOperation({
provider: input.provider,
operation: "rollback",
- run: async ({ operationId, startedAt, controller }) => {
+ run: async ({ operationId, startedAt, gate }) => {
const previousMetadata = await readReleaseRecord(
config.stateDir,
input.provider,
@@ -888,7 +1090,7 @@ export const ProviderRuntimeManagerLive = Layer.effect(
await smokeTestExecutable({
executable: previousExecutable,
args: previousMetadata.smokeArgs,
- signal: controller.signal,
+ signal: gate.signal,
});
const previousRecord: ProviderRuntimeCurrentRecord = {
...previousMetadata,
@@ -897,6 +1099,7 @@ export const ProviderRuntimeManagerLive = Layer.effect(
executableDigest: await hashExecutable(previousExecutable),
installedAt: new Date().toISOString(),
};
+ if (!gate.beginCommit()) throw new DOMException("Rollback cancelled.", "AbortError");
await writeCurrentRecord(config.stateDir, input.provider, previousRecord);
records.set(input.provider, previousRecord);
verifiedManagedReleases.add(`${input.provider}:${previousRecord.releaseId}`);
@@ -925,7 +1128,8 @@ export const ProviderRuntimeManagerLive = Layer.effect(
yield* startOperation({
provider: input.provider,
operation: "remove",
- run: async ({ operationId, startedAt }) => {
+ run: async ({ operationId, startedAt, gate }) => {
+ if (!gate.beginCommit()) throw new DOMException("Removal cancelled.", "AbortError");
await removeCurrentRecord(config.stateDir, input.provider);
records.delete(input.provider);
for (const key of verifiedManagedReleases) {
@@ -954,9 +1158,26 @@ export const ProviderRuntimeManagerLive = Layer.effect(
const configured = configuredExecutable?.trim() ?? "";
const explicitCustom = configured.length > 0 && configured !== recipe.executableName;
const record = records.get(provider) ?? null;
- const systemExecutable = explicitCustom
- ? await resolveConfiguredExecutable({ command: configured, pathValue: basePath })
- : await findExecutableOnPath({ command: recipe.executableName, pathValue: basePath });
+ const systemSearch = await currentSystemPath();
+ const pathExecutable = explicitCustom
+ ? await resolveConfiguredExecutable({
+ command: configured,
+ pathValue: systemSearch.pathValue,
+ })
+ : await findExecutableOnPath({
+ command: recipe.executableName,
+ pathValue: systemSearch.pathValue,
+ });
+ const knownExecutable =
+ !explicitCustom && process.platform === "win32"
+ ? await firstExistingFile(
+ windowsKnownProviderExecutableCandidates({
+ provider,
+ environment: { ...process.env, ...systemSearch.environment },
+ }),
+ )
+ : null;
+ const systemExecutable = pathExecutable ?? knownExecutable;
const source: ServerProviderRuntimeSource = explicitCustom
? "custom"
: systemExecutable
@@ -1017,18 +1238,23 @@ export const ProviderRuntimeManagerLive = Layer.effect(
});
yield* Effect.addFinalizer(() =>
- Effect.sync(() => {
+ Effect.gen(function* () {
disposed = true;
- for (const operation of active.values()) operation.controller.abort();
+ const activeOperations = [...active.values()];
+ yield* Effect.promise(() => cancelAndAwaitProviderRuntimeOperations(activeOperations));
active.clear();
const currentPath = process.env.PATH ?? "";
+ const delimiter = process.platform === "win32" ? ";" : ":";
process.env.PATH =
currentPath === lastAssignedPath
? basePath
: currentPath
- .split(pathDelimiter)
+ .split(delimiter)
.filter((directory) => !managedDirectoriesAdded.has(directory))
- .join(pathDelimiter);
+ .join(delimiter);
+ yield* Effect.promise(() =>
+ Promise.allSettled(installationStateWrites.values()).then(() => undefined),
+ );
}),
);
diff --git a/apps/server/src/provider/Services/ProviderConnection.ts b/apps/server/src/provider/Services/ProviderConnection.ts
index 3e43c1e2..7cc21a78 100644
--- a/apps/server/src/provider/Services/ProviderConnection.ts
+++ b/apps/server/src/provider/Services/ProviderConnection.ts
@@ -8,6 +8,8 @@
* @module ProviderConnection
*/
import type {
+ ProviderKind,
+ ServerProviderConnectionMethod,
ServerProviderConnectionCancelInput,
ServerProviderConnectionError,
ServerProviderConnectionResult,
@@ -27,6 +29,11 @@ export interface ProviderConnectionShape {
readonly submitAuthorizationCode: (
input: ServerProviderConnectionSubmitAuthorizationCodeInput,
) => Effect.Effect;
+ readonly startAfterInstallation: (input: {
+ readonly provider: ProviderKind;
+ readonly method: ServerProviderConnectionMethod;
+ readonly installationOperationId: string;
+ }) => Effect.Effect;
}
export class ProviderConnection extends ServiceMap.Service<
diff --git a/apps/server/src/wsRpc.ts b/apps/server/src/wsRpc.ts
index 41be16c2..ef2074e4 100644
--- a/apps/server/src/wsRpc.ts
+++ b/apps/server/src/wsRpc.ts
@@ -1117,6 +1117,15 @@ export const makeWsRpcLayer = () =>
providerRuntimeManager.prepareInstall(input.provider),
[WS_METHODS.serverInstallProvider]: (input) =>
providerRuntimeManager.install(input).pipe(
+ Effect.tap(() =>
+ input.connectionMethod
+ ? providerConnection.startAfterInstallation({
+ provider: input.provider,
+ method: input.connectionMethod,
+ installationOperationId: input.planToken,
+ })
+ : Effect.void,
+ ),
Effect.andThen(providerClientStatusProjection.getStatuses),
Effect.map((providers) => ({ providers })),
),
diff --git a/apps/web/src/components/ProviderConnectionDialog.browser.tsx b/apps/web/src/components/ProviderConnectionDialog.browser.tsx
index 14df1e47..673012a3 100644
--- a/apps/web/src/components/ProviderConnectionDialog.browser.tsx
+++ b/apps/web/src/components/ProviderConnectionDialog.browser.tsx
@@ -208,7 +208,7 @@ describe("ProviderConnectionDialog", () => {
).filter((button) => button.getAttribute("aria-label") !== "Close");
expect(popup, "Expected the provider dialog popup.").toBeTruthy();
- expect(buttons).toHaveLength(3);
+ expect(buttons).toHaveLength(4);
const popupRect = popup!.getBoundingClientRect();
for (const button of buttons) {
@@ -840,6 +840,208 @@ describe("ProviderConnectionDialog", () => {
}
});
+ it("keeps a device-code recovery action when normal browser opening fails", async () => {
+ const authorizationUrl =
+ "https://auth.openai.com/oauth/authorize?response_type=code&client_id=test-client&redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback&state=test-state&code_challenge=test-challenge&code_challenge_method=S256";
+ const active = {
+ provider: "codex",
+ status: "error",
+ available: true,
+ authStatus: "unauthenticated",
+ requiresProviderAccount: true,
+ checkedAt,
+ runtime: systemRuntime,
+ connectionState: {
+ operationId: "connect-codex-active",
+ method: "codex_browser",
+ status: "waiting_for_browser",
+ startedAt: new Date().toISOString(),
+ finishedAt: null,
+ message: "Finish signing in to ChatGPT in the browser window.",
+ authorizationUrl,
+ },
+ } satisfies ServerProviderStatus;
+ const openExternal = vi.fn().mockRejectedValue(new Error("browser unavailable"));
+ const cancelled = {
+ ...active,
+ connectionState: {
+ ...active.connectionState,
+ status: "cancelled",
+ finishedAt: checkedAt,
+ message: "Sign in was cancelled.",
+ },
+ } satisfies ServerProviderStatus;
+ const device = {
+ ...active,
+ connectionState: {
+ operationId: "connect-codex-device",
+ method: "codex_device_code",
+ status: "waiting_for_browser",
+ startedAt: active.connectionState.startedAt,
+ finishedAt: null,
+ message: "Enter the one-time code shown here.",
+ },
+ } satisfies ServerProviderStatus;
+ const cancelProviderConnection = vi.fn().mockResolvedValue({ providers: [cancelled] });
+ const startProviderConnection = vi.fn().mockResolvedValue({ providers: [device] });
+ const restoreNativeApi = installNativeApi({
+ openExternal,
+ cancelProviderConnection,
+ startProviderConnection,
+ });
+ const queryClient = createQueryClient(active);
+ useProviderConnectionDialogStore.getState().openDialog("codex", "provider_picker");
+
+ const screen = await render(
+
+
+ ,
+ );
+
+ try {
+ await page.getByRole("button", { name: "Open browser again" }).click();
+ await expect.element(page.getByText("browser unavailable")).toBeVisible();
+ await expect.element(page.getByRole("button", { name: "Open browser again" })).toBeVisible();
+ await expect
+ .element(page.getByRole("button", { name: "Use device code instead" }))
+ .toBeVisible();
+ await page.getByRole("button", { name: "Use device code instead" }).click();
+ await vi.waitFor(() => {
+ expect(cancelProviderConnection).toHaveBeenCalledWith({
+ provider: "codex",
+ operationId: "connect-codex-active",
+ });
+ expect(startProviderConnection).toHaveBeenCalledWith({
+ provider: "codex",
+ method: "codex_device_code",
+ });
+ });
+ } finally {
+ await screen.unmount();
+ queryClient.clear();
+ restoreNativeApi();
+ }
+ });
+
+ it("shows Codex's official device code without exposing provider credentials", async () => {
+ const authorizationUrl = "https://auth.openai.com/codex/device";
+ const active = {
+ provider: "codex",
+ status: "error",
+ available: true,
+ authStatus: "unauthenticated",
+ requiresProviderAccount: true,
+ checkedAt,
+ runtime: systemRuntime,
+ connectionState: {
+ operationId: "connect-codex-device",
+ method: "codex_device_code",
+ status: "waiting_for_browser",
+ startedAt: new Date().toISOString(),
+ finishedAt: null,
+ message: "Enter the one-time code shown here.",
+ authorizationUrl,
+ userCode: "ABCD-EFGH",
+ },
+ } satisfies ServerProviderStatus;
+ const openExternal = vi.fn().mockResolvedValue(undefined);
+ const restoreNativeApi = installNativeApi({ openExternal });
+ const queryClient = createQueryClient(active);
+ useProviderConnectionDialogStore.getState().openDialog("codex", "provider_picker");
+
+ const screen = await render(
+
+
+ ,
+ );
+
+ try {
+ await vi.waitFor(() => expect(openExternal).toHaveBeenCalledWith(authorizationUrl));
+ await expect.element(page.getByText("ABCD-EFGH")).toBeVisible();
+ const deviceCodeStatus = page.getByRole("status", { name: "Codex device code" });
+ await expect.element(deviceCodeStatus).toHaveAttribute("aria-live", "polite");
+ await expect.element(deviceCodeStatus).toHaveAttribute("aria-atomic", "true");
+ await expect.element(page.getByText(/Automatic timeout in (?:16:00|15:59)/u)).toBeVisible();
+ await expect.element(page.getByRole("button", { name: "Copy code" })).toBeVisible();
+ await page.getByRole("button", { name: "Copy code" }).click();
+ await expect.element(page.getByRole("button", { name: "Copied" })).toBeVisible();
+ await expect
+ .element(page.getByRole("button", { name: "Use device code instead" }))
+ .not.toBeInTheDocument();
+ } finally {
+ await screen.unmount();
+ queryClient.clear();
+ restoreNativeApi();
+ }
+ });
+
+ it("ignores a stale automatic-browser failure after the device operation changes", async () => {
+ let rejectFirstOpen: (error: Error) => void = () => undefined;
+ const firstOpen = new Promise((_resolve, reject) => {
+ rejectFirstOpen = reject;
+ });
+ const authorizationUrl = "https://auth.openai.com/codex/device";
+ const active = {
+ provider: "codex",
+ status: "error",
+ available: true,
+ authStatus: "unauthenticated",
+ checkedAt,
+ runtime: systemRuntime,
+ connectionState: {
+ operationId: "connect-codex-device-first",
+ method: "codex_device_code",
+ status: "waiting_for_browser",
+ startedAt: new Date().toISOString(),
+ finishedAt: null,
+ message: "Enter the one-time code shown here.",
+ authorizationUrl,
+ userCode: "ABCD-EFGH",
+ },
+ } satisfies ServerProviderStatus;
+ const openExternal = vi
+ .fn()
+ .mockImplementationOnce(() => firstOpen)
+ .mockResolvedValue(undefined);
+ const restoreNativeApi = installNativeApi({ openExternal });
+ const queryClient = createQueryClient(active);
+ useProviderConnectionDialogStore.getState().openDialog("codex", "provider_picker");
+
+ const screen = await render(
+
+
+ ,
+ );
+
+ try {
+ await vi.waitFor(() => expect(openExternal).toHaveBeenCalledTimes(1));
+ applyProviderStatusesToCache(queryClient, [
+ {
+ ...active,
+ connectionState: {
+ ...active.connectionState,
+ operationId: "connect-codex-device-successor",
+ userCode: "IJKL-MNOP",
+ },
+ },
+ ]);
+ await vi.waitFor(() => expect(openExternal).toHaveBeenCalledTimes(2));
+ rejectFirstOpen(new Error("stale browser failure"));
+ await new Promise((resolve) => window.setTimeout(resolve, 0));
+ await expect
+ .element(
+ page.getByText(
+ "Scient could not open the browser automatically. Use Open browser again below.",
+ ),
+ )
+ .not.toBeInTheDocument();
+ } finally {
+ await screen.unmount();
+ queryClient.clear();
+ restoreNativeApi();
+ }
+ });
+
it("reopens the validated Google authorization page without terminal use", async () => {
const authorizationUrl =
"https://accounts.google.com/o/oauth2/auth?response_type=code&redirect_uri=https%3A%2F%2Fantigravity.google%2Foauth-callback&client_id=test-client&state=test-state&code_challenge=test-challenge&code_challenge_method=S256";
@@ -1148,11 +1350,12 @@ describe("ProviderConnectionDialog", () => {
await expect.element(page.getByText("Ready to install version 1.1.5")).toBeVisible();
expect(installProvider).not.toHaveBeenCalled();
- await page.getByRole("button", { name: "Download and install" }).click();
+ await page.getByRole("button", { name: "Download, install and sign in" }).click();
await vi.waitFor(() => {
expect(installProvider).toHaveBeenCalledWith({
provider: "antigravity",
planToken: "trusted-plan-1",
+ connectionMethod: "antigravity_browser",
});
});
await expect.element(page.getByText("Downloading Antigravity 1.1.5.")).toBeVisible();
@@ -1252,6 +1455,130 @@ describe("ProviderConnectionDialog", () => {
}
});
+ it("shows a persisted installation failure after restart and keeps retry available", async () => {
+ const failureMessage =
+ "Installation failed while downloading the provider: the connection was reset.";
+ 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.",
+ },
+ installationState: {
+ operationId: "install-antigravity-failed",
+ operation: "install",
+ status: "failed",
+ startedAt: checkedAt,
+ finishedAt: "2026-07-19T12:01:00.000Z",
+ message: failureMessage,
+ },
+ } satisfies ServerProviderStatus;
+ const queryClient = createQueryClient(antigravity);
+ useProviderConnectionDialogStore.getState().openDialog("antigravity", "settings");
+
+ const screen = await render(
+
+
+ ,
+ );
+
+ try {
+ await expect.element(page.getByText(failureMessage)).toBeVisible();
+ await expect
+ .element(page.getByRole("button", { name: "Try installation again" }))
+ .toBeVisible();
+ } finally {
+ await screen.unmount();
+ queryClient.clear();
+ }
+ });
+
+ it("retries sign-in instead of reinstalling after an automatic handoff failure", async () => {
+ const failureMessage = "Installation succeeded, but sign in could not start.";
+ const codex = {
+ provider: "codex",
+ status: "error",
+ available: false,
+ authStatus: "unknown",
+ checkedAt,
+ runtime: {
+ source: "managed",
+ managedVersion: "0.145.0",
+ canInstall: false,
+ canRepair: true,
+ canRollback: false,
+ canRemove: true,
+ message: null,
+ },
+ installationState: {
+ operationId: "install-codex-1",
+ operation: "install",
+ status: "installed",
+ startedAt: checkedAt,
+ finishedAt: "2026-07-19T12:01:00.000Z",
+ message: "Codex is installed and verified.",
+ },
+ connectionState: {
+ operationId: "handoff-install-codex-1",
+ method: "codex_browser",
+ status: "failed",
+ startedAt: "2026-07-19T12:01:00.000Z",
+ finishedAt: "2026-07-19T12:01:01.000Z",
+ message: failureMessage,
+ },
+ } satisfies ServerProviderStatus;
+ const retrying = {
+ ...codex,
+ connectionState: {
+ operationId: "connect-codex-retry",
+ method: "codex_browser",
+ status: "waiting_for_browser",
+ startedAt: "2026-07-19T12:02:00.000Z",
+ finishedAt: null,
+ message: "Finish the provider sign-in in your browser.",
+ },
+ } satisfies ServerProviderStatus;
+ const startProviderConnection = vi.fn().mockResolvedValue({ providers: [retrying] });
+ const refreshProviders = vi.fn().mockResolvedValue({ providers: [codex] });
+ const restoreNativeApi = installNativeApi({ refreshProviders, startProviderConnection });
+ const queryClient = createQueryClient(codex);
+ useProviderConnectionDialogStore.getState().openDialog("codex", "settings");
+
+ const screen = await render(
+
+
+ ,
+ );
+
+ try {
+ await expect.element(page.getByText(failureMessage)).toBeVisible();
+ await expect.element(page.getByRole("button", { name: "Try again" })).toBeVisible();
+ await expect
+ .element(page.getByRole("button", { name: "Open installation guide" }))
+ .not.toBeInTheDocument();
+ await page.getByRole("button", { name: "Try again" }).click();
+ await vi.waitFor(() =>
+ expect(startProviderConnection).toHaveBeenCalledWith({
+ provider: "codex",
+ method: "codex_browser",
+ }),
+ );
+ } finally {
+ await screen.unmount();
+ queryClient.clear();
+ restoreNativeApi();
+ }
+ });
+
it("keeps managed installation available after a complete provider refresh", async () => {
const antigravity = {
provider: "antigravity",
diff --git a/apps/web/src/components/ProviderConnectionDialog.tsx b/apps/web/src/components/ProviderConnectionDialog.tsx
index 711a8a6d..cf952af0 100644
--- a/apps/web/src/components/ProviderConnectionDialog.tsx
+++ b/apps/web/src/components/ProviderConnectionDialog.tsx
@@ -7,6 +7,7 @@ import { compareSemverVersions } from "@synara/shared/providerVersions";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useRef, useState } from "react";
+import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
import {
CLAUDE_CONNECTION_METHOD_OPTIONS,
describeProviderConnection,
@@ -34,6 +35,7 @@ import { Input } from "./ui/input";
import { Spinner } from "./ui/spinner";
const CONNECTION_TIMEOUT_MS = 10 * 60 * 1_000;
+const CODEX_DEVICE_CODE_CONNECTION_TIMEOUT_MS = 16 * 60 * 1_000;
const ANTIGRAVITY_CONNECTION_TIMEOUT_MS = 10 * 60 * 1_000;
function formatRemainingTime(startedAt: string, nowMs: number, timeoutMs: number): string {
@@ -62,6 +64,9 @@ export function ProviderConnectionDialog() {
const activeAuthorizationCodeSubmissionRef = useRef<{
readonly operationId: string;
} | null>(null);
+ const openedAuthorizationUrlsRef = useRef(new Set());
+ const { copyToClipboard: copyDeviceCode, isCopied: isDeviceCodeCopied } =
+ useCopyToClipboard();
const activeConnectionOperationIdRef = useRef(null);
const status = provider
? configQuery.data?.providers.find((entry) => entry.provider === provider)
@@ -95,7 +100,14 @@ export function ProviderConnectionDialog() {
["starting", "waiting_for_browser", "verifying"].includes(status.connectionState.status)
? status.connectionState
: null;
- activeConnectionOperationIdRef.current = activeConnection?.operationId ?? null;
+ const activeConnectionOperationId = activeConnection?.operationId ?? null;
+ const activeConnectionMethod = activeConnection?.method ?? null;
+ const activeConnectionAuthorizationUrl = activeConnection?.authorizationUrl ?? null;
+ const activeCodexDeviceCode =
+ provider === "codex" && activeConnectionMethod === "codex_device_code"
+ ? (activeConnection?.userCode ?? null)
+ : null;
+ activeConnectionOperationIdRef.current = activeConnectionOperationId;
useEffect(() => {
setRuntimeReconnectBaselineOperationId(undefined);
@@ -156,6 +168,39 @@ export function ProviderConnectionDialog() {
if (!isOpen) setAuthorizationCode("");
}, [isOpen]);
+ useEffect(() => {
+ if (
+ !isOpen ||
+ activeConnectionMethod !== "codex_device_code" ||
+ !activeConnectionOperationId ||
+ !activeConnectionAuthorizationUrl
+ ) {
+ return;
+ }
+ const key = `${activeConnectionOperationId}:${activeConnectionAuthorizationUrl}`;
+ if (openedAuthorizationUrlsRef.current.has(key)) return;
+ openedAuthorizationUrlsRef.current.add(key);
+ let disposed = false;
+ void ensureNativeApi()
+ .shell.openExternal(activeConnectionAuthorizationUrl)
+ .catch(() => {
+ if (disposed || activeConnectionOperationIdRef.current !== activeConnectionOperationId) {
+ return;
+ }
+ setActionError(
+ "Scient could not open the browser automatically. Use Open browser again below.",
+ );
+ });
+ return () => {
+ disposed = true;
+ };
+ }, [
+ isOpen,
+ activeConnectionOperationId,
+ activeConnectionMethod,
+ activeConnectionAuthorizationUrl,
+ ]);
+
if (!provider || !presentation || !Icon) return null;
const startsProviderSignIn =
status?.available === true && providerConnectionMethod(provider) !== null;
@@ -242,6 +287,20 @@ export function ProviderConnectionDialog() {
});
};
+ const switchToCodexDeviceCode = () => {
+ const operation = status?.connectionState;
+ if (provider !== "codex" || !operation) return Promise.resolve();
+ invalidateAuthorizationCodeSubmission(operation.operationId);
+ return runAction(async () => {
+ const cancelled = await ensureNativeApi().server.cancelProviderConnection({
+ provider,
+ operationId: operation.operationId,
+ });
+ applyProviderStatusesToCache(queryClient, cancelled.providers);
+ await performStartSignIn("codex_device_code");
+ });
+ };
+
const reopenAuthorization = () => {
const authorizationUrl = activeConnection?.authorizationUrl;
if (!authorizationUrl) return Promise.resolve();
@@ -303,9 +362,11 @@ export function ProviderConnectionDialog() {
setInstallPlan(plan);
return;
}
+ const connectionMethod = managedUpdateFlow ? null : providerConnectionMethod(provider);
const result = await ensureNativeApi().server.installProvider({
provider,
planToken: installPlan.planToken,
+ ...(connectionMethod ? { connectionMethod } : {}),
});
if (managedUpdateFlow) setManagedUpdateStarted(true);
setInstallPlan(null);
@@ -384,7 +445,9 @@ export function ProviderConnectionDialog() {
clockMs,
provider === "antigravity"
? ANTIGRAVITY_CONNECTION_TIMEOUT_MS
- : CONNECTION_TIMEOUT_MS,
+ : activeConnectionMethod === "codex_device_code"
+ ? CODEX_DEVICE_CODE_CONNECTION_TIMEOUT_MS
+ : CONNECTION_TIMEOUT_MS,
)}
) : null}
@@ -394,8 +457,7 @@ export function ProviderConnectionDialog() {
role="group"
aria-label="Sign-in progress actions"
>
- {(provider === "grok" || provider === "antigravity") &&
- activeConnection.authorizationUrl ? (
+ {activeConnection.authorizationUrl ? (