diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 4101840530f..b3571756842 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -24,10 +24,15 @@ import * as DesktopPreReadyPlatform from "./DesktopPreReadyPlatform.ts"; import * as DesktopShutdown from "./DesktopShutdown.ts"; import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; +import * as DesktopClientSettings from "../settings/DesktopClientSettings.ts"; import * as DesktopShellEnvironment from "../shell/DesktopShellEnvironment.ts"; import * as DesktopState from "./DesktopState.ts"; import * as DesktopUpdates from "../updates/DesktopUpdates.ts"; import * as DesktopWslBackend from "../wsl/DesktopWslBackend.ts"; +import { + configureCuaDriverServerEnvironment, + disableCuaDriverServerEnvironment, +} from "../cua/CuaDriverServerEnvironment.ts"; const DEFAULT_DESKTOP_BACKEND_PORT = 3773; const MAX_TCP_PORT = 65_535; @@ -222,11 +227,13 @@ const startup = Effect.gen(function* () { const appIdentity = yield* DesktopAppIdentity.DesktopAppIdentity; const applicationMenu = yield* DesktopApplicationMenu.DesktopApplicationMenu; const electronApp = yield* ElectronApp.ElectronApp; + const electronDialog = yield* ElectronDialog.ElectronDialog; const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; const linuxUrlHandler = yield* DesktopLinuxUrlHandler.DesktopLinuxUrlHandler; const clerk = yield* DesktopClerk.DesktopClerk; const shellEnvironment = yield* DesktopShellEnvironment.DesktopShellEnvironment; const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; + const clientSettings = yield* DesktopClientSettings.DesktopClientSettings; const preReadyElectronOptions = yield* DesktopPreReadyPlatform.DesktopPreReadyElectronOptions; const safeStorage = yield* ElectronSafeStorage.ElectronSafeStorage; const updates = yield* DesktopUpdates.DesktopUpdates; @@ -283,6 +290,41 @@ const startup = Effect.gen(function* () { backend: Option.getOrElse(selectedBackend, () => "unknown"), }); } + const cuaEnabled = Option.match(yield* clientSettings.get, { + onNone: () => false, + onSome: (settings) => settings.enableCua, + }); + if (cuaEnabled) { + const continueWithoutCua = Effect.fn("desktop.continueWithoutCua")(function* ( + context: Readonly>, + ) { + yield* disableCuaDriverServerEnvironment(); + yield* logStartupError("embedded cua-driver failed to configure", context); + yield* electronDialog + .showMessageBox({ + type: "error", + title: "Computer use unavailable", + message: "Cua could not start.", + detail: + "T3 Code will continue without computer use. Check the logs, then restart to try again.", + buttons: ["Continue"], + }) + .pipe(Effect.ignore); + }); + yield* configureCuaDriverServerEnvironment( + environment.appUserModelId, + environment.platform, + environment.isPackaged ? environment.resourcesPath : undefined, + ).pipe( + Effect.catchTags({ + CuaDriverConfigurationError: (error) => + continueWithoutCua({ modulePath: error.modulePath, cause: error.cause }), + CuaDriverNotConfiguredError: () => continueWithoutCua({}), + }), + ); + } else { + yield* disableCuaDriverServerEnvironment(); + } yield* appIdentity.configure; yield* applicationMenu.configure; yield* updates.configure; diff --git a/apps/desktop/src/cua/CuaDriverServerEnvironment.test.ts b/apps/desktop/src/cua/CuaDriverServerEnvironment.test.ts new file mode 100644 index 00000000000..35a8056a213 --- /dev/null +++ b/apps/desktop/src/cua/CuaDriverServerEnvironment.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; + +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; + +import { + configureCuaDriverServerEnvironment, + CuaDriverNotConfiguredError, + disableCuaDriverServerEnvironment, + resolveEmbeddedDriverPath, + T3CODE_CUA_DRIVER_HOST_BUNDLE_ID_ENV, + T3CODE_CUA_DRIVER_MODULE_URL_ENV, + T3CODE_CUA_DRIVER_PATH_ENV, +} from "./CuaDriverServerEnvironment.ts"; + +describe("cua-driver server environment", () => { + it("uses a configured driver path", () => { + expect( + Option.getOrUndefined( + resolveEmbeddedDriverPath({ T3CODE_CUA_DRIVER_PATH: "/Applications/T3 Code/cua-driver" }), + ), + ).toBe("/Applications/T3 Code/cua-driver"); + }); + + it("uses the packaged resource when no override is configured", () => { + expect( + Option.getOrUndefined( + resolveEmbeddedDriverPath({}, "/Applications/T3 Code.app/Contents/Resources/cua-driver"), + ), + ).toBe("/Applications/T3 Code.app/Contents/Resources/cua-driver"); + }); + + it("ignores the development override in packaged builds", () => { + expect( + Option.getOrUndefined( + resolveEmbeddedDriverPath( + { T3CODE_CUA_DRIVER_PATH: "/tmp/untrusted-cua-driver" }, + "/Applications/T3 Code.app/Contents/Resources/cua-driver", + ), + ), + ).toBe("/Applications/T3 Code.app/Contents/Resources/cua-driver"); + }); + + it("ignores missing and empty paths", () => { + expect(Option.isNone(resolveEmbeddedDriverPath({}))).toBe(true); + expect(Option.isNone(resolveEmbeddedDriverPath({ T3CODE_CUA_DRIVER_PATH: " " }))).toBe(true); + }); + + it.effect("reports missing configuration without a fabricated path", () => { + const previous = process.env[T3CODE_CUA_DRIVER_PATH_ENV]; + delete process.env[T3CODE_CUA_DRIVER_PATH_ENV]; + return Effect.scoped( + Effect.gen(function* () { + yield* Effect.addFinalizer(() => + Effect.sync(() => { + if (previous === undefined) delete process.env[T3CODE_CUA_DRIVER_PATH_ENV]; + else process.env[T3CODE_CUA_DRIVER_PATH_ENV] = previous; + }), + ); + const error = yield* configureCuaDriverServerEnvironment( + "com.t3tools.t3code", + "linux", + ).pipe(Effect.flip); + expect(error).toBeInstanceOf(CuaDriverNotConfiguredError); + }), + ).pipe(Effect.provide(NodeServices.layer)); + }); + + it.effect("loads the packaged module from the asar archive", () => + Effect.scoped( + Effect.gen(function* () { + const path = yield* Path.Path; + const resourcesPath = "/Applications/T3 Code.app/Contents/Resources"; + yield* configureCuaDriverServerEnvironment("com.t3tools.t3code", "darwin", resourcesPath); + + const moduleUrl = yield* path.toFileUrl( + path.join(resourcesPath, "app.asar/node_modules/@trycua/cua-driver/dist/embedded.js"), + ); + expect(process.env[T3CODE_CUA_DRIVER_MODULE_URL_ENV]).toBe(moduleUrl.href); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("uses packaged executables outside macOS", () => + Effect.scoped( + Effect.gen(function* () { + const path = yield* Path.Path; + const resourcesPath = "/opt/T3 Code/resources"; + for (const [platform, executable] of [ + ["linux", "cua-driver"], + ["win32", "cua-driver.exe"], + ] as const) { + yield* configureCuaDriverServerEnvironment("com.t3tools.t3code", platform, resourcesPath); + expect(process.env[T3CODE_CUA_DRIVER_PATH_ENV]).toBe( + path.join(resourcesPath, "cua-driver", executable), + ); + expect(process.env[T3CODE_CUA_DRIVER_HOST_BUNDLE_ID_ENV]).toBe("com.t3tools.t3code"); + expect(process.env[T3CODE_CUA_DRIVER_MODULE_URL_ENV]).toContain( + "app.asar/node_modules/@trycua/cua-driver/dist/embedded.js", + ); + } + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("prevents inherited configuration from bypassing the desktop opt-in", () => { + const names = [ + T3CODE_CUA_DRIVER_PATH_ENV, + T3CODE_CUA_DRIVER_HOST_BUNDLE_ID_ENV, + T3CODE_CUA_DRIVER_MODULE_URL_ENV, + ] as const; + const previous = Object.fromEntries(names.map((name) => [name, process.env[name]] as const)); + for (const name of names) process.env[name] = `inherited-${name}`; + + return Effect.scoped( + Effect.gen(function* () { + yield* disableCuaDriverServerEnvironment(); + for (const name of names) expect(process.env[name]).toBeUndefined(); + }), + ).pipe( + Effect.tap(() => + Effect.sync(() => { + for (const name of names) expect(process.env[name]).toBe(`inherited-${name}`); + }), + ), + Effect.ensuring( + Effect.sync(() => { + for (const name of names) { + const value = previous[name]; + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + }), + ), + ); + }); +}); diff --git a/apps/desktop/src/cua/CuaDriverServerEnvironment.ts b/apps/desktop/src/cua/CuaDriverServerEnvironment.ts new file mode 100644 index 00000000000..f5a1cded978 --- /dev/null +++ b/apps/desktop/src/cua/CuaDriverServerEnvironment.ts @@ -0,0 +1,114 @@ +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +export const T3CODE_CUA_DRIVER_PATH_ENV = "T3CODE_CUA_DRIVER_PATH"; +export const T3CODE_CUA_DRIVER_HOST_BUNDLE_ID_ENV = "T3CODE_CUA_DRIVER_HOST_BUNDLE_ID"; +export const T3CODE_CUA_DRIVER_MODULE_URL_ENV = "T3CODE_CUA_DRIVER_MODULE_URL"; + +export class CuaDriverConfigurationError extends Schema.TaggedErrorClass()( + "CuaDriverConfigurationError", + { + modulePath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Could not configure embedded cua-driver module at '${this.modulePath}'.`; + } +} + +export class CuaDriverNotConfiguredError extends Schema.TaggedErrorClass()( + "CuaDriverNotConfiguredError", + {}, +) { + override get message(): string { + return "Cua Driver is not configured."; + } +} + +export const resolveEmbeddedDriverPath = ( + environment: NodeJS.ProcessEnv = process.env, + packagedDriverPath?: string, +): Option.Option => { + if (packagedDriverPath !== undefined) return Option.some(packagedDriverPath); + return Option.fromNullishOr(environment[T3CODE_CUA_DRIVER_PATH_ENV]).pipe( + Option.map((value) => value.trim()), + Option.filter((value) => value.length > 0), + ); +}; + +const cuaEnvironmentNames = [ + T3CODE_CUA_DRIVER_PATH_ENV, + T3CODE_CUA_DRIVER_HOST_BUNDLE_ID_ENV, + T3CODE_CUA_DRIVER_MODULE_URL_ENV, +] as const; + +const replaceCuaDriverServerEnvironment = Effect.fn("replaceCuaDriverServerEnvironment")(function* ( + updates: Readonly>, +) { + const previous = Object.fromEntries( + cuaEnvironmentNames.map((name) => [name, process.env[name]] as const), + ); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + for (const [name, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + }), + ); + for (const name of cuaEnvironmentNames) delete process.env[name]; + Object.assign(process.env, updates); +}); + +/** Prevents inherited development variables from bypassing the desktop opt-in. */ +export const disableCuaDriverServerEnvironment = Effect.fn("disableCuaDriverServerEnvironment")( + function* () { + yield* replaceCuaDriverServerEnvironment({}); + }, +); + +/** + * Configures the local Node server to own cua-driver. The backend inherits + * these values when Electron starts it; remote servers can set the same + * variables in their own environment. + */ +export const configureCuaDriverServerEnvironment = Effect.fn("configureCuaDriverServerEnvironment")( + function* (hostBundleId: string, platform: NodeJS.Platform, resourcesPath?: string) { + const path = yield* Path.Path; + const packagedDriverPath = + resourcesPath === undefined + ? undefined + : platform === "darwin" + ? path.join(resourcesPath, "cua-driver") + : path.join( + resourcesPath, + "cua-driver", + platform === "win32" ? "cua-driver.exe" : "cua-driver", + ); + const driverPath = resolveEmbeddedDriverPath(process.env, packagedDriverPath); + if (Option.isNone(driverPath)) { + return yield* new CuaDriverNotConfiguredError(); + } + + const updates: Record = { + [T3CODE_CUA_DRIVER_PATH_ENV]: driverPath.value, + [T3CODE_CUA_DRIVER_HOST_BUNDLE_ID_ENV]: hostBundleId, + }; + if (resourcesPath !== undefined) { + const modulePath = path.join( + resourcesPath, + "app.asar/node_modules/@trycua/cua-driver/dist/embedded.js", + ); + const moduleUrl = yield* path.toFileUrl(modulePath).pipe( + Effect.map((url) => url.href), + Effect.mapError((cause) => new CuaDriverConfigurationError({ modulePath, cause })), + ); + updates[T3CODE_CUA_DRIVER_MODULE_URL_ENV] = moduleUrl; + } + + yield* replaceCuaDriverServerEnvironment(updates); + }, +); diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index fca4f4b8a12..12a1bdb6cee 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -18,6 +18,7 @@ const clientSettings: ClientSettings = { dismissedProviderUpdateNotificationKeys: [], diffIgnoreWhitespace: true, environmentIdentificationMode: "artwork", + enableCua: false, favorites: [], fontFamilyCode: "", fontFamilyComposer: "", diff --git a/apps/server/package.json b/apps/server/package.json index 4613053aeca..6c7a7e60664 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -36,6 +36,7 @@ "@ff-labs/fff-node": "0.9.4", "@opencode-ai/sdk": "^1.3.15", "@pierre/diffs": "catalog:", + "@trycua/cua-driver": "0.12.2", "effect": "catalog:", "node-pty": "^1.1.0", "yaml": "catalog:" diff --git a/apps/server/src/cua/CuaDriverEmbedded.test.ts b/apps/server/src/cua/CuaDriverEmbedded.test.ts new file mode 100644 index 00000000000..76d420aa377 --- /dev/null +++ b/apps/server/src/cua/CuaDriverEmbedded.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from "@effect/vitest"; + +import * as Effect from "effect/Effect"; + +import type { EmbeddedDriverExit } from "@trycua/cua-driver/embedded"; + +import { T3CODE_CODEX_APPEND_LAUNCH_ARGS_ENV } from "../provider/Layers/codexLaunchArgs.ts"; +import { + buildCodexLaunchArgs, + installCodexLaunchArgs, + monitorEmbeddedCuaDriverExit, +} from "./CuaDriverEmbedded.ts"; + +const connection = { + mcp: { + command: "/Applications/T3 Code/cua-driver", + args: ["mcp", "--embedded", "--socket", "/tmp/t3 code.sock"], + environment: [ + { name: "CUA_DRIVER_EMBEDDED", value: "1" }, + { name: "CUA_DRIVER_HOST_BUNDLE_ID", value: "com.t3tools.t3code" }, + ], + }, +}; + +describe("embedded cua-driver Codex configuration", () => { + it("quotes MCP launch arguments", () => { + expect(buildCodexLaunchArgs(connection)).toBe( + '-c "mcp_servers.cua-driver.command=\\"/Applications/T3 Code/cua-driver\\"" -c "mcp_servers.cua-driver.args=[\\"mcp\\",\\"--embedded\\",\\"--socket\\",\\"/tmp/t3 code.sock\\"]" -c "mcp_servers.cua-driver.env={CUA_DRIVER_EMBEDDED=\\"1\\",CUA_DRIVER_HOST_BUNDLE_ID=\\"com.t3tools.t3code\\"}"', + ); + }); + + it.effect("reports every unexpected driver exit regardless of its status", () => + Effect.gen(function* () { + const exits: ReadonlyArray = [ + { generation: "generation-1", code: 9, success: false }, + { generation: "generation-2", code: 0, success: true }, + ]; + const observed: Array = []; + + for (const exit of exits) { + yield* monitorEmbeddedCuaDriverExit( + () => Promise.resolve(exit), + (value) => + Effect.sync(() => { + observed.push(value); + }), + ); + } + + expect(observed).toEqual(exits); + }), + ); + + it.effect("does not report an exit when its scope shuts down", () => { + let reported = false; + + return Effect.scoped( + Effect.gen(function* () { + yield* monitorEmbeddedCuaDriverExit( + () => new Promise(() => {}), + () => + Effect.sync(() => { + reported = true; + }), + ).pipe(Effect.forkScoped); + yield* Effect.yieldNow; + }), + ).pipe(Effect.tap(() => Effect.sync(() => expect(reported).toBe(false)))); + }); + + it.effect("surfaces exit monitor failures", () => { + const failure = new Error("wait failed"); + let reported = false; + + return monitorEmbeddedCuaDriverExit( + () => Promise.reject(failure), + () => + Effect.sync(() => { + reported = true; + }), + ).pipe( + Effect.flip, + Effect.tap((error) => + Effect.sync(() => { + expect(error.cause).toBe(failure); + expect(reported).toBe(false); + }), + ), + ); + }); + + it.effect("restores prior Codex launch arguments once when Cua becomes unavailable", () => { + const original = process.env[T3CODE_CODEX_APPEND_LAUNCH_ARGS_ENV]; + process.env[T3CODE_CODEX_APPEND_LAUNCH_ARGS_ENV] = "--existing"; + + return Effect.scoped( + Effect.gen(function* () { + const deactivate = yield* installCodexLaunchArgs("--cua"); + expect(process.env[T3CODE_CODEX_APPEND_LAUNCH_ARGS_ENV]).toBe("--existing --cua"); + expect(yield* deactivate()).toBe(true); + expect(process.env[T3CODE_CODEX_APPEND_LAUNCH_ARGS_ENV]).toBe("--existing"); + expect(yield* deactivate()).toBe(false); + }), + ).pipe( + Effect.tap(() => + Effect.sync(() => + expect(process.env[T3CODE_CODEX_APPEND_LAUNCH_ARGS_ENV]).toBe("--existing"), + ), + ), + Effect.ensuring( + Effect.sync(() => { + if (original === undefined) delete process.env[T3CODE_CODEX_APPEND_LAUNCH_ARGS_ENV]; + else process.env[T3CODE_CODEX_APPEND_LAUNCH_ARGS_ENV] = original; + }), + ), + ); + }); +}); diff --git a/apps/server/src/cua/CuaDriverEmbedded.ts b/apps/server/src/cua/CuaDriverEmbedded.ts new file mode 100644 index 00000000000..f601ae677e5 --- /dev/null +++ b/apps/server/src/cua/CuaDriverEmbedded.ts @@ -0,0 +1,163 @@ +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import type { EmbeddedDriverConnection, EmbeddedDriverExit } from "@trycua/cua-driver/embedded"; + +import { T3CODE_CODEX_APPEND_LAUNCH_ARGS_ENV } from "../provider/Layers/codexLaunchArgs.ts"; + +export const T3CODE_CUA_DRIVER_PATH_ENV = "T3CODE_CUA_DRIVER_PATH"; +export const T3CODE_CUA_DRIVER_HOST_BUNDLE_ID_ENV = "T3CODE_CUA_DRIVER_HOST_BUNDLE_ID"; +export const T3CODE_CUA_DRIVER_MODULE_URL_ENV = "T3CODE_CUA_DRIVER_MODULE_URL"; + +export class CuaDriverStartError extends Schema.TaggedErrorClass()( + "CuaDriverStartError", + { + binaryPath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Could not start embedded cua-driver at '${this.binaryPath}'.`; + } +} + +export class CuaDriverModuleLoadError extends Schema.TaggedErrorClass()( + "CuaDriverModuleLoadError", + { + modulePath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Could not load embedded cua-driver module at '${this.modulePath}'.`; + } +} + +const tomlString = (value: string): string => JSON.stringify(value); + +export const buildCodexLaunchArgs = (connection: Pick): string => { + const proxyArgs = connection.mcp.args.map(tomlString).join(","); + const proxyEnv = connection.mcp.environment + .map(({ name, value }) => `${name}=${tomlString(value)}`) + .join(","); + return [ + "-c", + tomlString(`mcp_servers.cua-driver.command=${tomlString(connection.mcp.command)}`), + "-c", + tomlString(`mcp_servers.cua-driver.args=[${proxyArgs}]`), + "-c", + tomlString(`mcp_servers.cua-driver.env={${proxyEnv}}`), + ].join(" "); +}; + +const importEmbeddedCuaDriver = ( + moduleUrl?: string, +): Promise => + moduleUrl === undefined ? import("@trycua/cua-driver/embedded") : import(moduleUrl); + +export const monitorEmbeddedCuaDriverExit = Effect.fn("server.monitorEmbeddedCuaDriverExit")( + function* ( + waitForExit: () => Promise, + onUnexpectedExit: (exit: EmbeddedDriverExit) => Effect.Effect, + ) { + const exit = yield* Effect.tryPromise(waitForExit); + yield* onUnexpectedExit(exit); + }, +); + +export const installCodexLaunchArgs = Effect.fn("server.installCodexLaunchArgs")(function* ( + launchArgs: string, +) { + const previous = process.env[T3CODE_CODEX_APPEND_LAUNCH_ARGS_ENV]; + let active = true; + const deactivate = Effect.sync(() => { + if (!active) return false; + active = false; + if (previous === undefined) delete process.env[T3CODE_CODEX_APPEND_LAUNCH_ARGS_ENV]; + else process.env[T3CODE_CODEX_APPEND_LAUNCH_ARGS_ENV] = previous; + return true; + }); + yield* Effect.addFinalizer(() => deactivate.pipe(Effect.asVoid)); + process.env[T3CODE_CODEX_APPEND_LAUNCH_ARGS_ENV] = [previous?.trim() ?? "", launchArgs] + .filter((value) => value.length > 0) + .join(" "); + return () => deactivate; +}); + +/** + * Starts a server-private driver when T3CODE_CUA_DRIVER_PATH is configured. + * The scoped finalizer owns both the native host and its child process. + */ +export const startEmbeddedCuaDriver = Effect.fn("server.startEmbeddedCuaDriver")(function* () { + const binaryPath = process.env[T3CODE_CUA_DRIVER_PATH_ENV]?.trim(); + if (!binaryPath) return; + + const hostBundleId = process.env[T3CODE_CUA_DRIVER_HOST_BUNDLE_ID_ENV]?.trim() || "t3-server"; + const moduleUrl = process.env[T3CODE_CUA_DRIVER_MODULE_URL_ENV]?.trim() || undefined; + const { EmbeddedCuaDriverHost } = yield* Effect.tryPromise({ + try: () => importEmbeddedCuaDriver(moduleUrl), + catch: (cause) => + new CuaDriverModuleLoadError({ + modulePath: moduleUrl ?? "@trycua/cua-driver/embedded", + cause, + }), + }); + const driver = yield* Effect.try({ + try: () => new EmbeddedCuaDriverHost(binaryPath, hostBundleId), + catch: (cause) => new CuaDriverStartError({ binaryPath, cause }), + }); + yield* Effect.addFinalizer(() => + Effect.tryPromise(() => driver.stop()).pipe( + Effect.ignore, + Effect.ensuring( + Effect.sync(() => driver.uniffiDestroy()).pipe(Effect.catchCause(() => Effect.void)), + ), + ), + ); + + const connection = yield* Effect.tryPromise({ + try: () => driver.start(), + catch: (cause) => new CuaDriverStartError({ binaryPath, cause }), + }); + + const disableCua = yield* installCodexLaunchArgs(buildCodexLaunchArgs(connection)); + yield* monitorEmbeddedCuaDriverExit( + () => driver.waitForExit(connection.generation), + (exit) => + disableCua().pipe( + Effect.flatMap((disabled) => + disabled + ? Effect.logWarning("embedded cua-driver exited; computer use is unavailable", { + component: "embedded-cua-driver", + generation: exit.generation, + code: exit.code, + success: exit.success, + }) + : Effect.void, + ), + ), + ).pipe( + Effect.catch((cause) => + disableCua().pipe( + Effect.flatMap((disabled) => + disabled + ? Effect.logWarning( + "embedded cua-driver exit monitor failed; computer use is unavailable", + { + component: "embedded-cua-driver", + cause, + }, + ) + : Effect.void, + ), + ), + ), + Effect.forkScoped, + ); + + yield* Effect.logInfo("embedded cua-driver ready", { + component: "embedded-cua-driver", + pid: connection.pid, + socketPath: connection.socketPath, + }); +}); diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index ca3e0cbbb97..de8ead9aa40 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -36,6 +36,7 @@ import { ChildProcess } from "effect/unstable/process"; import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import type { EventNdjsonLogger } from "../../provider/Layers/EventNdjsonLogger.ts"; +import { T3CODE_CODEX_APPEND_LAUNCH_ARGS_ENV } from "../../provider/Layers/codexLaunchArgs.ts"; import { layer as idAllocatorLayer, IdAllocatorV2 } from "../IdAllocator.ts"; import { ProviderAdapterOpenSessionError, @@ -57,6 +58,7 @@ import { makeCodexAppServerProtocolLogger, makeCodexAppServerSpawnCommand, projectCodexDynamicToolItem, + resolveCodexAdapterAppServerArgs, resolveCodexRollbackTurnCount, } from "./CodexAdapterV2.ts"; import { makeReplayServerConfig } from "./CodexAdapterV2.testkit.ts"; @@ -502,6 +504,32 @@ describe("CodexAdapterV2 process spawning", () => { } }); + it("uses live integration arguments and removes stale snapshots", () => { + const previous = process.env[T3CODE_CODEX_APPEND_LAUNCH_ARGS_ENV]; + try { + process.env[T3CODE_CODEX_APPEND_LAUNCH_ARGS_ENV] = + '-c mcp_servers.cua-driver.command="/bin/cua-driver"'; + assert.deepEqual(resolveCodexAdapterAppServerArgs("--strict-config", {}), [ + "app-server", + "--strict-config", + "-c", + "mcp_servers.cua-driver.command=/bin/cua-driver", + ]); + + delete process.env[T3CODE_CODEX_APPEND_LAUNCH_ARGS_ENV]; + assert.deepEqual( + resolveCodexAdapterAppServerArgs("--strict-config", { + [T3CODE_CODEX_APPEND_LAUNCH_ARGS_ENV]: + '-c mcp_servers.cua-driver.command="/stale/cua-driver"', + }), + ["app-server", "--strict-config"], + ); + } finally { + if (previous === undefined) delete process.env[T3CODE_CODEX_APPEND_LAUNCH_ARGS_ENV]; + else process.env[T3CODE_CODEX_APPEND_LAUNCH_ARGS_ENV] = previous; + } + }); + it.effect("resolves Windows command shims through the shared spawn policy", () => Effect.gen(function* () { const command = yield* makeCodexAppServerSpawnCommand({ diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 65abca30d9d..1f66f76c93f 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -60,6 +60,11 @@ import { } from "../../provider/Drivers/CodexHomeLayout.ts"; import type { EventNdjsonLogger } from "../../provider/Layers/EventNdjsonLogger.ts"; import { ProviderEventLoggers } from "../../provider/Layers/ProviderEventLoggers.ts"; +import { + codexAppServerArgs, + resolveCodexLaunchArgs, + T3CODE_CODEX_APPEND_LAUNCH_ARGS_ENV, +} from "../../provider/Layers/codexLaunchArgs.ts"; import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts"; import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import { @@ -1261,6 +1266,25 @@ function isSensitiveCodexProtocolKey(key: string): boolean { ); } +const withLiveCodexIntegrationEnvironment = (environment: NodeJS.ProcessEnv): NodeJS.ProcessEnv => { + const merged = { ...environment }; + const liveLaunchArgs = process.env[T3CODE_CODEX_APPEND_LAUNCH_ARGS_ENV]; + if (liveLaunchArgs === undefined) { + delete merged[T3CODE_CODEX_APPEND_LAUNCH_ARGS_ENV]; + } else { + merged[T3CODE_CODEX_APPEND_LAUNCH_ARGS_ENV] = liveLaunchArgs; + } + return merged; +}; + +export const resolveCodexAdapterAppServerArgs = ( + launchArgs: string | undefined, + environment: NodeJS.ProcessEnv, +): ReadonlyArray => + codexAppServerArgs( + resolveCodexLaunchArgs(launchArgs, withLiveCodexIntegrationEnvironment(environment)), + ); + export const codexAppServerClientFactoryFromSettingsLayer: Layer.Layer< CodexAppServerClientFactory, never, @@ -1281,7 +1305,7 @@ export const codexAppServerClientFactoryFromSettingsLayer: Layer.Layer< }; const command = yield* makeCodexAppServerSpawnCommand({ command: input.settings.binaryPath || "codex", - args: ["app-server"], + args: resolveCodexAdapterAppServerArgs(input.settings.launchArgs, environment), env: environment, }); const handle = yield* spawner.spawn(command).pipe( diff --git a/apps/server/src/provider/Layers/codexLaunchArgs.test.ts b/apps/server/src/provider/Layers/codexLaunchArgs.test.ts index 115ac28eaf9..35109932384 100644 --- a/apps/server/src/provider/Layers/codexLaunchArgs.test.ts +++ b/apps/server/src/provider/Layers/codexLaunchArgs.test.ts @@ -26,6 +26,15 @@ describe("resolveCodexLaunchArgs", () => { it("ignores whitespace-only environment values", () => { NodeAssert.equal(resolveCodexLaunchArgs("", { T3CODE_CODEX_LAUNCH_ARGS: " " }), ""); }); + + it("appends integration arguments without replacing configured settings", () => { + NodeAssert.equal( + resolveCodexLaunchArgs("--strict-config", { + T3CODE_CODEX_APPEND_LAUNCH_ARGS: '-c mcp_servers.cua-driver.command="/bin/cua-driver"', + }), + '--strict-config -c mcp_servers.cua-driver.command="/bin/cua-driver"', + ); + }); }); describe("codexAppServerArgs", () => { diff --git a/apps/server/src/provider/Layers/codexLaunchArgs.ts b/apps/server/src/provider/Layers/codexLaunchArgs.ts index 771a4f0b6ed..b3ea024c456 100644 --- a/apps/server/src/provider/Layers/codexLaunchArgs.ts +++ b/apps/server/src/provider/Layers/codexLaunchArgs.ts @@ -1,11 +1,16 @@ import { tokenizeCliArgs } from "@t3tools/shared/cliArgs"; export const T3CODE_CODEX_LAUNCH_ARGS_ENV = "T3CODE_CODEX_LAUNCH_ARGS"; +export const T3CODE_CODEX_APPEND_LAUNCH_ARGS_ENV = "T3CODE_CODEX_APPEND_LAUNCH_ARGS"; export const resolveCodexLaunchArgs = ( launchArgs?: string, environment: NodeJS.ProcessEnv = process.env, -) => environment[T3CODE_CODEX_LAUNCH_ARGS_ENV]?.trim() || launchArgs?.trim() || ""; +) => { + const configured = environment[T3CODE_CODEX_LAUNCH_ARGS_ENV]?.trim() || launchArgs?.trim() || ""; + const appended = environment[T3CODE_CODEX_APPEND_LAUNCH_ARGS_ENV]?.trim() || ""; + return [configured, appended].filter((value) => value.length > 0).join(" "); +}; export const codexLaunchArgv = (launchArgs?: string): ReadonlyArray => tokenizeCliArgs(launchArgs); diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 99af107696c..930870d23f4 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -24,6 +24,7 @@ import * as Schema from "effect/Schema"; import * as ServerConfig from "./config.ts"; import * as ServiceLauncherClient from "./cloud/serviceLauncherClient.ts"; +import { startEmbeddedCuaDriver } from "./cua/CuaDriverEmbedded.ts"; import * as Keybindings from "./keybindings.ts"; import * as ExternalLauncher from "./process/externalLauncher.ts"; import * as EffectWorker from "./orchestration-v2/EffectWorker.ts"; @@ -409,6 +410,23 @@ export const make = (options?: StartupOptions) => ); const startup = Effect.gen(function* () { + const logCuaUnavailable = (context: Readonly>) => + Effect.logWarning( + "embedded cua-driver failed to start; computer use is unavailable", + context, + ); + yield* runStartupPhase( + "cua-driver.start", + startEmbeddedCuaDriver().pipe( + Effect.catchTags({ + CuaDriverModuleLoadError: (error) => + logCuaUnavailable({ modulePath: error.modulePath, cause: error.cause }), + CuaDriverStartError: (error) => + logCuaUnavailable({ binaryPath: error.binaryPath, cause: error.cause }), + }), + ), + ); + yield* Effect.logDebug("startup phase: starting keybindings runtime"); yield* runStartupPhase( "keybindings.start", diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 01d80cbab1c..8cf51f8cab6 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -489,6 +489,7 @@ export function useSettingsRestore(onRestored?: () => void) { ? ["Provider update checks"] : []), ...(isBackgroundActivityDirty ? ["Background activity"] : []), + ...(settings.enableCua !== DEFAULT_UNIFIED_SETTINGS.enableCua ? ["Computer use"] : []), ...(settings.defaultThreadEnvMode !== DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode ? ["New thread mode"] : []), @@ -528,6 +529,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.glassOpacity, settings.persistComposerContextStrip, settings.enableAssistantStreaming, + settings.enableCua, settings.enableProviderUpdateChecks, settings.sidebarProjectGroupingMode, settings.sidebarThreadPreviewCount, @@ -609,6 +611,7 @@ export function useSettingsRestore(onRestored?: () => void) { sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount, sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode, enableAssistantStreaming: DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming, + enableCua: DEFAULT_UNIFIED_SETTINGS.enableCua, enableProviderUpdateChecks: DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks, backgroundActivity: DEFAULT_UNIFIED_SETTINGS.backgroundActivity, backgroundActivityProfile: DEFAULT_UNIFIED_SETTINGS.backgroundActivityProfile, @@ -1564,6 +1567,7 @@ function FontFamilySettingsRow({ } export function GeneralSettingsPanel() { + const supportsCua = typeof window !== "undefined" && Boolean(window.desktopBridge); const settings = usePrimarySettings(); const updateSettings = useUpdatePrimarySettings(); const [backgroundActivityDialogOpen, setBackgroundActivityDialogOpen] = useState(false); @@ -1750,6 +1754,28 @@ export function GeneralSettingsPanel() { } /> + {supportsCua ? ( + updateSettings({ enableCua: DEFAULT_UNIFIED_SETTINGS.enableCua })} + /> + ) : null + } + control={ + updateSettings({ enableCua: Boolean(checked) })} + aria-label="Enable Cua computer use" + /> + } + /> + ) : null} + { + it("defaults computer use off", () => { + expect(decodeClientSettings({}).enableCua).toBe(false); + }); + + it("accepts computer use updates", () => { + expect(decodeClientSettingsPatch({ enableCua: true }).enableCua).toBe(true); + }); +}); + describe("ClientSettings composer context strip", () => { it("defaults to draft-only and accepts a persistent strip preference", () => { expect(decodeClientSettings({}).persistComposerContextStrip).toBe(false); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index cc19c2c9092..fc2a5bb4190 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -123,6 +123,7 @@ export const ClientSettingsSchema = Schema.Struct({ glassOpacity: GlassOpacity.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_GLASS_OPACITY)), ), + enableCua: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), fontSizeInterface: InterfaceFontSize.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_INTERFACE_FONT_SIZE)), ), @@ -787,6 +788,7 @@ export const ClientSettingsPatch = Schema.Struct({ confirmThreadDelete: Schema.optionalKey(Schema.Boolean), diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean), environmentIdentificationMode: Schema.optionalKey(EnvironmentIdentificationMode), + enableCua: Schema.optionalKey(Schema.Boolean), glassOpacity: Schema.optionalKey(GlassOpacity), fontSizeInterface: Schema.optionalKey(InterfaceFontSize), fontSizePrompt: Schema.optionalKey(PromptFontSize), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 278107e6b1b..0d65aef4f2f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -475,6 +475,9 @@ importers: '@pierre/diffs': specifier: 'catalog:' version: 1.3.0-beta.10(patch_hash=7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@trycua/cua-driver': + specifier: 0.12.2 + version: 0.12.2 effect: specifier: 4.0.0-beta.103 version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) @@ -4825,6 +4828,41 @@ packages: peerDependencies: '@testing-library/dom': '>=7.21.4' + '@trycua/cua-driver-darwin-arm64@0.12.2': + resolution: {integrity: sha512-rR4KpxaE/R/RAQ718YAIJ+YXuHewdAxq1kmtcPh0thhZCxcckTfVHk6sa6XSfwmqeNF1kTR82zKaeOhr55uBlw==} + cpu: [arm64] + os: [darwin] + + '@trycua/cua-driver-darwin-x64@0.12.2': + resolution: {integrity: sha512-BBFhMjmKZTvb24Z7NiYmWR2+yvTQgYat1biStxd6T+yPv6aTNFho/QE6YrVYxEzQTNHv3Rr6aa9Fu72mcmzBag==} + cpu: [x64] + os: [darwin] + + '@trycua/cua-driver-linux-arm64-gnu@0.12.2': + resolution: {integrity: sha512-XJPPLwnUroKsD0vh2dkE+NUaSElKwWNmjfy48cc/j/iLve8m2xZAFcmHgEug6Ps94AdRQ/Ko3w9z9jeCzJr72w==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@trycua/cua-driver-linux-x64-gnu@0.12.2': + resolution: {integrity: sha512-JekaFKUol9DYJqw90Td8OISemRSDKyzOUoWfVRIQfLqm8YveySP3+sHGOovJDytTDXVuSKEJOIdV7q678gr4rA==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@trycua/cua-driver-win32-arm64-msvc@0.12.2': + resolution: {integrity: sha512-0sU/GSGyfrIjBpOsc9UObi2bdD1oUcuaYqB/HIzWXIDdUmoMfXIsHPnPb0QvUnsm0EI6cFXjBzURlBKHhITKQQ==} + cpu: [arm64] + os: [win32] + + '@trycua/cua-driver-win32-x64-msvc@0.12.2': + resolution: {integrity: sha512-ZZC8EPvuDPxg+/aOy8LX53AirEdqlO6xd4Vqsm3uWmNjfis+D8Fg9Fko2mOO5LWbuGzBYePyQlHji4n4Kqe2Lw==} + cpu: [x64] + os: [win32] + + '@trycua/cua-driver@0.12.2': + resolution: {integrity: sha512-Jbhb6KwyRWng6BFyoudxkUPGkJrdvw1TfSIpMNvQbQpcCPgQgc54r2aIoR+Kcl4P54JScY85dZFc5285qcHcKw==} + '@tybys/wasm-util@0.10.3': resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} @@ -5030,6 +5068,56 @@ packages: engines: {node: '>=16.20.0'} hasBin: true + '@ubjs/core@0.31.0-3': + resolution: {integrity: sha512-39XrJgUZ2VVb561sSnkXPhczNoeBsNiSRArecsV0JE7CJq69ajFkcn9/tBAUS2NpgHkLIDU+z6Ks2+1wXnboxg==} + + '@ubjs/node-darwin-arm64@0.31.0-3': + resolution: {integrity: sha512-GGQVPLkVo4Gc8qVLW4IGvS8bjl8eHXyeP4a97ntGmsAdXwE5gsS29o8xUEFROjhwHKD+9sgVeblCSWVG3CpHsw==} + cpu: [arm64] + os: [darwin] + + '@ubjs/node-darwin-x64@0.31.0-3': + resolution: {integrity: sha512-2sc47u4XFYOsbmP5EW+Gx8m/yGrYnfFDFQm6+kz7goSWTNg84eEiz3COs9HKJDVuNJ5Khv5XipTO8CFadMLXCw==} + cpu: [x64] + os: [darwin] + + '@ubjs/node-linux-arm64-gnu@0.31.0-3': + resolution: {integrity: sha512-YStVXhYz/5jvlWf/p4fhiVT72unYAbGugifFC9QmO/+hnroQDAQ5t8SARbsc15G4olMcamdIB+GETiUB7gmaYg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@ubjs/node-linux-arm64-musl@0.31.0-3': + resolution: {integrity: sha512-Izp4nvfy/LmibzFowAztkoDOksCR2fb2zl6fh1ojR1HEsg0rAGruxtI8d3fn8DI0lBXwqmn6SF///oD4mFNJPQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@ubjs/node-linux-x64-gnu@0.31.0-3': + resolution: {integrity: sha512-Xdm21blyg5U/kW6s7OMvgrr8coGTkUlt26DVR9x8gKISif+E3YwdEskbFScWqystAiJnfjw7xHEc8UMu0Qlz7Q==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@ubjs/node-linux-x64-musl@0.31.0-3': + resolution: {integrity: sha512-fFQ9BWS6i2LUH9SJgD9oEiKXXo/say59vHy7usFe1t7C2xvwvP34f5SuxXmWP9R8fHyA0aC4kIZ+TTwyHSv1Kw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@ubjs/node-win32-arm64-msvc@0.31.0-3': + resolution: {integrity: sha512-ID6rSz1NmPsWTNBBNAw4OnJ5Dj8pcbtNJtdPB3OxcGigLBd/e0x7buhSI7os6Mo5iYtEdCynBRQHFJwub5XSPg==} + cpu: [arm64] + os: [win32] + + '@ubjs/node-win32-x64-msvc@0.31.0-3': + resolution: {integrity: sha512-wevs+Y+szwcCUT8IJFbB4/1nfxyRv/51l8oG7FGUPWD1xLPyuHfJBG27C+PDeC7KRzes/2n6aLo4DGZbr/LYTw==} + cpu: [x64] + os: [win32] + + '@ubjs/node@0.31.0-3': + resolution: {integrity: sha512-qNMpi2LICNwxGXZyRF8fSDBSpbezyZbEsydrbiMPJOmtOWr4tmZIEl7jkWGHVGShoBvHfFo4eHp5B4UVP928Cg==} + '@ungap/structured-clone@1.3.1': resolution: {integrity: sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==} @@ -15041,6 +15129,36 @@ snapshots: dependencies: '@testing-library/dom': 10.4.1 + '@trycua/cua-driver-darwin-arm64@0.12.2': + optional: true + + '@trycua/cua-driver-darwin-x64@0.12.2': + optional: true + + '@trycua/cua-driver-linux-arm64-gnu@0.12.2': + optional: true + + '@trycua/cua-driver-linux-x64-gnu@0.12.2': + optional: true + + '@trycua/cua-driver-win32-arm64-msvc@0.12.2': + optional: true + + '@trycua/cua-driver-win32-x64-msvc@0.12.2': + optional: true + + '@trycua/cua-driver@0.12.2': + dependencies: + '@ubjs/core': 0.31.0-3 + '@ubjs/node': 0.31.0-3 + optionalDependencies: + '@trycua/cua-driver-darwin-arm64': 0.12.2 + '@trycua/cua-driver-darwin-x64': 0.12.2 + '@trycua/cua-driver-linux-arm64-gnu': 0.12.2 + '@trycua/cua-driver-linux-x64-gnu': 0.12.2 + '@trycua/cua-driver-win32-arm64-msvc': 0.12.2 + '@trycua/cua-driver-win32-x64-msvc': 0.12.2 + '@tybys/wasm-util@0.10.3': dependencies: tslib: 2.8.1 @@ -15263,6 +15381,43 @@ snapshots: '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260604.1 '@typescript/native-preview-win32-x64': 7.0.0-dev.20260604.1 + '@ubjs/core@0.31.0-3': {} + + '@ubjs/node-darwin-arm64@0.31.0-3': + optional: true + + '@ubjs/node-darwin-x64@0.31.0-3': + optional: true + + '@ubjs/node-linux-arm64-gnu@0.31.0-3': + optional: true + + '@ubjs/node-linux-arm64-musl@0.31.0-3': + optional: true + + '@ubjs/node-linux-x64-gnu@0.31.0-3': + optional: true + + '@ubjs/node-linux-x64-musl@0.31.0-3': + optional: true + + '@ubjs/node-win32-arm64-msvc@0.31.0-3': + optional: true + + '@ubjs/node-win32-x64-msvc@0.31.0-3': + optional: true + + '@ubjs/node@0.31.0-3': + optionalDependencies: + '@ubjs/node-darwin-arm64': 0.31.0-3 + '@ubjs/node-darwin-x64': 0.31.0-3 + '@ubjs/node-linux-arm64-gnu': 0.31.0-3 + '@ubjs/node-linux-arm64-musl': 0.31.0-3 + '@ubjs/node-linux-x64-gnu': 0.31.0-3 + '@ubjs/node-linux-x64-musl': 0.31.0-3 + '@ubjs/node-win32-arm64-msvc': 0.31.0-3 + '@ubjs/node-win32-x64-msvc': 0.31.0-3 + '@ungap/structured-clone@1.3.1': {} '@vercel/config@0.3.0': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 27d86fd1784..8b145fa4656 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -74,6 +74,13 @@ minimumReleaseAgeExclude: - "@effect/vitest@4.0.0-beta.103" - alchemy@2.0.0-beta.65 - effect@4.0.0-beta.103 + - "@trycua/cua-driver-darwin-arm64@0.12.2" + - "@trycua/cua-driver-darwin-x64@0.12.2" + - "@trycua/cua-driver-linux-arm64-gnu@0.12.2" + - "@trycua/cua-driver-linux-x64-gnu@0.12.2" + - "@trycua/cua-driver-win32-arm64-msvc@0.12.2" + - "@trycua/cua-driver-win32-x64-msvc@0.12.2" + - "@trycua/cua-driver@0.12.2" overrides: "@clerk/backend": "catalog:" diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index 7d2b7410a9e..d85017c9d23 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -10,6 +10,7 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import { BuildCommandFailedError, + CUA_DRIVER_EXTRA_RESOURCE, createStageWorkspaceConfig, createStagePatchedDependencies, createBuildConfig, @@ -26,6 +27,8 @@ import { MissingMacPasskeyProvisioningProfileError, renderMacPasskeyEntitlements, resolveClerkPasskeyNativeArtifacts, + resolveCuaDriverMacAsset, + resolveCuaDriverAsset, resolveMacPasskeySigningConfiguration, resolveDesktopRuntimeDependencies, resolveFffNativeDependencies, @@ -354,6 +357,14 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { assert.deepStrictEqual((linux.linux as Record).protocols, [ { name: "T3 Code", schemes: ["t3code", "t3code-dev"] }, ]); + assert.deepStrictEqual(linux.extraResources, [ + ...DESKTOP_EXTRA_RESOURCES, + CUA_DRIVER_EXTRA_RESOURCE, + ]); + assert.deepStrictEqual(win.extraResources, [ + ...DESKTOP_EXTRA_RESOURCES, + CUA_DRIVER_EXTRA_RESOURCE, + ]); for (const config of [mac, linux, win]) { assert.deepStrictEqual(config.electronLanguages, DESKTOP_ELECTRON_LANGUAGES); assert.deepStrictEqual(config.files, DESKTOP_FILE_EXCLUSIONS); @@ -525,6 +536,10 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { const mac = config.mac as Record; assert.equal(config.appId, "com.t3tools.t3code"); + assert.deepStrictEqual(config.extraResources, [ + ...DESKTOP_EXTRA_RESOURCES, + { from: "apps/desktop/prod-resources/cua-driver", to: "cua-driver" }, + ]); assert.equal(mac.entitlements, "/tmp/entitlements.mac.plist"); assert.equal(mac.provisioningProfile, "/tmp/t3code.provisionprofile"); assert.deepStrictEqual(mac.protocols, [ @@ -572,6 +587,41 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { assert.equal(resourceMonitorExecutableName("mac"), "t3-resource-monitor"); assert.equal(resourceMonitorExecutableName("win"), "t3-resource-monitor.exe"); }); + + it("pins Cua Driver executable assets for each macOS architecture", () => { + assert.deepInclude(resolveCuaDriverMacAsset("arm64"), { + archiveName: "cua-driver-rs-0.12.2-darwin-arm64.tar.gz", + executablePath: "cua-driver-rs-0.12.2-darwin-arm64/cua-driver", + }); + assert.deepInclude(resolveCuaDriverMacAsset("x64"), { + archiveName: "cua-driver-rs-0.12.2-darwin-x86_64.tar.gz", + executablePath: "cua-driver-rs-0.12.2-darwin-x86_64/cua-driver", + }); + assert.deepInclude(resolveCuaDriverMacAsset("universal"), { + archiveName: "cua-driver-rs-0.12.2-darwin-universal-binary.tar.gz", + executablePath: "cua-driver", + }); + }); + + it("pins Cua Driver assets for Linux and Windows", () => { + assert.deepInclude(resolveCuaDriverAsset("linux", "x64"), { + archiveName: "cua-driver-rs-0.12.2-linux-x86_64-binary.tar.gz", + executablePath: "cua-driver", + }); + assert.deepInclude(resolveCuaDriverAsset("linux", "arm64"), { + archiveName: "cua-driver-rs-0.12.2-linux-arm64-binary.tar.gz", + executablePath: "cua-driver", + }); + assert.deepInclude(resolveCuaDriverAsset("win", "x64"), { + archiveName: "cua-driver-rs-0.12.2-windows-x86_64-binary.zip", + executablePath: "cua-driver.exe", + }); + assert.deepInclude(resolveCuaDriverAsset("win", "arm64"), { + archiveName: "cua-driver-rs-0.12.2-windows-arm64-binary.zip", + executablePath: "cua-driver.exe", + }); + }); + it("promotes target fff binaries to direct staged dependencies", () => { assert.deepStrictEqual(resolveFffNativeDependencies("mac", "arm64", "0.9.4"), { "@ff-labs/fff-bin-darwin-arm64": "0.9.4", diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index a30b6d4a90a..8c4814bc92c 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -1,5 +1,6 @@ #!/usr/bin/env node +import * as NodeCrypto from "node:crypto"; import * as NodeModule from "node:module"; import { fromYaml } from "@t3tools/shared/schemaYaml"; @@ -37,10 +38,48 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; const LINUX_ICON_SIZES = [16, 22, 24, 32, 48, 64, 128, 256, 512] as const; const DESKTOP_APP_ID = "com.t3tools.t3code"; const APPLE_TEAM_ID_PATTERN = /^[A-Z0-9]{10}$/u; - const BuildPlatform = Schema.Literals(["mac", "linux", "win"]); const BuildArch = Schema.Literals(["arm64", "x64", "universal"]); +const CUA_DRIVER_RELEASE_VERSION = serverPackageJson.dependencies["@trycua/cua-driver"]; +const CUA_DRIVER_RELEASE_BASE_URL = `https://github.com/trycua/cua/releases/download/cua-driver-rs-v${CUA_DRIVER_RELEASE_VERSION}`; +const EXACT_RELEASE_VERSION_PATTERN = /^\d+\.\d+\.\d+$/u; + +interface CuaDriverMacAsset { + readonly archiveName: string; + readonly executablePath: string; + readonly sha256: string; +} + +const CUA_DRIVER_MAC_ASSETS: Record = { + arm64: { + archiveName: `cua-driver-rs-${CUA_DRIVER_RELEASE_VERSION}-darwin-arm64.tar.gz`, + executablePath: `cua-driver-rs-${CUA_DRIVER_RELEASE_VERSION}-darwin-arm64/cua-driver`, + sha256: "9cdd30d71c8b327bed711b39c1c642a3c3d5f2c86ff6dbbe85eee36c66b24ee7", + }, + x64: { + archiveName: `cua-driver-rs-${CUA_DRIVER_RELEASE_VERSION}-darwin-x86_64.tar.gz`, + executablePath: `cua-driver-rs-${CUA_DRIVER_RELEASE_VERSION}-darwin-x86_64/cua-driver`, + sha256: "678548fa9028b7ffce215d71a8ebe889b8b6eeb4590e84b53c668986e49f45d9", + }, + universal: { + archiveName: `cua-driver-rs-${CUA_DRIVER_RELEASE_VERSION}-darwin-universal-binary.tar.gz`, + executablePath: "cua-driver", + sha256: "fc810012a1870f4d10c17fdd183668c133c78f2d072a1ff2fcf35daaed44d28f", + }, +}; + +const CUA_DRIVER_PLATFORM_SHA256 = { + linux: { + arm64: "176365815fac4fc7e1f472a9ce18c5c67aa0cd0b7453950651c24cf8b9d7d52c", + x64: "686bb354420a3019c812bb940cf531a830db6a86ccaeb329ad754cac2b869c6e", + }, + win: { + arm64: "9a639c4e77b4f280ae632a736fb26dbcae8de4db966f06ada294478846eadd85", + x64: "421b2a092e101d6d54c8256fadc6b252410f3bdea0b6994a351235b3525d34fd", + }, +} as const; + const WorkspaceConfig = Schema.Struct({ catalog: Schema.optional(Schema.Record(Schema.String, Schema.String)), overrides: Schema.optional(Schema.Record(Schema.String, Schema.String)), @@ -132,6 +171,38 @@ const PLATFORM_CONFIG: Record = { }, }; +export function resolveCuaDriverMacAsset(arch: typeof BuildArch.Type) { + if (!EXACT_RELEASE_VERSION_PATTERN.test(CUA_DRIVER_RELEASE_VERSION)) { + throw new Error("Cua Driver must use an exact package version for release asset resolution."); + } + const asset = CUA_DRIVER_MAC_ASSETS[arch]; + return { + ...asset, + url: `${CUA_DRIVER_RELEASE_BASE_URL}/${asset.archiveName}`, + }; +} + +export function resolveCuaDriverAsset( + platform: typeof BuildPlatform.Type, + arch: typeof BuildArch.Type, +) { + if (platform === "mac") return resolveCuaDriverMacAsset(arch); + if (!EXACT_RELEASE_VERSION_PATTERN.test(CUA_DRIVER_RELEASE_VERSION) || arch === "universal") { + throw new Error(`Cua Driver has no release asset for ${platform}/${arch}.`); + } + + const os = platform === "win" ? "windows" : "linux"; + const releaseArch = arch === "x64" ? "x86_64" : "arm64"; + const extension = platform === "win" ? "zip" : "tar.gz"; + const archiveName = `cua-driver-rs-${CUA_DRIVER_RELEASE_VERSION}-${os}-${releaseArch}-binary.${extension}`; + return { + archiveName, + executablePath: platform === "win" ? "cua-driver.exe" : "cua-driver", + sha256: CUA_DRIVER_PLATFORM_SHA256[platform][arch], + url: `${CUA_DRIVER_RELEASE_BASE_URL}/${archiveName}`, + }; +} + interface BuildCliInput { readonly platform: Option.Option; readonly target: Option.Option; @@ -249,6 +320,19 @@ export class InvalidMockUpdateServerPortError extends Schema.TaggedErrorClass()( + "CuaDriverChecksumMismatchError", + { + archiveName: Schema.String, + expected: Schema.String, + actual: Schema.String, + }, +) { + override get message(): string { + return `Cua Driver checksum mismatch for ${this.archiveName}.`; + } +} + export class BuildCommandFailedError extends Schema.TaggedErrorClass()( "BuildCommandFailedError", { @@ -646,6 +730,10 @@ export const DESKTOP_EXTRA_RESOURCES = [ to: "resource-monitor", }, ] as const; +export const CUA_DRIVER_EXTRA_RESOURCE = { + from: "apps/desktop/prod-resources/cua-driver", + to: "cua-driver", +} as const; export interface MacPasskeySigningConfiguration { readonly appId: string; @@ -1564,6 +1652,10 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( } if (platform === "mac") { + buildConfig.extraResources = [ + ...DESKTOP_EXTRA_RESOURCES, + { from: "apps/desktop/prod-resources/cua-driver", to: "cua-driver" }, + ]; buildConfig.mac = { target: target === "dmg" ? [target, "zip"] : [target], icon: "icon.icns", @@ -1583,6 +1675,10 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( }; } + if (platform !== "mac") { + buildConfig.extraResources = [...DESKTOP_EXTRA_RESOURCES, CUA_DRIVER_EXTRA_RESOURCE]; + } + if (platform === "linux") { buildConfig.linux = { target: [target], @@ -1646,6 +1742,94 @@ const assertPlatformBuildResources = Effect.fn("assertPlatformBuildResources")(f } }); +const stageCuaDriverExecutable = Effect.fn("stageCuaDriverExecutable")(function* (input: { + readonly platform: typeof BuildPlatform.Type; + readonly arch: typeof BuildArch.Type; + readonly stageRoot: string; + readonly stageResourcesDir: string; + readonly verbose: boolean; +}) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const repoRoot = yield* RepoRoot; + const asset = resolveCuaDriverAsset(input.platform, input.arch); + const cacheDir = path.join(repoRoot, "node_modules/.cache/t3code/cua-driver"); + const archivePath = path.join(cacheDir, asset.archiveName); + const extractDir = path.join(input.stageRoot, "cua-driver/extract"); + yield* fs.makeDirectory(cacheDir, { recursive: true }); + yield* fs.makeDirectory(extractDir, { recursive: true }); + + const checksum = (filePath: string) => + fs + .readFile(filePath) + .pipe( + Effect.map((contents) => NodeCrypto.createHash("sha256").update(contents).digest("hex")), + ); + if ((yield* fs.exists(archivePath)) && (yield* checksum(archivePath)) !== asset.sha256) { + yield* fs.remove(archivePath, { force: true }); + } + + if (!(yield* fs.exists(archivePath))) { + const temporaryArchivePath = `${archivePath}.${process.pid}.tmp`; + yield* Effect.gen(function* () { + yield* runCommand( + ChildProcess.make("curl", [ + "--fail", + "--location", + "--silent", + "--show-error", + "--output", + temporaryArchivePath, + asset.url, + ]), + { label: `download ${asset.archiveName}`, verbose: input.verbose }, + ); + const actualChecksum = yield* checksum(temporaryArchivePath); + if (actualChecksum !== asset.sha256) { + return yield* new CuaDriverChecksumMismatchError({ + archiveName: asset.archiveName, + expected: asset.sha256, + actual: actualChecksum, + }); + } + yield* fs.rename(temporaryArchivePath, archivePath); + }).pipe(Effect.ensuring(fs.remove(temporaryArchivePath, { force: true }).pipe(Effect.ignore))); + } + + const extractCommand = + input.platform === "win" + ? ChildProcess.make("powershell.exe", [ + "-NoProfile", + "-NonInteractive", + "-Command", + "Expand-Archive", + "-LiteralPath", + archivePath, + "-DestinationPath", + extractDir, + "-Force", + ]) + : ChildProcess.make("tar", ["-xzf", archivePath, "-C", extractDir]); + yield* runCommand(extractCommand, { + label: `extract ${asset.archiveName}`, + verbose: input.verbose, + }); + + const extractedExecutable = path.join(extractDir, asset.executablePath); + if (input.platform === "mac") { + const destination = path.join(input.stageResourcesDir, "cua-driver"); + yield* fs.copyFile(extractedExecutable, destination); + yield* fs.chmod(destination, 0o755); + } else { + const destination = path.join(input.stageResourcesDir, "cua-driver"); + yield* fs.copy(extractDir, destination); + if (input.platform === "linux") { + yield* fs.chmod(path.join(destination, "cua-driver"), 0o755); + } + } + yield* Effect.log(`[desktop-artifact] Staged Cua Driver ${CUA_DRIVER_RELEASE_VERSION}.`); +}); + // Stage the prebuilt Linux node-pty binary into the packaged app so the WSL // backend never compiles on the user's machine. node-pty publishes no Linux // prebuilt and the WSL Linux Node can't load the Windows/Electron binary, so the @@ -1855,6 +2039,13 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( }, options.verbose, ); + yield* stageCuaDriverExecutable({ + platform: options.platform, + arch: options.arch, + stageRoot, + stageResourcesDir, + verbose: options.verbose, + }); // electron-builder is filtering out stageResourcesDir directory in the AppImage for production yield* fs.copy(stageResourcesDir, path.join(stageAppDir, "apps/desktop/prod-resources"));