diff --git a/apps/desktop/src/backend/DesktopBackendPool.test.ts b/apps/desktop/src/backend/DesktopBackendPool.test.ts index fa0811d5df7..9aaac689c34 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.test.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.test.ts @@ -11,6 +11,7 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import * as DesktopObservability from "../app/DesktopObservability.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as ElectronDialog from "../electron/ElectronDialog.ts"; +import * as MacApplicationIcon from "../electron/MacApplicationIcon.ts"; import * as DesktopWindow from "../window/DesktopWindow.ts"; import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts"; import * as DesktopBackendPool from "./DesktopBackendPool.ts"; @@ -66,7 +67,13 @@ function makePoolLayer( resolveWsl: () => Effect.die("unexpected WSL config resolve"), } satisfies DesktopBackendConfiguration.DesktopBackendConfiguration["Service"]), DesktopAppSettings.layerTest(), - ElectronDialog.layer, + ElectronDialog.layer.pipe( + Layer.provide( + Layer.succeed(MacApplicationIcon.MacApplicationIcon, { + resolveDataUrl: () => Effect.die("unexpected application icon resolution"), + } satisfies MacApplicationIcon.MacApplicationIcon["Service"]), + ), + ), Layer.succeed(DesktopWindow.DesktopWindow, { createMain: Effect.die("unexpected window create"), ensureMain: Effect.die("unexpected window ensure"), diff --git a/apps/desktop/src/electron/ElectronDialog.test.ts b/apps/desktop/src/electron/ElectronDialog.test.ts index 388b3fd2c15..d54d82e1ca2 100644 --- a/apps/desktop/src/electron/ElectronDialog.test.ts +++ b/apps/desktop/src/electron/ElectronDialog.test.ts @@ -1,17 +1,22 @@ import { assert, describe, it } from "@effect/vitest"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import type { BrowserWindow } from "electron"; import { beforeEach, vi } from "vite-plus/test"; import * as ElectronDialog from "./ElectronDialog.ts"; +import * as MacApplicationIcon from "./MacApplicationIcon.ts"; -const { showMessageBoxMock, showOpenDialogMock, showErrorBoxMock } = vi.hoisted(() => ({ - showMessageBoxMock: vi.fn(), - showOpenDialogMock: vi.fn(), - showErrorBoxMock: vi.fn(), -})); +const { showMessageBoxMock, showOpenDialogMock, showErrorBoxMock, resolveDataUrlMock } = vi.hoisted( + () => ({ + showMessageBoxMock: vi.fn(), + showOpenDialogMock: vi.fn(), + showErrorBoxMock: vi.fn(), + resolveDataUrlMock: vi.fn(), + }), +); vi.mock("electron", () => ({ dialog: { @@ -21,13 +26,79 @@ vi.mock("electron", () => ({ }, })); +const applicationIconLayer = Layer.succeed(MacApplicationIcon.MacApplicationIcon, { + resolveDataUrl: (applicationPath) => + Effect.tryPromise({ + try: () => resolveDataUrlMock(applicationPath), + catch: (cause) => + new MacApplicationIcon.MacApplicationIconResolutionError({ applicationPath, cause }), + }), +} satisfies MacApplicationIcon.MacApplicationIcon["Service"]); +const dialogLayer = ElectronDialog.layer.pipe(Layer.provide(applicationIconLayer)); + describe("ElectronDialog", () => { beforeEach(() => { showMessageBoxMock.mockReset(); showOpenDialogMock.mockReset(); showErrorBoxMock.mockReset(); + resolveDataUrlMock.mockReset(); }); + it.effect("selects a macOS application and resolves its system icon", () => + Effect.gen(function* () { + const owner = { id: 12 } as BrowserWindow; + showOpenDialogMock.mockResolvedValue({ + canceled: false, + filePaths: ["/Applications/Terminal.app"], + }); + resolveDataUrlMock.mockResolvedValue("data:image/png;base64,icon"); + const dialog = yield* ElectronDialog.ElectronDialog; + + const result = yield* dialog.pickApplication({ owner: Option.some(owner) }); + + assert.deepEqual(Option.getOrNull(result), { + applicationPath: "/Applications/Terminal.app", + suggestedName: "Terminal", + iconDataUrl: "data:image/png;base64,icon", + }); + assert.deepEqual(showOpenDialogMock.mock.calls[0], [ + owner, + { + defaultPath: "/Applications", + properties: ["openFile"], + filters: [{ name: "Applications", extensions: ["app"] }], + }, + ]); + assert.deepEqual(resolveDataUrlMock.mock.calls[0], ["/Applications/Terminal.app"]); + }).pipe(Effect.provide(dialogLayer)), + ); + + it.effect("returns none when application selection is cancelled", () => + Effect.gen(function* () { + showOpenDialogMock.mockResolvedValue({ canceled: true, filePaths: [] }); + const dialog = yield* ElectronDialog.ElectronDialog; + assert.isTrue(Option.isNone(yield* dialog.pickApplication({ owner: Option.none() }))); + assert.equal(resolveDataUrlMock.mock.calls.length, 0); + }).pipe(Effect.provide(dialogLayer)), + ); + + it.effect("keeps a valid selection when icon extraction fails", () => + Effect.gen(function* () { + showOpenDialogMock.mockResolvedValue({ + canceled: false, + filePaths: ["/Users/test/Applications/My Tool.app"], + }); + resolveDataUrlMock.mockRejectedValue(new Error("icon unavailable")); + const dialog = yield* ElectronDialog.ElectronDialog; + + assert.deepEqual(Option.getOrNull(yield* dialog.pickApplication({ owner: Option.none() })), { + applicationPath: "/Users/test/Applications/My Tool.app", + suggestedName: "My Tool", + iconDataUrl: null, + }); + }).pipe(Effect.provide(dialogLayer)), + ); + it.effect("returns false without opening a confirm dialog for empty messages", () => Effect.gen(function* () { const dialog = yield* ElectronDialog.ElectronDialog; @@ -39,7 +110,7 @@ describe("ElectronDialog", () => { assert.isFalse(result); assert.equal(showMessageBoxMock.mock.calls.length, 0); - }).pipe(Effect.provide(ElectronDialog.layer)), + }).pipe(Effect.provide(dialogLayer)), ); it.effect("opens a confirm dialog for the owner window", () => @@ -65,7 +136,7 @@ describe("ElectronDialog", () => { message: "Delete worktree?", }, ]); - }).pipe(Effect.provide(ElectronDialog.layer)), + }).pipe(Effect.provide(dialogLayer)), ); it.effect("opens an app-level confirm dialog when there is no owner window", () => @@ -89,7 +160,7 @@ describe("ElectronDialog", () => { message: "Delete worktree?", }, ]); - }).pipe(Effect.provide(ElectronDialog.layer)), + }).pipe(Effect.provide(dialogLayer)), ); it.effect("preserves folder picker request context and cause", () => @@ -114,7 +185,7 @@ describe("ElectronDialog", () => { assert.include(error.message, "window 7"); assert.include(error.message, "/workspace"); assert.notInclude(error.message, cause.message); - }).pipe(Effect.provide(ElectronDialog.layer)), + }).pipe(Effect.provide(dialogLayer)), ); it.effect("preserves confirmation request context and cause", () => @@ -139,7 +210,7 @@ describe("ElectronDialog", () => { assert.include(error.message, "window 9"); assert.notInclude(error.message, "Confirm removal?"); assert.notInclude(error.message, cause.message); - }).pipe(Effect.provide(ElectronDialog.layer)), + }).pipe(Effect.provide(dialogLayer)), ); it.effect("preserves message box request context and cause", () => @@ -176,7 +247,7 @@ describe("ElectronDialog", () => { assert.notInclude(error.message, "Cancel"); assert.notInclude(error.message, "Discard"); assert.notInclude(error.message, cause.message); - }).pipe(Effect.provide(ElectronDialog.layer)), + }).pipe(Effect.provide(dialogLayer)), ); it.effect("preserves error box request context and cause in the defect", () => @@ -201,6 +272,6 @@ describe("ElectronDialog", () => { assert.notInclude(error.message, "Startup failed"); assert.notInclude(error.message, "Could not start."); assert.notInclude(error.message, cause.message); - }).pipe(Effect.provide(ElectronDialog.layer)), + }).pipe(Effect.provide(dialogLayer)), ); }); diff --git a/apps/desktop/src/electron/ElectronDialog.ts b/apps/desktop/src/electron/ElectronDialog.ts index be633971bea..83d53d9dab9 100644 --- a/apps/desktop/src/electron/ElectronDialog.ts +++ b/apps/desktop/src/electron/ElectronDialog.ts @@ -1,10 +1,15 @@ +// @effect-diagnostics nodeBuiltinImport:off - Electron's native picker returns host paths. import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; +import * as NodePath from "node:path"; import * as Electron from "electron"; +import type { DesktopApplicationSelection } from "@t3tools/contracts"; + +import * as MacApplicationIcon from "./MacApplicationIcon.ts"; const CONFIRM_BUTTON_INDEX = 1; @@ -23,6 +28,19 @@ export class ElectronDialogPickFolderError extends Schema.TaggedErrorClass()( + "ElectronDialogPickApplicationError", + { + ownerWindowId: Schema.NullOr(Schema.Number), + selectedPath: Schema.NullOr(Schema.String), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Failed to select a macOS application."; + } +} + export class ElectronDialogConfirmError extends Schema.TaggedErrorClass()( "ElectronDialogConfirmError", { @@ -69,6 +87,7 @@ export class ElectronDialogShowErrorBoxError extends Schema.TaggedErrorClass; } +export interface ElectronDialogPickApplicationInput { + readonly owner: Option.Option; +} + export interface ElectronDialogConfirmInput { readonly owner: Option.Option; readonly message: string; @@ -92,6 +115,12 @@ export class ElectronDialog extends Context.Service< readonly pickFolder: ( input: ElectronDialogPickFolderInput, ) => Effect.Effect, ElectronDialogPickFolderError>; + readonly pickApplication: ( + input: ElectronDialogPickApplicationInput, + ) => Effect.Effect< + Option.Option, + ElectronDialogPickApplicationError + >; readonly confirm: ( input: ElectronDialogConfirmInput, ) => Effect.Effect; @@ -102,97 +131,148 @@ export class ElectronDialog extends Context.Service< } >()("@t3tools/desktop/electron/ElectronDialog") {} -export const make = ElectronDialog.of({ - pickFolder: Effect.fn("desktop.electron.dialog.pickFolder")(function* (input) { - const ownerWindowId = Option.match(input.owner, { - onNone: () => null, - onSome: (owner) => owner.id, - }); - const defaultPath = Option.getOrNull(input.defaultPath); - const openDialogOptions: Electron.OpenDialogOptions = Option.match(input.defaultPath, { - onNone: () => ({ - properties: ["openDirectory", "createDirectory"], - }), - onSome: (defaultPath) => ({ - properties: ["openDirectory", "createDirectory"], - defaultPath, - }), - }); - const result = yield* Effect.tryPromise({ - try: () => - Option.match(input.owner, { - onNone: () => Electron.dialog.showOpenDialog(openDialogOptions), - onSome: (owner) => Electron.dialog.showOpenDialog(owner, openDialogOptions), +export const make = Effect.gen(function* () { + const applicationIcon = yield* MacApplicationIcon.MacApplicationIcon; + + return ElectronDialog.of({ + pickFolder: Effect.fn("desktop.electron.dialog.pickFolder")(function* (input) { + const ownerWindowId = Option.match(input.owner, { + onNone: () => null, + onSome: (owner) => owner.id, + }); + const defaultPath = Option.getOrNull(input.defaultPath); + const openDialogOptions: Electron.OpenDialogOptions = Option.match(input.defaultPath, { + onNone: () => ({ + properties: ["openDirectory", "createDirectory"], }), - catch: (cause) => - new ElectronDialogPickFolderError({ - ownerWindowId, + onSome: (defaultPath) => ({ + properties: ["openDirectory", "createDirectory"], defaultPath, - cause, - }), - }); - - if (result.canceled) { - return Option.none(); - } - return Option.fromNullishOr(result.filePaths[0]); - }), - confirm: Effect.fn("desktop.electron.dialog.confirm")(function* (input) { - const normalizedMessage = input.message.trim(); - if (normalizedMessage.length === 0) { - return false; - } - - const options = { - type: "question" as const, - buttons: ["No", "Yes"], - defaultId: 0, - cancelId: 0, - noLink: true, - message: normalizedMessage, - }; - const ownerWindowId = Option.match(input.owner, { - onNone: () => null, - onSome: (owner) => owner.id, - }); - const result = yield* Effect.tryPromise({ - try: () => - Option.match(input.owner, { - onNone: () => Electron.dialog.showMessageBox(options), - onSome: (owner) => Electron.dialog.showMessageBox(owner, options), }), - catch: (cause) => - new ElectronDialogConfirmError({ + }); + const result = yield* Effect.tryPromise({ + try: () => + Option.match(input.owner, { + onNone: () => Electron.dialog.showOpenDialog(openDialogOptions), + onSome: (owner) => Electron.dialog.showOpenDialog(owner, openDialogOptions), + }), + catch: (cause) => + new ElectronDialogPickFolderError({ + ownerWindowId, + defaultPath, + cause, + }), + }); + + if (result.canceled) { + return Option.none(); + } + return Option.fromNullishOr(result.filePaths[0]); + }), + pickApplication: Effect.fn("desktop.electron.dialog.pickApplication")(function* (input) { + const ownerWindowId = Option.match(input.owner, { + onNone: () => null, + onSome: (owner) => owner.id, + }); + const options: Electron.OpenDialogOptions = { + defaultPath: "/Applications", + properties: ["openFile"], + filters: [{ name: "Applications", extensions: ["app"] }], + }; + const result = yield* Effect.tryPromise({ + try: () => + Option.match(input.owner, { + onNone: () => Electron.dialog.showOpenDialog(options), + onSome: (owner) => Electron.dialog.showOpenDialog(owner, options), + }), + catch: (cause) => + new ElectronDialogPickApplicationError({ + ownerWindowId, + selectedPath: null, + cause, + }), + }); + if (result.canceled) return Option.none(); + + const applicationPath = result.filePaths[0]; + if ( + applicationPath === undefined || + !NodePath.isAbsolute(applicationPath) || + !applicationPath.toLowerCase().endsWith(".app") + ) { + return yield* new ElectronDialogPickApplicationError({ ownerWindowId, - promptLength: normalizedMessage.length, - cause, - }), - }); - return result.response === CONFIRM_BUTTON_INDEX; - }), - showMessageBox: (options) => - Effect.tryPromise({ - try: () => Electron.dialog.showMessageBox(options), - catch: (cause) => - new ElectronDialogShowMessageBoxError({ - type: options.type ?? null, - titleLength: options.title?.length ?? null, - messageLength: options.message.length, - detailLength: options.detail?.length ?? null, - buttonCount: options.buttons?.length ?? 0, - cause, - }), + selectedPath: applicationPath ?? null, + cause: new Error("The selected path is not an absolute .app bundle."), + }); + } + + const iconDataUrl = yield* applicationIcon + .resolveDataUrl(applicationPath) + .pipe(Effect.orElseSucceed(() => null)); + return Option.some({ + applicationPath, + suggestedName: NodePath.basename(applicationPath, NodePath.extname(applicationPath)), + iconDataUrl, + }); }), - showErrorBox: (title, content) => - Effect.try({ - try: () => Electron.dialog.showErrorBox(title, content), - catch: (cause) => - new ElectronDialogShowErrorBoxError({ - titleLength: title.length, - contentLength: content.length, - cause, - }), - }).pipe(Effect.orDie), + confirm: Effect.fn("desktop.electron.dialog.confirm")(function* (input) { + const normalizedMessage = input.message.trim(); + if (normalizedMessage.length === 0) { + return false; + } + + const options = { + type: "question" as const, + buttons: ["No", "Yes"], + defaultId: 0, + cancelId: 0, + noLink: true, + message: normalizedMessage, + }; + const ownerWindowId = Option.match(input.owner, { + onNone: () => null, + onSome: (owner) => owner.id, + }); + const result = yield* Effect.tryPromise({ + try: () => + Option.match(input.owner, { + onNone: () => Electron.dialog.showMessageBox(options), + onSome: (owner) => Electron.dialog.showMessageBox(owner, options), + }), + catch: (cause) => + new ElectronDialogConfirmError({ + ownerWindowId, + promptLength: normalizedMessage.length, + cause, + }), + }); + return result.response === CONFIRM_BUTTON_INDEX; + }), + showMessageBox: (options) => + Effect.tryPromise({ + try: () => Electron.dialog.showMessageBox(options), + catch: (cause) => + new ElectronDialogShowMessageBoxError({ + type: options.type ?? null, + titleLength: options.title?.length ?? null, + messageLength: options.message.length, + detailLength: options.detail?.length ?? null, + buttonCount: options.buttons?.length ?? 0, + cause, + }), + }), + showErrorBox: (title, content) => + Effect.try({ + try: () => Electron.dialog.showErrorBox(title, content), + catch: (cause) => + new ElectronDialogShowErrorBoxError({ + titleLength: title.length, + contentLength: content.length, + cause, + }), + }).pipe(Effect.orDie), + }); }); -export const layer = Layer.succeed(ElectronDialog, make); +export const layer = Layer.effect(ElectronDialog, make); diff --git a/apps/desktop/src/electron/MacApplicationIcon.test.ts b/apps/desktop/src/electron/MacApplicationIcon.test.ts new file mode 100644 index 00000000000..85f168b0e62 --- /dev/null +++ b/apps/desktop/src/electron/MacApplicationIcon.test.ts @@ -0,0 +1,60 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { beforeEach, vi } from "vite-plus/test"; + +import * as MacApplicationIcon from "./MacApplicationIcon.ts"; + +const { createFromBufferMock, getFileIconMock } = vi.hoisted(() => ({ + createFromBufferMock: vi.fn(), + getFileIconMock: vi.fn(), +})); + +vi.mock("electron", () => ({ + app: { getFileIcon: getFileIconMock }, + nativeImage: { createFromBuffer: createFromBufferMock }, +})); + +const applicationIconLayer = MacApplicationIcon.layer.pipe(Layer.provide(NodeServices.layer)); + +const provideLayer = (effect: Effect.Effect) => + effect.pipe(Effect.provide(applicationIconLayer)); + +describe("MacApplicationIcon", () => { + beforeEach(() => { + createFromBufferMock.mockReset(); + getFileIconMock.mockReset(); + }); + + it.effect("falls back to Electron's system icon lookup", () => + provideLayer( + Effect.gen(function* () { + getFileIconMock.mockResolvedValue({ toDataURL: () => "data:image/png;base64,icon" }); + const applicationIcon = yield* MacApplicationIcon.MacApplicationIcon; + + assert.equal( + yield* applicationIcon.resolveDataUrl("/missing/Fixture.app"), + "data:image/png;base64,icon", + ); + assert.deepEqual(getFileIconMock.mock.calls[0], [ + "/missing/Fixture.app", + { size: "large" }, + ]); + }), + ), + ); + + it.effect("reports system icon lookup failures with the application path", () => + provideLayer( + Effect.gen(function* () { + getFileIconMock.mockRejectedValue(new Error("icon unavailable")); + const applicationIcon = yield* MacApplicationIcon.MacApplicationIcon; + + const error = yield* Effect.flip(applicationIcon.resolveDataUrl("/missing/Fixture.app")); + assert.equal(error._tag, "MacApplicationIconResolutionError"); + assert.equal(error.applicationPath, "/missing/Fixture.app"); + }), + ), + ); +}); diff --git a/apps/desktop/src/electron/MacApplicationIcon.ts b/apps/desktop/src/electron/MacApplicationIcon.ts new file mode 100644 index 00000000000..48f096a2e71 --- /dev/null +++ b/apps/desktop/src/electron/MacApplicationIcon.ts @@ -0,0 +1,125 @@ +import * as Electron from "electron"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; + +export class MacApplicationIconResolutionError extends Schema.TaggedErrorClass()( + "MacApplicationIconResolutionError", + { + applicationPath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to resolve the macOS application icon for ${this.applicationPath}.`; + } +} + +export class MacApplicationIcon extends Context.Service< + MacApplicationIcon, + { + readonly resolveDataUrl: ( + applicationPath: string, + ) => Effect.Effect; + } +>()("@t3tools/desktop/electron/MacApplicationIcon") {} + +export const make = Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const pathService = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; + + const resolveBundleIconPath = Effect.fn( + "desktop.electron.macApplicationIcon.resolveBundleIconPath", + )(function* (applicationPath: string) { + const infoPlistPath = pathService.join(applicationPath, "Contents", "Info.plist"); + const configuredName = yield* spawner + .string( + ChildProcess.make( + "/usr/bin/plutil", + ["-extract", "CFBundleIconFile", "raw", infoPlistPath], + { stdin: "ignore", stdout: "pipe", stderr: "pipe" }, + ), + ) + .pipe(Effect.map((output) => output.trim())); + if ( + configuredName.length === 0 || + pathService.basename(configuredName) !== configuredName || + configuredName === "." || + configuredName === ".." + ) { + return Option.none(); + } + const iconName = pathService.extname(configuredName) + ? configuredName + : `${configuredName}.icns`; + const iconPath = pathService.join(applicationPath, "Contents", "Resources", iconName); + const stat = yield* fs.stat(iconPath); + return stat.type === "File" ? Option.some(iconPath) : Option.none(); + }); + + const convertIcnsToDataUrl = Effect.fn( + "desktop.electron.macApplicationIcon.convertIcnsToDataUrl", + )(function* (applicationPath: string, iconPath: string) { + return yield* Effect.gen(function* () { + const temporaryDirectory = yield* fs.makeTempDirectoryScoped({ + prefix: "t3code-open-with-icon-", + }); + const pngPath = pathService.join(temporaryDirectory, "icon.png"); + yield* spawner.string( + ChildProcess.make("/usr/bin/sips", ["-s", "format", "png", iconPath, "--out", pngPath], { + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }), + ); + const png = yield* fs.readFile(pngPath); + const image = yield* Effect.try({ + try: () => Electron.nativeImage.createFromBuffer(Buffer.from(png)), + catch: (cause) => new MacApplicationIconResolutionError({ applicationPath, cause }), + }); + if (image.isEmpty()) { + return yield* new MacApplicationIconResolutionError({ + applicationPath, + cause: new Error("The converted application icon is empty."), + }); + } + return image.toDataURL(); + }).pipe(Effect.scoped); + }); + + const resolveDataUrl = Effect.fn("desktop.electron.macApplicationIcon.resolveDataUrl")(function* ( + applicationPath: string, + ) { + // Electron can return a generic bundle icon, so prefer the app's declared ICNS asset. + const iconPath = yield* resolveBundleIconPath(applicationPath).pipe( + Effect.orElseSucceed(() => Option.none()), + ); + if (Option.isSome(iconPath)) { + const bundleIcon = yield* convertIcnsToDataUrl(applicationPath, iconPath.value).pipe( + Effect.map(Option.some), + Effect.orElseSucceed(() => Option.none()), + ); + if (Option.isSome(bundleIcon)) return bundleIcon.value; + } + + const image = yield* Effect.tryPromise({ + try: () => Electron.app.getFileIcon(applicationPath, { size: "large" }), + catch: (cause) => new MacApplicationIconResolutionError({ applicationPath, cause }), + }); + return yield* Effect.try({ + try: () => image.toDataURL(), + catch: (cause) => new MacApplicationIconResolutionError({ applicationPath, cause }), + }); + }); + + return MacApplicationIcon.of({ resolveDataUrl }); +}); + +export const layer = Layer.effect(MacApplicationIcon, make); diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index e478d0c6eff..402e9d75a23 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -42,6 +42,11 @@ import { showContextMenu, } from "./methods/window.ts"; import * as PreviewIpc from "./methods/preview.ts"; +import { + openWith, + pickOpenWithApplication, + resolveOpenWithPresentations, +} from "./methods/openWith.ts"; import { getWslState, setWslBackendEnabled, setWslDistro, setWslOnly } from "./methods/wsl.ts"; export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers")(function* () { @@ -83,6 +88,9 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(setTheme); yield* ipc.handle(showContextMenu); yield* ipc.handle(openExternal); + yield* ipc.handle(pickOpenWithApplication); + yield* ipc.handle(resolveOpenWithPresentations); + yield* ipc.handle(openWith); yield* ipc.handle(getUpdateState); yield* ipc.handle(setUpdateChannel); yield* ipc.handle(downloadUpdate); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 95c725130e5..459fd1982ce 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -3,6 +3,9 @@ export const CONFIRM_CHANNEL = "desktop:confirm"; export const SET_THEME_CHANNEL = "desktop:set-theme"; export const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; +export const PICK_OPEN_WITH_APPLICATION_CHANNEL = "desktop:pick-open-with-application"; +export const RESOLVE_OPEN_WITH_PRESENTATIONS_CHANNEL = "desktop:resolve-open-with-presentations"; +export const OPEN_WITH_CHANNEL = "desktop:open-with"; export const MENU_ACTION_CHANNEL = "desktop:menu-action"; export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state"; export const WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:window-fullscreen-state"; diff --git a/apps/desktop/src/ipc/methods/openWith.test.ts b/apps/desktop/src/ipc/methods/openWith.test.ts new file mode 100644 index 00000000000..9e606199ebd --- /dev/null +++ b/apps/desktop/src/ipc/methods/openWith.test.ts @@ -0,0 +1,43 @@ +import { assert, describe, it } from "@effect/vitest"; +import { OpenWithEntryId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; + +import * as DesktopOpenWith from "../../shell/DesktopOpenWith.ts"; +import { openWith, resolveOpenWithPresentations } from "./openWith.ts"; + +describe("Open With IPC methods", () => { + it.effect("encodes presentations and delegates launch inputs", () => + Effect.gen(function* () { + const opened = yield* Ref.make(null); + const entryId = OpenWithEntryId.make("terminal"); + const layer = Layer.succeed( + DesktopOpenWith.DesktopOpenWith, + DesktopOpenWith.DesktopOpenWith.of({ + resolvePresentations: Effect.succeed([ + { entryId, available: true, iconDataUrl: "data:image/png;base64,abc" }, + ]), + open: (input) => Ref.set(opened, input), + }), + ); + + assert.deepEqual( + yield* resolveOpenWithPresentations.handler(undefined).pipe(Effect.provide(layer)), + [{ entryId: "terminal", available: true, iconDataUrl: "data:image/png;base64,abc" }], + ); + yield* openWith + .handler({ + environmentId: "primary", + entryId: "terminal", + directory: "/tmp/project", + }) + .pipe(Effect.provide(layer)); + assert.deepEqual(yield* Ref.get(opened), { + environmentId: "primary", + entryId: "terminal", + directory: "/tmp/project", + }); + }), + ); +}); diff --git a/apps/desktop/src/ipc/methods/openWith.ts b/apps/desktop/src/ipc/methods/openWith.ts new file mode 100644 index 00000000000..eb19e0ec7e3 --- /dev/null +++ b/apps/desktop/src/ipc/methods/openWith.ts @@ -0,0 +1,47 @@ +import { + DesktopApplicationSelection, + DesktopOpenWithInput, + OpenWithEntryPresentation, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import * as ElectronDialog from "../../electron/ElectronDialog.ts"; +import * as ElectronWindow from "../../electron/ElectronWindow.ts"; +import * as DesktopOpenWith from "../../shell/DesktopOpenWith.ts"; +import * as IpcChannels from "../channels.ts"; +import * as DesktopIpc from "../DesktopIpc.ts"; + +export const pickOpenWithApplication = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PICK_OPEN_WITH_APPLICATION_CHANNEL, + payload: Schema.Void, + result: Schema.NullOr(DesktopApplicationSelection), + handler: Effect.fn("desktop.ipc.openWith.pickApplication")(function* () { + const dialog = yield* ElectronDialog.ElectronDialog; + const electronWindow = yield* ElectronWindow.ElectronWindow; + return Option.getOrNull( + yield* dialog.pickApplication({ owner: yield* electronWindow.focusedMainOrFirst }), + ); + }), +}); + +export const resolveOpenWithPresentations = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.RESOLVE_OPEN_WITH_PRESENTATIONS_CHANNEL, + payload: Schema.Void, + result: Schema.Array(OpenWithEntryPresentation), + handler: Effect.fn("desktop.ipc.openWith.resolvePresentations")(function* () { + const openWith = yield* DesktopOpenWith.DesktopOpenWith; + return yield* openWith.resolvePresentations; + }), +}); + +export const openWith = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.OPEN_WITH_CHANNEL, + payload: DesktopOpenWithInput, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.openWith.open")(function* (input) { + const openWith = yield* DesktopOpenWith.DesktopOpenWith; + yield* openWith.open(input); + }), +}); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 9795f04e8ae..26e5d3ac412 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -23,6 +23,7 @@ import serverPackageJson from "../../server/package.json" with { type: "json" }; import * as DesktopIpc from "./ipc/DesktopIpc.ts"; import * as ElectronApp from "./electron/ElectronApp.ts"; import * as ElectronDialog from "./electron/ElectronDialog.ts"; +import * as MacApplicationIcon from "./electron/MacApplicationIcon.ts"; import * as ElectronMenu from "./electron/ElectronMenu.ts"; import * as ElectronProtocol from "./electron/ElectronProtocol.ts"; import * as ElectronSafeStorage from "./electron/ElectronSafeStorage.ts"; @@ -49,6 +50,7 @@ import * as DesktopClientSettings from "./settings/DesktopClientSettings.ts"; import * as DesktopSavedEnvironments from "./settings/DesktopSavedEnvironments.ts"; import * as DesktopAppSettings from "./settings/DesktopAppSettings.ts"; import * as DesktopShellEnvironment from "./shell/DesktopShellEnvironment.ts"; +import * as DesktopOpenWith from "./shell/DesktopOpenWith.ts"; import * as DesktopSshEnvironment from "./ssh/DesktopSshEnvironment.ts"; import * as DesktopSshPasswordPrompts from "./ssh/DesktopSshPasswordPrompts.ts"; import * as DesktopState from "./app/DesktopState.ts"; @@ -120,7 +122,7 @@ const electronLayer = Layer.mergeAll( ElectronUpdater.layer, ElectronWindow.layer, DesktopIpc.layer(Electron.ipcMain), -); +).pipe(Layer.provideMerge(MacApplicationIcon.layer)); const desktopFoundationLayer = Layer.mergeAll( DesktopState.layer, @@ -178,6 +180,7 @@ const desktopApplicationLayer = Layer.mergeAll( DesktopLifecycle.layer, DesktopApplicationMenu.layer, DesktopShellEnvironment.layer, + DesktopOpenWith.layer, desktopSshLayer, ).pipe( Layer.provideMerge(DesktopUpdates.layer), @@ -195,10 +198,10 @@ const desktopRuntimeLayer = desktopClerkLayer.pipe( Layer.flatMap((clerkContext) => desktopApplicationLayer.pipe( Layer.provideMerge(Layer.succeedContext(clerkContext)), - Layer.provideMerge(NodeServices.layer), Layer.provideMerge(NodeHttpClient.layerUndici), Layer.provideMerge(NetService.layer), Layer.provideMerge(electronLayer), + Layer.provideMerge(NodeServices.layer), ), ), ); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 228114fd1d1..3a12ae1f39e 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -105,6 +105,10 @@ contextBridge.exposeInMainWorld("desktopBridge", { ...(position === undefined ? {} : { position }), }), openExternal: (url: string) => ipcRenderer.invoke(IpcChannels.OPEN_EXTERNAL_CHANNEL, url), + pickOpenWithApplication: () => ipcRenderer.invoke(IpcChannels.PICK_OPEN_WITH_APPLICATION_CHANNEL), + resolveOpenWithPresentations: () => + ipcRenderer.invoke(IpcChannels.RESOLVE_OPEN_WITH_PRESENTATIONS_CHANNEL), + openWith: (input) => ipcRenderer.invoke(IpcChannels.OPEN_WITH_CHANNEL, input), onMenuAction: (listener) => { const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => { if (typeof action !== "string") return; diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 8f50aa8f882..bc1a06e7c34 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -1,6 +1,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; -import { ClientSettingsSchema, type ClientSettings } from "@t3tools/contracts"; +import { ClientSettingsSchema, OpenWithEntryId, type ClientSettings } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -20,6 +20,17 @@ const clientSettings: ClientSettings = { diffIgnoreWhitespace: true, favorites: [], glassOpacity: 80, + openWithEntries: [ + { + id: OpenWithEntryId.make("terminal"), + name: "Terminal", + kind: "terminal", + invocation: { type: "mac-application", applicationPath: "/Applications/Terminal.app" }, + directoryMode: "open-target", + arguments: [], + }, + ], + preferredOpenWith: { type: "custom", id: OpenWithEntryId.make("terminal") }, providerModelPreferences: {}, sidebarAutoSettleAfterDays: 3, sidebarProjectGroupingMode: "repository_path", diff --git a/apps/desktop/src/shell/DesktopOpenWith.test.ts b/apps/desktop/src/shell/DesktopOpenWith.test.ts new file mode 100644 index 00000000000..55dbafb8fbd --- /dev/null +++ b/apps/desktop/src/shell/DesktopOpenWith.test.ts @@ -0,0 +1,224 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import { + DEFAULT_CLIENT_SETTINGS, + EnvironmentId, + OpenWithEntry, + OpenWithEntryId, + PRIMARY_LOCAL_ENVIRONMENT_ID, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import * as DesktopConfig from "../app/DesktopConfig.ts"; +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as MacApplicationIcon from "../electron/MacApplicationIcon.ts"; +import * as DesktopClientSettings from "../settings/DesktopClientSettings.ts"; +import * as DesktopOpenWith from "./DesktopOpenWith.ts"; + +const decodeEntry = Schema.decodeUnknownSync(OpenWithEntry); + +const configuredEntries = [ + decodeEntry({ + id: "echo", + name: "Echo", + kind: "other", + invocation: { type: "command", executable: "/bin/echo" }, + directoryMode: "open-target", + arguments: [], + }), + decodeEntry({ + id: "missing", + name: "Missing", + kind: "other", + invocation: { type: "command", executable: "/definitely/missing/t3-open-with" }, + directoryMode: "open-target", + arguments: [], + }), +] as const; + +const environmentLayer = DesktopEnvironment.layer({ + dirname: "/repo/apps/desktop/src", + homeDirectory: "/tmp", + platform: "darwin", + processArch: "arm64", + appVersion: "1.0.0", + appPath: "/repo", + isPackaged: true, + resourcesPath: "/repo/resources", + runningUnderArm64Translation: false, +}).pipe( + Layer.provide( + Layer.mergeAll( + NodeServices.layer, + DesktopConfig.layerTest({ T3CODE_HOME: "/tmp/t3-open-with" }), + ), + ), +); + +const openWithLayer = DesktopOpenWith.layer.pipe( + Layer.provideMerge( + Layer.succeed(MacApplicationIcon.MacApplicationIcon, { + resolveDataUrl: () => Effect.die("unexpected application icon resolution"), + } satisfies MacApplicationIcon.MacApplicationIcon["Service"]), + ), + Layer.provideMerge( + DesktopClientSettings.layerTest( + Option.some({ + ...DEFAULT_CLIENT_SETTINGS, + openWithEntries: configuredEntries, + }), + ), + ), + Layer.provideMerge(environmentLayer), + Layer.provideMerge(NodeServices.layer), +); + +describe("DesktopOpenWith launch resolution", () => { + it.effect("appends the directory for open-target command entries", () => + Effect.gen(function* () { + const launch = yield* DesktopOpenWith.resolveOpenWithLaunch( + decodeEntry({ + id: "echo", + name: "Echo", + kind: "other", + invocation: { type: "command", executable: "/bin/echo" }, + directoryMode: "open-target", + arguments: ["--flag"], + }), + "/tmp/work tree", + "darwin", + ); + assert.deepEqual(launch, { + command: "/bin/echo", + args: ["--flag", "/tmp/work tree"], + shell: false, + cwd: null, + }); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("sets cwd without adding a directory argument in working-directory mode", () => + Effect.gen(function* () { + const launch = yield* DesktopOpenWith.resolveOpenWithLaunch( + decodeEntry({ + id: "echo", + name: "Echo", + kind: "other", + invocation: { type: "command", executable: "/bin/echo" }, + directoryMode: "working-directory", + arguments: ["one argument"], + }), + "/tmp/work tree", + "darwin", + ); + assert.deepEqual(launch, { + command: "/bin/echo", + args: ["one argument"], + shell: false, + cwd: "/tmp/work tree", + }); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("expands placeholders within independent argument rows without shell parsing", () => + Effect.gen(function* () { + const launch = yield* DesktopOpenWith.resolveOpenWithLaunch( + decodeEntry({ + id: "echo", + name: "Echo", + kind: "other", + invocation: { type: "command", executable: "/bin/echo" }, + directoryMode: "custom-arguments", + arguments: ["prefix={directory}=suffix", "literal value"], + }), + "/tmp/work tree", + "darwin", + ); + assert.deepEqual(launch.args, ["prefix=/tmp/work tree=suffix", "literal value"]); + assert.isFalse(launch.shell ?? false); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("resolves CFBundleExecutable and reports missing bundle executables", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const base = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-open-with-test-" }); + const applicationPath = path.join(base, "Fixture.app"); + const contentsPath = path.join(applicationPath, "Contents"); + const executableDirectory = path.join(contentsPath, "MacOS"); + yield* fileSystem.makeDirectory(executableDirectory, { recursive: true }); + yield* fileSystem.writeFileString( + path.join(contentsPath, "Info.plist"), + ` + CFBundleExecutableFixture`, + ); + const executablePath = path.join(executableDirectory, "Fixture"); + yield* fileSystem.writeFileString(executablePath, "#!/bin/sh\n"); + + assert.equal( + yield* DesktopOpenWith.resolveMacBundleExecutable(applicationPath), + executablePath, + ); + yield* fileSystem.remove(executablePath); + const error = yield* Effect.flip(DesktopOpenWith.resolveMacBundleExecutable(applicationPath)); + assert.equal(error.reason, "missing-executable"); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); + + it.effect("reports available and missing command presentations", () => + Effect.gen(function* () { + const openWith = yield* DesktopOpenWith.DesktopOpenWith; + const presentations = yield* openWith.resolvePresentations; + assert.deepEqual(presentations, [ + { entryId: configuredEntries[0].id, available: true, iconDataUrl: null }, + { + entryId: configuredEntries[1].id, + available: false, + iconDataUrl: null, + unavailableReason: "Executable not found: /definitely/missing/t3-open-with", + }, + ]); + }).pipe(Effect.provide(openWithLayer)), + ); + + it.effect("rejects secondary environments, invalid targets, and unknown entry ids", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-open-target-" }); + const openWith = yield* DesktopOpenWith.DesktopOpenWith; + + const remoteError = yield* Effect.flip( + openWith.open({ + environmentId: EnvironmentId.make("ssh:test"), + entryId: configuredEntries[0].id, + directory, + }), + ); + assert.equal(remoteError._tag, "OpenWithEnvironmentError"); + + const relativeError = yield* Effect.flip( + openWith.open({ + environmentId: EnvironmentId.make(PRIMARY_LOCAL_ENVIRONMENT_ID), + entryId: configuredEntries[0].id, + directory: "relative/path", + }), + ); + assert.equal(relativeError._tag, "OpenWithInvalidTargetError"); + + const unknownError = yield* Effect.flip( + openWith.open({ + environmentId: EnvironmentId.make(PRIMARY_LOCAL_ENVIRONMENT_ID), + entryId: OpenWithEntryId.make("unknown"), + directory, + }), + ); + assert.equal(unknownError._tag, "OpenWithMissingEntryError"); + }).pipe(Effect.provide(openWithLayer), Effect.scoped), + ); +}); diff --git a/apps/desktop/src/shell/DesktopOpenWith.ts b/apps/desktop/src/shell/DesktopOpenWith.ts new file mode 100644 index 00000000000..464d71fce48 --- /dev/null +++ b/apps/desktop/src/shell/DesktopOpenWith.ts @@ -0,0 +1,323 @@ +// @effect-diagnostics nodeBuiltinImport:off - macOS bundle paths use the host path grammar. +import { + OpenWithBundleResolutionError, + OpenWithEnvironmentError, + OpenWithInvalidTargetError, + OpenWithMissingEntryError, + OpenWithSpawnError, + OpenWithUnavailableApplicationError, + PRIMARY_LOCAL_ENVIRONMENT_ID, + type DesktopOpenWithInput, + type OpenWithEntry, + type OpenWithEntryPresentation, + type OpenWithLaunchError, +} from "@t3tools/contracts"; +import { resolveCommandPath, resolveSpawnCommand } from "@t3tools/shared/shell"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as NodePath from "node:path"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; + +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as MacApplicationIcon from "../electron/MacApplicationIcon.ts"; +import * as DesktopClientSettings from "../settings/DesktopClientSettings.ts"; + +interface ResolvedLaunch { + readonly command: string; + readonly args: readonly string[]; + readonly cwd: string | null; + readonly shell?: boolean; +} + +const statPath = (path: string) => + FileSystem.FileSystem.pipe( + Effect.flatMap((fileSystem) => fileSystem.stat(path)), + Effect.map(Option.some), + Effect.orElseSucceed(() => Option.none()), + ); + +const applicationUnavailableReason = (entry: OpenWithEntry): string => + entry.invocation.type === "mac-application" + ? `Application not found: ${entry.invocation.applicationPath}` + : `Executable not found: ${entry.invocation.executable}`; + +const isMacApplicationPath = (value: string): boolean => + NodePath.isAbsolute(value) && value.toLowerCase().endsWith(".app"); + +const commandExists = Effect.fn("desktop.openWith.commandExists")(function* (command: string) { + return yield* resolveCommandPath(command).pipe( + Effect.as(true), + Effect.catchTag("CommandResolutionError", () => Effect.succeed(false)), + ); +}); + +const entryIsAvailable = Effect.fn("desktop.openWith.entryIsAvailable")(function* ( + entry: OpenWithEntry, + platform: NodeJS.Platform, +) { + if (entry.invocation.type === "command") { + return yield* commandExists(entry.invocation.executable); + } + if (platform !== "darwin" || !isMacApplicationPath(entry.invocation.applicationPath)) { + return false; + } + const stat = yield* statPath(entry.invocation.applicationPath); + return Option.isSome(stat) && stat.value.type === "Directory"; +}); + +export const resolveMacBundleExecutable = Effect.fn("desktop.openWith.resolveMacBundleExecutable")( + function* (applicationPath: string) { + if (!isMacApplicationPath(applicationPath)) { + return yield* new OpenWithBundleResolutionError({ + applicationPath, + reason: "invalid-application-path", + }); + } + const infoPlistPath = NodePath.join(applicationPath, "Contents", "Info.plist"); + const plistStat = yield* statPath(infoPlistPath); + if (Option.isNone(plistStat) || plistStat.value.type !== "File") { + return yield* new OpenWithBundleResolutionError({ + applicationPath, + reason: "missing-info-plist", + }); + } + + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const executableName = yield* spawner + .string( + ChildProcess.make( + "/usr/bin/plutil", + ["-extract", "CFBundleExecutable", "raw", "-o", "-", infoPlistPath], + { stdin: "ignore", stdout: "pipe", stderr: "pipe" }, + ), + ) + .pipe( + Effect.map((output) => output.trim()), + Effect.mapError( + (cause) => + new OpenWithBundleResolutionError({ + applicationPath, + reason: "malformed-info-plist", + cause, + }), + ), + ); + if ( + executableName.length === 0 || + executableName.includes("/") || + executableName.includes("\\") + ) { + return yield* new OpenWithBundleResolutionError({ + applicationPath, + reason: "malformed-info-plist", + }); + } + const executablePath = NodePath.join(applicationPath, "Contents", "MacOS", executableName); + const executableStat = yield* statPath(executablePath); + if (Option.isNone(executableStat) || executableStat.value.type !== "File") { + return yield* new OpenWithBundleResolutionError({ + applicationPath, + reason: "missing-executable", + }); + } + return executablePath; + }, +); + +const expandDirectoryArguments = (args: readonly string[], directory: string): string[] => + args.map((argument) => argument.replaceAll("{directory}", directory)); + +export const resolveOpenWithLaunch = Effect.fn("desktop.openWith.resolveLaunch")(function* ( + entry: OpenWithEntry, + directory: string, + platform: NodeJS.Platform, +): Effect.fn.Return< + ResolvedLaunch, + OpenWithLaunchError, + FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner +> { + if (!(yield* entryIsAvailable(entry, platform))) { + return yield* new OpenWithUnavailableApplicationError({ + entryId: entry.id, + executable: + entry.invocation.type === "mac-application" + ? entry.invocation.applicationPath + : entry.invocation.executable, + }); + } + + if (entry.directoryMode === "open-target") { + if (entry.invocation.type === "mac-application") { + return { + command: "/usr/bin/open", + args: ["-a", entry.invocation.applicationPath, directory], + cwd: null, + }; + } + const command = yield* resolveSpawnCommand(entry.invocation.executable, [ + ...entry.arguments, + directory, + ]); + return { ...command, cwd: null }; + } + + if (entry.directoryMode === "working-directory") { + if (entry.invocation.type === "mac-application") { + return { + command: yield* resolveMacBundleExecutable(entry.invocation.applicationPath), + args: [...entry.arguments], + cwd: directory, + }; + } + const command = yield* resolveSpawnCommand(entry.invocation.executable, entry.arguments); + return { ...command, cwd: directory }; + } + + if (!entry.arguments.some((argument) => argument.includes("{directory}"))) { + return yield* new OpenWithUnavailableApplicationError({ + entryId: entry.id, + executable: "Custom arguments must include {directory}", + }); + } + const args = expandDirectoryArguments(entry.arguments, directory); + if (entry.invocation.type === "mac-application") { + return { + command: yield* resolveMacBundleExecutable(entry.invocation.applicationPath), + args, + cwd: null, + }; + } + const command = yield* resolveSpawnCommand(entry.invocation.executable, args); + return { ...command, cwd: null }; +}); + +const spawnDetached = ( + entry: OpenWithEntry, + launch: ResolvedLaunch & { readonly shell?: boolean }, +) => + ChildProcessSpawner.ChildProcessSpawner.pipe( + Effect.flatMap((spawner) => + spawner.spawn( + ChildProcess.make(launch.command, launch.args, { + ...(launch.cwd === null ? {} : { cwd: launch.cwd }), + detached: true, + shell: launch.shell ?? false, + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }), + ), + ), + Effect.flatMap((handle) => handle.unref), + Effect.asVoid, + Effect.scoped, + Effect.mapError( + (cause) => + new OpenWithSpawnError({ + entryId: entry.id, + command: launch.command, + args: [...launch.args], + cwd: launch.cwd, + cause, + }), + ), + ); + +export class DesktopOpenWith extends Context.Service< + DesktopOpenWith, + { + readonly resolvePresentations: Effect.Effect; + readonly open: (input: DesktopOpenWithInput) => Effect.Effect; + } +>()("@t3tools/desktop/shell/DesktopOpenWith") {} + +export const make = Effect.gen(function* () { + const clientSettings = yield* DesktopClientSettings.DesktopClientSettings; + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const applicationIcon = yield* MacApplicationIcon.MacApplicationIcon; + + const providePlatformServices = ( + effect: Effect.Effect< + A, + E, + FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner + >, + ): Effect.Effect => + effect.pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + + const resolvePresentations = Effect.gen(function* () { + const settings = yield* clientSettings.get; + if (Option.isNone(settings)) return []; + return yield* Effect.forEach(settings.value.openWithEntries, (entry) => + Effect.gen(function* () { + const available = yield* providePlatformServices( + entryIsAvailable(entry, environment.platform), + ); + const iconDataUrl = + available && entry.invocation.type === "mac-application" + ? yield* applicationIcon + .resolveDataUrl(entry.invocation.applicationPath) + .pipe(Effect.orElseSucceed(() => null)) + : null; + return { + entryId: entry.id, + available, + iconDataUrl, + ...(available ? {} : { unavailableReason: applicationUnavailableReason(entry) }), + } satisfies OpenWithEntryPresentation; + }), + ); + }).pipe(Effect.withSpan("desktop.openWith.resolvePresentations")); + + const open = Effect.fn("desktop.openWith.open")(function* (input: DesktopOpenWithInput) { + if (input.environmentId !== PRIMARY_LOCAL_ENVIRONMENT_ID) { + return yield* new OpenWithEnvironmentError({ environmentId: input.environmentId }); + } + if (!NodePath.isAbsolute(input.directory)) { + return yield* new OpenWithInvalidTargetError({ + directory: input.directory, + reason: "relative", + }); + } + const targetStat = yield* providePlatformServices(statPath(input.directory)); + if (Option.isNone(targetStat)) { + return yield* new OpenWithInvalidTargetError({ + directory: input.directory, + reason: "missing", + }); + } + if (targetStat.value.type !== "Directory") { + return yield* new OpenWithInvalidTargetError({ + directory: input.directory, + reason: "not-directory", + }); + } + const settings = yield* clientSettings.get; + const entry = Option.isSome(settings) + ? settings.value.openWithEntries.find((candidate) => candidate.id === input.entryId) + : undefined; + if (entry === undefined) { + return yield* new OpenWithMissingEntryError({ entryId: input.entryId }); + } + const launch = yield* providePlatformServices( + resolveOpenWithLaunch(entry, input.directory, environment.platform), + ); + yield* providePlatformServices(spawnDetached(entry, launch)); + }); + + return DesktopOpenWith.of({ resolvePresentations, open }); +}); + +export const layer = Layer.effect(DesktopOpenWith, make); diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index 168846466ed..149caa79747 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -50,6 +50,7 @@ const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, { const electronDialogLayer = Layer.succeed(ElectronDialog.ElectronDialog, { pickFolder: () => Effect.succeed(Option.none()), + pickApplication: () => Effect.succeed(Option.none()), confirm: () => Effect.succeed(false), showMessageBox: () => Effect.succeed({ response: 0, checkboxChecked: false }), showErrorBox: () => Effect.void, diff --git a/apps/mobile/src/state/use-selected-thread-git-actions.ts b/apps/mobile/src/state/use-selected-thread-git-actions.ts index f320e9da710..9d9c7e2eb2b 100644 --- a/apps/mobile/src/state/use-selected-thread-git-actions.ts +++ b/apps/mobile/src/state/use-selected-thread-git-actions.ts @@ -3,6 +3,8 @@ import { useCallback, useEffect, useMemo } from "react"; import { EnvironmentProject, EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; import { + buildUnsignedCommitRetryInput, + isCommitSigningFailure, type GitActionRequestInput, type VcsActionOperation, type VcsRef, @@ -14,6 +16,7 @@ import { } from "@t3tools/shared/git"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/unstable/reactivity"; +import { Alert } from "react-native"; import { useBranches } from "../state/queries"; import { threadEnvironment } from "../state/threads"; @@ -131,7 +134,10 @@ export function useSelectedThreadGitActions() { readonly project: EnvironmentProject; readonly cwd: string; }) => Promise>, - options?: { readonly managedExternally?: boolean }, + options?: { + readonly managedExternally?: boolean; + readonly onFailure?: (error: unknown) => boolean; + }, ): Promise => { if (!selectedThread || !selectedThreadProject || !selectedThreadCwd) { return null; @@ -154,6 +160,9 @@ export function useSelectedThreadGitActions() { : await vcsActionManager.track(appAtomRegistry, target, { operation, label }, run); if (AsyncResult.isFailure(result)) { const error = Cause.squash(result.cause); + if (options?.onFailure?.(error) === true) { + return null; + } const message = error instanceof Error ? error.message : "Git action failed."; setPendingConnectionError(message); showGitActionResult({ type: "error", title: "Git action failed", description: message }); @@ -319,49 +328,95 @@ export function useSelectedThreadGitActions() { const onRunSelectedThreadGitAction = useCallback( async (input: GitActionRequestInput): Promise => { - const actionId = uuidv4(); - return await runSelectedThreadGitMutation( - "run_change_request", - "Running source control action", - async ({ thread, cwd }) => { - const result = await runStackedAction({ - actionId, - action: input.action, - ...(input.commitMessage ? { commitMessage: input.commitMessage } : {}), - ...(input.featureBranch ? { featureBranch: input.featureBranch } : {}), - ...(input.filePaths?.length ? { filePaths: [...input.filePaths] } : {}), - }); - if (AsyncResult.isFailure(result)) { - return result; - } - - showGitActionResult({ - type: "success", - title: result.value.toast.title, - description: result.value.toast.description, - prUrl: - result.value.toast.cta.kind === "open_pr" ? result.value.toast.cta.url : undefined, - }); + async function runAction( + actionInput: GitActionRequestInput, + options?: { readonly syncCurrentBranchOnSuccess?: boolean }, + ): Promise { + const actionId = uuidv4(); + return await runSelectedThreadGitMutation( + "run_change_request", + "Running source control action", + async ({ thread, cwd }) => { + const result = await runStackedAction({ + actionId, + action: actionInput.action, + ...(actionInput.commitMessage ? { commitMessage: actionInput.commitMessage } : {}), + ...(actionInput.featureBranch ? { featureBranch: actionInput.featureBranch } : {}), + ...(actionInput.disableCommitSigning ? { disableCommitSigning: true } : {}), + ...(actionInput.filePaths?.length ? { filePaths: [...actionInput.filePaths] } : {}), + }); + if (AsyncResult.isFailure(result)) { + return result; + } - if (result.value.branch.status === "created" && result.value.branch.name) { - const syncResult = await syncSelectedThreadBranchState({ - thread, - cwd, - nextThreadState: { - branch: result.value.branch.name, - worktreePath: selectedThreadWorktreePath, - }, + showGitActionResult({ + type: "success", + title: result.value.toast.title, + description: result.value.toast.description, + prUrl: + result.value.toast.cta.kind === "open_pr" ? result.value.toast.cta.url : undefined, }); - if (AsyncResult.isFailure(syncResult)) { - return AsyncResult.failure(syncResult.cause); + + if (result.value.branch.status === "created" && result.value.branch.name) { + const syncResult = await syncSelectedThreadBranchState({ + thread, + cwd, + nextThreadState: { + branch: result.value.branch.name, + worktreePath: selectedThreadWorktreePath, + }, + }); + if (AsyncResult.isFailure(syncResult)) { + return AsyncResult.failure(syncResult.cause); + } + } else if (options?.syncCurrentBranchOnSuccess) { + const status = await refreshSelectedThreadGitStatus({ quiet: true, cwd }); + if (status?.refName) { + const syncResult = await syncSelectedThreadBranchState({ + thread, + cwd, + nextThreadState: { + branch: status.refName, + worktreePath: selectedThreadWorktreePath, + }, + }); + if (AsyncResult.isFailure(syncResult)) { + return AsyncResult.failure(syncResult.cause); + } + } + } else { + await refreshSelectedThreadGitStatus({ quiet: true, cwd }); } - } else { - await refreshSelectedThreadGitStatus({ quiet: true, cwd }); - } - return result; - }, - { managedExternally: true }, - ); + return result; + }, + { + managedExternally: true, + onFailure: (error) => { + if (actionInput.disableCommitSigning || !isCommitSigningFailure(error)) { + return false; + } + Alert.alert( + "Commit signing failed", + "Git couldn’t sign the commit. Retry this commit without signing?", + [ + { text: "Cancel", style: "cancel" }, + { + text: "Retry without signing", + onPress: () => { + void runAction(buildUnsignedCommitRetryInput(actionInput), { + syncCurrentBranchOnSuccess: actionInput.featureBranch === true, + }); + }, + }, + ], + ); + return true; + }, + }, + ); + } + + return await runAction(input); }, [ runStackedAction, diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 256f10fb3b8..eeb00f6105e 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -247,6 +247,7 @@ function initRepo( yield* runGit(cwd, ["init", "--initial-branch=main"]); yield* runGit(cwd, ["config", "user.email", "test@example.com"]); yield* runGit(cwd, ["config", "user.name", "Test User"]); + yield* runGit(cwd, ["config", "commit.gpgSign", "false"]); yield* fs.writeFileString(NodePath.join(cwd, "README.md"), "hello\n"); yield* runGit(cwd, ["add", "README.md"]); yield* runGit(cwd, ["commit", "-m", "Initial commit"]); @@ -610,6 +611,7 @@ function runStackedAction( actionId?: string; commitMessage?: string; featureBranch?: boolean; + disableCommitSigning?: boolean; filePaths?: readonly string[]; }, options?: Parameters[1], @@ -623,6 +625,19 @@ function runStackedAction( ); } +const configureFailingCommitSigner = Effect.fn("configureFailingCommitSigner")(function* ( + repoDir: string, +) { + const signerPath = NodePath.join(repoDir, "failing-signer.sh"); + NodeFS.writeFileSync( + signerPath, + "#!/bin/sh\necho 'gpg: signing failed: No secret key' >&2\nexit 1\n", + { mode: 0o755 }, + ); + yield* runGit(repoDir, ["config", "commit.gpgSign", "true"]); + yield* runGit(repoDir, ["config", "gpg.program", signerPath]); +}); + function resolvePullRequest( manager: GitManager.GitManager["Service"], input: { cwd: string; reference: string }, @@ -3742,6 +3757,63 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("does not attribute ambiguous output to the wrong commit hook", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + NodeFS.writeFileSync(NodePath.join(repoDir, "multi-hook.txt"), "multi hook\n"); + NodeFS.writeFileSync( + NodePath.join(repoDir, ".git", "hooks", "pre-commit"), + '#!/bin/sh\necho "output from pre-commit" >&2\n', + { mode: 0o755 }, + ); + NodeFS.writeFileSync( + NodePath.join(repoDir, ".git", "hooks", "commit-msg"), + '#!/bin/sh\necho "output from commit-msg" >&2\n', + { mode: 0o755 }, + ); + + const { manager } = yield* makeManager(); + const events: GitActionProgressEvent[] = []; + + const result = yield* runStackedAction( + manager, + { + cwd: repoDir, + action: "commit", + commitMessage: "test: preserve hook output attribution", + }, + { + actionId: "action-multi-hook", + progressReporter: { + publish: (event) => + Effect.sync(() => { + events.push(event); + }), + }, + }, + ); + + expect(result.commit.status).toBe("created"); + const hookOutput = events.filter((event) => event.kind === "hook_output"); + const preCommitOutput = hookOutput.find((event) => + event.text.includes("output from pre-commit"), + ); + const commitMsgOutput = hookOutput.find((event) => + event.text.includes("output from commit-msg"), + ); + const gitOutput = hookOutput.find((event) => + event.text.includes("test: preserve hook output attribution"), + ); + + expect(preCommitOutput).toBeDefined(); + expect([null, "pre-commit"]).toContain(preCommitOutput?.hookName); + expect(commitMsgOutput).toBeDefined(); + expect([null, "commit-msg"]).toContain(commitMsgOutput?.hookName); + expect(gitOutput).toMatchObject({ hookName: null }); + }), + ); + it.effect("emits action_failed when a commit hook rejects", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -3791,12 +3863,155 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect.objectContaining({ kind: "action_failed", phase: "commit", + failureKind: "unknown", }), ]), ); }), ); + it.effect( + "classifies signing failures and retries a created feature branch without branching again", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + NodeFS.writeFileSync(NodePath.join(repoDir, "signing.txt"), "signing retry\n"); + yield* configureFailingCommitSigner(repoDir); + + const { manager } = yield* makeManager(); + const events: GitActionProgressEvent[] = []; + yield* runStackedAction( + manager, + { + cwd: repoDir, + action: "commit", + commitMessage: "feat: signing retry", + featureBranch: true, + }, + { + progressReporter: { + publish: (event) => + Effect.sync(() => { + events.push(event); + }), + }, + }, + ).pipe(Effect.flip); + + expect(events).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "action_failed", + phase: "commit", + failureKind: "commit_signing_failed", + }), + ]), + ); + expect( + events.some( + (event) => event.kind === "hook_output" && event.text.includes("No secret key"), + ), + ).toBe(false); + const branchAfterFailure = yield* runGit(repoDir, ["branch", "--show-current"]).pipe( + Effect.map((result) => result.stdout.trim()), + ); + const branchCountAfterFailure = yield* runGit(repoDir, [ + "branch", + "--format=%(refname)", + ]).pipe(Effect.map((result) => result.stdout.trim().split("\n").length)); + + const retried = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit", + commitMessage: "feat: signing retry", + disableCommitSigning: true, + }); + const branchCountAfterRetry = yield* runGit(repoDir, [ + "branch", + "--format=%(refname)", + ]).pipe(Effect.map((result) => result.stdout.trim().split("\n").length)); + + expect(retried.commit.status).toBe("created"); + expect(retried.branch.status).toBe("skipped_not_requested"); + expect(branchAfterFailure).toBe("feature/feat-signing-retry"); + expect(branchCountAfterRetry).toBe(branchCountAfterFailure); + }), + ); + + for (const action of ["commit", "commit_push", "commit_push_pr"] as const) { + it.effect(`forwards the unsigned override for ${action}`, () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + if (action !== "commit") { + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", `feature/unsigned-${action}`]); + } + NodeFS.writeFileSync(NodePath.join(repoDir, `${action}.txt`), `${action}\n`); + yield* configureFailingCommitSigner(repoDir); + + const { manager } = yield* makeManager(); + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action, + commitMessage: `test: ${action} unsigned`, + disableCommitSigning: true, + }); + + expect(result.commit.status).toBe("created"); + if (action !== "commit") { + expect(result.push.status).toBe("pushed"); + } + if (action === "commit_push_pr") { + expect(result.pr.status).toBe("created"); + } + }), + ); + } + + it.effect("does not advertise another retry when an unsigned attempt fails", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + NodeFS.writeFileSync(NodePath.join(repoDir, "unsigned-hook.txt"), "hook failure\n"); + NodeFS.writeFileSync( + NodePath.join(repoDir, ".git", "hooks", "pre-commit"), + "#!/bin/sh\necho 'error: gpg failed to sign the data' >&2\nexit 1\n", + { mode: 0o755 }, + ); + + const { manager } = yield* makeManager(); + const events: GitActionProgressEvent[] = []; + yield* runStackedAction( + manager, + { + cwd: repoDir, + action: "commit", + commitMessage: "test: unsigned hook failure", + disableCommitSigning: true, + }, + { + progressReporter: { + publish: (event) => + Effect.sync(() => { + events.push(event); + }), + }, + }, + ).pipe(Effect.flip); + + const failure = events.find((event) => event.kind === "action_failed"); + expect(failure).toMatchObject({ + kind: "action_failed", + phase: "commit", + failureKind: "unknown", + }); + }), + ); + it.effect("create_pr emits only the PR phase when the branch is already pushed", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 86e10d9f930..79612a9fea4 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -1377,6 +1377,7 @@ export const make = Effect.gen(function* () { commitMessage?: string, preResolvedSuggestion?: CommitAndBranchSuggestion, filePaths?: readonly string[], + disableSigning?: boolean, progressReporter?: GitActionProgressReporter, actionId?: string, ) { @@ -1419,28 +1420,51 @@ export const make = Effect.gen(function* () { }); let currentHookName: string | null = null; + let sawCommitHook = false; + let pendingUnattributedOutput: Array<{ stream: "stdout" | "stderr"; text: string }> = []; + const emitHookOutput = ( + hookName: string | null, + { stream, text }: { stream: "stdout" | "stderr"; text: string }, + ) => { + const sanitized = sanitizeProgressText(text); + if (!sanitized) { + return Effect.void; + } + return emit({ + kind: "hook_output", + hookName, + stream, + text: sanitized, + }); + }; + const finalizeUnattributedOutput = (shouldEmit: boolean) => + Effect.suspend(() => { + const pending = pendingUnattributedOutput; + pendingUnattributedOutput = []; + return shouldEmit + ? Effect.forEach(pending, (output) => emitHookOutput(null, output), { discard: true }) + : Effect.void; + }); const commitProgress = progressReporter && actionId ? { - onOutputLine: ({ stream, text }: { stream: "stdout" | "stderr"; text: string }) => { - const sanitized = sanitizeProgressText(text); - if (!sanitized) { - return Effect.void; - } - return emit({ - kind: "hook_output", - hookName: currentHookName, - stream, - text: sanitized, - }); - }, - onHookStarted: (hookName: string) => { - currentHookName = hookName; - return emit({ - kind: "hook_started", - hookName, - }); - }, + onOutputLine: (output: { stream: "stdout" | "stderr"; text: string }) => + Effect.suspend(() => { + if (currentHookName === null) { + pendingUnattributedOutput.push(output); + return Effect.void; + } + return emitHookOutput(currentHookName, output); + }), + onHookStarted: (hookName: string) => + Effect.suspend(() => { + sawCommitHook = true; + currentHookName = hookName; + return emit({ + kind: "hook_started", + hookName, + }); + }), onHookFinished: ({ hookName, exitCode, @@ -1462,10 +1486,19 @@ export const make = Effect.gen(function* () { }, } : null; - const { commitSha } = yield* gitCore.commit(cwd, suggestion.subject, suggestion.body, { - timeoutMs: COMMIT_TIMEOUT_MS, - ...(commitProgress ? { progress: commitProgress } : {}), - }); + const { commitSha } = yield* gitCore + .commit(cwd, suggestion.subject, suggestion.body, { + timeoutMs: COMMIT_TIMEOUT_MS, + ...(disableSigning ? { disableSigning: true } : {}), + ...(commitProgress ? { progress: commitProgress } : {}), + }) + .pipe( + Effect.tapError((error) => + finalizeUnattributedOutput( + sawCommitHook && error.failureKind !== "commit_signing_failed", + ), + ), + ); if (currentHookName !== null) { yield* emit({ kind: "hook_finished", @@ -1475,6 +1508,7 @@ export const make = Effect.gen(function* () { }); currentHookName = null; } + yield* finalizeUnattributedOutput(sawCommitHook); return { status: "created" as const, commitSha, @@ -1975,6 +2009,7 @@ export const make = Effect.gen(function* () { commitMessageForStep, preResolvedCommitSuggestion, input.filePaths, + input.disableCommitSigning, options?.progressReporter, progress.actionId, ), @@ -2041,6 +2076,10 @@ export const make = Effect.gen(function* () { kind: "action_failed", phase: Option.getOrNull(phase), message: error.message, + failureKind: + !input.disableCommitSigning && error._tag === "GitCommandError" + ? error.failureKind + : "unknown", }), ), ), diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index 100b9beadba..ca67f421908 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -170,6 +170,7 @@ export const make = Effect.gen(function* () { operation, command: "vcs-route", cwd, + failureKind: "unknown", detail: "Failed to resolve the VCS driver for this Git command.", cause, }), @@ -180,6 +181,7 @@ export const make = Effect.gen(function* () { operation, command: "vcs-route", cwd, + failureKind: "unknown", detail: `The ${operation} command currently supports Git repositories only; detected ${handle.kind}.`, }); } @@ -222,6 +224,7 @@ export const make = Effect.gen(function* () { operation, command: "vcs-route", cwd, + failureKind: "unknown", detail: "Failed to detect a VCS repository for this Git command.", cause, }), @@ -235,6 +238,7 @@ export const make = Effect.gen(function* () { operation, command: "vcs-route", cwd, + failureKind: "unknown", detail: `The ${operation} command currently supports Git repositories only; detected ${handle.kind}.`, }); } diff --git a/apps/server/src/processRunner.test.ts b/apps/server/src/processRunner.test.ts index e264ba7849d..8a7267c6ec4 100644 --- a/apps/server/src/processRunner.test.ts +++ b/apps/server/src/processRunner.test.ts @@ -19,6 +19,8 @@ type ChildProcessCommand = { readonly args: ReadonlyArray; readonly options: { readonly shell?: boolean | string; + readonly env?: NodeJS.ProcessEnv; + readonly extendEnv?: boolean; }; }; @@ -80,6 +82,24 @@ const runWith = ); describe("runProcess", () => { + it.effect("can launch with an exact non-extending environment", () => { + const environment = { PATH: "/project/bin", KEEP: "value" }; + const spawner = makeSpawner((command) => + Effect.sync(() => { + expect(command.options.env).toEqual(environment); + expect(command.options.extendEnv).toBe(false); + return makeHandle({ stdout: "ok" }); + }), + ); + + return runWith(spawner)({ + command: "/project/bin/direnv", + args: ["export", "json"], + env: environment, + extendEnv: false, + }); + }); + it.effect("collects stdout through an injected ChildProcessSpawner", () => Effect.gen(function* () { const spawner = makeSpawner((command) => diff --git a/apps/server/src/processRunner.ts b/apps/server/src/processRunner.ts index c1ee2b2cb0c..bbc11a2e6cd 100644 --- a/apps/server/src/processRunner.ts +++ b/apps/server/src/processRunner.ts @@ -23,6 +23,7 @@ export interface ProcessRunInput { readonly spawnCwd?: string | undefined; readonly timeout?: Duration.Input | undefined; readonly env?: NodeJS.ProcessEnv | undefined; + readonly extendEnv?: boolean | undefined; readonly stdin?: string | undefined; readonly maxOutputBytes?: number | undefined; readonly outputMode?: "error" | "truncate" | undefined; @@ -290,7 +291,7 @@ const runProcessCore = Effect.fn("processRunner.runProcessCore")(function* ( const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; const outputMode = input.outputMode ?? "error"; const truncatedMarker = input.truncatedMarker ?? ""; - const extendEnv = input.env !== undefined; + const extendEnv = input.env === undefined ? false : (input.extendEnv ?? true); const spawnCommand = yield* resolveSpawnCommand( input.command, input.args, diff --git a/apps/server/src/provider/DirenvEnvironment.test.ts b/apps/server/src/provider/DirenvEnvironment.test.ts new file mode 100644 index 00000000000..34dc696cba9 --- /dev/null +++ b/apps/server/src/provider/DirenvEnvironment.test.ts @@ -0,0 +1,236 @@ +import { describe, expect, it, vi } from "@effect/vitest"; +import { NodeServices } from "@effect/platform-node"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import * as ProcessRunner from "../processRunner.ts"; +import { DirenvEnvironment, DirenvEnvironmentError, layer } from "./DirenvEnvironment.ts"; + +const successfulOutput = ( + stdout: string, + stderr = "", + code = 0, +): ProcessRunner.ProcessRunOutput => ({ + stdout, + stderr, + code: ChildProcessSpawner.ExitCode(code), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, +}); + +function testLayer(run: ProcessRunner.ProcessRunner["Service"]["run"]) { + return layer.pipe( + Layer.provide(Layer.succeed(ProcessRunner.ProcessRunner, { run })), + Layer.provideMerge(NodeServices.layer), + ); +} + +const makeDirenvExecutable = Effect.fn("makeDirenvExecutable")(function* (directory: string) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDirectory = path.join(directory, "bin"); + const executable = path.join(binDirectory, "direnv"); + yield* fileSystem.makeDirectory(binDirectory, { recursive: true }); + yield* fileSystem.writeFileString(executable, "#!/bin/sh\nexit 0\n"); + yield* fileSystem.chmod(executable, 0o755); + return { binDirectory, executable }; +}); + +/** A temp project with an `.envrc` and a fake direnv on PATH. */ +const setupDirenvProject = Effect.fn("setupDirenvProject")(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-direnv-" }); + const envrcPath = path.join(cwd, ".envrc"); + yield* fileSystem.writeFileString(envrcPath, "export VALUE=next\n"); + const { binDirectory, executable } = yield* makeDirenvExecutable(cwd); + return { cwd, envrcPath, binDirectory, executable }; +}); + +describe("DirenvEnvironment", () => { + describe("new worktree approval", () => { + it.effect("approves the exact .envrc in a newly created worktree", () => { + const run = vi.fn(() => + Effect.succeed(successfulOutput("")), + ); + return Effect.gen(function* () { + const { cwd, envrcPath, binDirectory, executable } = yield* setupDirenvProject(); + const environment = { PATH: binDirectory, KEEP: "value" }; + const direnvEnvironment = yield* DirenvEnvironment; + + yield* direnvEnvironment.allow({ cwd, environment }); + + expect(run).toHaveBeenCalledOnce(); + expect(run.mock.calls[0]?.[0]).toMatchObject({ + command: executable, + args: ["allow", envrcPath], + cwd, + env: environment, + extendEnv: false, + }); + }).pipe(Effect.provide(testLayer(run))); + }); + + it.effect("does not approve an ancestor .envrc outside the new worktree", () => { + const run = vi.fn(); + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const parent = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-direnv-worktree-parent-", + }); + const cwd = path.join(parent, "worktree"); + yield* fileSystem.makeDirectory(cwd); + yield* fileSystem.writeFileString(path.join(parent, ".envrc"), "export VALUE=parent\n"); + const direnvEnvironment = yield* DirenvEnvironment; + + yield* direnvEnvironment.allow({ cwd, environment: { PATH: "/not-used" } }); + + expect(run).not.toHaveBeenCalled(); + }).pipe(Effect.provide(testLayer(run))); + }); + }); + + it.effect( + "returns the environment unchanged without inspecting PATH when no .envrc exists", + () => { + const run = vi.fn(); + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-direnv-none-" }); + const environment = { PATH: "/not-used", KEEP: "value" }; + const resolver = yield* DirenvEnvironment; + + expect(yield* resolver.resolve({ cwd, environment })).toBe(environment); + expect(run).not.toHaveBeenCalled(); + }).pipe(Effect.provide(testLayer(run))); + }, + ); + + it.effect("discovers a parent-directory .envrc and runs direnv in the requested cwd", () => { + const run = vi.fn(() => + Effect.succeed(successfulOutput("{}")), + ); + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-direnv-parent-" }); + const cwd = path.join(root, "nested", "project"); + yield* fileSystem.makeDirectory(cwd, { recursive: true }); + yield* fileSystem.writeFileString(path.join(root, ".envrc"), "export PROJECT=parent\n"); + const { binDirectory, executable } = yield* makeDirenvExecutable(root); + const environment = { PATH: binDirectory }; + const resolver = yield* DirenvEnvironment; + + expect(yield* resolver.resolve({ cwd, environment })).toEqual(environment); + expect(run).toHaveBeenCalledOnce(); + expect(run.mock.calls[0]?.[0]).toMatchObject({ + command: executable, + args: ["export", "json"], + cwd, + env: environment, + extendEnv: false, + }); + }).pipe(Effect.provide(testLayer(run))); + }); + + it.effect( + "returns the environment unchanged when .envrc exists but direnv is unavailable", + () => { + const run = vi.fn(); + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-direnv-missing-" }); + yield* fileSystem.writeFileString(path.join(cwd, ".envrc"), "export VALUE=next\n"); + const environment = { PATH: "", KEEP: "value" }; + const resolver = yield* DirenvEnvironment; + + expect(yield* resolver.resolve({ cwd, environment })).toBe(environment); + expect(run).not.toHaveBeenCalled(); + }).pipe(Effect.provide(testLayer(run))); + }, + ); + + it.effect("applies additions, overrides, and removals from a successful export", () => { + const run = vi.fn(() => + Effect.succeed( + successfulOutput(JSON.stringify({ ADDED: "new", OVERRIDDEN: "direnv", REMOVED: null })), + ), + ); + return Effect.gen(function* () { + const { cwd, binDirectory } = yield* setupDirenvProject(); + const resolver = yield* DirenvEnvironment; + + expect( + yield* resolver.resolve({ + cwd, + environment: { + PATH: binDirectory, + OVERRIDDEN: "provider-instance", + REMOVED: "host", + PRESERVED: "yes", + }, + }), + ).toEqual({ + PATH: binDirectory, + ADDED: "new", + OVERRIDDEN: "direnv", + PRESERVED: "yes", + }); + }).pipe(Effect.provide(testLayer(run))); + }); + + it.effect("returns actionable stderr for a blocked .envrc", () => { + const run = vi.fn(() => + Effect.succeed( + successfulOutput( + "", + "secret-environment-value: .envrc is blocked. Run `direnv allow` to approve", + 1, + ), + ), + ); + return Effect.gen(function* () { + const { cwd, binDirectory } = yield* setupDirenvProject(); + const resolver = yield* DirenvEnvironment; + const error = yield* resolver + .resolve({ + cwd, + environment: { PATH: binDirectory, SECRET: "secret-environment-value" }, + }) + .pipe(Effect.flip); + + expect(error).toBeInstanceOf(DirenvEnvironmentError); + expect(error.stage).toBe("execution"); + expect(error.message).toContain("direnv allow"); + expect(error.message).not.toContain("secret-environment-value"); + }).pipe(Effect.provide(testLayer(run))); + }); + + it.effect("rejects malformed or structurally invalid output without exposing stdout", () => { + let stdout = ""; + const run = vi.fn(() => + Effect.succeed(successfulOutput(stdout)), + ); + return Effect.gen(function* () { + const { cwd, binDirectory } = yield* setupDirenvProject(); + const resolver = yield* DirenvEnvironment; + + for (const rawStdout of ["not-json secret-value", '{"SAFE":"value","INVALID":42}']) { + stdout = rawStdout; + const error = yield* resolver + .resolve({ cwd, environment: { PATH: binDirectory } }) + .pipe(Effect.flip); + + expect(error.stage).toBe("invalid-output"); + expect(error.message).not.toContain(rawStdout); + expect(error.cause).toBeUndefined(); + } + }).pipe(Effect.provide(testLayer(run))); + }); +}); diff --git a/apps/server/src/provider/DirenvEnvironment.ts b/apps/server/src/provider/DirenvEnvironment.ts new file mode 100644 index 00000000000..9260336b541 --- /dev/null +++ b/apps/server/src/provider/DirenvEnvironment.ts @@ -0,0 +1,249 @@ +import { resolveCommandPath } from "@t3tools/shared/shell"; +import { decodeJsonResult } from "@t3tools/shared/schemaJson"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; + +import * as ProcessRunner from "../processRunner.ts"; +import { ProviderAdapterProcessError } from "./Errors.ts"; + +const DIRENV_MAX_OUTPUT_BYTES = 64 * 1024; +const DIRENV_TIMEOUT = "30 seconds"; +const STDERR_DETAIL_MAX_LENGTH = 2_048; +// `decodeJsonResult` diagnostics deliberately exclude the raw values, so a +// failure here never leaks direnv stdout across process and UI boundaries. +const decodeDirenvPatch = decodeJsonResult( + Schema.Record(Schema.String, Schema.NullOr(Schema.String)), +); + +export class DirenvEnvironmentError extends Schema.TaggedErrorClass()( + "DirenvEnvironmentError", + { + stage: Schema.Literals(["inspection", "execution", "invalid-output"]), + detail: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `Failed to resolve direnv environment during ${this.stage}: ${this.detail}`; + } +} + +export class DirenvEnvironment extends Context.Service< + DirenvEnvironment, + { + readonly allow: (input: { + readonly cwd: string; + readonly environment: NodeJS.ProcessEnv; + }) => Effect.Effect; + readonly resolve: (input: { + readonly cwd: string; + readonly environment: NodeJS.ProcessEnv; + }) => Effect.Effect; + } +>()("t3/provider/DirenvEnvironment") {} + +export const identityDirenvEnvironmentResolver: DirenvEnvironment["Service"]["resolve"] = (input) => + Effect.succeed(input.environment); + +export const noopDirenvEnvironmentAllow: DirenvEnvironment["Service"]["allow"] = () => Effect.void; + +/** + * Resolves a provider session environment through the optional direnv + * resolver, mapping failures into the adapter error domain. Adapters that + * are constructed without a resolver (tests) keep the base environment. + */ +export const resolveProviderSessionEnvironment = (input: { + readonly resolve: DirenvEnvironment["Service"]["resolve"] | undefined; + readonly provider: string; + readonly threadId: string; + readonly cwd: string; + readonly environment: NodeJS.ProcessEnv; +}): Effect.Effect => + input.resolve === undefined + ? Effect.succeed(input.environment) + : input.resolve({ cwd: input.cwd, environment: input.environment }).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterProcessError({ + provider: input.provider, + threadId: input.threadId, + detail: cause.message, + cause, + }), + ), + ); + +function conciseStderr(stderr: string, environment: NodeJS.ProcessEnv): string | undefined { + let redacted = stderr; + const environmentValues = Array.from( + new Set( + Object.values(environment).filter( + (value): value is string => typeof value === "string" && value.length >= 4, + ), + ), + ).sort((left, right) => right.length - left.length); + for (const value of environmentValues) { + redacted = redacted.split(value).join("[redacted]"); + } + + const normalized = redacted.replace(/\s+/g, " ").trim(); + if (normalized.length === 0) return undefined; + if (normalized.length <= STDERR_DETAIL_MAX_LENGTH) return normalized; + return `${normalized.slice(0, STDERR_DETAIL_MAX_LENGTH)}…`; +} + +export const make = Effect.fn("DirenvEnvironment.make")(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const processRunner = yield* ProcessRunner.ProcessRunner; + + const envrcExists = (candidate: string) => + fileSystem.exists(candidate).pipe( + Effect.mapError( + (cause) => + new DirenvEnvironmentError({ + stage: "inspection", + detail: `Could not inspect '${candidate}'.`, + cause, + }), + ), + ); + + // Cached per PATH value for the lifetime of the service: sessions in the + // same environment would otherwise re-scan PATH on every start. + const direnvPathCache = new Map(); + const findDirenv = Effect.fn("DirenvEnvironment.findDirenv")(function* ( + environment: NodeJS.ProcessEnv, + ) { + const cacheKey = environment.PATH ?? ""; + if (direnvPathCache.has(cacheKey)) return direnvPathCache.get(cacheKey); + const direnvPath = yield* resolveCommandPath("direnv", { env: environment }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.catchTag("CommandResolutionError", () => Effect.void), + ); + direnvPathCache.set(cacheKey, direnvPath ?? undefined); + return direnvPath ?? undefined; + }); + + /** Runs direnv, or returns `undefined` when direnv is not installed. */ + const runDirenv = Effect.fn("DirenvEnvironment.runDirenv")(function* (input: { + readonly commandLabel: string; + readonly args: ReadonlyArray; + readonly cwd: string; + readonly environment: NodeJS.ProcessEnv; + }) { + const direnvPath = yield* findDirenv(input.environment); + if (direnvPath === undefined) return undefined; + + const output = yield* processRunner + .run({ + command: direnvPath, + args: input.args, + cwd: input.cwd, + env: input.environment, + extendEnv: false, + maxOutputBytes: DIRENV_MAX_OUTPUT_BYTES, + timeout: DIRENV_TIMEOUT, + }) + .pipe( + Effect.mapError( + (cause) => + new DirenvEnvironmentError({ + stage: "execution", + detail: `Could not execute ${input.commandLabel}.`, + cause, + }), + ), + ); + + if (output.code !== 0) { + const stderr = conciseStderr(output.stderr, input.environment); + return yield* new DirenvEnvironmentError({ + stage: "execution", + detail: stderr + ? `${input.commandLabel} exited unsuccessfully: ${stderr}` + : `${input.commandLabel} exited unsuccessfully.`, + }); + } + return output; + }); + + /** Approves only the `.envrc` at the root of a worktree T3 just created. */ + const allow: DirenvEnvironment["Service"]["allow"] = Effect.fn("DirenvEnvironment.allow")( + function* (input) { + const cwd = path.resolve(input.cwd); + const envrcPath = path.join(cwd, ".envrc"); + if (!(yield* envrcExists(envrcPath))) return; + yield* runDirenv({ + commandLabel: "direnv allow", + args: ["allow", envrcPath], + cwd, + environment: input.environment, + }); + }, + ); + + const findNearestEnvrc = Effect.fn("DirenvEnvironment.findNearestEnvrc")(function* ( + cwd: string, + ): Effect.fn.Return { + let directory = path.resolve(cwd); + while (true) { + const candidate = path.join(directory, ".envrc"); + if (yield* envrcExists(candidate)) return candidate; + + const parent = path.dirname(directory); + if (parent === directory) return undefined; + directory = parent; + } + }); + + const resolve: DirenvEnvironment["Service"]["resolve"] = Effect.fn("DirenvEnvironment.resolve")( + function* (input) { + const envrcPath = yield* findNearestEnvrc(input.cwd); + if (envrcPath === undefined) return input.environment; + + const output = yield* runDirenv({ + commandLabel: "direnv", + args: ["export", "json"], + cwd: input.cwd, + environment: input.environment, + }); + if (output === undefined) return input.environment; + + const decoded = decodeDirenvPatch(output.stdout); + if (Result.isFailure(decoded)) { + return yield* new DirenvEnvironmentError({ + stage: "invalid-output", + detail: "direnv returned an invalid environment patch.", + }); + } + + const resolvedEnvironment = { ...input.environment }; + for (const [name, value] of Object.entries(decoded.success)) { + if (value === null) { + delete resolvedEnvironment[name]; + } else { + resolvedEnvironment[name] = value; + } + } + return resolvedEnvironment; + }, + ); + + return DirenvEnvironment.of({ allow, resolve }); +}); + +export const layer = Layer.effect(DirenvEnvironment, make()); + +export const layerLive = layer.pipe(Layer.provide(ProcessRunner.layer)); + +export const layerNoop = Layer.succeed(DirenvEnvironment, { + allow: noopDirenvEnvironmentAllow, + resolve: identityDirenvEnvironmentResolver, +}); diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index c2fc11311aa..7208639a086 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -26,6 +26,7 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import { makeClaudeTextGeneration } from "../../textGeneration/ClaudeTextGeneration.ts"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; +import { DirenvEnvironment } from "../DirenvEnvironment.ts"; import { ProviderDriverError } from "../Errors.ts"; import { makeClaudeAdapter } from "../Layers/ClaudeAdapter.ts"; import { @@ -84,6 +85,7 @@ const UPDATE = makePackageManagedProviderMaintenanceResolver({ export type ClaudeDriverEnv = | ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto + | DirenvEnvironment | FileSystem.FileSystem | HttpClient.HttpClient | Path.Path @@ -124,6 +126,7 @@ export const ClaudeDriver: ProviderDriver = { const httpClient = yield* HttpClient.HttpClient; const serverSettings = yield* ServerSettingsService; const eventLoggers = yield* ProviderEventLoggers; + const direnvEnvironment = yield* DirenvEnvironment; const processEnv = mergeProviderInstanceEnvironment(environment); const fallbackContinuationIdentity = defaultProviderContinuationIdentity({ driverKind: DRIVER_KIND, @@ -145,6 +148,7 @@ export const ClaudeDriver: ProviderDriver = { const adapterOptions = { instanceId, environment: processEnv, + resolveEnvironment: direnvEnvironment.resolve, ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), }; const adapter = yield* makeClaudeAdapter(effectiveConfig, adapterOptions); diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index ffcc94ca77d..f42acf2404b 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -34,6 +34,7 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import { makeCodexTextGeneration } from "../../textGeneration/CodexTextGeneration.ts"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; +import { DirenvEnvironment } from "../DirenvEnvironment.ts"; import { ProviderDriverError } from "../Errors.ts"; import { makeCodexAdapter } from "../Layers/CodexAdapter.ts"; import { checkCodexProviderStatus, makePendingCodexProvider } from "../Layers/CodexProvider.ts"; @@ -76,6 +77,7 @@ const UPDATE = makePackageManagedProviderMaintenanceResolver({ export type CodexDriverEnv = | ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto + | DirenvEnvironment | FileSystem.FileSystem | HttpClient.HttpClient | Path.Path @@ -119,6 +121,7 @@ export const CodexDriver: ProviderDriver = { const httpClient = yield* HttpClient.HttpClient; const serverSettings = yield* ServerSettingsService; const eventLoggers = yield* ProviderEventLoggers; + const direnvEnvironment = yield* DirenvEnvironment; const processEnv = mergeProviderInstanceEnvironment(environment); const homeLayout = yield* resolveCodexHomeLayout(config); const continuationIdentity = codexContinuationIdentity(homeLayout); @@ -158,6 +161,7 @@ export const CodexDriver: ProviderDriver = { const adapter = yield* makeCodexAdapter(effectiveConfig, { instanceId, environment: processEnv, + resolveEnvironment: direnvEnvironment.resolve, ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), }); const textGeneration = yield* makeCodexTextGeneration(effectiveConfig, processEnv); diff --git a/apps/server/src/provider/Drivers/CursorDriver.ts b/apps/server/src/provider/Drivers/CursorDriver.ts index 61f80489774..de56d9459d1 100644 --- a/apps/server/src/provider/Drivers/CursorDriver.ts +++ b/apps/server/src/provider/Drivers/CursorDriver.ts @@ -25,6 +25,7 @@ import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { makeCursorTextGeneration } from "../../textGeneration/CursorTextGeneration.ts"; import { ProviderDriverError } from "../Errors.ts"; +import { DirenvEnvironment } from "../DirenvEnvironment.ts"; import { makeCursorAdapter } from "../Layers/CursorAdapter.ts"; import { buildInitialCursorProviderSnapshot, @@ -68,6 +69,7 @@ const UPDATE: ProviderMaintenanceCapabilitiesResolver = { export type CursorDriverEnv = | ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto + | DirenvEnvironment | FileSystem.FileSystem | HttpClient.HttpClient | Path.Path @@ -108,6 +110,7 @@ export const CursorDriver: ProviderDriver = { const httpClient = yield* HttpClient.HttpClient; const serverSettings = yield* ServerSettingsService; const eventLoggers = yield* ProviderEventLoggers; + const direnvEnvironment = yield* DirenvEnvironment; const processEnv = mergeProviderInstanceEnvironment(environment); const continuationIdentity = defaultProviderContinuationIdentity({ driverKind: DRIVER_KIND, @@ -127,6 +130,7 @@ export const CursorDriver: ProviderDriver = { const adapter = yield* makeCursorAdapter(effectiveConfig, { environment: processEnv, + resolveEnvironment: direnvEnvironment.resolve, ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), instanceId, }); diff --git a/apps/server/src/provider/Drivers/GrokDriver.ts b/apps/server/src/provider/Drivers/GrokDriver.ts index 4eb32c20c47..a9dc19ffb18 100644 --- a/apps/server/src/provider/Drivers/GrokDriver.ts +++ b/apps/server/src/provider/Drivers/GrokDriver.ts @@ -12,6 +12,7 @@ import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { makeGrokTextGeneration } from "../../textGeneration/GrokTextGeneration.ts"; import { ProviderDriverError } from "../Errors.ts"; +import { DirenvEnvironment } from "../DirenvEnvironment.ts"; import { makeGrokAdapter } from "../Layers/GrokAdapter.ts"; import { buildInitialGrokProviderSnapshot, @@ -51,6 +52,7 @@ const UPDATE = makeStaticProviderMaintenanceResolver( export type GrokDriverEnv = | ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto + | DirenvEnvironment | FileSystem.FileSystem | HttpClient.HttpClient | Path.Path @@ -89,6 +91,7 @@ export const GrokDriver: ProviderDriver = { const httpClient = yield* HttpClient.HttpClient; const serverSettings = yield* ServerSettingsService; const eventLoggers = yield* ProviderEventLoggers; + const direnvEnvironment = yield* DirenvEnvironment; const processEnv = mergeProviderInstanceEnvironment(environment); const continuationIdentity = defaultProviderContinuationIdentity({ driverKind: DRIVER_KIND, @@ -108,6 +111,7 @@ export const GrokDriver: ProviderDriver = { const adapter = yield* makeGrokAdapter(effectiveConfig, { environment: processEnv, + resolveEnvironment: direnvEnvironment.resolve, ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), instanceId, }); diff --git a/apps/server/src/provider/Drivers/OpenCodeDriver.ts b/apps/server/src/provider/Drivers/OpenCodeDriver.ts index 6342d176590..727d051a652 100644 --- a/apps/server/src/provider/Drivers/OpenCodeDriver.ts +++ b/apps/server/src/provider/Drivers/OpenCodeDriver.ts @@ -26,6 +26,7 @@ import { makeOpenCodeTextGeneration } from "../../textGeneration/OpenCodeTextGen import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { ProviderDriverError } from "../Errors.ts"; +import { DirenvEnvironment } from "../DirenvEnvironment.ts"; import { makeOpenCodeAdapter } from "../Layers/OpenCodeAdapter.ts"; import { checkOpenCodeProviderStatus, @@ -80,6 +81,7 @@ const UPDATE = makePackageManagedProviderMaintenanceResolver({ export type OpenCodeDriverEnv = | ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto + | DirenvEnvironment | FileSystem.FileSystem | HttpClient.HttpClient | OpenCodeRuntime @@ -119,6 +121,7 @@ export const OpenCodeDriver: ProviderDriver const httpClient = yield* HttpClient.HttpClient; const serverSettings = yield* ServerSettingsService; const eventLoggers = yield* ProviderEventLoggers; + const direnvEnvironment = yield* DirenvEnvironment; const processEnv = mergeProviderInstanceEnvironment(environment); const continuationIdentity = defaultProviderContinuationIdentity({ driverKind: DRIVER_KIND, @@ -139,6 +142,7 @@ export const OpenCodeDriver: ProviderDriver const adapter = yield* makeOpenCodeAdapter(effectiveConfig, { instanceId, environment: processEnv, + resolveEnvironment: direnvEnvironment.resolve, ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), }); const textGeneration = yield* makeOpenCodeTextGeneration(effectiveConfig, processEnv); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index ba8b1b848a8..97f55fd5031 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -22,7 +22,7 @@ import { ProviderInstanceId, } from "@t3tools/contracts"; import { createModelSelection } from "@t3tools/shared/model"; -import { assert, describe, it } from "@effect/vitest"; +import { assert, describe, it, vi } from "@effect/vitest"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; @@ -156,6 +156,8 @@ function makeHarness(config?: { readonly baseDir?: string; readonly claudeConfig?: Partial; readonly instanceId?: ProviderInstanceId; + readonly environment?: NodeJS.ProcessEnv; + readonly resolveEnvironment?: ClaudeAdapterLiveOptions["resolveEnvironment"]; }) { const query = new FakeClaudeQuery(); let createInput: @@ -167,6 +169,8 @@ function makeHarness(config?: { const adapterOptions: ClaudeAdapterLiveOptions = { ...(config?.instanceId ? { instanceId: config.instanceId } : {}), + ...(config?.environment ? { environment: config.environment } : {}), + ...(config?.resolveEnvironment ? { resolveEnvironment: config.resolveEnvironment } : {}), createQuery: (input) => { createInput = input; return query; @@ -268,6 +272,42 @@ const THREAD_ID = ThreadId.make("thread-claude-1"); const RESUME_THREAD_ID = ThreadId.make("thread-claude-resume"); describe("ClaudeAdapterLive", () => { + it.effect("passes the resolved environment to the SDK after applying Claude invariants", () => { + const claudeConfigDir = "/tmp/t3-claude-direnv-home"; + const resolveEnvironment = vi.fn((_input: { readonly cwd: string }) => + Effect.succeed({ + PATH: process.env.PATH, + PROVIDER_VALUE: "from-direnv", + CLAUDE_CONFIG_DIR: "/tmp/direnv-must-not-win", + }), + ); + const harness = makeHarness({ + claudeConfig: { homePath: claudeConfigDir }, + environment: { PATH: process.env.PATH, PROVIDER_VALUE: "configured" }, + resolveEnvironment, + }); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + yield* adapter.startSession({ + threadId: ThreadId.make("thread-claude-direnv"), + provider: ProviderDriverKind.make("claudeAgent"), + cwd: ".", + runtimeMode: "full-access", + }); + + assert.equal(resolveEnvironment.mock.calls[0]?.[0].cwd, process.cwd()); + assert.deepEqual(harness.getLastCreateQueryInput()?.options.env, { + PATH: process.env.PATH, + PROVIDER_VALUE: "from-direnv", + CLAUDE_CONFIG_DIR: claudeConfigDir, + }); + assert.equal(harness.getLastCreateQueryInput()?.options.cwd, process.cwd()); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("returns validation error for non-claude provider on startSession", () => { const harness = makeHarness(); return Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 285d9dac608..45c75faee84 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -89,6 +89,7 @@ import { type ProviderAdapterError, } from "../Errors.ts"; import { type ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts"; +import { type DirenvEnvironment, resolveProviderSessionEnvironment } from "../DirenvEnvironment.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.UnknownFromJsonString); const decodeUnknownJsonStringExit = Schema.decodeUnknownExit(Schema.UnknownFromJsonString); @@ -216,6 +217,7 @@ interface ClaudeQueryRuntime extends AsyncIterable { export interface ClaudeAdapterLiveOptions { readonly instanceId?: ProviderInstanceId; readonly environment?: NodeJS.ProcessEnv; + readonly resolveEnvironment?: DirenvEnvironment["Service"]["resolve"]; readonly createQuery?: (input: { readonly prompt: AsyncIterable; readonly options: ClaudeQueryOptions; @@ -1340,7 +1342,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const path = yield* Path.Path; const serverConfig = yield* ServerConfig; const crypto = yield* Crypto.Crypto; - const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, options?.environment).pipe( + const baseEnvironment = options?.environment ?? process.env; + const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, baseEnvironment).pipe( Effect.provideService(Path.Path, path), ); const claudeSdkExecutablePath = yield* resolveClaudeSdkExecutablePath( @@ -3144,6 +3147,28 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }); } + if (input.cwd !== undefined && !input.cwd.trim()) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: "cwd must be non-empty when provided.", + }); + } + const cwd = input.cwd === undefined ? undefined : path.resolve(input.cwd.trim()); + const sessionEnvironment = + cwd === undefined + ? claudeEnvironment + : yield* resolveProviderSessionEnvironment({ + resolve: options?.resolveEnvironment, + provider: PROVIDER, + threadId: input.threadId, + cwd, + environment: baseEnvironment, + }).pipe( + Effect.flatMap((environment) => makeClaudeEnvironment(claudeSettings, environment)), + Effect.provideService(Path.Path, path), + ); + const existingContext = sessions.get(input.threadId); if (existingContext) { yield* Effect.logWarning("claude.session.replacing", { @@ -3520,7 +3545,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }; const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); const queryOptions: ClaudeQueryOptions = { - ...(input.cwd ? { cwd: input.cwd } : {}), + ...(cwd ? { cwd } : {}), ...(apiModelId ? { model: apiModelId } : {}), pathToClaudeCodeExecutable: claudeBinaryPath, systemPrompt: { type: "preset", preset: "claude_code" }, @@ -3541,8 +3566,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...(newSessionId ? { sessionId: newSessionId } : {}), includePartialMessages: true, canUseTool, - env: claudeEnvironment, - ...(input.cwd ? { additionalDirectories: [input.cwd] } : {}), + env: sessionEnvironment, + ...(cwd ? { additionalDirectories: [cwd] } : {}), ...(Object.keys(extraArgs).length > 0 ? { extraArgs } : {}), ...(mcpSession ? { @@ -3569,7 +3594,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( "claude.resume.session_id": existingResumeSessionId ?? "", "claude.resume.session_at": resumeState?.resumeSessionAt ?? "", "claude.resume.turn_count": resumeState?.turnCount ?? -1, - "claude.query.cwd": input.cwd ?? "", + "claude.query.cwd": cwd ?? "", "claude.query.model": apiModelId ?? "", "claude.query.effort": effectiveEffort ?? "", "claude.query.permission_mode": permissionMode ?? "", @@ -3577,7 +3602,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( "claude.query.resume": existingResumeSessionId ?? "", "claude.query.session_id": newSessionId ?? "", "claude.query.include_partial_messages": true, - "claude.query.additional_directories": input.cwd ? [input.cwd] : [], + "claude.query.additional_directories": cwd ? [cwd] : [], "claude.query.setting_sources": [...CLAUDE_SETTING_SOURCES], "claude.query.settings_json": encodeJsonStringForDiagnostics(settings) ?? "", "claude.query.extra_args_json": encodeJsonStringForDiagnostics(extraArgs) ?? "", @@ -3605,7 +3630,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( providerInstanceId: boundInstanceId, status: "ready", runtimeMode: input.runtimeMode, - ...(input.cwd ? { cwd: input.cwd } : {}), + ...(cwd ? { cwd } : {}), ...(modelSelection?.model ? { model: modelSelection.model } : {}), ...(threadId ? { threadId } : {}), resumeCursor: { diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 4ae654a5187..39f6250d921 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -37,6 +37,7 @@ import * as CodexErrors from "effect-codex-app-server/errors"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { ProviderAdapterValidationError } from "../Errors.ts"; +import { DirenvEnvironmentError } from "../DirenvEnvironment.ts"; import type { CodexAdapterShape } from "../Services/CodexAdapter.ts"; import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; import { @@ -309,6 +310,78 @@ const sessionErrorLayer = it.layer( ); sessionErrorLayer("CodexAdapterLive session errors", (it) => { + it.effect( + "uses the resolved environment and preserves an existing session on resolver failure", + () => { + const runtimeFactory = makeRuntimeFactory(); + let shouldFail = false; + const resolvedEnvironment = { + PATH: process.env.PATH, + PROVIDER_VALUE: "from-direnv", + T3CODE_CODEX_LAUNCH_ARGS: "--enable direnv-feature", + }; + const resolveEnvironment = vi.fn((_input: { readonly cwd: string }) => + shouldFail + ? Effect.fail( + new DirenvEnvironmentError({ + stage: "execution", + detail: "direnv allow is required.", + }), + ) + : Effect.succeed(resolvedEnvironment), + ); + const layer = Layer.effect( + CodexAdapter, + Effect.gen(function* () { + const codexConfig = decodeCodexSettings({}); + return yield* makeCodexAdapter(codexConfig, { + environment: { PATH: process.env.PATH, PROVIDER_VALUE: "configured" }, + resolveEnvironment, + makeRuntime: runtimeFactory.factory, + }); + }), + ).pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(providerSessionDirectoryTestLayer), + Layer.provideMerge(NodeServices.layer), + ); + + return Effect.gen(function* () { + const adapter = yield* CodexAdapter; + const threadId = asThreadId("sess-direnv-preserve"); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("codex"), + threadId, + cwd: ".", + runtimeMode: "full-access", + }); + + const runtime = runtimeFactory.lastRuntime; + NodeAssert.ok(runtime); + NodeAssert.strictEqual(runtime.options.environment, resolvedEnvironment); + NodeAssert.equal(runtime.options.launchArgs, "--enable direnv-feature"); + NodeAssert.equal(resolveEnvironment.mock.calls[0]?.[0].cwd, process.cwd()); + + shouldFail = true; + const error = yield* adapter + .startSession({ + provider: ProviderDriverKind.make("codex"), + threadId, + cwd: ".", + runtimeMode: "full-access", + }) + .pipe(Effect.flip); + + NodeAssert.equal(error._tag, "ProviderAdapterProcessError"); + NodeAssert.match(error.detail, /direnv allow/u); + NodeAssert.equal(runtimeFactory.factory.mock.calls.length, 1); + NodeAssert.equal(runtime.closeImpl.mock.calls.length, 0); + NodeAssert.equal((yield* adapter.listSessions()).length, 1); + }).pipe(Effect.provide(layer)); + }, + ); + it.effect("maps missing adapter sessions to ProviderAdapterSessionNotFoundError", () => Effect.gen(function* () { const adapter = yield* CodexAdapter; diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 38a5887cdc3..130eace6151 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -29,6 +29,7 @@ import * as Crypto from "effect/Crypto"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; @@ -50,6 +51,7 @@ import { type ProviderAdapterError, } from "../Errors.ts"; import { type CodexAdapterShape } from "../Services/CodexAdapter.ts"; +import { type DirenvEnvironment, resolveProviderSessionEnvironment } from "../DirenvEnvironment.ts"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; import { @@ -74,6 +76,7 @@ const PROVIDER = ProviderDriverKind.make("codex"); export interface CodexAdapterLiveOptions { readonly instanceId?: ProviderInstanceId; readonly environment?: NodeJS.ProcessEnv; + readonly resolveEnvironment?: DirenvEnvironment["Service"]["resolve"]; readonly makeRuntime?: ( options: CodexSessionRuntimeOptions, ) => Effect.Effect< @@ -1359,6 +1362,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( ) { const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("codex"); const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const crypto = yield* Crypto.Crypto; const serverConfig = yield* Effect.service(ServerConfig); @@ -1385,6 +1389,25 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( }); } + if (input.cwd !== undefined && !input.cwd.trim()) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: "cwd must be non-empty when provided.", + }); + } + const cwd = input.cwd === undefined ? process.cwd() : path.resolve(input.cwd.trim()); + const environment = + input.cwd === undefined + ? options?.environment + : yield* resolveProviderSessionEnvironment({ + resolve: options?.resolveEnvironment, + provider: PROVIDER, + threadId: input.threadId, + cwd, + environment: options?.environment ?? process.env, + }); + const existing = sessions.get(input.threadId); if (existing && !existing.stopped) { yield* Effect.suspend(() => stopSessionInternal(existing)); @@ -1398,10 +1421,10 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( const runtimeInput: CodexSessionRuntimeOptions = { threadId: input.threadId, providerInstanceId: boundInstanceId, - cwd: input.cwd ?? process.cwd(), + cwd, binaryPath: codexConfig.binaryPath, - launchArgs: resolveCodexLaunchArgs(codexConfig.launchArgs, options?.environment), - ...(options?.environment ? { environment: options.environment } : {}), + launchArgs: resolveCodexLaunchArgs(codexConfig.launchArgs, environment), + ...(environment ? { environment } : {}), ...(codexConfig.homePath ? { homePath: codexConfig.homePath } : {}), ...(isCodexResumeCursorSchema(input.resumeCursor) ? { resumeCursor: input.resumeCursor } @@ -1414,7 +1437,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( ...(mcpSession ? { environment: { - ...(options?.environment ?? process.env), + ...(environment ?? process.env), T3_MCP_BEARER_TOKEN: mcpSession.authorizationHeader.replace(/^Bearer\s+/, ""), }, appServerArgs: [ diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts index 491f718a977..ffdd95efff2 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.test.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -5,7 +5,7 @@ import * as NodeFSP from "node:fs/promises"; import * as NodeURL from "node:url"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, it } from "@effect/vitest"; +import { assert, it, vi } from "@effect/vitest"; import * as Context from "effect/Context"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -168,6 +168,41 @@ const cursorAdapterTestLayer = it.layer( ); cursorAdapterTestLayer("CursorAdapterLive", (it) => { + it.effect("passes the resolved complete environment to the Cursor ACP child", () => + Effect.gen(function* () { + const tempDirectory = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cursor-direnv-")), + ); + const requestLogPath = NodePath.join(tempDirectory, "requests.jsonl"); + const wrapperPath = yield* Effect.promise(() => makeMockAgentWrapper()); + const resolveEnvironment = vi.fn((input) => + Effect.succeed({ + ...input.environment, + PROVIDER_VALUE: "from-direnv", + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + }), + ); + const adapter = yield* makeCursorAdapter(decodeCursorSettings({ binaryPath: wrapperPath }), { + environment: { ...process.env, PROVIDER_VALUE: "configured" }, + resolveEnvironment, + }); + const threadId = ThreadId.make("cursor-direnv-thread"); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("cursor"), + cwd: ".", + runtimeMode: "full-access", + }); + + assert.equal(resolveEnvironment.mock.calls[0]?.[0].cwd, process.cwd()); + // The wait only succeeds once the child saw the resolved environment + // (the request log path only exists inside it). + yield* waitForJsonLogMatch(requestLogPath, (entry) => entry.method === "initialize"); + yield* adapter.stopSession(threadId); + }), + ); + it.effect("starts a session and maps mock ACP prompt flow to runtime events", () => Effect.gen(function* () { const adapter = yield* CursorAdapter; diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index 4dd38519c5a..f6b74a41ac4 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -75,6 +75,7 @@ import { extractTodosAsPlan, } from "../acp/CursorAcpExtension.ts"; import { type CursorAdapterShape } from "../Services/CursorAdapter.ts"; +import { type DirenvEnvironment, resolveProviderSessionEnvironment } from "../DirenvEnvironment.ts"; import { resolveCursorAcpBaseModelId } from "./CursorProvider.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.UnknownFromJsonString); @@ -92,6 +93,7 @@ function encodeJsonStringForDiagnostics(input: unknown): string | undefined { export interface CursorAdapterLiveOptions { readonly environment?: NodeJS.ProcessEnv; + readonly resolveEnvironment?: DirenvEnvironment["Service"]["resolve"]; readonly nativeEventLogPath?: string; readonly nativeEventLogger?: EventNdjsonLogger; /** @@ -496,6 +498,13 @@ export function makeCursorAdapter( } const cwd = path.resolve(input.cwd.trim()); + const environment = yield* resolveProviderSessionEnvironment({ + resolve: options?.resolveEnvironment, + provider: PROVIDER, + threadId: input.threadId, + cwd, + environment: options?.environment ?? process.env, + }); const cursorModelSelection = input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; const existing = sessions.get(input.threadId); @@ -534,7 +543,7 @@ export function makeCursorAdapter( const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); const acp = yield* makeCursorAcpRuntime({ cursorSettings: effectiveCursorSettings, - ...(options?.environment ? { environment: options.environment } : {}), + environment, childProcessSpawner, cwd, ...(resumeSessionId ? { resumeSessionId } : {}), diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index d8c288a8292..87e3f061d36 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -67,6 +67,7 @@ import { XAiAskUserQuestionRequest, } from "../acp/XAiAcpExtension.ts"; import { type GrokAdapterShape } from "../Services/GrokAdapter.ts"; +import { type DirenvEnvironment, resolveProviderSessionEnvironment } from "../DirenvEnvironment.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.UnknownFromJsonString); @@ -81,6 +82,7 @@ function encodeJsonStringForDiagnostics(input: unknown): string | undefined { export interface GrokAdapterLiveOptions { readonly environment?: NodeJS.ProcessEnv; + readonly resolveEnvironment?: DirenvEnvironment["Service"]["resolve"]; readonly nativeEventLogPath?: string; readonly nativeEventLogger?: EventNdjsonLogger; readonly instanceId?: ProviderInstanceId; @@ -547,6 +549,13 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte } const cwd = path.resolve(input.cwd.trim()); + const environment = yield* resolveProviderSessionEnvironment({ + resolve: options?.resolveEnvironment, + provider: PROVIDER, + threadId: input.threadId, + cwd, + environment: options?.environment ?? process.env, + }); const grokModelSelection = input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; const existing = sessions.get(input.threadId); @@ -572,7 +581,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); const acp = yield* makeGrokAcpRuntime({ grokSettings, - ...(options?.environment ? { environment: options.environment } : {}), + environment, childProcessSpawner, cwd, ...(resumeSessionId ? { resumeSessionId } : {}), diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 1385ccbaabe..aa646c73a0c 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -1,6 +1,6 @@ import * as NodeAssert from "node:assert/strict"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { it } from "@effect/vitest"; +import { it, vi } from "@effect/vitest"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -25,6 +25,7 @@ import { createModelSelection } from "@t3tools/shared/model"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; +import { DirenvEnvironmentError } from "../DirenvEnvironment.ts"; import type { OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts"; import { OpenCodeRuntime, @@ -45,6 +46,7 @@ class OpenCodeAdapter extends Context.Service ThreadId.make(value); +const decodeOpenCodeSettings = Schema.decodeSync(OpenCodeSettings); type MessageEntry = { info: { @@ -57,6 +59,11 @@ type MessageEntry = { const runtimeMock = { state: { startCalls: [] as string[], + connectCalls: [] as Array<{ + serverUrl?: string | null; + environment?: NodeJS.ProcessEnv; + cwd?: string; + }>, sessionCreateUrls: [] as string[], sessionCreateInputs: [] as Array>, authHeaders: [] as Array, @@ -77,6 +84,7 @@ const runtimeMock = { }, reset() { this.state.startCalls.length = 0; + this.state.connectCalls.length = 0; this.state.sessionCreateUrls.length = 0; this.state.sessionCreateInputs.length = 0; this.state.authHeaders.length = 0; @@ -115,8 +123,13 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { exitCode: Effect.never, }; }), - connectToOpenCodeServer: ({ serverUrl }) => + connectToOpenCodeServer: ({ serverUrl, environment, cwd }) => Effect.gen(function* () { + runtimeMock.state.connectCalls.push({ + ...(serverUrl !== undefined ? { serverUrl } : {}), + ...(environment !== undefined ? { environment } : {}), + ...(cwd !== undefined ? { cwd } : {}), + }); const url = serverUrl ?? "http://127.0.0.1:4301"; // Always register a finalizer so the closeCalls/closeError probes fire; // production attaches none for external servers. @@ -246,7 +259,7 @@ const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory // the layer graph reach for it — but the routing values the assertions // probe (serverUrl, serverPassword) must be threaded directly through the // decoded `OpenCodeSettings`. -const openCodeAdapterTestSettings = Schema.decodeSync(OpenCodeSettings)({ +const openCodeAdapterTestSettings = decodeOpenCodeSettings({ binaryPath: "fake-opencode", serverUrl: "http://127.0.0.1:9999", serverPassword: "secret-password", @@ -280,7 +293,81 @@ beforeEach(() => { const advanceTestClock = (ms: number) => TestClock.adjust(`${ms} millis`).pipe(Effect.andThen(Effect.yieldNow)); +const makeDirenvAdapterLayer = ( + settings: typeof openCodeAdapterTestSettings, + resolveEnvironment: NonNullable< + NonNullable[1]>["resolveEnvironment"] + >, +) => + Layer.effect( + OpenCodeAdapter, + makeOpenCodeAdapter(settings, { + environment: { PATH: process.env.PATH, PROVIDER_VALUE: "configured" }, + resolveEnvironment, + }), + ).pipe( + Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(NodeServices.layer), + ); + it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { + it.effect("passes the resolved environment and project cwd to a local OpenCode server", () => { + const resolvedEnvironment = { PATH: process.env.PATH, PROVIDER_VALUE: "from-direnv" }; + const resolveEnvironment = vi.fn((_input: { readonly cwd: string }) => + Effect.succeed(resolvedEnvironment), + ); + const settings = decodeOpenCodeSettings({ + binaryPath: "fake-opencode", + serverUrl: "", + }); + const adapterLayer = makeDirenvAdapterLayer(settings, resolveEnvironment); + + return Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId: asThreadId("thread-opencode-direnv"), + cwd: ".", + runtimeMode: "full-access", + }); + + NodeAssert.equal(resolveEnvironment.mock.calls[0]?.[0].cwd, process.cwd()); + NodeAssert.deepEqual(runtimeMock.state.connectCalls, [ + { + serverUrl: "", + environment: resolvedEnvironment, + cwd: process.cwd(), + }, + ]); + }).pipe(Effect.provide(adapterLayer)); + }); + + it.effect("does not resolve direnv for a configured external OpenCode server", () => { + const resolveEnvironment = vi.fn(() => + Effect.fail( + new DirenvEnvironmentError({ + stage: "execution", + detail: "must not run for external servers", + }), + ), + ); + const adapterLayer = makeDirenvAdapterLayer(openCodeAdapterTestSettings, resolveEnvironment); + + return Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId: asThreadId("thread-opencode-external-direnv"), + cwd: ".", + runtimeMode: "full-access", + }); + + NodeAssert.equal(resolveEnvironment.mock.calls.length, 0); + NodeAssert.equal(runtimeMock.state.connectCalls[0]?.serverUrl, "http://127.0.0.1:9999"); + }).pipe(Effect.provide(adapterLayer)); + }); + it.effect("reuses a configured OpenCode server URL instead of spawning a local server", () => Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 73c23b77e68..dda34cf3598 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -38,6 +38,7 @@ import { ProviderAdapterValidationError, } from "../Errors.ts"; import { type OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts"; +import { type DirenvEnvironment, resolveProviderSessionEnvironment } from "../DirenvEnvironment.ts"; import { buildOpenCodePermissionRules, OpenCodeRuntime, @@ -240,6 +241,7 @@ interface OpenCodeSessionContext { export interface OpenCodeAdapterLiveOptions { readonly instanceId?: ProviderInstanceId; readonly environment?: NodeJS.ProcessEnv; + readonly resolveEnvironment?: DirenvEnvironment["Service"]["resolve"]; readonly nativeEventLogPath?: string; readonly nativeEventLogger?: EventNdjsonLogger; } @@ -1189,7 +1191,25 @@ export function makeOpenCodeAdapter( const binaryPath = openCodeSettings.binaryPath; const serverUrl = openCodeSettings.serverUrl; const serverPassword = openCodeSettings.serverPassword; - const directory = input.cwd ?? serverConfig.cwd; + if (input.cwd !== undefined && !input.cwd.trim()) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: "cwd must be non-empty when provided.", + }); + } + const directory = + input.cwd === undefined ? serverConfig.cwd : path.resolve(input.cwd.trim()); + const environment = + input.cwd === undefined || serverUrl?.trim() + ? options?.environment + : yield* resolveProviderSessionEnvironment({ + resolve: options?.resolveEnvironment, + provider: PROVIDER, + threadId: input.threadId, + cwd: directory, + environment: options?.environment ?? process.env, + }); const resumeSessionId = parseOpenCodeResume(input.resumeCursor)?.sessionId; const existing = sessions.get(input.threadId); if (existing) { @@ -1207,7 +1227,8 @@ export function makeOpenCodeAdapter( const server = yield* openCodeRuntime.connectToOpenCodeServer({ binaryPath, serverUrl, - ...(options?.environment ? { environment: options.environment } : {}), + ...(environment ? { environment } : {}), + cwd: directory, }); const client = openCodeRuntime.createOpenCodeSdkClient({ baseUrl: server.url, diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts index 384de852f9b..1054816e63f 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts @@ -46,6 +46,7 @@ import { CursorDriver } from "../Drivers/CursorDriver.ts"; import { GrokDriver } from "../Drivers/GrokDriver.ts"; import { OpenCodeDriver } from "../Drivers/OpenCodeDriver.ts"; import { OpenCodeRuntimeLive } from "../opencodeRuntime.ts"; +import * as DirenvEnvironment from "../DirenvEnvironment.ts"; import { NoOpProviderEventLoggers, ProviderEventLoggers } from "./ProviderEventLoggers.ts"; import { makeProviderInstanceRegistry } from "./ProviderInstanceRegistryLive.ts"; @@ -112,6 +113,7 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(TestHttpClientLive), Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), + Layer.provideMerge(DirenvEnvironment.layerNoop), ); it.live("boots two independent codex instances from a ProviderInstanceConfigMap", () => @@ -242,7 +244,10 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { // provides `OpenCodeRuntimeLive`'s deps while keeping its own outputs // surfaced; that merged layer then provides `ServerConfig.layerTest`'s // `FileSystem` dep while keeping everything else surfaced to the test. - const infraLayer = OpenCodeRuntimeLive.pipe(Layer.provideMerge(NodeServices.layer)); + const infraLayer = OpenCodeRuntimeLive.pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(DirenvEnvironment.layerNoop), + ); const testLayer = ServerConfig.layerTest(process.cwd(), { prefix: "provider-instance-registry-all-drivers-test", }).pipe( diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 5efbb6f1c14..703e1a85f7c 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -33,6 +33,7 @@ import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; import { checkCodexProviderStatus, type CodexAppServerProviderSnapshot } from "./CodexProvider.ts"; import { checkClaudeProviderStatus } from "./ClaudeProvider.ts"; import * as OpenCodeRuntime from "../opencodeRuntime.ts"; +import * as DirenvEnvironment from "../DirenvEnvironment.ts"; import * as ProviderEventLoggers from "./ProviderEventLoggers.ts"; import { ProviderInstanceRegistryHydrationLive } from "./ProviderInstanceRegistryHydration.ts"; import { @@ -301,230 +302,561 @@ function makeMutableServerSettingsService( }); } -it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), TestHttpClientLive))( - "ProviderRegistry", - (it) => { - describe("checkCodexProviderStatus", () => { - it.effect("uses the app-server account and model list for provider status", () => - Effect.gen(function* () { - const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => - Effect.succeed( - makeCodexProbeSnapshot({ - skills: [ - { - name: "github:gh-fix-ci", - path: "/Users/test/.codex/skills/gh-fix-ci/SKILL.md", - enabled: true, - displayName: "CI Debug", - shortDescription: "Debug failing GitHub Actions checks", - }, - ], - }), - ), - ); - assert.strictEqual(status.status, "ready"); - assert.strictEqual(status.installed, true); - assert.strictEqual(status.version, "1.0.0"); - assert.strictEqual(status.auth.status, "authenticated"); - assert.strictEqual(status.auth.type, "chatgpt"); - assert.strictEqual(status.auth.label, "ChatGPT Pro 20x Subscription"); - assert.strictEqual(status.auth.email, "test@example.com"); - assert.deepStrictEqual(status.models, [ - { - slug: "gpt-live-codex", - name: "GPT Live Codex", - isCustom: false, - capabilities: codexModelCapabilities, - }, - ]); - assert.deepStrictEqual(status.skills, [ - { - name: "github:gh-fix-ci", - path: "/Users/test/.codex/skills/gh-fix-ci/SKILL.md", - enabled: true, - displayName: "CI Debug", - shortDescription: "Debug failing GitHub Actions checks", - }, - ]); - }), - ); +const TestLayer = Layer.mergeAll( + NodeServices.layer, + ServerSettingsModule.layerTest(), + TestHttpClientLive, + DirenvEnvironment.layerNoop, +); - it.effect("passes configured launch args to the Codex provider probe", () => - Effect.gen(function* () { - let observedLaunchArgs: string | undefined; - const settings = decodeCodexSettings({ launchArgs: "--strict-config --enable foo" }); +it.layer(TestLayer)("ProviderRegistry", (it) => { + describe("checkCodexProviderStatus", () => { + it.effect("uses the app-server account and model list for provider status", () => + Effect.gen(function* () { + const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => + Effect.succeed( + makeCodexProbeSnapshot({ + skills: [ + { + name: "github:gh-fix-ci", + path: "/Users/test/.codex/skills/gh-fix-ci/SKILL.md", + enabled: true, + displayName: "CI Debug", + shortDescription: "Debug failing GitHub Actions checks", + }, + ], + }), + ), + ); + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.installed, true); + assert.strictEqual(status.version, "1.0.0"); + assert.strictEqual(status.auth.status, "authenticated"); + assert.strictEqual(status.auth.type, "chatgpt"); + assert.strictEqual(status.auth.label, "ChatGPT Pro 20x Subscription"); + assert.strictEqual(status.auth.email, "test@example.com"); + assert.deepStrictEqual(status.models, [ + { + slug: "gpt-live-codex", + name: "GPT Live Codex", + isCustom: false, + capabilities: codexModelCapabilities, + }, + ]); + assert.deepStrictEqual(status.skills, [ + { + name: "github:gh-fix-ci", + path: "/Users/test/.codex/skills/gh-fix-ci/SKILL.md", + enabled: true, + displayName: "CI Debug", + shortDescription: "Debug failing GitHub Actions checks", + }, + ]); + }), + ); - const status = yield* checkCodexProviderStatus(settings, (input) => { - observedLaunchArgs = input.launchArgs; - return Effect.succeed(makeCodexProbeSnapshot()); - }); + it.effect("passes configured launch args to the Codex provider probe", () => + Effect.gen(function* () { + let observedLaunchArgs: string | undefined; + const settings = decodeCodexSettings({ launchArgs: "--strict-config --enable foo" }); - assert.strictEqual(status.status, "ready"); - assert.strictEqual(observedLaunchArgs, "--strict-config --enable foo"); - }), - ); + const status = yield* checkCodexProviderStatus(settings, (input) => { + observedLaunchArgs = input.launchArgs; + return Effect.succeed(makeCodexProbeSnapshot()); + }); - it.effect("returns unauthenticated when app-server requires OpenAI auth", () => - Effect.gen(function* () { - const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => - Effect.succeed( - makeCodexProbeSnapshot({ - account: { - account: null, - requiresOpenaiAuth: true, - }, - }), - ), - ); + assert.strictEqual(status.status, "ready"); + assert.strictEqual(observedLaunchArgs, "--strict-config --enable foo"); + }), + ); - assert.strictEqual(status.status, "error"); - assert.strictEqual(status.auth.status, "unauthenticated"); - assert.strictEqual( - status.message, - "Codex CLI is not authenticated. Run `codex login` and try again.", - ); - }), - ); + it.effect("returns unauthenticated when app-server requires OpenAI auth", () => + Effect.gen(function* () { + const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => + Effect.succeed( + makeCodexProbeSnapshot({ + account: { + account: null, + requiresOpenaiAuth: true, + }, + }), + ), + ); - it.effect( - "returns ready with unknown auth when app-server does not require OpenAI auth", - () => - Effect.gen(function* () { - const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => - Effect.succeed( - makeCodexProbeSnapshot({ - account: { - account: null, - requiresOpenaiAuth: false, - }, - }), - ), - ); + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.auth.status, "unauthenticated"); + assert.strictEqual( + status.message, + "Codex CLI is not authenticated. Run `codex login` and try again.", + ); + }), + ); - assert.strictEqual(status.status, "ready"); - assert.strictEqual(status.auth.status, "unknown"); - }), - ); + it.effect("returns ready with unknown auth when app-server does not require OpenAI auth", () => + Effect.gen(function* () { + const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => + Effect.succeed( + makeCodexProbeSnapshot({ + account: { + account: null, + requiresOpenaiAuth: false, + }, + }), + ), + ); - it.effect("returns an api key label for codex api key auth", () => - Effect.gen(function* () { - const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => - Effect.succeed( - makeCodexProbeSnapshot({ - account: { - account: { type: "apiKey" }, - requiresOpenaiAuth: false, - }, - }), - ), - ); + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.auth.status, "unknown"); + }), + ); - assert.strictEqual(status.status, "ready"); - assert.strictEqual(status.auth.status, "authenticated"); - assert.strictEqual(status.auth.type, "apiKey"); - assert.strictEqual(status.auth.label, "OpenAI API Key"); - }), - ); + it.effect("returns an api key label for codex api key auth", () => + Effect.gen(function* () { + const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => + Effect.succeed( + makeCodexProbeSnapshot({ + account: { + account: { type: "apiKey" }, + requiresOpenaiAuth: false, + }, + }), + ), + ); - it.effect("returns an Amazon Bedrock label for codex Bedrock auth", () => - Effect.gen(function* () { - const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => - Effect.succeed( - makeCodexProbeSnapshot({ - account: { - account: { type: "amazonBedrock" }, - requiresOpenaiAuth: false, - }, - }), - ), - ); + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.auth.status, "authenticated"); + assert.strictEqual(status.auth.type, "apiKey"); + assert.strictEqual(status.auth.label, "OpenAI API Key"); + }), + ); - assert.strictEqual(status.status, "ready"); - assert.strictEqual(status.auth.status, "authenticated"); - assert.strictEqual(status.auth.type, "amazonBedrock"); - assert.strictEqual(status.auth.label, "Amazon Bedrock"); - }), - ); + it.effect("returns an Amazon Bedrock label for codex Bedrock auth", () => + Effect.gen(function* () { + const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => + Effect.succeed( + makeCodexProbeSnapshot({ + account: { + account: { type: "amazonBedrock" }, + requiresOpenaiAuth: false, + }, + }), + ), + ); - it.effect("returns unavailable when codex is missing", () => - Effect.gen(function* () { - const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => - Effect.fail( - new CodexErrors.CodexAppServerSpawnError({ - command: "codex app-server", - cause: new Error("spawn codex ENOENT"), - }), - ), - ); - assert.strictEqual(status.status, "error"); - assert.strictEqual(status.installed, false); - assert.strictEqual(status.auth.status, "unknown"); - assert.strictEqual( - status.message, - "Codex CLI (`codex`) is not installed or not on PATH.", - ); - }), - ); + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.auth.status, "authenticated"); + assert.strictEqual(status.auth.type, "amazonBedrock"); + assert.strictEqual(status.auth.label, "Amazon Bedrock"); + }), + ); - it.effect("closes the app-server probe scope when provider status times out", () => - Effect.gen(function* () { - const killCalls = yield* Ref.make(0); - const statusFiber = yield* checkCodexProviderStatus(defaultCodexSettings).pipe( - Effect.provide(hangingScopedSpawnerLayer(killCalls)), - Effect.forkChild, - ); + it.effect("returns unavailable when codex is missing", () => + Effect.gen(function* () { + const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => + Effect.fail( + new CodexErrors.CodexAppServerSpawnError({ + command: "codex app-server", + cause: new Error("spawn codex ENOENT"), + }), + ), + ); + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.installed, false); + assert.strictEqual(status.auth.status, "unknown"); + assert.strictEqual(status.message, "Codex CLI (`codex`) is not installed or not on PATH."); + }), + ); - yield* Effect.yieldNow; - yield* TestClock.adjust("11 seconds"); - yield* Effect.yieldNow; + it.effect("closes the app-server probe scope when provider status times out", () => + Effect.gen(function* () { + const killCalls = yield* Ref.make(0); + const statusFiber = yield* checkCodexProviderStatus(defaultCodexSettings).pipe( + Effect.provide(hangingScopedSpawnerLayer(killCalls)), + Effect.forkChild, + ); - const status = yield* Fiber.join(statusFiber); - assert.strictEqual(status.status, "error"); - assert.strictEqual( - status.message, - "Timed out while checking Codex app-server provider status.", - ); - assert.strictEqual(yield* Ref.get(killCalls), 1); - }), - ); + yield* Effect.yieldNow; + yield* TestClock.adjust("11 seconds"); + yield* Effect.yieldNow; + + const status = yield* Fiber.join(statusFiber); + assert.strictEqual(status.status, "error"); + assert.strictEqual( + status.message, + "Timed out while checking Codex app-server provider status.", + ); + assert.strictEqual(yield* Ref.get(killCalls), 1); + }), + ); + }); + + describe("ProviderRegistryLive", () => { + it("treats equal provider snapshots as unchanged", () => { + const providers = [ + { + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-03-25T00:00:00.000Z", + version: "1.0.0", + models: [], + slashCommands: [], + skills: [], + }, + { + instanceId: ProviderInstanceId.make("claudeAgent"), + driver: ProviderDriverKind.make("claudeAgent"), + status: "warning", + enabled: true, + installed: true, + auth: { status: "unknown" }, + checkedAt: "2026-03-25T00:00:00.000Z", + version: "1.0.0", + models: [], + slashCommands: [], + skills: [], + }, + ] as const satisfies ReadonlyArray; + + assert.strictEqual(haveProvidersChanged(providers, [...providers]), false); }); - describe("ProviderRegistryLive", () => { - it("treats equal provider snapshots as unchanged", () => { - const providers = [ + it("preserves previously discovered provider models when a refresh returns none", () => { + const previousProvider = { + instanceId: ProviderInstanceId.make("cursor"), + driver: ProviderDriverKind.make("cursor"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-04-14T00:00:00.000Z", + version: "2026.04.09-f2b0fcd", + models: [ { - instanceId: ProviderInstanceId.make("codex"), - driver: ProviderDriverKind.make("codex"), - status: "ready", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - checkedAt: "2026-03-25T00:00:00.000Z", - version: "1.0.0", - models: [], - slashCommands: [], - skills: [], + slug: "claude-opus-4-6", + name: "Opus 4.6", + isCustom: false, + capabilities: createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("reasoning", "Reasoning", [ + { id: "high", label: "High", isDefault: true }, + ]), + booleanDescriptor("fastMode", "Fast Mode"), + booleanDescriptor("thinking", "Thinking"), + ], + }), }, + ], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const refreshedProvider = { + ...previousProvider, + checkedAt: "2026-04-14T00:01:00.000Z", + models: [], + } satisfies ServerProvider; + + assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, refreshedProvider).models, [ + ...previousProvider.models, + ]); + }); + + it("drops stale OpenCode models missing from a successful refresh", () => { + const previousProvider = { + instanceId: ProviderInstanceId.make("opencode"), + driver: ProviderDriverKind.make("opencode"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-07-17T00:00:00.000Z", + version: "1.0.0", + models: [ { - instanceId: ProviderInstanceId.make("claudeAgent"), - driver: ProviderDriverKind.make("claudeAgent"), - status: "warning", - enabled: true, - installed: true, - auth: { status: "unknown" }, - checkedAt: "2026-03-25T00:00:00.000Z", - version: "1.0.0", - models: [], - slashCommands: [], - skills: [], + slug: "github/gpt-5", + name: "GPT-5", + subProvider: "GitHub", + isCustom: false, + capabilities: null, + }, + { + slug: "removed-plugin/model", + name: "Removed Plugin Model", + subProvider: "Removed Plugin", + isCustom: false, + capabilities: null, + }, + ], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const refreshedProvider = { + ...previousProvider, + checkedAt: "2026-07-17T00:01:00.000Z", + models: [ + { + slug: "github/gpt-5", + name: "GPT-5", + subProvider: "GitHub", + isCustom: false, + capabilities: null, }, - ] as const satisfies ReadonlyArray; + ], + } satisfies ServerProvider; - assert.strictEqual(haveProvidersChanged(providers, [...providers]), false); - }); + assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, refreshedProvider).models, [ + ...refreshedProvider.models, + ]); + }); + + it("retains stale OpenCode models when a refresh fails", () => { + const previousProvider = { + instanceId: ProviderInstanceId.make("opencode"), + driver: ProviderDriverKind.make("opencode"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-07-17T00:00:00.000Z", + version: "1.0.0", + models: [ + { + slug: "github/gpt-5", + name: "GPT-5", + subProvider: "GitHub", + isCustom: false, + capabilities: null, + }, + ], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const refreshedProvider = { + ...previousProvider, + status: "error", + auth: { status: "unknown" }, + checkedAt: "2026-07-17T00:01:00.000Z", + models: [], + message: "Failed to refresh OpenCode models.", + } satisfies ServerProvider; + + assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, refreshedProvider).models, [ + ...previousProvider.models, + ]); + }); + + it("classifies pending, logout, uninstall, and reconnect OpenCode inventories", () => { + const previousProvider = { + instanceId: ProviderInstanceId.make("opencode"), + driver: ProviderDriverKind.make("opencode"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-07-17T00:00:00.000Z", + version: "1.0.0", + models: [ + { + slug: "github/gpt-5", + name: "GPT-5", + subProvider: "GitHub", + isCustom: false, + capabilities: null, + }, + { + slug: "removed-plugin/model", + name: "Removed Plugin Model", + subProvider: "Removed Plugin", + isCustom: false, + capabilities: null, + }, + ], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const pendingProvider = { + ...previousProvider, + status: "warning", + installed: false, + auth: { status: "unknown" }, + checkedAt: "2026-07-17T00:01:00.000Z", + version: null, + models: [], + message: "OpenCode provider status has not been checked in this session yet.", + } satisfies ServerProvider; + const loggedOutProvider = { + ...previousProvider, + status: "warning", + auth: { status: "unknown" }, + checkedAt: "2026-07-17T00:02:00.000Z", + models: [], + message: "OpenCode is available, but it did not report any connected upstream providers.", + } satisfies ServerProvider; + const missingProvider = { + ...previousProvider, + status: "error", + installed: false, + auth: { status: "unknown" }, + checkedAt: "2026-07-17T00:03:00.000Z", + version: null, + models: [], + message: "OpenCode CLI (`opencode`) is not installed or not on PATH.", + } satisfies ServerProvider; + const authoritativeProvider = { + ...previousProvider, + checkedAt: "2026-07-17T00:04:00.000Z", + models: [previousProvider.models[0]!], + } satisfies ServerProvider; + const failedProvider = { + ...authoritativeProvider, + status: "error", + auth: { status: "unknown" }, + checkedAt: "2026-07-17T00:05:00.000Z", + models: [], + message: "Failed to refresh OpenCode models.", + } satisfies ServerProvider; + + assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, pendingProvider).models, [ + ...previousProvider.models, + ]); + assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, loggedOutProvider).models, []); + assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, missingProvider).models, []); + + const afterRemoval = mergeProviderSnapshot(previousProvider, authoritativeProvider); + const afterFailure = mergeProviderSnapshot(afterRemoval, failedProvider); + + assert.deepStrictEqual(afterFailure.models, [authoritativeProvider.models[0]!]); + }); + + it("fills missing capabilities from the previous provider snapshot", () => { + const previousProvider = { + instanceId: ProviderInstanceId.make("cursor"), + driver: ProviderDriverKind.make("cursor"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-04-14T00:00:00.000Z", + version: "2026.04.09-f2b0fcd", + models: [ + { + slug: "claude-opus-4-6", + name: "Opus 4.6", + isCustom: false, + capabilities: createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("reasoning", "Reasoning", [ + { id: "high", label: "High", isDefault: true }, + ]), + booleanDescriptor("fastMode", "Fast Mode"), + booleanDescriptor("thinking", "Thinking"), + ], + }), + }, + ], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const refreshedProvider = { + ...previousProvider, + checkedAt: "2026-04-14T00:01:00.000Z", + models: [ + { + slug: "claude-opus-4-6", + name: "Opus 4.6", + isCustom: false, + capabilities: createModelCapabilities({ + optionDescriptors: [], + }), + }, + ], + } satisfies ServerProvider; + + assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, refreshedProvider).models, [ + ...previousProvider.models, + ]); + }); + + it.effect("does not run provider probes during layer construction", () => + Effect.gen(function* () { + const codexDriver = ProviderDriverKind.make("codex"); + const codexInstanceId = ProviderInstanceId.make("codex"); + const initialProvider = { + instanceId: codexInstanceId, + driver: codexDriver, + status: "warning", + enabled: true, + installed: false, + auth: { status: "unknown" }, + checkedAt: "2026-06-10T00:00:00.000Z", + version: null, + message: "Checking Codex provider status.", + models: [], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const refreshCalls = yield* Ref.make(0); + const instance = { + instanceId: codexInstanceId, + driverKind: codexDriver, + continuationIdentity: { + driverKind: codexDriver, + continuationKey: "codex:instance:codex", + }, + displayName: undefined, + enabled: true, + snapshot: { + maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({ + provider: codexDriver, + packageName: null, + }), + getSnapshot: Effect.succeed(initialProvider), + refresh: Ref.update(refreshCalls, (count) => count + 1).pipe( + Effect.andThen(Effect.never), + ), + streamChanges: Stream.empty, + }, + adapter: {} as ProviderInstance["adapter"], + textGeneration: {} as ProviderInstance["textGeneration"], + } satisfies ProviderInstance; + const instanceRegistryLayer = Layer.succeed( + ProviderInstanceRegistry.ProviderInstanceRegistry, + { + getInstance: (instanceId) => + Effect.succeed(instanceId === codexInstanceId ? instance : undefined), + listInstances: Effect.succeed([instance]), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.empty, + subscribeChanges: Effect.flatMap(PubSub.unbounded(), PubSub.subscribe), + }, + ); + const scope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); + const runtimeServices = yield* Layer.build( + ProviderRegistryLive.pipe( + Layer.provideMerge(instanceRegistryLayer), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-provider-registry-background-refresh-", + }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ).pipe(Scope.provide(scope)); + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry.ProviderRegistry; + assert.deepStrictEqual(yield* registry.getProviders, [initialProvider]); + assert.strictEqual(yield* Ref.get(refreshCalls), 0); + }).pipe(Effect.provide(runtimeServices)); + }), + ); - it("preserves previously discovered provider models when a refresh returns none", () => { - const previousProvider = { + it("persists merged provider snapshots for the providers that were refreshed", () => { + const previousProviders = [ + { instanceId: ProviderInstanceId.make("cursor"), driver: ProviderDriverKind.make("cursor"), status: "ready", @@ -551,427 +883,221 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ], slashCommands: [], skills: [], - } as const satisfies ServerProvider; - const refreshedProvider = { - ...previousProvider, - checkedAt: "2026-04-14T00:01:00.000Z", - models: [], - } satisfies ServerProvider; - - assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, refreshedProvider).models, [ - ...previousProvider.models, - ]); - }); - - it("drops stale OpenCode models missing from a successful refresh", () => { - const previousProvider = { - instanceId: ProviderInstanceId.make("opencode"), - driver: ProviderDriverKind.make("opencode"), + }, + { + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), status: "ready", enabled: true, installed: true, auth: { status: "authenticated" }, - checkedAt: "2026-07-17T00:00:00.000Z", + checkedAt: "2026-04-14T00:00:00.000Z", version: "1.0.0", - models: [ - { - slug: "github/gpt-5", - name: "GPT-5", - subProvider: "GitHub", - isCustom: false, - capabilities: null, - }, - { - slug: "removed-plugin/model", - name: "Removed Plugin Model", - subProvider: "Removed Plugin", - isCustom: false, - capabilities: null, - }, - ], + models: [], slashCommands: [], skills: [], - } as const satisfies ServerProvider; - const refreshedProvider = { - ...previousProvider, - checkedAt: "2026-07-17T00:01:00.000Z", - models: [ - { - slug: "github/gpt-5", - name: "GPT-5", - subProvider: "GitHub", - isCustom: false, - capabilities: null, - }, - ], - } satisfies ServerProvider; + }, + ] as const satisfies ReadonlyArray; + const refreshedCursor = { + ...previousProviders[0], + checkedAt: "2026-04-14T00:01:00.000Z", + models: [], + } satisfies ServerProvider; + + const mergedProviders = mergeProviderSnapshots(previousProviders, [refreshedCursor]); + const persistedProviders = selectProvidersByKind( + mergedProviders, + new Set([ProviderDriverKind.make("cursor")]), + ); - assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, refreshedProvider).models, [ - ...refreshedProvider.models, - ]); - }); + assert.deepStrictEqual(persistedProviders, [ + { + ...refreshedCursor, + models: [...previousProviders[0].models], + }, + ]); + }); - it("retains stale OpenCode models when a refresh fails", () => { - const previousProvider = { - instanceId: ProviderInstanceId.make("opencode"), - driver: ProviderDriverKind.make("opencode"), + it.effect("persists the merged snapshot when a live update has empty models", () => + Effect.gen(function* () { + const cursorDriver = ProviderDriverKind.make("cursor"); + const cursorInstanceId = ProviderInstanceId.make("cursor"); + const initialProvider = { + instanceId: cursorInstanceId, + driver: cursorDriver, status: "ready", enabled: true, installed: true, auth: { status: "authenticated" }, - checkedAt: "2026-07-17T00:00:00.000Z", - version: "1.0.0", + checkedAt: "2026-04-14T00:00:00.000Z", + version: "2026.04.09-f2b0fcd", models: [ { - slug: "github/gpt-5", - name: "GPT-5", - subProvider: "GitHub", + slug: "claude-opus-4-6", + name: "Opus 4.6", isCustom: false, - capabilities: null, + capabilities: createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("reasoning", "Reasoning", [ + { id: "high", label: "High", isDefault: true }, + ]), + ], + }), }, ], slashCommands: [], skills: [], } as const satisfies ServerProvider; const refreshedProvider = { - ...previousProvider, - status: "error", - auth: { status: "unknown" }, - checkedAt: "2026-07-17T00:01:00.000Z", + ...initialProvider, + checkedAt: "2026-04-14T00:01:00.000Z", models: [], - message: "Failed to refresh OpenCode models.", } satisfies ServerProvider; - - assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, refreshedProvider).models, [ - ...previousProvider.models, - ]); - }); - - it("classifies pending, logout, uninstall, and reconnect OpenCode inventories", () => { - const previousProvider = { - instanceId: ProviderInstanceId.make("opencode"), - driver: ProviderDriverKind.make("opencode"), - status: "ready", + const changes = yield* PubSub.unbounded(); + const instance = { + instanceId: cursorInstanceId, + driverKind: cursorDriver, + continuationIdentity: { + driverKind: cursorDriver, + continuationKey: "cursor:instance:cursor", + }, + displayName: undefined, enabled: true, - installed: true, - auth: { status: "authenticated" }, - checkedAt: "2026-07-17T00:00:00.000Z", - version: "1.0.0", - models: [ - { - slug: "github/gpt-5", - name: "GPT-5", - subProvider: "GitHub", - isCustom: false, - capabilities: null, - }, - { - slug: "removed-plugin/model", - name: "Removed Plugin Model", - subProvider: "Removed Plugin", - isCustom: false, - capabilities: null, - }, - ], - slashCommands: [], - skills: [], - } as const satisfies ServerProvider; - const pendingProvider = { - ...previousProvider, - status: "warning", - installed: false, - auth: { status: "unknown" }, - checkedAt: "2026-07-17T00:01:00.000Z", - version: null, - models: [], - message: "OpenCode provider status has not been checked in this session yet.", - } satisfies ServerProvider; - const loggedOutProvider = { - ...previousProvider, - status: "warning", - auth: { status: "unknown" }, - checkedAt: "2026-07-17T00:02:00.000Z", - models: [], - message: "OpenCode is available, but it did not report any connected upstream providers.", - } satisfies ServerProvider; - const missingProvider = { - ...previousProvider, - status: "error", - installed: false, - auth: { status: "unknown" }, - checkedAt: "2026-07-17T00:03:00.000Z", - version: null, - models: [], - message: "OpenCode CLI (`opencode`) is not installed or not on PATH.", - } satisfies ServerProvider; - const authoritativeProvider = { - ...previousProvider, - checkedAt: "2026-07-17T00:04:00.000Z", - models: [previousProvider.models[0]!], - } satisfies ServerProvider; - const failedProvider = { - ...authoritativeProvider, - status: "error", - auth: { status: "unknown" }, - checkedAt: "2026-07-17T00:05:00.000Z", - models: [], - message: "Failed to refresh OpenCode models.", - } satisfies ServerProvider; - - assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, pendingProvider).models, [ - ...previousProvider.models, - ]); - assert.deepStrictEqual( - mergeProviderSnapshot(previousProvider, loggedOutProvider).models, - [], + snapshot: { + maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({ + provider: cursorDriver, + packageName: null, + }), + getSnapshot: Effect.succeed(initialProvider), + refresh: Effect.succeed(refreshedProvider), + streamChanges: Stream.fromPubSub(changes), + }, + adapter: {} as ProviderInstance["adapter"], + textGeneration: {} as ProviderInstance["textGeneration"], + } satisfies ProviderInstance; + const instanceRegistryLayer = Layer.succeed( + ProviderInstanceRegistry.ProviderInstanceRegistry, + { + getInstance: (instanceId) => + Effect.succeed(instanceId === cursorInstanceId ? instance : undefined), + listInstances: Effect.succeed([instance]), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.empty, + subscribeChanges: Effect.flatMap(PubSub.unbounded(), (pubsub) => + PubSub.subscribe(pubsub), + ), + }, ); - assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, missingProvider).models, []); - - const afterRemoval = mergeProviderSnapshot(previousProvider, authoritativeProvider); - const afterFailure = mergeProviderSnapshot(afterRemoval, failedProvider); - - assert.deepStrictEqual(afterFailure.models, [authoritativeProvider.models[0]!]); - }); - - it("fills missing capabilities from the previous provider snapshot", () => { - const previousProvider = { - instanceId: ProviderInstanceId.make("cursor"), - driver: ProviderDriverKind.make("cursor"), - status: "ready", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - checkedAt: "2026-04-14T00:00:00.000Z", - version: "2026.04.09-f2b0fcd", - models: [ - { - slug: "claude-opus-4-6", - name: "Opus 4.6", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [ - selectDescriptor("reasoning", "Reasoning", [ - { id: "high", label: "High", isDefault: true }, - ]), - booleanDescriptor("fastMode", "Fast Mode"), - booleanDescriptor("thinking", "Thinking"), - ], - }), - }, - ], - slashCommands: [], - skills: [], - } as const satisfies ServerProvider; - const refreshedProvider = { - ...previousProvider, - checkedAt: "2026-04-14T00:01:00.000Z", - models: [ - { - slug: "claude-opus-4-6", - name: "Opus 4.6", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [], + const scope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); + const runtimeServices = yield* Layer.build( + ProviderRegistryLive.pipe( + Layer.provideMerge(instanceRegistryLayer), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-provider-registry-merged-persist-", }), - }, - ], - } satisfies ServerProvider; + ), + Layer.provideMerge(NodeServices.layer), + ), + ).pipe(Scope.provide(scope)); - assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, refreshedProvider).models, [ - ...previousProvider.models, - ]); - }); + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry.ProviderRegistry; + const config = yield* ServerConfig.ServerConfig; + const filePath = yield* resolveProviderStatusCachePath({ + cacheDir: config.providerStatusCacheDir, + instanceId: cursorInstanceId, + }); + + assert.deepStrictEqual((yield* registry.getProviders)[0]?.models, [ + ...initialProvider.models, + ]); + yield* PubSub.publish(changes, refreshedProvider); + + let cachedProvider = yield* readProviderStatusCache(filePath); + for ( + let attempt = 0; + attempt < 50 && cachedProvider?.checkedAt !== refreshedProvider.checkedAt; + attempt += 1 + ) { + yield* TestClock.adjust("10 millis"); + yield* Effect.yieldNow; + cachedProvider = yield* readProviderStatusCache(filePath); + } + + assert.deepStrictEqual(cachedProvider, { + ...refreshedProvider, + models: [...initialProvider.models], + }); + }).pipe(Effect.provide(runtimeServices)); + }), + ); - it.effect("does not run provider probes during layer construction", () => + it.effect( + "persists authoritative OpenCode removals without resurrecting them on a failed live refresh", + () => Effect.gen(function* () { - const codexDriver = ProviderDriverKind.make("codex"); - const codexInstanceId = ProviderInstanceId.make("codex"); + const openCodeDriver = ProviderDriverKind.make("opencode"); + const openCodeInstanceId = ProviderInstanceId.make("opencode"); const initialProvider = { - instanceId: codexInstanceId, - driver: codexDriver, - status: "warning", - enabled: true, - installed: false, - auth: { status: "unknown" }, - checkedAt: "2026-06-10T00:00:00.000Z", - version: null, - message: "Checking Codex provider status.", - models: [], - slashCommands: [], - skills: [], - } as const satisfies ServerProvider; - const refreshCalls = yield* Ref.make(0); - const instance = { - instanceId: codexInstanceId, - driverKind: codexDriver, - continuationIdentity: { - driverKind: codexDriver, - continuationKey: "codex:instance:codex", - }, - displayName: undefined, - enabled: true, - snapshot: { - maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({ - provider: codexDriver, - packageName: null, - }), - getSnapshot: Effect.succeed(initialProvider), - refresh: Ref.update(refreshCalls, (count) => count + 1).pipe( - Effect.andThen(Effect.never), - ), - streamChanges: Stream.empty, - }, - adapter: {} as ProviderInstance["adapter"], - textGeneration: {} as ProviderInstance["textGeneration"], - } satisfies ProviderInstance; - const instanceRegistryLayer = Layer.succeed( - ProviderInstanceRegistry.ProviderInstanceRegistry, - { - getInstance: (instanceId) => - Effect.succeed(instanceId === codexInstanceId ? instance : undefined), - listInstances: Effect.succeed([instance]), - listUnavailable: Effect.succeed([]), - streamChanges: Stream.empty, - subscribeChanges: Effect.flatMap(PubSub.unbounded(), PubSub.subscribe), - }, - ); - const scope = yield* Scope.make(); - yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); - const runtimeServices = yield* Layer.build( - ProviderRegistryLive.pipe( - Layer.provideMerge(instanceRegistryLayer), - Layer.provideMerge( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3-provider-registry-background-refresh-", - }), - ), - Layer.provideMerge(NodeServices.layer), - ), - ).pipe(Scope.provide(scope)); - yield* Effect.gen(function* () { - const registry = yield* ProviderRegistry.ProviderRegistry; - assert.deepStrictEqual(yield* registry.getProviders, [initialProvider]); - assert.strictEqual(yield* Ref.get(refreshCalls), 0); - }).pipe(Effect.provide(runtimeServices)); - }), - ); - - it("persists merged provider snapshots for the providers that were refreshed", () => { - const previousProviders = [ - { - instanceId: ProviderInstanceId.make("cursor"), - driver: ProviderDriverKind.make("cursor"), + instanceId: openCodeInstanceId, + driver: openCodeDriver, status: "ready", enabled: true, installed: true, auth: { status: "authenticated" }, - checkedAt: "2026-04-14T00:00:00.000Z", - version: "2026.04.09-f2b0fcd", + checkedAt: "2026-07-17T00:00:00.000Z", + version: "1.0.0", models: [ { - slug: "claude-opus-4-6", - name: "Opus 4.6", + slug: "github/gpt-5", + name: "GPT-5", + subProvider: "GitHub", isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [ - selectDescriptor("reasoning", "Reasoning", [ - { id: "high", label: "High", isDefault: true }, - ]), - booleanDescriptor("fastMode", "Fast Mode"), - booleanDescriptor("thinking", "Thinking"), - ], - }), + capabilities: null, }, - ], - slashCommands: [], - skills: [], - }, - { - instanceId: ProviderInstanceId.make("codex"), - driver: ProviderDriverKind.make("codex"), - status: "ready", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - checkedAt: "2026-04-14T00:00:00.000Z", - version: "1.0.0", - models: [], - slashCommands: [], - skills: [], - }, - ] as const satisfies ReadonlyArray; - const refreshedCursor = { - ...previousProviders[0], - checkedAt: "2026-04-14T00:01:00.000Z", - models: [], - } satisfies ServerProvider; - - const mergedProviders = mergeProviderSnapshots(previousProviders, [refreshedCursor]); - const persistedProviders = selectProvidersByKind( - mergedProviders, - new Set([ProviderDriverKind.make("cursor")]), - ); - - assert.deepStrictEqual(persistedProviders, [ - { - ...refreshedCursor, - models: [...previousProviders[0].models], - }, - ]); - }); - - it.effect("persists the merged snapshot when a live update has empty models", () => - Effect.gen(function* () { - const cursorDriver = ProviderDriverKind.make("cursor"); - const cursorInstanceId = ProviderInstanceId.make("cursor"); - const initialProvider = { - instanceId: cursorInstanceId, - driver: cursorDriver, - status: "ready", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - checkedAt: "2026-04-14T00:00:00.000Z", - version: "2026.04.09-f2b0fcd", - models: [ { - slug: "claude-opus-4-6", - name: "Opus 4.6", + slug: "removed-plugin/model", + name: "Removed Plugin Model", + subProvider: "Removed Plugin", isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [ - selectDescriptor("reasoning", "Reasoning", [ - { id: "high", label: "High", isDefault: true }, - ]), - ], - }), + capabilities: null, }, ], slashCommands: [], skills: [], } as const satisfies ServerProvider; - const refreshedProvider = { + const authoritativeProvider = { ...initialProvider, - checkedAt: "2026-04-14T00:01:00.000Z", + checkedAt: "2026-07-17T00:01:00.000Z", + models: [initialProvider.models[0]!], + } satisfies ServerProvider; + const failedProvider = { + ...authoritativeProvider, + status: "error", + auth: { status: "unknown" }, + checkedAt: "2026-07-17T00:02:00.000Z", models: [], + message: "Failed to refresh OpenCode models.", } satisfies ServerProvider; const changes = yield* PubSub.unbounded(); const instance = { - instanceId: cursorInstanceId, - driverKind: cursorDriver, + instanceId: openCodeInstanceId, + driverKind: openCodeDriver, continuationIdentity: { - driverKind: cursorDriver, - continuationKey: "cursor:instance:cursor", + driverKind: openCodeDriver, + continuationKey: "opencode:instance:opencode", }, displayName: undefined, enabled: true, snapshot: { maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({ - provider: cursorDriver, + provider: openCodeDriver, packageName: null, }), getSnapshot: Effect.succeed(initialProvider), - refresh: Effect.succeed(refreshedProvider), + refresh: Effect.succeed(authoritativeProvider), streamChanges: Stream.fromPubSub(changes), }, adapter: {} as ProviderInstance["adapter"], @@ -981,7 +1107,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ProviderInstanceRegistry.ProviderInstanceRegistry, { getInstance: (instanceId) => - Effect.succeed(instanceId === cursorInstanceId ? instance : undefined), + Effect.succeed(instanceId === openCodeInstanceId ? instance : undefined), listInstances: Effect.succeed([instance]), listUnavailable: Effect.succeed([]), streamChanges: Stream.empty, @@ -997,7 +1123,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te Layer.provideMerge(instanceRegistryLayer), Layer.provideMerge( ServerConfig.layerTest(process.cwd(), { - prefix: "t3-provider-registry-merged-persist-", + prefix: "t3-provider-registry-opencode-authoritative-persist-", }), ), Layer.provideMerge(NodeServices.layer), @@ -1009,18 +1135,15 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te const config = yield* ServerConfig.ServerConfig; const filePath = yield* resolveProviderStatusCachePath({ cacheDir: config.providerStatusCacheDir, - instanceId: cursorInstanceId, + instanceId: openCodeInstanceId, }); - assert.deepStrictEqual((yield* registry.getProviders)[0]?.models, [ - ...initialProvider.models, - ]); - yield* PubSub.publish(changes, refreshedProvider); + yield* PubSub.publish(changes, authoritativeProvider); let cachedProvider = yield* readProviderStatusCache(filePath); for ( let attempt = 0; - attempt < 50 && cachedProvider?.checkedAt !== refreshedProvider.checkedAt; + attempt < 50 && cachedProvider?.checkedAt !== authoritativeProvider.checkedAt; attempt += 1 ) { yield* TestClock.adjust("10 millis"); @@ -1028,1259 +1151,1125 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te cachedProvider = yield* readProviderStatusCache(filePath); } - assert.deepStrictEqual(cachedProvider, { - ...refreshedProvider, - models: [...initialProvider.models], - }); + assert.deepStrictEqual(cachedProvider?.models, [authoritativeProvider.models[0]!]); + + yield* PubSub.publish(changes, failedProvider); + for ( + let attempt = 0; + attempt < 50 && cachedProvider?.checkedAt !== failedProvider.checkedAt; + attempt += 1 + ) { + yield* TestClock.adjust("10 millis"); + yield* Effect.yieldNow; + cachedProvider = yield* readProviderStatusCache(filePath); + } + + assert.deepStrictEqual(cachedProvider?.models, [authoritativeProvider.models[0]!]); + assert.deepStrictEqual((yield* registry.getProviders)[0]?.models, [ + authoritativeProvider.models[0]!, + ]); }).pipe(Effect.provide(runtimeServices)); }), - ); + ); - it.effect( - "persists authoritative OpenCode removals without resurrecting them on a failed live refresh", - () => - Effect.gen(function* () { - const openCodeDriver = ProviderDriverKind.make("opencode"); - const openCodeInstanceId = ProviderInstanceId.make("opencode"); - const initialProvider = { - instanceId: openCodeInstanceId, - driver: openCodeDriver, - status: "ready", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - checkedAt: "2026-07-17T00:00:00.000Z", - version: "1.0.0", - models: [ - { - slug: "github/gpt-5", - name: "GPT-5", - subProvider: "GitHub", - isCustom: false, - capabilities: null, - }, - { - slug: "removed-plugin/model", - name: "Removed Plugin Model", - subProvider: "Removed Plugin", - isCustom: false, - capabilities: null, - }, - ], - slashCommands: [], - skills: [], - } as const satisfies ServerProvider; - const authoritativeProvider = { - ...initialProvider, - checkedAt: "2026-07-17T00:01:00.000Z", - models: [initialProvider.models[0]!], - } satisfies ServerProvider; - const failedProvider = { - ...authoritativeProvider, - status: "error", - auth: { status: "unknown" }, - checkedAt: "2026-07-17T00:02:00.000Z", - models: [], - message: "Failed to refresh OpenCode models.", - } satisfies ServerProvider; - const changes = yield* PubSub.unbounded(); - const instance = { - instanceId: openCodeInstanceId, - driverKind: openCodeDriver, - continuationIdentity: { - driverKind: openCodeDriver, - continuationKey: "opencode:instance:opencode", - }, - displayName: undefined, - enabled: true, - snapshot: { - maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({ - provider: openCodeDriver, - packageName: null, - }), - getSnapshot: Effect.succeed(initialProvider), - refresh: Effect.succeed(authoritativeProvider), - streamChanges: Stream.fromPubSub(changes), - }, - adapter: {} as ProviderInstance["adapter"], - textGeneration: {} as ProviderInstance["textGeneration"], - } satisfies ProviderInstance; - const instanceRegistryLayer = Layer.succeed( - ProviderInstanceRegistry.ProviderInstanceRegistry, - { - getInstance: (instanceId) => - Effect.succeed(instanceId === openCodeInstanceId ? instance : undefined), - listInstances: Effect.succeed([instance]), - listUnavailable: Effect.succeed([]), - streamChanges: Stream.empty, - subscribeChanges: Effect.flatMap(PubSub.unbounded(), (pubsub) => - PubSub.subscribe(pubsub), - ), - }, - ); - const scope = yield* Scope.make(); - yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); - const runtimeServices = yield* Layer.build( - ProviderRegistryLive.pipe( - Layer.provideMerge(instanceRegistryLayer), - Layer.provideMerge( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3-provider-registry-opencode-authoritative-persist-", - }), - ), - Layer.provideMerge(NodeServices.layer), - ), - ).pipe(Scope.provide(scope)); - - yield* Effect.gen(function* () { - const registry = yield* ProviderRegistry.ProviderRegistry; - const config = yield* ServerConfig.ServerConfig; - const filePath = yield* resolveProviderStatusCachePath({ - cacheDir: config.providerStatusCacheDir, - instanceId: openCodeInstanceId, - }); - - yield* PubSub.publish(changes, authoritativeProvider); - - let cachedProvider = yield* readProviderStatusCache(filePath); - for ( - let attempt = 0; - attempt < 50 && cachedProvider?.checkedAt !== authoritativeProvider.checkedAt; - attempt += 1 - ) { - yield* TestClock.adjust("10 millis"); - yield* Effect.yieldNow; - cachedProvider = yield* readProviderStatusCache(filePath); - } + it.effect("returns the cached provider list when a manual refresh fails", () => + Effect.gen(function* () { + const codexDriver = ProviderDriverKind.make("codex"); + const codexInstanceId = ProviderInstanceId.make("codex"); + const cachedProvider = { + instanceId: codexInstanceId, + driver: codexDriver, + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-04-29T10:00:00.000Z", + version: "1.0.0", + models: [], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const instance = { + instanceId: codexInstanceId, + driverKind: codexDriver, + continuationIdentity: { + driverKind: codexDriver, + continuationKey: "codex:instance:codex", + }, + displayName: undefined, + enabled: true, + snapshot: { + maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({ + provider: codexDriver, + packageName: null, + }), + getSnapshot: Effect.succeed(cachedProvider), + refresh: Effect.die(new Error("simulated refresh failure")), + streamChanges: Stream.empty, + }, + adapter: {} as ProviderInstance["adapter"], + textGeneration: {} as ProviderInstance["textGeneration"], + } satisfies ProviderInstance; + const instanceRegistryLayer = Layer.succeed( + ProviderInstanceRegistry.ProviderInstanceRegistry, + { + getInstance: (instanceId) => + Effect.succeed(instanceId === codexInstanceId ? instance : undefined), + listInstances: Effect.succeed([instance]), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.empty, + subscribeChanges: Effect.flatMap(PubSub.unbounded(), (pubsub) => + PubSub.subscribe(pubsub), + ), + }, + ); + const scope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); + const runtimeServices = yield* Layer.build( + ProviderRegistryLive.pipe( + Layer.provideMerge(instanceRegistryLayer), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-provider-registry-refresh-failure-", + }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ).pipe(Scope.provide(scope)); - assert.deepStrictEqual(cachedProvider?.models, [authoritativeProvider.models[0]!]); + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry.ProviderRegistry; - yield* PubSub.publish(changes, failedProvider); - for ( - let attempt = 0; - attempt < 50 && cachedProvider?.checkedAt !== failedProvider.checkedAt; - attempt += 1 - ) { - yield* TestClock.adjust("10 millis"); - yield* Effect.yieldNow; - cachedProvider = yield* readProviderStatusCache(filePath); - } - - assert.deepStrictEqual(cachedProvider?.models, [authoritativeProvider.models[0]!]); - assert.deepStrictEqual((yield* registry.getProviders)[0]?.models, [ - authoritativeProvider.models[0]!, - ]); - }).pipe(Effect.provide(runtimeServices)); - }), - ); - - it.effect("returns the cached provider list when a manual refresh fails", () => - Effect.gen(function* () { - const codexDriver = ProviderDriverKind.make("codex"); - const codexInstanceId = ProviderInstanceId.make("codex"); - const cachedProvider = { - instanceId: codexInstanceId, - driver: codexDriver, - status: "ready", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - checkedAt: "2026-04-29T10:00:00.000Z", - version: "1.0.0", - models: [], - slashCommands: [], - skills: [], - } as const satisfies ServerProvider; - const instance = { - instanceId: codexInstanceId, - driverKind: codexDriver, - continuationIdentity: { - driverKind: codexDriver, - continuationKey: "codex:instance:codex", - }, - displayName: undefined, - enabled: true, - snapshot: { - maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({ - provider: codexDriver, - packageName: null, - }), - getSnapshot: Effect.succeed(cachedProvider), - refresh: Effect.die(new Error("simulated refresh failure")), - streamChanges: Stream.empty, - }, - adapter: {} as ProviderInstance["adapter"], - textGeneration: {} as ProviderInstance["textGeneration"], - } satisfies ProviderInstance; - const instanceRegistryLayer = Layer.succeed( - ProviderInstanceRegistry.ProviderInstanceRegistry, - { - getInstance: (instanceId) => - Effect.succeed(instanceId === codexInstanceId ? instance : undefined), - listInstances: Effect.succeed([instance]), - listUnavailable: Effect.succeed([]), - streamChanges: Stream.empty, - subscribeChanges: Effect.flatMap(PubSub.unbounded(), (pubsub) => - PubSub.subscribe(pubsub), - ), - }, - ); - const scope = yield* Scope.make(); - yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); - const runtimeServices = yield* Layer.build( - ProviderRegistryLive.pipe( - Layer.provideMerge(instanceRegistryLayer), - Layer.provideMerge( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3-provider-registry-refresh-failure-", - }), - ), - Layer.provideMerge(NodeServices.layer), - ), - ).pipe(Scope.provide(scope)); - - yield* Effect.gen(function* () { - const registry = yield* ProviderRegistry.ProviderRegistry; - - assert.deepStrictEqual(yield* registry.getProviders, [cachedProvider]); - assert.deepStrictEqual(yield* registry.refresh(codexDriver), [cachedProvider]); - assert.deepStrictEqual(yield* registry.refreshInstance(codexInstanceId), [ - cachedProvider, - ]); - }).pipe(Effect.provide(runtimeServices)); - }), - ); + assert.deepStrictEqual(yield* registry.getProviders, [cachedProvider]); + assert.deepStrictEqual(yield* registry.refresh(codexDriver), [cachedProvider]); + assert.deepStrictEqual(yield* registry.refreshInstance(codexInstanceId), [ + cachedProvider, + ]); + }).pipe(Effect.provide(runtimeServices)); + }), + ); - it.effect("keeps consuming registry changes after one sync fails", () => - Effect.gen(function* () { - const codexDriver = ProviderDriverKind.make("codex"); - const codexInstanceId = ProviderInstanceId.make("codex"); - const claudeDriver = ProviderDriverKind.make("claudeAgent"); - const claudeInstanceId = ProviderInstanceId.make("claudeAgent"); - const codexProvider = { - instanceId: codexInstanceId, - driver: codexDriver, - status: "ready", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - checkedAt: "2026-04-29T10:00:00.000Z", - version: "1.0.0", - models: [], - slashCommands: [], - skills: [], - } as const satisfies ServerProvider; - const claudeProvider = { - instanceId: claudeInstanceId, - driver: claudeDriver, - status: "ready", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - checkedAt: "2026-04-29T10:01:00.000Z", - version: "1.0.0", - models: [], - slashCommands: [], - skills: [], - } as const satisfies ServerProvider; - const makeInstance = (provider: ServerProvider): ProviderInstance => ({ - instanceId: provider.instanceId, + it.effect("keeps consuming registry changes after one sync fails", () => + Effect.gen(function* () { + const codexDriver = ProviderDriverKind.make("codex"); + const codexInstanceId = ProviderInstanceId.make("codex"); + const claudeDriver = ProviderDriverKind.make("claudeAgent"); + const claudeInstanceId = ProviderInstanceId.make("claudeAgent"); + const codexProvider = { + instanceId: codexInstanceId, + driver: codexDriver, + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-04-29T10:00:00.000Z", + version: "1.0.0", + models: [], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const claudeProvider = { + instanceId: claudeInstanceId, + driver: claudeDriver, + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-04-29T10:01:00.000Z", + version: "1.0.0", + models: [], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const makeInstance = (provider: ServerProvider): ProviderInstance => ({ + instanceId: provider.instanceId, + driverKind: provider.driver, + continuationIdentity: { driverKind: provider.driver, - continuationIdentity: { - driverKind: provider.driver, - continuationKey: `${provider.driver}:instance:${provider.instanceId}`, - }, - displayName: undefined, - enabled: true, - snapshot: { - maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({ - provider: provider.driver, - packageName: null, - }), - getSnapshot: Effect.succeed(provider), - refresh: Effect.succeed(provider), - streamChanges: Stream.empty, - }, - adapter: {} as ProviderInstance["adapter"], - textGeneration: {} as ProviderInstance["textGeneration"], - }); - const codexInstance = makeInstance(codexProvider); - const claudeInstance = makeInstance(claudeProvider); - const changes = yield* PubSub.unbounded(); - const instancesRef = yield* Ref.make>([codexInstance]); - const failNextList = yield* Ref.make(false); - const wait = () => Effect.yieldNow; - const instanceRegistryLayer = Layer.succeed( - ProviderInstanceRegistry.ProviderInstanceRegistry, - { - getInstance: (instanceId) => - Ref.get(instancesRef).pipe( - Effect.map((instances) => - instances.find((instance) => instance.instanceId === instanceId), - ), + continuationKey: `${provider.driver}:instance:${provider.instanceId}`, + }, + displayName: undefined, + enabled: true, + snapshot: { + maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({ + provider: provider.driver, + packageName: null, + }), + getSnapshot: Effect.succeed(provider), + refresh: Effect.succeed(provider), + streamChanges: Stream.empty, + }, + adapter: {} as ProviderInstance["adapter"], + textGeneration: {} as ProviderInstance["textGeneration"], + }); + const codexInstance = makeInstance(codexProvider); + const claudeInstance = makeInstance(claudeProvider); + const changes = yield* PubSub.unbounded(); + const instancesRef = yield* Ref.make>([codexInstance]); + const failNextList = yield* Ref.make(false); + const wait = () => Effect.yieldNow; + const instanceRegistryLayer = Layer.succeed( + ProviderInstanceRegistry.ProviderInstanceRegistry, + { + getInstance: (instanceId) => + Ref.get(instancesRef).pipe( + Effect.map((instances) => + instances.find((instance) => instance.instanceId === instanceId), ), - listInstances: Effect.gen(function* () { - const shouldFail = yield* Ref.get(failNextList); - if (shouldFail) { - yield* Ref.set(failNextList, false); - return yield* Effect.die(new Error("simulated registry list failure")); - } - return yield* Ref.get(instancesRef); - }), - listUnavailable: Effect.succeed([]), - streamChanges: Stream.fromPubSub(changes), - subscribeChanges: PubSub.subscribe(changes), - }, - ); - const scope = yield* Scope.make(); - yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); - const runtimeServices = yield* Layer.build( - ProviderRegistryLive.pipe( - Layer.provideMerge(instanceRegistryLayer), - Layer.provideMerge( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3-provider-registry-sync-failure-", - }), - ), - Layer.provideMerge(NodeServices.layer), - ), - ).pipe(Scope.provide(scope)); - - yield* Effect.gen(function* () { - const registry = yield* ProviderRegistry.ProviderRegistry; - assert.deepStrictEqual(yield* registry.getProviders, [codexProvider]); - - yield* Ref.set(failNextList, true); - yield* PubSub.publish(changes, undefined); - - yield* Ref.set(instancesRef, [codexInstance, claudeInstance]); - yield* PubSub.publish(changes, undefined); - - let providers = yield* registry.getProviders; - for ( - let attempt = 0; - attempt < 50 && - !providers.some((provider) => provider.instanceId === claudeInstanceId); - attempt += 1 - ) { - yield* wait(); - providers = yield* registry.getProviders; - } - - assert.deepStrictEqual( - providers.map((provider) => provider.instanceId).toSorted(), - [codexInstanceId, claudeInstanceId].toSorted(), - ); - }).pipe(Effect.provide(runtimeServices)); - }), - ); - - // This test intentionally avoids `mockCommandSpawnerLayer` so the real - // `probeCodexAppServerProvider` path runs — including the full - // `codex app-server` RPC handshake via `CodexClient.layerChildProcess`. - // We point `binaryPath` at a name that cannot exist on any machine so - // the real `ChildProcessSpawner` deterministically returns ENOENT; the - // probe wraps that as `CodexAppServerSpawnError` and - // `checkCodexProviderStatus` turns it into the user-visible "not - // installed" error snapshot. If the aggregator's `syncLiveSources` - // breaks — the `codex_personal`-never-probes bug we are guarding - // against — that snapshot never lands in `getProviders` and the - // assertions below fail. - it.effect("propagates real Codex probe failures to the aggregator at boot", () => - Effect.gen(function* () { - const missingBinary = `t3code_codex_missing_`; - const serverSettings = yield* makeMutableServerSettingsService( - decodeServerSettings( - deepMerge(encodedDefaultServerSettings, { - providers: { - // Disable every built-in probe that would otherwise spawn - // on the CI host. `enabled: false` short-circuits each - // driver's probe *before* it touches the spawner, so the - // test environment stays isolated from the dev - // machine's PATH. - codex: { enabled: false }, - claudeAgent: { enabled: false }, - cursor: { enabled: false }, - grok: { enabled: false }, - opencode: { enabled: false }, - }, - // `providerInstances` keys are branded `ProviderInstanceId`; - // the branded index signature rejects plain string literals - // at the TS level even though the runtime schema happily - // accepts + decodes them. Cast the patch to `unknown` so - // the `Schema.decodeSync` below does the real validation. - providerInstances: { - // Matches the shape the user had in `.t3/dev/settings.json` - // when the bug was reported: a custom enabled Codex instance - // pointing at a binary the server has to actually spawn. - codex_personal: { - driver: "codex", - displayName: "Codex Personal", - enabled: true, - config: { - binaryPath: missingBinary, - homePath: `/tmp/${missingBinary}_home`, - }, - }, - } as unknown as ContractServerSettings["providerInstances"], - }), - ), - ); - const scope = yield* Scope.make(); - yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); - const providerRegistryLayer = ProviderRegistryLive.pipe( - Layer.provideMerge(ProviderInstanceRegistryHydrationLive), - Layer.provideMerge( - Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings), - ), - Layer.provideMerge( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3-provider-registry-", - }), - ), - Layer.provideMerge(TestHttpClientLive), - Layer.provideMerge( - Layer.succeed( - ProviderEventLoggers.ProviderEventLoggers, - ProviderEventLoggers.NoOpProviderEventLoggers, - ), - ), - Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), - // NO spawner mock — `ChildProcessSpawner` is supplied by the - // outer `NodeServices.layer` on `it.layer(...)` and will - // genuinely spawn a subprocess. The missing-binary ENOENT is - // what exercises the same failure mode as a misconfigured - // production `binaryPath`. - ); - const runtimeServices = yield* Layer.build(providerRegistryLayer).pipe( - Scope.provide(scope), - ); - - yield* Effect.gen(function* () { - const registry = yield* ProviderRegistry.ProviderRegistry; - let providers = yield* registry.getProviders; - for ( - let attempts = 0; - attempts < 50 && - providers.find((provider) => provider.instanceId === "codex_personal")?.status !== - "error"; - attempts += 1 - ) { - yield* Effect.yieldNow; - providers = yield* registry.getProviders; - } - const codexPersonal = providers.find( - (provider) => provider.instanceId === "codex_personal", - ); - assert.notStrictEqual( - codexPersonal, - undefined, - `Expected the aggregator to know about codex_personal; instead saw: ${providers - .map((provider) => provider.instanceId) - .join(", ")}`, - ); - assert.strictEqual( - codexPersonal?.status, - "error", - "Real Codex probe against a missing binary should surface as 'error' in the aggregator", - ); - assert.strictEqual(codexPersonal?.installed, false); - assert.strictEqual( - codexPersonal?.message, - "Codex CLI (`codex`) is not installed or not on PATH.", - ); - }).pipe(Effect.provide(runtimeServices)); - }), - ); - - // Guards the second half of the reported bug: changing - // `providers.codex.binaryPath` in settings must tear down the live - // instance and rebuild it so a fresh probe runs with the new binary. - // This test drives the real settings stream → registry reconcile → - // aggregator sync pipeline and asserts that `getProviders` reflects - // the new background probe's outcome. - // - it.effect("re-probes when settings change the codex binaryPath", () => - Effect.gen(function* () { - const firstMissing = `t3code_codex_first_`; - const secondMissing = `t3code_codex_second_`; - const spawnedCommands: Array = []; - const serverSettings = yield* makeMutableServerSettingsService( - decodeServerSettings( - deepMerge(encodedDefaultServerSettings, { - providers: { - codex: { enabled: true, binaryPath: firstMissing }, - claudeAgent: { enabled: false }, - cursor: { enabled: false }, - grok: { enabled: false }, - opencode: { enabled: false }, - }, - }), - ), - ); - const scope = yield* Scope.make(); - yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); - const providerRegistryLayer = ProviderRegistryLive.pipe( - Layer.provideMerge(ProviderInstanceRegistryHydrationLive), - Layer.provideMerge( - Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings), - ), - Layer.provideMerge( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3-provider-registry-", - }), - ), - Layer.provideMerge(TestHttpClientLive), - Layer.provideMerge( - Layer.succeed( - ProviderEventLoggers.ProviderEventLoggers, - ProviderEventLoggers.NoOpProviderEventLoggers, ), - ), - Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), - Layer.updateService(ChildProcessSpawner.ChildProcessSpawner, (spawner) => - ChildProcessSpawner.make((command) => { - spawnedCommands.push((command as { readonly command: string }).command); - return spawner.spawn(command); - }), - ), - Layer.provideMerge(NodeServices.layer), - ); - const runtimeServices = yield* Layer.build(providerRegistryLayer).pipe( - Scope.provide(scope), - ); - - yield* Effect.gen(function* () { - const registry = yield* ProviderRegistry.ProviderRegistry; - // Boot-time probe: the default codex instance is enabled with - // `firstMissing`, so the real spawner yields ENOENT and the - // snapshot should be `status: "error"`. - let initialProviders = yield* registry.getProviders; - for ( - let attempts = 0; - attempts < 50 && - initialProviders.find((provider) => provider.instanceId === "codex")?.status !== - "error"; - attempts += 1 - ) { - yield* TestClock.adjust("10 millis"); - yield* Effect.yieldNow; - initialProviders = yield* registry.getProviders; - } - const initialCodex = initialProviders.find( - (provider) => provider.instanceId === "codex", - ); - assert.strictEqual(initialCodex?.status, "error"); - assert.strictEqual(initialCodex?.installed, false); - assert.deepStrictEqual(spawnedCommands, [firstMissing]); - - // Drive a settings change. The Hydration layer's - // `SettingsWatcherLive` consumes this via `streamChanges`, - // calls `reconcile`, which rebuilds the codex instance (the - // envelope changed because `binaryPath` differs → `entryEqual` - // is false). The registry's `Stream.runForEach( - // instanceRegistry.streamChanges, () => syncLiveSources)` - // fires `syncLiveSources`, which subscribes and launches a fresh - // background refresh on the rebuilt instance. - yield* serverSettings.updateSettings({ - providers: { - codex: { enabled: true, binaryPath: secondMissing }, - }, - }); - - // Poll until the injected process boundary observes the new - // executable. This verifies the public settings-to-probe behavior - // without depending on timestamps assigned by TestClock. - const refreshed = yield* Effect.gen(function* () { - for (let attempts = 0; attempts < 60; attempts += 1) { - const providers = yield* registry.getProviders; - const codex = providers.find((provider) => provider.instanceId === "codex"); - if ( - codex !== undefined && - codex.status === "error" && - spawnedCommands.includes(secondMissing) - ) { - return providers; - } - yield* TestClock.adjust("50 millis"); - yield* Effect.yieldNow; + listInstances: Effect.gen(function* () { + const shouldFail = yield* Ref.get(failNextList); + if (shouldFail) { + yield* Ref.set(failNextList, false); + return yield* Effect.die(new Error("simulated registry list failure")); } - return yield* registry.getProviders; - }); - - const reprobedCodex = refreshed.find((provider) => provider.instanceId === "codex"); - assert.deepStrictEqual(spawnedCommands, [firstMissing, secondMissing]); - assert.strictEqual(reprobedCodex?.status, "error"); - assert.strictEqual(reprobedCodex?.installed, false); - }).pipe(Effect.provide(runtimeServices)); - }), - ); - - it.effect("includes unavailable instance snapshots in getProviders", () => - Effect.gen(function* () { - const serverSettings = yield* makeMutableServerSettingsService( - decodeServerSettings( - deepMerge(encodedDefaultServerSettings, { - providers: { - codex: { enabled: false }, - claudeAgent: { enabled: false }, - cursor: { enabled: false }, - grok: { enabled: false }, - opencode: { enabled: false }, - }, - providerInstances: { - ghost_main: { - driver: "ghostDriver", - displayName: "A fork-only driver we don't ship", - enabled: false, - config: { arbitrary: "payload" }, - }, - } as unknown as ContractServerSettings["providerInstances"], - }), - ), - ); - const scope = yield* Scope.make(); - yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); - const providerRegistryLayer = ProviderRegistryLive.pipe( - Layer.provideMerge(ProviderInstanceRegistryHydrationLive), - Layer.provideMerge( - Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings), - ), + return yield* Ref.get(instancesRef); + }), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.fromPubSub(changes), + subscribeChanges: PubSub.subscribe(changes), + }, + ); + const scope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); + const runtimeServices = yield* Layer.build( + ProviderRegistryLive.pipe( + Layer.provideMerge(instanceRegistryLayer), Layer.provideMerge( ServerConfig.layerTest(process.cwd(), { - prefix: "t3-provider-registry-", + prefix: "t3-provider-registry-sync-failure-", }), ), - Layer.provideMerge(TestHttpClientLive), - Layer.provideMerge( - Layer.succeed( - ProviderEventLoggers.ProviderEventLoggers, - ProviderEventLoggers.NoOpProviderEventLoggers, - ), - ), - Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), Layer.provideMerge(NodeServices.layer), - ); - const runtimeServices = yield* Layer.build(providerRegistryLayer).pipe( - Scope.provide(scope), - ); + ), + ).pipe(Scope.provide(scope)); - yield* Effect.gen(function* () { - const registry = yield* ProviderRegistry.ProviderRegistry; - const providers = yield* registry.getProviders; - const ghost = providers.find((provider) => provider.instanceId === "ghost_main"); + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry.ProviderRegistry; + assert.deepStrictEqual(yield* registry.getProviders, [codexProvider]); - assert.notStrictEqual(ghost, undefined); - assert.strictEqual(ghost?.driver, "ghostDriver"); - assert.strictEqual(ghost?.availability, "unavailable"); - assert.match(ghost?.unavailableReason ?? "", /ghostDriver/); - }).pipe(Effect.provide(runtimeServices)); - }), - ); + yield* Ref.set(failNextList, true); + yield* PubSub.publish(changes, undefined); - it.effect( - "keeps cursor disabled and skips probing when the provider setting is disabled", - () => - Effect.gen(function* () { - const serverSettings = yield* makeMutableServerSettingsService( - decodeServerSettings( - deepMerge(encodedDefaultServerSettings, { - providers: { - codex: { - enabled: false, - }, - cursor: { - enabled: false, - }, - grok: { - enabled: false, - }, - }, - }), - ), - ); - let cursorSpawned = false; - const scope = yield* Scope.make(); - yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); - const providerRegistryLayer = ProviderRegistryLive.pipe( - Layer.provideMerge(ProviderInstanceRegistryHydrationLive), - Layer.provideMerge( - Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings), - ), - Layer.provideMerge( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3-provider-registry-", - }), - ), - Layer.provideMerge(TestHttpClientLive), - Layer.provideMerge( - Layer.succeed( - ProviderEventLoggers.ProviderEventLoggers, - ProviderEventLoggers.NoOpProviderEventLoggers, - ), - ), - Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), - Layer.provideMerge( - mockCommandSpawnerLayer((command, args) => { - if (command === "cursor-agent") { - cursorSpawned = true; - } - const joined = args.join(" "); - if (joined === "--version") { - return { - stdout: `${command} 1.0.0\n`, - stderr: "", - code: 0, - }; - } - if (joined === "auth status") { - return { - stdout: '{"authenticated":true}\n', - stderr: "", - code: 0, - }; - } - throw new Error(`Unexpected args: ${command} ${joined}`); - }), - ), - ); - const runtimeServices = yield* Layer.build( - Layer.mergeAll( - Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings), - providerRegistryLayer, - ), - ).pipe(Scope.provide(scope)); + yield* Ref.set(instancesRef, [codexInstance, claudeInstance]); + yield* PubSub.publish(changes, undefined); - yield* Effect.gen(function* () { - const registry = yield* ProviderRegistry.ProviderRegistry; - const providers = yield* registry.getProviders; - const cursorProvider = providers.find( - (provider) => provider.instanceId === ProviderInstanceId.make("cursor"), - ); - - assert.deepStrictEqual(providers.map((provider) => provider.instanceId).toSorted(), [ - "claudeAgent", - "codex", - "cursor", - "grok", - "opencode", - ]); - assert.strictEqual(cursorProvider?.enabled, false); - assert.strictEqual(cursorProvider?.status, "disabled"); - assert.strictEqual( - cursorProvider?.message, - "Cursor is disabled in T3 Code settings.", - ); - assert.strictEqual(cursorSpawned, false); - }).pipe(Effect.provide(runtimeServices)); - }), - ); + let providers = yield* registry.getProviders; + for ( + let attempt = 0; + attempt < 50 && !providers.some((provider) => provider.instanceId === claudeInstanceId); + attempt += 1 + ) { + yield* wait(); + providers = yield* registry.getProviders; + } - it.effect("skips codex probes entirely when the provider is disabled", () => - Effect.gen(function* () { - const status = yield* checkCodexProviderStatus(disabledCodexSettings).pipe( - Effect.provide(failingSpawnerLayer("spawn codex ENOENT")), - ); - assert.strictEqual(status.enabled, false); - assert.strictEqual(status.status, "disabled"); - assert.strictEqual(status.installed, false); - assert.strictEqual(status.message, "Codex is disabled in T3 Code settings."); - }), - ); - }); - - // ── checkClaudeProviderStatus tests ────────────────────────── - - describe("checkClaudeProviderStatus", () => { - it.effect("returns ready when claude is installed and authenticated", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - claudeCapabilities(), + assert.deepStrictEqual( + providers.map((provider) => provider.instanceId).toSorted(), + [codexInstanceId, claudeInstanceId].toSorted(), ); - assert.strictEqual(status.status, "ready"); - assert.strictEqual(status.installed, true); - assert.strictEqual(status.auth.status, "authenticated"); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; - if (joined === "auth status") - return { - stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', - stderr: "", - code: 0, - }; - throw new Error(`Unexpected args: ${joined}`); + }).pipe(Effect.provide(runtimeServices)); + }), + ); + + // This test intentionally avoids `mockCommandSpawnerLayer` so the real + // `probeCodexAppServerProvider` path runs — including the full + // `codex app-server` RPC handshake via `CodexClient.layerChildProcess`. + // We point `binaryPath` at a name that cannot exist on any machine so + // the real `ChildProcessSpawner` deterministically returns ENOENT; the + // probe wraps that as `CodexAppServerSpawnError` and + // `checkCodexProviderStatus` turns it into the user-visible "not + // installed" error snapshot. If the aggregator's `syncLiveSources` + // breaks — the `codex_personal`-never-probes bug we are guarding + // against — that snapshot never lands in `getProviders` and the + // assertions below fail. + it.effect("propagates real Codex probe failures to the aggregator at boot", () => + Effect.gen(function* () { + const missingBinary = `t3code_codex_missing_`; + const serverSettings = yield* makeMutableServerSettingsService( + decodeServerSettings( + deepMerge(encodedDefaultServerSettings, { + providers: { + // Disable every built-in probe that would otherwise spawn + // on the CI host. `enabled: false` short-circuits each + // driver's probe *before* it touches the spawner, so the + // test environment stays isolated from the dev + // machine's PATH. + codex: { enabled: false }, + claudeAgent: { enabled: false }, + cursor: { enabled: false }, + grok: { enabled: false }, + opencode: { enabled: false }, + }, + // `providerInstances` keys are branded `ProviderInstanceId`; + // the branded index signature rejects plain string literals + // at the TS level even though the runtime schema happily + // accepts + decodes them. Cast the patch to `unknown` so + // the `Schema.decodeSync` below does the real validation. + providerInstances: { + // Matches the shape the user had in `.t3/dev/settings.json` + // when the bug was reported: a custom enabled Codex instance + // pointing at a binary the server has to actually spawn. + codex_personal: { + driver: "codex", + displayName: "Codex Personal", + enabled: true, + config: { + binaryPath: missingBinary, + homePath: `/tmp/${missingBinary}_home`, + }, + }, + } as unknown as ContractServerSettings["providerInstances"], }), ), - ), - ); - - it.effect("returns ready and labels Bedrock-backed Claude as authenticated", () => - Effect.gen(function* () { - // Bedrock authenticates via external AWS credentials, so the SDK init - // reports only `apiProvider` with no subscription or token. - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - claudeCapabilities({ apiProvider: "bedrock" }), - ); - assert.strictEqual(status.status, "ready"); - assert.strictEqual(status.installed, true); - assert.strictEqual(status.auth.status, "authenticated"); - assert.strictEqual(status.auth.type, "bedrock"); - assert.strictEqual(status.auth.label, "Amazon Bedrock"); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; - throw new Error(`Unexpected args: ${joined}`); - }), + ); + const scope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); + const providerRegistryLayer = ProviderRegistryLive.pipe( + Layer.provideMerge(ProviderInstanceRegistryHydrationLive), + Layer.provideMerge( + Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings), ), - ), - ); - - it.effect("includes Claude Fable 5 on supported Claude Code versions", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - claudeCapabilities(), - ); - const fable5 = status.models.find((model) => model.slug === "claude-fable-5"); - assert.strictEqual(fable5?.name, "Claude Fable 5"); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "2.1.169\n", stderr: "", code: 0 }; - if (joined === "auth status") - return { - stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', - stderr: "", - code: 0, - }; - throw new Error(`Unexpected args: ${joined}`); + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-provider-registry-", }), ), - ), - ); - - it.effect("hides Claude Fable 5 on older Claude Code versions", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - claudeCapabilities(), - ); - assert.strictEqual( - status.models.some((model) => model.slug === "claude-fable-5"), - false, - ); - assert.strictEqual( - status.message, - "Claude Code v2.1.168 is too old for Claude Fable 5. Upgrade to v2.1.169 or newer to access it.", - ); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "2.1.168\n", stderr: "", code: 0 }; - if (joined === "auth status") - return { - stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', - stderr: "", - code: 0, - }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - - it.effect( - "includes Claude Opus 4.7 with xhigh as the default effort on supported versions", - () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - claudeCapabilities(), - ); - const opus47 = status.models.find((model) => model.slug === "claude-opus-4-7"); - if (!opus47) { - assert.fail("Expected Claude Opus 4.7 to be present for Claude Code v2.1.111."); - } - if (!opus47.capabilities) { - assert.fail( - "Expected Claude Opus 4.7 capabilities to be present for Claude Code v2.1.111.", - ); - } - const effortDescriptor = opus47.capabilities.optionDescriptors?.find( - (descriptor) => descriptor.type === "select" && descriptor.id === "effort", - ); - assert.deepStrictEqual( - effortDescriptor?.type === "select" - ? effortDescriptor.options.find((option) => option.isDefault) - : undefined, - { id: "xhigh", label: "Extra High", isDefault: true }, - ); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "2.1.111\n", stderr: "", code: 0 }; - if (joined === "auth status") - return { - stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', - stderr: "", - code: 0, - }; - throw new Error(`Unexpected args: ${joined}`); - }), + Layer.provideMerge(TestHttpClientLive), + Layer.provideMerge( + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, ), ), - ); + Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), + // NO spawner mock — `ChildProcessSpawner` is supplied by the + // outer `NodeServices.layer` on `it.layer(...)` and will + // genuinely spawn a subprocess. The missing-binary ENOENT is + // what exercises the same failure mode as a misconfigured + // production `binaryPath`. + ); + const runtimeServices = yield* Layer.build(providerRegistryLayer).pipe( + Scope.provide(scope), + ); - it.effect("hides Claude Opus 4.7 on older Claude Code versions", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - claudeCapabilities(), + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry.ProviderRegistry; + let providers = yield* registry.getProviders; + for ( + let attempts = 0; + attempts < 50 && + providers.find((provider) => provider.instanceId === "codex_personal")?.status !== + "error"; + attempts += 1 + ) { + yield* Effect.yieldNow; + providers = yield* registry.getProviders; + } + const codexPersonal = providers.find( + (provider) => provider.instanceId === "codex_personal", + ); + assert.notStrictEqual( + codexPersonal, + undefined, + `Expected the aggregator to know about codex_personal; instead saw: ${providers + .map((provider) => provider.instanceId) + .join(", ")}`, ); assert.strictEqual( - status.models.some((model) => model.slug === "claude-opus-4-7"), - false, + codexPersonal?.status, + "error", + "Real Codex probe against a missing binary should surface as 'error' in the aggregator", ); + assert.strictEqual(codexPersonal?.installed, false); assert.strictEqual( - status.message, - "Claude Code v2.1.110 is too old for Claude Opus 4.7. Upgrade to v2.1.111 or newer to access it.", + codexPersonal?.message, + "Codex CLI (`codex`) is not installed or not on PATH.", ); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "2.1.110\n", stderr: "", code: 0 }; - if (joined === "auth status") - return { - stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', - stderr: "", - code: 0, - }; - throw new Error(`Unexpected args: ${joined}`); + }).pipe(Effect.provide(runtimeServices)); + }), + ); + + // Guards the second half of the reported bug: changing + // `providers.codex.binaryPath` in settings must tear down the live + // instance and rebuild it so a fresh probe runs with the new binary. + // This test drives the real settings stream → registry reconcile → + // aggregator sync pipeline and asserts that `getProviders` reflects + // the new background probe's outcome. + // + it.effect("re-probes when settings change the codex binaryPath", () => + Effect.gen(function* () { + const firstMissing = `t3code_codex_first_`; + const secondMissing = `t3code_codex_second_`; + const spawnedCommands: Array = []; + const serverSettings = yield* makeMutableServerSettingsService( + decodeServerSettings( + deepMerge(encodedDefaultServerSettings, { + providers: { + codex: { enabled: true, binaryPath: firstMissing }, + claudeAgent: { enabled: false }, + cursor: { enabled: false }, + grok: { enabled: false }, + opencode: { enabled: false }, + }, }), ), - ), - ); - - it.effect("returns a display label for claude subscription types", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - claudeCapabilities({ subscriptionType: "maxplan" }), - ); - assert.strictEqual(status.status, "ready"); - assert.strictEqual(status.auth.status, "authenticated"); - assert.strictEqual(status.auth.type, "maxplan"); - assert.strictEqual(status.auth.label, "Claude Max Subscription"); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; - if (joined === "auth status") - return { - stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', - stderr: "", - code: 0, - }; - throw new Error(`Unexpected args: ${joined}`); - }), + ); + const scope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); + const providerRegistryLayer = ProviderRegistryLive.pipe( + Layer.provideMerge(ProviderInstanceRegistryHydrationLive), + Layer.provideMerge( + Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings), ), - ), - ); - - it.effect("does not duplicate Claude in full subscription labels", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - claudeCapabilities({ - subscriptionType: "Claude Max Subscription", - }), - ); - assert.strictEqual(status.auth.status, "authenticated"); - assert.strictEqual(status.auth.type, "Claude Max Subscription"); - assert.strictEqual(status.auth.label, "Claude Max Subscription"); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; - throw new Error(`Unexpected args: ${joined}`); + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-provider-registry-", }), ), - ), - ); - - it.effect("does not duplicate Claude in provider-prefixed subscription names", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - claudeCapabilities({ - subscriptionType: "Claude Max", - }), - ); - assert.strictEqual(status.auth.status, "authenticated"); - assert.strictEqual(status.auth.type, "Claude Max"); - assert.strictEqual(status.auth.label, "Claude Max Subscription"); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; - throw new Error(`Unexpected args: ${joined}`); - }), + Layer.provideMerge(TestHttpClientLive), + Layer.provideMerge( + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), ), - ), - ); - - it.effect("returns claude auth email from initialization result", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - claudeCapabilities({ email: "claude@example.com" }), - ); - assert.strictEqual(status.auth.status, "authenticated"); - assert.strictEqual(status.auth.email, "claude@example.com"); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; - if (joined === "auth status") - return { - stdout: - '{"loggedIn":true,"authMethod":"claude.ai","account":{"email":"claude@example.com"}}\n', - stderr: "", - code: 0, - }; - throw new Error(`Unexpected args: ${joined}`); + Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), + Layer.updateService(ChildProcessSpawner.ChildProcessSpawner, (spawner) => + ChildProcessSpawner.make((command) => { + spawnedCommands.push((command as { readonly command: string }).command); + return spawner.spawn(command); }), ), - ), - ); + Layer.provideMerge(NodeServices.layer), + ); + const runtimeServices = yield* Layer.build(providerRegistryLayer).pipe( + Scope.provide(scope), + ); - it.effect("runs Claude status probes with the configured CLAUDE_CONFIG_DIR", () => { - const claudeConfigDir = "/tmp/t3code-claude-home"; - const recorded = recordingMockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; - if (joined === "auth status") - return { - stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', - stderr: "", - code: 0, - }; - throw new Error(`Unexpected args: ${joined}`); - }); + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry.ProviderRegistry; + // Boot-time probe: the default codex instance is enabled with + // `firstMissing`, so the real spawner yields ENOENT and the + // snapshot should be `status: "error"`. + let initialProviders = yield* registry.getProviders; + for ( + let attempts = 0; + attempts < 50 && + initialProviders.find((provider) => provider.instanceId === "codex")?.status !== + "error"; + attempts += 1 + ) { + yield* TestClock.adjust("10 millis"); + yield* Effect.yieldNow; + initialProviders = yield* registry.getProviders; + } + const initialCodex = initialProviders.find((provider) => provider.instanceId === "codex"); + assert.strictEqual(initialCodex?.status, "error"); + assert.strictEqual(initialCodex?.installed, false); + assert.deepStrictEqual(spawnedCommands, [firstMissing]); + + // Drive a settings change. The Hydration layer's + // `SettingsWatcherLive` consumes this via `streamChanges`, + // calls `reconcile`, which rebuilds the codex instance (the + // envelope changed because `binaryPath` differs → `entryEqual` + // is false). The registry's `Stream.runForEach( + // instanceRegistry.streamChanges, () => syncLiveSources)` + // fires `syncLiveSources`, which subscribes and launches a fresh + // background refresh on the rebuilt instance. + yield* serverSettings.updateSettings({ + providers: { + codex: { enabled: true, binaryPath: secondMissing }, + }, + }); + + // Poll until the injected process boundary observes the new + // executable. This verifies the public settings-to-probe behavior + // without depending on timestamps assigned by TestClock. + const refreshed = yield* Effect.gen(function* () { + for (let attempts = 0; attempts < 60; attempts += 1) { + const providers = yield* registry.getProviders; + const codex = providers.find((provider) => provider.instanceId === "codex"); + if ( + codex !== undefined && + codex.status === "error" && + spawnedCommands.includes(secondMissing) + ) { + return providers; + } + yield* TestClock.adjust("50 millis"); + yield* Effect.yieldNow; + } + return yield* registry.getProviders; + }); - return Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - { - ...defaultClaudeSettings, - homePath: claudeConfigDir, - }, - claudeCapabilities(), - ); - assert.strictEqual(status.status, "ready"); - assert.deepStrictEqual( - recorded.commands.map((command) => command.env?.CLAUDE_CONFIG_DIR), - [claudeConfigDir], - ); - }).pipe(Effect.provide(recorded.layer)); - }); + const reprobedCodex = refreshed.find((provider) => provider.instanceId === "codex"); + assert.deepStrictEqual(spawnedCommands, [firstMissing, secondMissing]); + assert.strictEqual(reprobedCodex?.status, "error"); + assert.strictEqual(reprobedCodex?.installed, false); + }).pipe(Effect.provide(runtimeServices)); + }), + ); - it.effect("includes probed claude slash commands in the provider snapshot", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - claudeCapabilities({ - subscriptionType: "maxplan", - slashCommands: [ - { - name: "review", - description: "Review a pull request", - input: { hint: "pr-or-branch" }, + it.effect("includes unavailable instance snapshots in getProviders", () => + Effect.gen(function* () { + const serverSettings = yield* makeMutableServerSettingsService( + decodeServerSettings( + deepMerge(encodedDefaultServerSettings, { + providers: { + codex: { enabled: false }, + claudeAgent: { enabled: false }, + cursor: { enabled: false }, + grok: { enabled: false }, + opencode: { enabled: false }, + }, + providerInstances: { + ghost_main: { + driver: "ghostDriver", + displayName: "A fork-only driver we don't ship", + enabled: false, + config: { arbitrary: "payload" }, }, - ], + } as unknown as ContractServerSettings["providerInstances"], }), - ); - - assert.deepStrictEqual(status.slashCommands, [ - { - name: "review", - description: "Review a pull request", - input: { hint: "pr-or-branch" }, - }, - ]); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; - if (joined === "auth status") - return { - stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', - stderr: "", - code: 0, - }; - throw new Error(`Unexpected args: ${joined}`); + ), + ); + const scope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); + const providerRegistryLayer = ProviderRegistryLive.pipe( + Layer.provideMerge(ProviderInstanceRegistryHydrationLive), + Layer.provideMerge( + Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings), + ), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-provider-registry-", }), ), - ), - ); + Layer.provideMerge(TestHttpClientLive), + Layer.provideMerge( + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + ), + Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), + Layer.provideMerge(NodeServices.layer), + ); + const runtimeServices = yield* Layer.build(providerRegistryLayer).pipe( + Scope.provide(scope), + ); - it.effect("deduplicates probed claude slash commands by name", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - claudeCapabilities({ - subscriptionType: "maxplan", - slashCommands: [ - { - name: "ui", - description: "Explore and refine UI", + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry.ProviderRegistry; + const providers = yield* registry.getProviders; + const ghost = providers.find((provider) => provider.instanceId === "ghost_main"); + + assert.notStrictEqual(ghost, undefined); + assert.strictEqual(ghost?.driver, "ghostDriver"); + assert.strictEqual(ghost?.availability, "unavailable"); + assert.match(ghost?.unavailableReason ?? "", /ghostDriver/); + }).pipe(Effect.provide(runtimeServices)); + }), + ); + + it.effect("keeps cursor disabled and skips probing when the provider setting is disabled", () => + Effect.gen(function* () { + const serverSettings = yield* makeMutableServerSettingsService( + decodeServerSettings( + deepMerge(encodedDefaultServerSettings, { + providers: { + codex: { + enabled: false, }, - { - name: "ui", - input: { hint: "component-or-screen" }, + cursor: { + enabled: false, }, - ], + grok: { + enabled: false, + }, + }, }), - ); - - assert.deepStrictEqual(status.slashCommands, [ - { - name: "ui", - description: "Explore and refine UI", - input: { hint: "component-or-screen" }, - }, - ]); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { + ), + ); + let cursorSpawned = false; + const scope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); + const providerRegistryLayer = ProviderRegistryLive.pipe( + Layer.provideMerge(ProviderInstanceRegistryHydrationLive), + Layer.provideMerge( + Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings), + ), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-provider-registry-", + }), + ), + Layer.provideMerge(TestHttpClientLive), + Layer.provideMerge( + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + ), + Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), + Layer.provideMerge( + mockCommandSpawnerLayer((command, args) => { + if (command === "cursor-agent") { + cursorSpawned = true; + } const joined = args.join(" "); - if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; - if (joined === "auth status") + if (joined === "--version") { return { - stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', + stdout: `${command} 1.0.0\n`, stderr: "", code: 0, }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - - it.effect("returns an api key label for claude api key auth", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - claudeCapabilities({ tokenSource: "ANTHROPIC_AUTH_TOKEN" }), - ); - assert.strictEqual(status.status, "ready"); - assert.strictEqual(status.auth.status, "authenticated"); - assert.strictEqual(status.auth.type, "apiKey"); - assert.strictEqual(status.auth.label, "Claude API Key"); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; - if (joined === "auth status") + } + if (joined === "auth status") { return { - stdout: '{"loggedIn":true,"authMethod":"api-key"}\n', + stdout: '{"authenticated":true}\n', stderr: "", code: 0, }; - throw new Error(`Unexpected args: ${joined}`); + } + throw new Error(`Unexpected args: ${command} ${joined}`); }), ), - ), - ); + ); + const runtimeServices = yield* Layer.build( + Layer.mergeAll( + Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings), + providerRegistryLayer, + ), + ).pipe(Scope.provide(scope)); - it.effect("returns unavailable when claude is missing", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - claudeCapabilities(), - ); - assert.strictEqual(status.status, "error"); - assert.strictEqual(status.installed, false); - assert.strictEqual(status.auth.status, "unknown"); - assert.strictEqual( - status.message, - "Claude Agent CLI (`claude`) is not installed or not on PATH.", + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry.ProviderRegistry; + const providers = yield* registry.getProviders; + const cursorProvider = providers.find( + (provider) => provider.instanceId === ProviderInstanceId.make("cursor"), ); - }).pipe(Effect.provide(failingSpawnerLayer("spawn claude ENOENT"))), - ); - it.effect("returns error when version check fails with non-zero exit code", () => { - const secretStderr = "Something went wrong: secret-token-value"; - return Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - claudeCapabilities(), - ); - assert.strictEqual(status.status, "error"); - assert.strictEqual(status.installed, true); - assert.strictEqual(status.message, "Claude Agent CLI is installed but failed to run."); - assert.ok(!(status.message ?? "").includes(secretStderr)); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") - return { - stdout: "", - stderr: secretStderr, - code: 1, - }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), + assert.deepStrictEqual(providers.map((provider) => provider.instanceId).toSorted(), [ + "claudeAgent", + "codex", + "cursor", + "grok", + "opencode", + ]); + assert.strictEqual(cursorProvider?.enabled, false); + assert.strictEqual(cursorProvider?.status, "disabled"); + assert.strictEqual(cursorProvider?.message, "Cursor is disabled in T3 Code settings."); + assert.strictEqual(cursorSpawned, false); + }).pipe(Effect.provide(runtimeServices)); + }), + ); + + it.effect("skips codex probes entirely when the provider is disabled", () => + Effect.gen(function* () { + const status = yield* checkCodexProviderStatus(disabledCodexSettings).pipe( + Effect.provide(failingSpawnerLayer("spawn codex ENOENT")), ); - }); + assert.strictEqual(status.enabled, false); + assert.strictEqual(status.status, "disabled"); + assert.strictEqual(status.installed, false); + assert.strictEqual(status.message, "Codex is disabled in T3 Code settings."); + }), + ); + }); + + // ── checkClaudeProviderStatus tests ────────────────────────── + + describe("checkClaudeProviderStatus", () => { + it.effect("returns ready when claude is installed and authenticated", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities(), + ); + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.installed, true); + assert.strictEqual(status.auth.status, "authenticated"); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', + stderr: "", + code: 0, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("returns ready and labels Bedrock-backed Claude as authenticated", () => + Effect.gen(function* () { + // Bedrock authenticates via external AWS credentials, so the SDK init + // reports only `apiProvider` with no subscription or token. + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities({ apiProvider: "bedrock" }), + ); + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.installed, true); + assert.strictEqual(status.auth.status, "authenticated"); + assert.strictEqual(status.auth.type, "bedrock"); + assert.strictEqual(status.auth.label, "Amazon Bedrock"); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("includes Claude Fable 5 on supported Claude Code versions", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities(), + ); + const fable5 = status.models.find((model) => model.slug === "claude-fable-5"); + assert.strictEqual(fable5?.name, "Claude Fable 5"); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "2.1.169\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', + stderr: "", + code: 0, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("hides Claude Fable 5 on older Claude Code versions", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities(), + ); + assert.strictEqual( + status.models.some((model) => model.slug === "claude-fable-5"), + false, + ); + assert.strictEqual( + status.message, + "Claude Code v2.1.168 is too old for Claude Fable 5. Upgrade to v2.1.169 or newer to access it.", + ); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "2.1.168\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', + stderr: "", + code: 0, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); - it.effect("returns warning when the Claude initialization result is unavailable", () => + it.effect( + "includes Claude Opus 4.7 with xhigh as the default effort on supported versions", + () => Effect.gen(function* () { const status = yield* checkClaudeProviderStatus( defaultClaudeSettings, - noClaudeCapabilities, + claudeCapabilities(), ); - assert.strictEqual(status.status, "warning"); - assert.strictEqual(status.installed, true); - assert.strictEqual(status.auth.status, "unknown"); - assert.strictEqual( - status.message, - "Could not verify Claude authentication status from initialization result.", + const opus47 = status.models.find((model) => model.slug === "claude-opus-4-7"); + if (!opus47) { + assert.fail("Expected Claude Opus 4.7 to be present for Claude Code v2.1.111."); + } + if (!opus47.capabilities) { + assert.fail( + "Expected Claude Opus 4.7 capabilities to be present for Claude Code v2.1.111.", + ); + } + const effortDescriptor = opus47.capabilities.optionDescriptors?.find( + (descriptor) => descriptor.type === "select" && descriptor.id === "effort", + ); + assert.deepStrictEqual( + effortDescriptor?.type === "select" + ? effortDescriptor.options.find((option) => option.isDefault) + : undefined, + { id: "xhigh", label: "Extra High", isDefault: true }, ); }).pipe( Effect.provide( mockSpawnerLayer((args) => { const joined = args.join(" "); - if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + if (joined === "--version") return { stdout: "2.1.111\n", stderr: "", code: 0 }; if (joined === "auth status") return { - stdout: '{"loggedIn":false}\n', + stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', stderr: "", - code: 1, + code: 0, }; throw new Error(`Unexpected args: ${joined}`); }), ), ), + ); + + it.effect("hides Claude Opus 4.7 on older Claude Code versions", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities(), + ); + assert.strictEqual( + status.models.some((model) => model.slug === "claude-opus-4-7"), + false, + ); + assert.strictEqual( + status.message, + "Claude Code v2.1.110 is too old for Claude Opus 4.7. Upgrade to v2.1.111 or newer to access it.", + ); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "2.1.110\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', + stderr: "", + code: 0, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("returns a display label for claude subscription types", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities({ subscriptionType: "maxplan" }), + ); + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.auth.status, "authenticated"); + assert.strictEqual(status.auth.type, "maxplan"); + assert.strictEqual(status.auth.label, "Claude Max Subscription"); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', + stderr: "", + code: 0, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("does not duplicate Claude in full subscription labels", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities({ + subscriptionType: "Claude Max Subscription", + }), + ); + assert.strictEqual(status.auth.status, "authenticated"); + assert.strictEqual(status.auth.type, "Claude Max Subscription"); + assert.strictEqual(status.auth.label, "Claude Max Subscription"); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("does not duplicate Claude in provider-prefixed subscription names", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities({ + subscriptionType: "Claude Max", + }), + ); + assert.strictEqual(status.auth.status, "authenticated"); + assert.strictEqual(status.auth.type, "Claude Max"); + assert.strictEqual(status.auth.label, "Claude Max Subscription"); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("returns claude auth email from initialization result", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities({ email: "claude@example.com" }), + ); + assert.strictEqual(status.auth.status, "authenticated"); + assert.strictEqual(status.auth.email, "claude@example.com"); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: + '{"loggedIn":true,"authMethod":"claude.ai","account":{"email":"claude@example.com"}}\n', + stderr: "", + code: 0, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("runs Claude status probes with the configured CLAUDE_CONFIG_DIR", () => { + const claudeConfigDir = "/tmp/t3code-claude-home"; + const recorded = recordingMockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', + stderr: "", + code: 0, + }; + throw new Error(`Unexpected args: ${joined}`); + }); + + return Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + { + ...defaultClaudeSettings, + homePath: claudeConfigDir, + }, + claudeCapabilities(), + ); + assert.strictEqual(status.status, "ready"); + assert.deepStrictEqual( + recorded.commands.map((command) => command.env?.CLAUDE_CONFIG_DIR), + [claudeConfigDir], + ); + }).pipe(Effect.provide(recorded.layer)); + }); + + it.effect("includes probed claude slash commands in the provider snapshot", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities({ + subscriptionType: "maxplan", + slashCommands: [ + { + name: "review", + description: "Review a pull request", + input: { hint: "pr-or-branch" }, + }, + ], + }), + ); + + assert.deepStrictEqual(status.slashCommands, [ + { + name: "review", + description: "Review a pull request", + input: { hint: "pr-or-branch" }, + }, + ]); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', + stderr: "", + code: 0, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("deduplicates probed claude slash commands by name", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities({ + subscriptionType: "maxplan", + slashCommands: [ + { + name: "ui", + description: "Explore and refine UI", + }, + { + name: "ui", + input: { hint: "component-or-screen" }, + }, + ], + }), + ); + + assert.deepStrictEqual(status.slashCommands, [ + { + name: "ui", + description: "Explore and refine UI", + input: { hint: "component-or-screen" }, + }, + ]); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', + stderr: "", + code: 0, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("returns an api key label for claude api key auth", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities({ tokenSource: "ANTHROPIC_AUTH_TOKEN" }), + ); + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.auth.status, "authenticated"); + assert.strictEqual(status.auth.type, "apiKey"); + assert.strictEqual(status.auth.label, "Claude API Key"); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":true,"authMethod":"api-key"}\n', + stderr: "", + code: 0, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("returns unavailable when claude is missing", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities(), + ); + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.installed, false); + assert.strictEqual(status.auth.status, "unknown"); + assert.strictEqual( + status.message, + "Claude Agent CLI (`claude`) is not installed or not on PATH.", + ); + }).pipe(Effect.provide(failingSpawnerLayer("spawn claude ENOENT"))), + ); + + it.effect("returns error when version check fails with non-zero exit code", () => { + const secretStderr = "Something went wrong: secret-token-value"; + return Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities(), + ); + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.installed, true); + assert.strictEqual(status.message, "Claude Agent CLI is installed but failed to run."); + assert.ok(!(status.message ?? "").includes(secretStderr)); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") + return { + stdout: "", + stderr: secretStderr, + code: 1, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), ); }); - }, -); + + it.effect("returns warning when the Claude initialization result is unavailable", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + noClaudeCapabilities, + ); + assert.strictEqual(status.status, "warning"); + assert.strictEqual(status.installed, true); + assert.strictEqual(status.auth.status, "unknown"); + assert.strictEqual( + status.message, + "Could not verify Claude authentication status from initialization result.", + ); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":false}\n', + stderr: "", + code: 1, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + }); +}); diff --git a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts index b1ef0d3e595..5b15c5394d9 100644 --- a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts +++ b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts @@ -8,9 +8,12 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Sink from "effect/Sink"; import * as TestClock from "effect/testing/TestClock"; import * as Stream from "effect/Stream"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import { describe, expect } from "vite-plus/test"; import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; @@ -22,6 +25,57 @@ const mockAgentCommand = "node"; const mockAgentArgs = [mockAgentPath]; describe("AcpSessionRuntime", () => { + it.effect("passes an exact environment to the ACP child when extension is disabled", () => { + let spawnedCommand: unknown; + const spawner = ChildProcessSpawner.make((command) => { + spawnedCommand = command; + return Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.never, + isRunning: Effect.succeed(true), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.never, + stderr: Stream.never, + all: Stream.never, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.never, + }), + ); + }); + const exactEnvironment = { PATH: "/project/bin", KEEP: "value" }; + + return Layer.build( + AcpSessionRuntime.layer({ + spawn: { + command: "/project/bin/agent", + args: ["acp"], + env: exactEnvironment, + extendEnv: false, + }, + cwd: "/project", + clientInfo: { name: "t3-test", version: "0.0.0" }, + authMethodId: "test", + }).pipe( + Layer.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)), + Layer.provideMerge(NodeServices.layer), + ), + ).pipe( + Effect.tap(() => + Effect.sync(() => { + const command = spawnedCommand as { + readonly options: { readonly env?: NodeJS.ProcessEnv; readonly extendEnv?: boolean }; + }; + expect(command.options.env).toEqual(exactEnvironment); + expect(command.options.extendEnv).toBe(false); + }), + ), + Effect.scoped, + ); + }); + it.effect("merges custom initialize client capabilities into the ACP handshake", () => { const requestEvents: Array = []; return Effect.gen(function* () { diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index 09fce6d56f9..e91c437b6a1 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -55,6 +55,7 @@ export interface AcpSpawnInput { readonly args: ReadonlyArray; readonly cwd?: string; readonly env?: NodeJS.ProcessEnv; + readonly extendEnv?: boolean; } export interface AcpSessionRuntimeOptions { @@ -329,16 +330,17 @@ export const make = ( ), ); + const extendEnv = options.spawn.extendEnv ?? true; const spawnCommand = yield* resolveSpawnCommand( options.spawn.command, options.spawn.args, - options.spawn.env ? { env: options.spawn.env, extendEnv: true } : {}, + options.spawn.env ? { env: options.spawn.env, extendEnv } : {}, ); const child = yield* spawner .spawn( ChildProcess.make(spawnCommand.command, spawnCommand.args, { ...(options.spawn.cwd ? { cwd: options.spawn.cwd } : {}), - ...(options.spawn.env ? { env: options.spawn.env, extendEnv: true } : {}), + ...(options.spawn.env ? { env: options.spawn.env, extendEnv } : {}), shell: spawnCommand.shell, }), ) diff --git a/apps/server/src/provider/acp/CursorAcpSupport.test.ts b/apps/server/src/provider/acp/CursorAcpSupport.test.ts index a095fdd679b..4ceb98a7848 100644 --- a/apps/server/src/provider/acp/CursorAcpSupport.test.ts +++ b/apps/server/src/provider/acp/CursorAcpSupport.test.ts @@ -74,6 +74,21 @@ describe("buildCursorAcpSpawnInput", () => { cwd: "/tmp/project", }); }); + + it("uses a complete non-extending environment when one is supplied", () => { + expect( + buildCursorAcpSpawnInput(undefined, "/tmp/project", { + PATH: "/project/bin", + KEEP: "value", + }), + ).toEqual({ + command: "cursor-agent", + args: ["acp"], + cwd: "/tmp/project", + env: { PATH: "/project/bin", KEEP: "value" }, + extendEnv: false, + }); + }); }); describe("applyCursorAcpModelSelection", () => { diff --git a/apps/server/src/provider/acp/CursorAcpSupport.ts b/apps/server/src/provider/acp/CursorAcpSupport.ts index 30203ad77b1..aa765c51be5 100644 --- a/apps/server/src/provider/acp/CursorAcpSupport.ts +++ b/apps/server/src/provider/acp/CursorAcpSupport.ts @@ -42,7 +42,7 @@ export function buildCursorAcpSpawnInput( "acp", ], cwd, - ...(environment ? { env: environment } : {}), + ...(environment ? { env: environment, extendEnv: false } : {}), }; } diff --git a/apps/server/src/provider/acp/GrokAcpSupport.test.ts b/apps/server/src/provider/acp/GrokAcpSupport.test.ts index 02d60976b24..80e595dddb1 100644 --- a/apps/server/src/provider/acp/GrokAcpSupport.test.ts +++ b/apps/server/src/provider/acp/GrokAcpSupport.test.ts @@ -31,6 +31,7 @@ describe("buildGrokAcpSpawnInput", () => { XAI_API_KEY: "secret", GROK_OAUTH2_REFERRER: "t3code", }, + extendEnv: false, }); }); }); diff --git a/apps/server/src/provider/acp/GrokAcpSupport.ts b/apps/server/src/provider/acp/GrokAcpSupport.ts index c928b3ed80e..3ef3cb7efe3 100644 --- a/apps/server/src/provider/acp/GrokAcpSupport.ts +++ b/apps/server/src/provider/acp/GrokAcpSupport.ts @@ -42,6 +42,7 @@ export function buildGrokAcpSpawnInput( ...environment, [GROK_OAUTH2_REFERRER_ENV]: T3_CODE_OAUTH_REFERRER, }, + ...(environment ? { extendEnv: false } : {}), }; } diff --git a/apps/server/src/provider/opencodeRuntime.test.ts b/apps/server/src/provider/opencodeRuntime.test.ts new file mode 100644 index 00000000000..e659042ae39 --- /dev/null +++ b/apps/server/src/provider/opencodeRuntime.test.ts @@ -0,0 +1,75 @@ +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Scope from "effect/Scope"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import * as TestClock from "effect/testing/TestClock"; + +import { OpenCodeRuntime, OpenCodeRuntimeLive } from "./opencodeRuntime.ts"; + +it.effect("launches a local OpenCode server with the project cwd and final environment", () => { + let spawnedCommand: unknown; + const spawner = ChildProcessSpawner.make((command) => { + spawnedCommand = command; + return Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(987_654), + exitCode: Effect.never, + isRunning: Effect.succeed(true), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.encodeText( + Stream.make("opencode server listening on http://127.0.0.1:4310\n"), + ), + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }), + ); + }); + const runtimeLayer = OpenCodeRuntimeLive.pipe( + Layer.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)), + ); + + return Effect.gen(function* () { + const runtime = yield* OpenCodeRuntime; + const sessionScope = yield* Scope.make(); + const server = yield* runtime + .startOpenCodeServerProcess({ + binaryPath: "/project/bin/opencode", + cwd: "/project/worktree", + environment: { + PATH: "/project/bin", + KEEP: "value", + OPENCODE_CONFIG_CONTENT: "direnv-must-not-win", + }, + port: 4310, + }) + .pipe(Effect.provideService(Scope.Scope, sessionScope)); + + expect(server.url).toBe("http://127.0.0.1:4310"); + const command = spawnedCommand as { + readonly options: { + readonly cwd?: string; + readonly env?: NodeJS.ProcessEnv; + readonly extendEnv?: boolean; + }; + }; + expect(command.options.cwd).toBe("/project/worktree"); + expect(command.options.extendEnv).toBe(false); + expect(command.options.env).toEqual({ + PATH: "/project/bin", + KEEP: "value", + OPENCODE_CONFIG_CONTENT: "{}", + }); + const closeFiber = yield* Scope.close(sessionScope, Exit.void).pipe(Effect.forkChild); + yield* TestClock.adjust("1 second"); + yield* Fiber.join(closeFiber); + }).pipe(Effect.provide(runtimeLayer)); +}); diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index 63fcea22d19..e21a6f2c84d 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -118,6 +118,7 @@ export interface OpenCodeRuntimeShape { readonly startOpenCodeServerProcess: (input: { readonly binaryPath: string; readonly environment?: NodeJS.ProcessEnv; + readonly cwd?: string; readonly port?: number; readonly hostname?: string; readonly timeoutMs?: number; @@ -131,6 +132,7 @@ export interface OpenCodeRuntimeShape { readonly binaryPath: string; readonly serverUrl?: string | null; readonly environment?: NodeJS.ProcessEnv; + readonly cwd?: string; readonly port?: number; readonly hostname?: string; readonly timeoutMs?: number; @@ -457,6 +459,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () { const child = yield* spawner .spawn( ChildProcess.make(spawnCommand.command, spawnCommand.args, { + ...(input.cwd ? { cwd: input.cwd } : {}), detached: hostPlatform !== "win32", shell: spawnCommand.shell, env: { @@ -602,6 +605,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () { return startOpenCodeServerProcess({ binaryPath: input.binaryPath, ...(input.environment !== undefined ? { environment: input.environment } : {}), + ...(input.cwd !== undefined ? { cwd: input.cwd } : {}), ...(input.port !== undefined ? { port: input.port } : {}), ...(input.hostname !== undefined ? { hostname: input.hostname } : {}), ...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}), diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 871b79eca90..94b58160a5c 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -5118,6 +5118,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { operation: "pull", command: "git pull --ff-only", cwd: "/tmp/repo", + failureKind: "unknown", detail: "upstream missing", }); let invalidationCalls = 0; @@ -5198,6 +5199,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { operation: "commit", command: "git commit", cwd: "/tmp/repo", + failureKind: "unknown", detail: "nothing to commit", }); let invalidationCalls = 0; diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 66b9823afb3..c4b05d204d6 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -25,6 +25,7 @@ import * as ProviderEventLoggers from "./provider/Layers/ProviderEventLoggers.ts import { ProviderServiceLive } from "./provider/Layers/ProviderService.ts"; import { ProviderSessionReaperLive } from "./provider/Layers/ProviderSessionReaper.ts"; import * as OpenCodeRuntime from "./provider/opencodeRuntime.ts"; +import * as DirenvEnvironment from "./provider/DirenvEnvironment.ts"; import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; import * as CheckpointStore from "./checkpointing/CheckpointStore.ts"; import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; @@ -309,7 +310,9 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( // `ProviderService` (canonical stream, written after event normalization). // Provided once at the runtime level so every consumer sees the same // logger instances. - Layer.provideMerge(ProviderEventLoggers.ProviderEventLoggersLive), + Layer.provideMerge( + Layer.mergeAll(ProviderEventLoggers.ProviderEventLoggersLive, DirenvEnvironment.layerLive), + ), // `OpenCodeDriver.create()` yields `OpenCodeRuntime`; previously the old // `ProviderRegistryLive` pulled `OpenCodeRuntimeLive` in for itself, but // the rewritten registry reads snapshots off the instance registry and diff --git a/apps/server/src/sourceControl/BitbucketApi.test.ts b/apps/server/src/sourceControl/BitbucketApi.test.ts index 5a9759ace0b..141d1cef4c9 100644 --- a/apps/server/src/sourceControl/BitbucketApi.test.ts +++ b/apps/server/src/sourceControl/BitbucketApi.test.ts @@ -656,6 +656,7 @@ it.effect("preserves Git checkout failures without deriving the domain message f operation: "fetchRemoteBranch", command: "git fetch origin feature/source-control", cwd: "/repo", + failureKind: "unknown", detail: "remote rejected the request", }); const { layer } = makeLayer({ diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts index 861da9a10e0..a5de4117b7c 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts @@ -364,6 +364,7 @@ it.effect("publish succeeds with status remote_added when the local repo has no operation: input.operation, command: "git rev-parse --verify HEAD", cwd: input.cwd, + failureKind: "unknown", detail: "fatal: Needed a single revision", }), ) diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 55aa8f38835..5174abe3bd0 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -28,6 +28,7 @@ import { type VcsStatusInput, type VcsStatusResult, } from "@t3tools/contracts"; +import * as DirenvEnvironment from "../provider/DirenvEnvironment.ts"; import { makeGitVcsDriverCore } from "./GitVcsDriverCore.ts"; import * as VcsDriver from "./VcsDriver.ts"; import * as VcsProcess from "./VcsProcess.ts"; @@ -111,6 +112,7 @@ export interface GitCommitProgress { export interface GitCommitOptions { readonly timeoutMs?: number; readonly progress?: GitCommitProgress; + readonly disableSigning?: boolean; } export interface GitPushResult { @@ -873,5 +875,9 @@ export const make = Effect.gen(function* () { return GitVcsDriver.of(git); }); -export const vcsLayer = Layer.effect(VcsDriver.VcsDriver, makeVcsDriver); -export const layer = Layer.effect(GitVcsDriver, make); +export const vcsLayer = Layer.effect(VcsDriver.VcsDriver, makeVcsDriver).pipe( + Layer.provide(DirenvEnvironment.layerLive), +); +export const layer = Layer.effect(GitVcsDriver, make).pipe( + Layer.provide(DirenvEnvironment.layerLive), +); diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 7b5956de07f..f6c45e7f77c 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -12,7 +12,15 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { GitCommandError } from "@t3tools/contracts"; import { ServerConfig } from "../config.ts"; -import { splitNullSeparatedGitStdoutPaths } from "./GitVcsDriverCore.ts"; +import { + DirenvEnvironment, + identityDirenvEnvironmentResolver, +} from "../provider/DirenvEnvironment.ts"; +import { + isCommitSigningFailureStderr, + makeGitVcsDriverCore, + splitNullSeparatedGitStdoutPaths, +} from "./GitVcsDriverCore.ts"; import * as GitVcsDriver from "./GitVcsDriver.ts"; const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { @@ -88,6 +96,7 @@ const initRepoWithCommit = ( yield* driver.initRepo({ cwd }); yield* git(cwd, ["config", "user.email", "test@test.com"]); yield* git(cwd, ["config", "user.name", "Test"]); + yield* git(cwd, ["config", "commit.gpgSign", "false"]); yield* writeTextFile(cwd, "README.md", "# test\n"); yield* git(cwd, ["add", "."]); yield* git(cwd, ["commit", "-m", "initial commit"]); @@ -671,8 +680,19 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { "feature-worktree", ); const driver = yield* GitVcsDriver.GitVcsDriver; + const approvedWorktrees: Array = []; + const driverWithApprovalSpy = yield* makeGitVcsDriverCore().pipe( + Effect.provide(ServerConfigLayer), + Effect.provideService(DirenvEnvironment, { + allow: ({ cwd }) => + Effect.sync(() => { + approvedWorktrees.push(cwd); + }), + resolve: identityDirenvEnvironmentResolver, + }), + ); - const created = yield* driver.createWorktree({ + const created = yield* driverWithApprovalSpy.createWorktree({ cwd, path: worktreePath, refName: initialBranch, @@ -682,6 +702,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { assert.equal(created.worktree.path, worktreePath); assert.equal(created.worktree.refName, "feature/worktree"); assert.equal(yield* git(worktreePath, ["branch", "--show-current"]), "feature/worktree"); + assert.deepStrictEqual(approvedWorktrees, [worktreePath]); yield* driver.removeWorktree({ cwd, path: worktreePath }); const fileSystem = yield* FileSystem.FileSystem; @@ -786,6 +807,75 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { assert.include(status, "?? selected1.txt"); }), ); + + it("recognizes representative GPG, pinentry, and SSH signing diagnostics", () => { + assert.isTrue(isCommitSigningFailureStderr("error: gpg failed to sign the data")); + assert.isTrue( + isCommitSigningFailureStderr( + "gpg: signing failed: Inappropriate ioctl for device\nfatal: failed to write commit object", + ), + ); + assert.isTrue(isCommitSigningFailureStderr("error: ssh-keygen failed to sign the data")); + assert.isTrue( + isCommitSigningFailureStderr( + "error: Couldn't load public key /tmp/missing.pub: No such file or directory", + ), + ); + assert.isFalse(isCommitSigningFailureStderr("fatal: failed to write commit object")); + }); + + it.effect("classifies signing failures and can commit unsigned for one attempt", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + const pathService = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const signerPath = pathService.join(cwd, "failing-signer.sh"); + yield* fileSystem.writeFileString( + signerPath, + "#!/bin/sh\necho 'gpg: signing failed: No secret key' >&2\nexit 1\n", + ); + yield* fileSystem.chmod(signerPath, 0o755); + yield* git(cwd, ["config", "commit.gpgSign", "true"]); + yield* git(cwd, ["config", "gpg.program", signerPath]); + yield* writeTextFile(cwd, "signed.txt", "sign me\n"); + yield* git(cwd, ["add", "signed.txt"]); + + const error = yield* driver.commit(cwd, "Signed commit", "").pipe(Effect.flip); + assert.equal(error.failureKind, "commit_signing_failed"); + assert.notProperty(error, "stderr"); + + const commit = yield* driver.commit(cwd, "Unsigned commit", "", { + disableSigning: true, + }); + assert.match(commit.commitSha, /^[a-f0-9]{40}$/); + assert.equal(yield* git(cwd, ["log", "-1", "--pretty=%s"]), "Unsigned commit"); + assert.notInclude(yield* git(cwd, ["cat-file", "commit", "HEAD"]), "gpgsig "); + assert.equal(yield* git(cwd, ["config", "--bool", "commit.gpgSign"]), "true"); + }), + ); + + it.effect("does not classify a failed commit hook as a signing failure", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + const pathService = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const hookPath = pathService.join(cwd, ".git", "hooks", "pre-commit"); + yield* fileSystem.writeFileString( + hookPath, + "#!/bin/sh\necho 'error: gpg failed to sign the data' >&2\nexit 1\n", + ); + yield* fileSystem.chmod(hookPath, 0o755); + yield* writeTextFile(cwd, "hooked.txt", "fail first\n"); + yield* git(cwd, ["add", "hooked.txt"]); + + const error = yield* driver.commit(cwd, "Hook failure", "").pipe(Effect.flip); + assert.equal(error.failureKind, "unknown"); + }), + ); }); describe("remote operations", () => { diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 33eb6d40d76..db6c06303bb 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -28,6 +28,7 @@ import { import { dedupeRemoteBranchesWithLocalMatches, normalizeGitRemoteUrl } from "@t3tools/shared/git"; import { compactTraceAttributes } from "@t3tools/shared/observability"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; +import { DirenvEnvironment } from "../provider/DirenvEnvironment.ts"; import { gitCommandDuration, gitCommandsTotal, withMetrics } from "../observability/Metrics.ts"; import * as GitVcsDriver from "./GitVcsDriver.ts"; import { @@ -61,6 +62,29 @@ const STATUS_UPSTREAM_REFRESH_ENV = Object.freeze({ } satisfies NodeJS.ProcessEnv); const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const; const GIT_LIST_BRANCHES_DEFAULT_LIMIT = 100; + +const COMMIT_SIGNING_FAILURE_PATTERNS = [ + /gpg(?:2)?(?:\.exe)?: .*failed to sign/i, + /gpg failed to sign the data/i, + /signing failed:/i, + /failed to sign the data/i, + /pinentry.*(?:failed|error|not found|no such file|cancell?ed)/i, + /(?:failed|error|no such file|cancell?ed).*pinentry/i, + /inappropriate ioctl for device/i, + /cannot open \/dev\/tty/i, + /no secret key/i, + /secret key not available/i, + /ssh-keygen(?:\.exe)?:?.*(?:failed|error|couldn['’]t).*sign/i, + /couldn['’]t sign (?:message|data)/i, + /couldn['’]t load public key/i, + /no private key found for public key/i, + /load key .*: (?:invalid format|no such file or directory|permission denied)/i, + /agent refused operation/i, +] as const; + +export function isCommitSigningFailureStderr(stderr: string): boolean { + return COMMIT_SIGNING_FAILURE_PATTERNS.some((pattern) => pattern.test(stderr)); +} const NON_REPOSITORY_STATUS_DETAILS = Object.freeze({ isRepo: false, hasOriginRemote: false, @@ -325,6 +349,7 @@ function gitCommandContext( command: "git", cwd: input.cwd, argumentCount: input.args.length, + failureKind: "unknown" as const, } as const; } @@ -437,16 +462,15 @@ const createTrace2Monitor = Effect.fn("createTrace2Monitor")(function* ( return; } - if (traceRecord.success.child_class !== "hook") { - return; - } - const event = traceRecord.success.event; const childKey = trace2ChildKey(traceRecord.success); if (childKey === null) { return; } const started = hookStartByChildKey.get(childKey); + if (traceRecord.success.child_class !== "hook" && started === undefined) { + return; + } const hookNameFromEvent = typeof traceRecord.success.hook_name === "string" ? traceRecord.success.hook_name.trim() : ""; const hookName = hookNameFromEvent.length > 0 ? hookNameFromEvent : (started?.hookName ?? ""); @@ -468,7 +492,7 @@ const createTrace2Monitor = Effect.fn("createTrace2Monitor")(function* ( if (event === "child_exit") { hookStartByChildKey.delete(childKey); - const code = traceRecord.success.exitCode; + const code = traceRecord.success.code ?? traceRecord.success.exitCode; const exitCode = typeof code === "number" && Number.isInteger(code) ? code : null; const now = yield* DateTime.now; const durationMs = started @@ -640,6 +664,17 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const { worktreesDir } = yield* ServerConfig; const crypto = yield* Crypto.Crypto; + const direnvEnvironment = yield* DirenvEnvironment; + + const approveWorktreeEnvironment = (cwd: string) => + direnvEnvironment.allow({ cwd, environment: process.env }).pipe( + Effect.catch((error) => + Effect.logWarning("Failed to approve direnv for a newly created worktree.", { + cwd, + detail: error.message, + }), + ), + ); const executeRaw: GitVcsDriver.GitVcsDriver["Service"]["execute"] = Effect.fnUntraced( function* (input) { @@ -1571,25 +1606,52 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* body, options?: GitVcsDriver.GitCommitOptions, ) { - const args = ["commit", "-m", subject]; + const args = ["commit"]; + if (options?.disableSigning) { + args.push("--no-gpg-sign"); + } + args.push("-m", subject); const trimmedBody = body.trim(); if (trimmedBody.length > 0) { args.push("-m", trimmedBody); } - const progress = - options?.progress?.onOutputLine === undefined - ? options?.progress - : { - ...options.progress, + let hookFailed = false; + const progress: GitVcsDriver.ExecuteGitProgress = { + ...(options?.progress?.onOutputLine + ? { onStdoutLine: (line: string) => options.progress?.onOutputLine?.({ stream: "stdout", text: line }) ?? Effect.void, onStderrLine: (line: string) => options.progress?.onOutputLine?.({ stream: "stderr", text: line }) ?? Effect.void, - }; - yield* executeGit("GitVcsDriver.commit.commit", cwd, args, { + } + : {}), + ...(options?.progress?.onHookStarted + ? { onHookStarted: options.progress.onHookStarted } + : {}), + onHookFinished: (input) => { + if (input.exitCode !== null && input.exitCode !== 0) { + hookFailed = true; + } + return options?.progress?.onHookFinished?.(input) ?? Effect.void; + }, + }; + const result = yield* executeGitWithStableDiagnostics("GitVcsDriver.commit.commit", cwd, args, { + allowNonZeroExit: true, ...(options?.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), - ...(progress ? { progress } : {}), - }).pipe(Effect.asVoid); + progress, + }); + if (result.exitCode !== 0) { + return yield* new GitCommandError({ + ...gitCommandContext({ operation: "GitVcsDriver.commit.commit", cwd, args }), + detail: "Git command exited with a non-zero status.", + ...(result.exitCode === null ? {} : { exitCode: result.exitCode }), + stdoutLength: result.stdout.length, + stderrLength: result.stderr.length, + ...(!options?.disableSigning && !hookFailed && isCommitSigningFailureStderr(result.stderr) + ? { failureKind: "commit_signing_failed" as const } + : {}), + }); + } const commitSha = yield* runGitStdout("GitVcsDriver.commit.revParseHead", cwd, [ "rev-parse", "HEAD", @@ -1941,6 +2003,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* operation: "GitVcsDriver.getReviewDiffPreview.hash", command: "crypto.digest SHA-256", cwd: input.cwd, + failureKind: "unknown", detail: "Failed to hash review diff.", cause, }), @@ -2276,6 +2339,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* fallbackErrorDetail: "git worktree add failed", }); + yield* approveWorktreeEnvironment(worktreePath); + if (input.newRefName && input.baseRefName) { const remoteNames = yield* listRemoteNames(input.cwd).pipe(Effect.orElseSucceed(() => [])); const parsedBaseRef = parseRemoteRefWithRemoteNames( diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 63750e91041..8804b45de9c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2330,7 +2330,8 @@ function ChatViewContent(props: ChatViewProps) { }), ); const keybindings = useAtomValue(primaryServerKeybindingsAtom); - const availableEditors = useAtomValue(primaryServerAvailableEditorsAtom); + const activeServerConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); + const availableEditors = activeServerConfig?.availableEditors ?? []; // Prefer an instance-id match so a custom Codex instance (e.g. // `codex_personal`) surfaces its own status/message in the banner rather // than the default Codex's. Falls back to first-match-by-kind when no diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 11716e935ae..414f93f19c5 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -4,6 +4,10 @@ import { isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; +import { + buildUnsignedCommitRetryInput, + isCommitSigningFailure, +} from "@t3tools/client-runtime/state/vcs"; import type { GitActionProgressEvent, GitRunStackedActionResult, @@ -103,7 +107,7 @@ interface PendingDefaultBranchAction { includesCommit: boolean; commitMessage?: string; onConfirmed?: () => void; - filePaths?: string[]; + filePaths?: ReadonlyArray; } type PublishProviderKind = Extract< @@ -132,8 +136,9 @@ interface RunGitActionWithToastInput { skipDefaultBranchPrompt?: boolean; statusOverride?: VcsStatusResult | null; featureBranch?: boolean; + disableCommitSigning?: boolean; progressToastId?: GitActionToastId; - filePaths?: string[]; + filePaths?: ReadonlyArray; } const GIT_STATUS_WINDOW_REFRESH_DEBOUNCE_MS = 250; @@ -1019,6 +1024,7 @@ export default function GitActionsControl({ title: progress.title, description: resolveProgressDescription(progress), timeout: 0, + actionProps: undefined, data: progress.toastData, }); }, []); @@ -1250,6 +1256,7 @@ export default function GitActionsControl({ skipDefaultBranchPrompt = false, statusOverride, featureBranch = false, + disableCommitSigning = false, progressToastId, filePaths, }: RunGitActionWithToastInput) => { @@ -1326,6 +1333,7 @@ export default function GitActionsControl({ title: progressStages[0] ?? "Running git action...", description: "Waiting for Git...", timeout: 0, + actionProps: undefined, data: scopedToastData, }); } @@ -1391,6 +1399,7 @@ export default function GitActionsControl({ action, ...(commitMessage ? { commitMessage } : {}), ...(featureBranch ? { featureBranch } : {}), + ...(disableCommitSigning ? { disableCommitSigning: true } : {}), ...(filePaths ? { filePaths } : {}), onProgress: applyProgressEvent, }); @@ -1403,6 +1412,42 @@ export default function GitActionsControl({ } const error = squashAtomCommandFailure(result); + if (!disableCommitSigning && isCommitSigningFailure(error)) { + const retryInput = buildUnsignedCommitRetryInput({ + action, + ...(commitMessage ? { commitMessage } : {}), + ...(featureBranch ? { featureBranch: true } : {}), + ...(filePaths ? { filePaths } : {}), + }); + toastManager.update( + resolvedProgressToastId, + stackedThreadToast({ + type: "error", + title: "Commit signing failed", + description: "Git couldn’t sign the commit. Retry this commit without signing?", + timeout: 0, + actionProps: { + children: "Retry without signing", + onClick: () => { + void runGitActionWithToast({ + action: retryInput.action, + ...(retryInput.commitMessage !== undefined + ? { commitMessage: retryInput.commitMessage } + : {}), + ...(retryInput.filePaths !== undefined + ? { filePaths: retryInput.filePaths } + : {}), + disableCommitSigning: true, + skipDefaultBranchPrompt: true, + progressToastId: resolvedProgressToastId, + }); + }, + }, + ...(scopedToastData !== undefined ? { data: scopedToastData } : {}), + }), + ); + return; + } toastManager.update( resolvedProgressToastId, stackedThreadToast({ diff --git a/apps/web/src/components/chat/ChatHeader.test.ts b/apps/web/src/components/chat/ChatHeader.test.ts index d716092fc3e..36508296203 100644 --- a/apps/web/src/components/chat/ChatHeader.test.ts +++ b/apps/web/src/components/chat/ChatHeader.test.ts @@ -16,24 +16,24 @@ describe("shouldShowOpenInPicker", () => { ).toBe(true); }); - it("hides the picker when hosted static mode has no primary environment", () => { + it("keeps built-in applications visible when hosted static mode has no primary environment", () => { expect( shouldShowOpenInPicker({ activeProjectName: "codething-mvp", activeThreadEnvironmentId: EnvironmentId.make("environment-remote"), primaryEnvironmentId: null, }), - ).toBe(false); + ).toBe(true); }); - it("hides the picker for remote environments", () => { + it("keeps built-in applications visible for remote environments", () => { expect( shouldShowOpenInPicker({ activeProjectName: "codething-mvp", activeThreadEnvironmentId: EnvironmentId.make("environment-remote"), primaryEnvironmentId, }), - ).toBe(false); + ).toBe(true); }); it("hides the picker when there is no active project", () => { diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index 3a7d57859a8..c0bb8417e54 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -48,11 +48,7 @@ export function shouldShowOpenInPicker(input: { readonly activeThreadEnvironmentId: EnvironmentId; readonly primaryEnvironmentId: EnvironmentId | null; }): boolean { - return ( - Boolean(input.activeProjectName) && - input.primaryEnvironmentId !== null && - input.activeThreadEnvironmentId === input.primaryEnvironmentId - ); + return Boolean(input.activeProjectName); } export const ChatHeader = memo(function ChatHeader({ @@ -82,7 +78,7 @@ export const ChatHeader = memo(function ChatHeader({ const showOpenInPicker = shouldShowOpenInPicker({ activeProjectName, activeThreadEnvironmentId, - primaryEnvironmentId, + primaryEnvironmentId: null, }); return (
diff --git a/apps/web/src/components/chat/OpenInPicker.tsx b/apps/web/src/components/chat/OpenInPicker.tsx index de5a2f7cfff..2c635a0a0f0 100644 --- a/apps/web/src/components/chat/OpenInPicker.tsx +++ b/apps/web/src/components/chat/OpenInPicker.tsx @@ -1,11 +1,68 @@ -import { EditorId, type EnvironmentId, type ResolvedKeybindingsConfig } from "@t3tools/contracts"; -import { memo, useCallback, useEffect, useMemo } from "react"; +import { + EditorId, + EnvironmentId, + OpenWithEntry as OpenWithEntrySchema, + PRIMARY_LOCAL_ENVIRONMENT_ID, + type OpenWithDirectoryMode, + type OpenWithEntry, + type OpenWithEntryKind, + type OpenWithEntryPresentation, + type OpenWithEntryRef, + type ResolvedKeybindingsConfig, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import { + AppWindowIcon, + ChevronDownIcon, + Code2Icon, + FolderClosedIcon, + PlusIcon, + SettingsIcon, + SquareTerminalIcon, + Trash2Icon, + TriangleAlertIcon, +} from "lucide-react"; +import { memo, useCallback, useEffect, useId, useMemo, useState, type FormEvent } from "react"; + +import { readLegacyPreferredEditor } from "../../editorPreferences"; import { isOpenFavoriteEditorShortcut, shortcutLabelForCommand } from "../../keybindings"; -import { usePreferredEditor } from "../../editorPreferences"; -import { ChevronDownIcon, FolderClosedIcon } from "lucide-react"; +import { + mergeOpenWithOptions, + nextOpenWithEntryId, + refForOpenWithOption, + resolveEffectiveOpenWith, + type OpenWithOption, +} from "../../openWith"; +import { useClientSettings, useUpdateClientSettings } from "../../hooks/useSettings"; +import { ensureLocalApi } from "../../localApi"; +import { usePrimaryEnvironmentId } from "../../state/environments"; +import { shellEnvironment } from "../../state/shell"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { + AlertDialog, + AlertDialogClose, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogPopup, + AlertDialogTitle, +} from "../ui/alert-dialog"; import { Button } from "../ui/button"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "../ui/dialog"; import { Group, GroupSeparator } from "../ui/group"; -import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "../ui/menu"; +import { Input } from "../ui/input"; +import { Label } from "../ui/label"; +import { Menu, MenuItem, MenuPopup, MenuSeparator, MenuShortcut, MenuTrigger } from "../ui/menu"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; +import { stackedThreadToast, toastManager } from "../ui/toast"; import { AntigravityIcon, CursorIcon, @@ -31,156 +88,137 @@ import { RustRoverIcon, WebStormIcon, } from "../JetBrainsIcons"; -import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils"; -import { shellEnvironment } from "~/state/shell"; -import { useAtomCommand } from "~/state/use-atom-command"; - -type OpenInOption = { - label: string; - Icon: Icon; - value: EditorId; - kind: "brand" | "generic"; +import { cn, isMacPlatform, isWindowsPlatform, randomUUID } from "~/lib/utils"; + +type BuiltinPresentation = { + readonly label: string; + readonly Icon: Icon; + readonly value: EditorId; + readonly kind: "brand" | "generic"; }; -const resolveOptions = (platform: string, availableEditors: ReadonlyArray) => { - const baseOptions: ReadonlyArray = [ - { - label: "Cursor", - Icon: CursorIcon, - value: "cursor", - kind: "brand", - }, - { - label: "Trae", - Icon: TraeIcon, - value: "trae", - kind: "brand", - }, - { - label: "Kiro", - Icon: KiroIcon, - value: "kiro", - kind: "brand", - }, - { - label: "VS Code", - Icon: VisualStudioCode, - value: "vscode", - kind: "brand", - }, - { - label: "VS Code Insiders", - Icon: VisualStudioCodeInsiders, - value: "vscode-insiders", - kind: "brand", - }, - { - label: "VSCodium", - Icon: VSCodium, - value: "vscodium", - kind: "brand", - }, - { - label: "Zed", - Icon: Zed, - value: "zed", - kind: "brand", - }, - { - label: "Antigravity", - Icon: AntigravityIcon, - value: "antigravity", - kind: "brand", - }, - { - label: "IntelliJ IDEA", - Icon: IntelliJIdeaIcon, - value: "idea", - kind: "brand", - }, - { - label: "Aqua", - Icon: AquaIcon, - value: "aqua", - kind: "brand", - }, - { - label: "CLion", - Icon: CLionIcon, - value: "clion", - kind: "brand", - }, - { - label: "DataGrip", - Icon: DataGripIcon, - value: "datagrip", - kind: "brand", - }, - { - label: "DataSpell", - Icon: DataSpellIcon, - value: "dataspell", - kind: "brand", - }, - { - label: "GoLand", - Icon: GoLandIcon, - value: "goland", - kind: "brand", - }, - { - label: "PhpStorm", - Icon: PhpStormIcon, - value: "phpstorm", - kind: "brand", - }, - { - label: "PyCharm", - Icon: PyCharmIcon, - value: "pycharm", - kind: "brand", - }, - { - label: "Rider", - Icon: RiderIcon, - value: "rider", - kind: "brand", - }, - { - label: "RubyMine", - Icon: RubyMineIcon, - value: "rubymine", - kind: "brand", - }, - { - label: "RustRover", - Icon: RustRoverIcon, - value: "rustrover", - kind: "brand", - }, - { - label: "WebStorm", - Icon: WebStormIcon, - value: "webstorm", - kind: "brand", - }, - { - label: isMacPlatform(platform) - ? "Finder" - : isWindowsPlatform(platform) - ? "Explorer" - : "Files", - Icon: FolderClosedIcon, - value: "file-manager", - kind: "generic", - }, - ]; - const availableEditorSet = new Set(availableEditors); - return baseOptions.filter((option) => availableEditorSet.has(option.value)); +const BUILTIN_PRESENTATIONS: readonly BuiltinPresentation[] = [ + { label: "Cursor", Icon: CursorIcon, value: "cursor", kind: "brand" }, + { label: "Trae", Icon: TraeIcon, value: "trae", kind: "brand" }, + { label: "Kiro", Icon: KiroIcon, value: "kiro", kind: "brand" }, + { label: "VS Code", Icon: VisualStudioCode, value: "vscode", kind: "brand" }, + { + label: "VS Code Insiders", + Icon: VisualStudioCodeInsiders, + value: "vscode-insiders", + kind: "brand", + }, + { label: "VSCodium", Icon: VSCodium, value: "vscodium", kind: "brand" }, + { label: "Zed", Icon: Zed, value: "zed", kind: "brand" }, + { label: "Antigravity", Icon: AntigravityIcon, value: "antigravity", kind: "brand" }, + { label: "IntelliJ IDEA", Icon: IntelliJIdeaIcon, value: "idea", kind: "brand" }, + { label: "Aqua", Icon: AquaIcon, value: "aqua", kind: "brand" }, + { label: "CLion", Icon: CLionIcon, value: "clion", kind: "brand" }, + { label: "DataGrip", Icon: DataGripIcon, value: "datagrip", kind: "brand" }, + { label: "DataSpell", Icon: DataSpellIcon, value: "dataspell", kind: "brand" }, + { label: "GoLand", Icon: GoLandIcon, value: "goland", kind: "brand" }, + { label: "PhpStorm", Icon: PhpStormIcon, value: "phpstorm", kind: "brand" }, + { label: "PyCharm", Icon: PyCharmIcon, value: "pycharm", kind: "brand" }, + { label: "Rider", Icon: RiderIcon, value: "rider", kind: "brand" }, + { label: "RubyMine", Icon: RubyMineIcon, value: "rubymine", kind: "brand" }, + { label: "RustRover", Icon: RustRoverIcon, value: "rustrover", kind: "brand" }, + { label: "WebStorm", Icon: WebStormIcon, value: "webstorm", kind: "brand" }, + { label: "File Manager", Icon: FolderClosedIcon, value: "file-manager", kind: "generic" }, +]; + +const DESKTOP_PRIMARY_ENVIRONMENT_ID = EnvironmentId.make(PRIMARY_LOCAL_ENVIRONMENT_ID); + +const builtinPresentationById = new Map(BUILTIN_PRESENTATIONS.map((entry) => [entry.value, entry])); +const decodeOpenWithEntry = Schema.decodeUnknownSync(OpenWithEntrySchema); + +type ArgumentRow = { readonly id: string; readonly value: string }; +const makeArgumentRow = (value = ""): ArgumentRow => ({ id: randomUUID(), value }); + +const OPEN_WITH_KIND_LABELS: Record = { + editor: "Editor", + terminal: "Terminal", + "file-manager": "File manager", + other: "Other", +}; + +const DIRECTORY_MODE_LABELS: Record = { + "open-target": "Open target", + "working-directory": "Working directory", + "custom-arguments": "Custom arguments", }; -function getOpenInIconClass(kind: OpenInOption["kind"]) { - return cn(kind === "brand" ? "text-foreground opacity-100" : "text-muted-foreground"); +function categoryIcon(kind: OpenWithEntryKind): Icon { + if (kind === "editor") return Code2Icon; + if (kind === "terminal") return SquareTerminalIcon; + if (kind === "file-manager") return FolderClosedIcon; + return AppWindowIcon; +} + +function CustomIcon({ + entry, + presentation, + className, +}: { + entry: OpenWithEntry; + presentation: OpenWithEntryPresentation | null; + className?: string; +}) { + if (presentation?.iconDataUrl) { + return ; + } + const FallbackIcon = categoryIcon(entry.kind); + return