diff --git a/.gitattributes b/.gitattributes index 705bd8f43e00..00b0e5eccb98 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,5 @@ # git autocrlf=true converts LF to CRLF on Windows, causing issues with oxfmt * text=auto eol=lf +# Batch files need CRLF for cmd.exe to parse them reliably. +*.cmd text eol=crlf +*.bat text eol=crlf diff --git a/AGENTS.md b/AGENTS.md index 48a6374ab0af..e3b5771d797c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,7 +72,7 @@ The most common defect in this repo is a change that works on the path you teste - **Contracts.** Anything crossing the wire is typed in `packages/contracts`. Change the schema and the server, web, mobile, and desktop all follow. - **Reverse states.** If you added a way in, add the way out and the way to see it. Snooze needs unsnooze. Close needs reopen. A one-way door is a bug. - **Connection modes.** Local, remote/relay, and tunnel behave differently. Multi-device and multi-environment cases are real. -- **Docs.** `docs/` splits by audience. Behavior changes that a user would notice belong in `docs/user/` (shipped-product voice, no repo tooling or source paths); architecture and contributor changes in `docs/internals/`; runbooks in `docs/operations/`; new vocabulary in `docs/internals/glossary.md`. +- **Docs.** Check whether the change makes existing guidance inaccurate. Apply the [documentation rules](#documentation) before adding anything. ## Dev servers @@ -120,11 +120,22 @@ An empty database is a bad test. Seed your worktree's `.t3` with a copy of real - One concern per PR. If the description says "also", split it. - When babysitting: poll checks and comments newer than the last push, verify each bot finding against the source, fix real ones, dismiss false positives with a written reason. Stay quiet when nothing is new. Stop when the bots are green on the latest commit. +## Documentation + +Most code changes do not need an internal documentation change. Agents can read the code. + +- `docs/internals/` is for architectural decisions and their reasons, constraints that span components, and implementation traps that are hard to discover from the source. Before adding a paragraph, ask what a maintainer would get wrong without it. If reading the relevant code answers the question, leave it out. +- Do not document every feature, enumerate fields or methods, narrate control flow, maintain file catalogs, or append PR summaries. Types, tests, and code already record the implementation. The glossary defines shared vocabulary; it is not a feature index. +- Keep a local implementation explanation in a nearby code comment. Use an internal doc when the reasoning crosses boundaries or needs context the code cannot carry well. Link to the relevant source instead of copying it. +- When a documented decision or constraint changes, rewrite or remove the affected text. Do not append another account of the new behavior. A new internal page needs a distinct, durable reason to exist. +- `docs/user/` helps users accomplish tasks. Give each major feature a concise section explaining what it does, how to start, and anything unintuitive. A settings path is useful; descriptions of visible buttons, icons, layouts, animations, or every UI state are not. Before adding text, ask what task or decision it helps the user with. +- Keep user docs in the shipped product's voice, without implementation details or contributor tooling. Update the relevant feature section when how to use it changes. A UI tweak does not need a documentation entry, and a new control does not need its own page. +- `docs/operations/` holds maintainer setup, release, and debugging procedures. Keep instructions for operating an installed T3 Code server in the user guides. + ## Plans and work artifacts - Do not commit implementation plans, research notes, or agent scratch files. Keep temporary working material outside the worktree. `.plans/` is gitignored only as a safety net for legacy tooling. - Track active maintainer work in the GitHub issue or project item that owns it. External proposals follow `CONTRIBUTING.md` and belong in Ideas discussions. -- Put durable architecture, constraints, and decisions in `docs/internals/`. Update those docs when the product changes so agents find current facts instead of abandoned intentions. - A merged PR is the implementation record. Close or update its tracking item when the work lands; do not preserve a second checklist in the repository. ## How it works diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5cd75ef96edb..caa8f8309bb9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,7 +2,7 @@ ## Developer Setup -See the [maintainer scripts guide](docs/internals/scripts.md#first-checkout) for the initial checkout, +See the [development runbook](docs/operations/development.md#first-checkout) for the initial checkout, development commands, tests, and platform-specific desktop packaging prerequisites. ## Read This First @@ -49,6 +49,10 @@ Explain exactly what changed. Explain exactly why the change should exist. +Follow the [documentation rules](AGENTS.md#documentation). Keep internal docs for decisions and +hard-to-discover constraints. Update user guides when how to use a feature changes; skip descriptions +of obvious controls and cosmetic changes. + Do not mix unrelated fixes together. If the PR makes anything resembling a UI change, include clear before/after images. diff --git a/README.md b/README.md index f1cc79730843..27b5dc491693 100644 --- a/README.md +++ b/README.md @@ -79,12 +79,12 @@ Full docs live in [docs/](./docs). There's no docs site yet. - [Install and first run](./docs/user/install.md) - [Permission modes](./docs/user/permission-modes.md) - [Keyboard shortcuts](./docs/user/keybindings.md) -- [Customize a project icon](./docs/user/project-settings.md) +- [Project settings](./docs/user/project-settings.md) - [Remote access from a phone or another machine](./docs/user/remote-access.md) - [Keeping app and server in sync](./docs/user/updating.md) - [Source control integrations](./docs/user/source-control.md) - Multiple accounts: [Codex](./docs/user/providers-codex.md) · [Claude](./docs/user/providers-claude.md) -- Linux: [run T3 Code as a background service](./docs/user/background-service.md) +- [Run T3 Code as a background service](./docs/user/background-service.md) Building from source? Start at [docs/internals/overview.md](./docs/internals/overview.md). diff --git a/apps/desktop/scripts/electron-launcher.mjs b/apps/desktop/scripts/electron-launcher.mjs index 07fb87b051f1..496a8a27a7b6 100644 --- a/apps/desktop/scripts/electron-launcher.mjs +++ b/apps/desktop/scripts/electron-launcher.mjs @@ -165,10 +165,14 @@ function registerMacLauncherBundle(appBundlePath) { } } +// Bundle-internal paths are macOS paths whatever host builds them. export function resolveMacLauncherIconPaths(runtimeDir, development = isDevelopment) { return { sourceIconPath: development ? developmentMacIconPngPath : productionMacIconPngPath, - generatedIconPath: NodePath.join(runtimeDir, development ? "icon-dev.icns" : "icon-prod.icns"), + generatedIconPath: NodePath.posix.join( + runtimeDir, + development ? "icon-dev.icns" : "icon-prod.icns", + ), }; } @@ -280,12 +284,12 @@ function readJson(path) { } export function resolveMacLauncherPaths(appBundlePath, displayName = APP_DISPLAY_NAME) { - const executableDir = NodePath.join(appBundlePath, "Contents", "MacOS"); + const executableDir = NodePath.posix.join(appBundlePath, "Contents", "MacOS"); const launcherExecutableName = `${displayName} Launcher`; return { launcherExecutableName, - launcherBinaryPath: NodePath.join(executableDir, launcherExecutableName), - runtimeElectronBinaryPath: NodePath.join(executableDir, "Electron"), + launcherBinaryPath: NodePath.posix.join(executableDir, launcherExecutableName), + runtimeElectronBinaryPath: NodePath.posix.join(executableDir, "Electron"), }; } diff --git a/apps/desktop/scripts/electron-launcher.test.mjs b/apps/desktop/scripts/electron-launcher.test.mjs index 1ed5a1b8ebf9..9d2a907c73d6 100644 --- a/apps/desktop/scripts/electron-launcher.test.mjs +++ b/apps/desktop/scripts/electron-launcher.test.mjs @@ -84,9 +84,10 @@ describe("electron development launcher", () => { const development = resolveMacLauncherIconPaths("/runtime", true); const production = resolveMacLauncherIconPaths("/runtime", false); - assert.match(development.sourceIconPath, /assets\/dev\/blueprint-macos-1024\.png$/); + // The source icons are real repo paths, joined for the host. + assert.match(development.sourceIconPath, /assets[\\/]dev[\\/]blueprint-macos-1024\.png$/); assert.equal(development.generatedIconPath, "/runtime/icon-dev.icns"); - assert.match(production.sourceIconPath, /assets\/prod\/black-macos-1024\.png$/); + assert.match(production.sourceIconPath, /assets[\\/]prod[\\/]black-macos-1024\.png$/); assert.equal(production.generatedIconPath, "/runtime/icon-prod.icns"); }); }); diff --git a/apps/desktop/src/app/DesktopAppIdentity.test.ts b/apps/desktop/src/app/DesktopAppIdentity.test.ts index 5c39ff304b3b..71bcf5f7aef1 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.test.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.test.ts @@ -1,3 +1,4 @@ +import * as NodePath from "@effect/platform-node/NodePath"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; @@ -88,6 +89,7 @@ const makeEnvironmentLayer = (overrides: TestEnvironmentInput = {}) => { Layer.provide( Layer.mergeAll( NodeServices.layer, + NodePath.layerPosix, DesktopConfig.layerTest({ ...env, }), diff --git a/apps/desktop/src/app/DesktopAssets.test.ts b/apps/desktop/src/app/DesktopAssets.test.ts index bb118d43d29a..78819d06e6cc 100644 --- a/apps/desktop/src/app/DesktopAssets.test.ts +++ b/apps/desktop/src/app/DesktopAssets.test.ts @@ -1,3 +1,4 @@ +import * as NodePath from "@effect/platform-node/NodePath"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; @@ -20,7 +21,11 @@ const environmentLayer = DesktopEnvironment.layer({ isPackaged: true, resourcesPath: "/Applications/T3 Code.app/Contents/Resources", runningUnderArm64Translation: false, -}).pipe(Layer.provide(Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({})))); +}).pipe( + Layer.provide( + Layer.mergeAll(NodeServices.layer, NodePath.layerPosix, DesktopConfig.layerTest({})), + ), +); describe("DesktopAssets", () => { it.effect("uses canonical source-tree icons for unpackaged development", () => @@ -39,6 +44,7 @@ describe("DesktopAssets", () => { Layer.provide( Layer.mergeAll( NodeServices.layer, + NodePath.layerPosix, DesktopConfig.layerTest({ VITE_DEV_SERVER_URL: "http://localhost:5733" }), ), ), diff --git a/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts b/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts index c58830b30a7f..aa28ff8d86eb 100644 --- a/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts +++ b/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts @@ -4,6 +4,7 @@ import { ConnectionCatalogDocument } from "@t3tools/client-runtime/platform"; import { EnvironmentId, type PersistedSavedEnvironmentRecord } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; @@ -226,10 +227,11 @@ describe("DesktopConnectionCatalogStore", () => { it.effect("surfaces malformed catalog documents without deleting them", () => withStore( Effect.gen(function* () { + const path = yield* Path.Path; const environment = yield* DesktopEnvironment.DesktopEnvironment; const fileSystem = yield* FileSystem.FileSystem; const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore; - const catalogPath = `${environment.stateDir}/connection-catalog.json`; + const catalogPath = path.join(environment.stateDir, "connection-catalog.json"); yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); yield* fileSystem.writeFileString(catalogPath, "{not-json"); @@ -247,6 +249,7 @@ describe("DesktopConnectionCatalogStore", () => { it.effect("surfaces catalog filesystem failures instead of treating them as missing", () => Effect.gen(function* () { + const path = yield* Path.Path; const baseFileSystem = yield* FileSystem.FileSystem; const baseDir = yield* baseFileSystem.makeTempDirectoryScoped({ prefix: "t3-desktop-connection-catalog-test-", @@ -255,7 +258,7 @@ describe("DesktopConnectionCatalogStore", () => { _tag: "PermissionDenied", module: "FileSystem", method: "readFileString", - pathOrDescriptor: `${baseDir}/userdata/connection-catalog.json`, + pathOrDescriptor: path.join(baseDir, "userdata", "connection-catalog.json"), }); const fileSystemLayer = Layer.succeed( FileSystem.FileSystem, @@ -272,11 +275,11 @@ describe("DesktopConnectionCatalogStore", () => { error, DesktopConnectionCatalogStore.DesktopConnectionCatalogStoreReadError, ); - assert.equal(error.catalogPath, `${baseDir}/userdata/connection-catalog.json`); + assert.equal(error.catalogPath, path.join(baseDir, "userdata", "connection-catalog.json")); assert.strictEqual(error.cause, permissionError); assert.equal( error.message, - `Failed to read the desktop connection catalog at ${baseDir}/userdata/connection-catalog.json.`, + `Failed to read the desktop connection catalog at ${path.join(baseDir, "userdata", "connection-catalog.json")}.`, ); assert.notEqual(error.message, permissionError.message); }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), @@ -285,6 +288,7 @@ describe("DesktopConnectionCatalogStore", () => { it.effect("reports the failed catalog write operation and path", () => Effect.gen(function* () { const baseFileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const baseDir = yield* baseFileSystem.makeTempDirectoryScoped({ prefix: "t3-desktop-connection-catalog-test-", }); @@ -292,7 +296,7 @@ describe("DesktopConnectionCatalogStore", () => { _tag: "PermissionDenied", module: "FileSystem", method: "makeDirectory", - pathOrDescriptor: `${baseDir}/userdata`, + pathOrDescriptor: path.join(baseDir, "userdata"), }); const fileSystemLayer = Layer.succeed( FileSystem.FileSystem, @@ -310,11 +314,11 @@ describe("DesktopConnectionCatalogStore", () => { DesktopConnectionCatalogStore.DesktopConnectionCatalogStoreWriteError, ); assert.equal(error.operation, "create-directory"); - assert.equal(error.path, `${baseDir}/userdata`); + assert.equal(error.path, path.join(baseDir, "userdata")); assert.strictEqual(error.cause, permissionError); assert.equal( error.message, - `Desktop connection catalog write failed during create-directory at ${baseDir}/userdata.`, + `Desktop connection catalog write failed during create-directory at ${path.join(baseDir, "userdata")}.`, ); assert.notEqual(error.message, permissionError.message); }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), @@ -323,6 +327,7 @@ describe("DesktopConnectionCatalogStore", () => { it.effect("reports the legacy migration stage", () => withStore( Effect.gen(function* () { + const path = yield* Path.Path; const environment = yield* DesktopEnvironment.DesktopEnvironment; const fileSystem = yield* FileSystem.FileSystem; const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore; @@ -335,7 +340,7 @@ describe("DesktopConnectionCatalogStore", () => { DesktopConnectionCatalogStore.DesktopConnectionCatalogStoreMigrationError, ); assert.equal(error.operation, "read-legacy-registry"); - assert.equal(error.catalogPath, `${environment.stateDir}/connection-catalog.json`); + assert.equal(error.catalogPath, path.join(environment.stateDir, "connection-catalog.json")); assert.instanceOf( error.cause, DesktopSavedEnvironments.DesktopSavedEnvironmentsDocumentDecodeError, @@ -345,7 +350,7 @@ describe("DesktopConnectionCatalogStore", () => { assert.exists(registryError.cause); assert.equal( error.message, - `Legacy desktop saved-environment migration failed during read-legacy-registry into ${environment.stateDir}/connection-catalog.json.`, + `Legacy desktop saved-environment migration failed during read-legacy-registry into ${path.join(environment.stateDir, "connection-catalog.json")}.`, ); assert.notEqual(error.message, registryError.message); }), @@ -355,10 +360,11 @@ describe("DesktopConnectionCatalogStore", () => { it.effect("reports invalid encrypted catalog data without exposing it", () => withStore( Effect.gen(function* () { + const path = yield* Path.Path; const environment = yield* DesktopEnvironment.DesktopEnvironment; const fileSystem = yield* FileSystem.FileSystem; const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore; - const catalogPath = `${environment.stateDir}/connection-catalog.json`; + const catalogPath = path.join(environment.stateDir, "connection-catalog.json"); yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); yield* fileSystem.writeFileString(catalogPath, '{"version":1,"encryptedCatalog":"%%%"}\n'); @@ -381,6 +387,7 @@ describe("DesktopConnectionCatalogStore", () => { it.effect("surfaces a catalog that can no longer be decrypted without deleting it", () => Effect.gen(function* () { + const path = yield* Path.Path; const fileSystem = yield* FileSystem.FileSystem; const baseDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-desktop-connection-catalog-test-", @@ -399,14 +406,14 @@ describe("DesktopConnectionCatalogStore", () => { DesktopConnectionCatalogStore.DesktopConnectionCatalogStoreProtectionError, ); assert.equal(error.operation, "decrypt-catalog"); - assert.equal(error.catalogPath, `${baseDir}/userdata/connection-catalog.json`); + assert.equal(error.catalogPath, path.join(baseDir, "userdata", "connection-catalog.json")); assert.instanceOf(error.cause, ElectronSafeStorage.ElectronSafeStorageDecryptError); const decryptError = error.cause as ElectronSafeStorage.ElectronSafeStorageDecryptError; assert.instanceOf(decryptError.cause, Error); assert.equal(decryptError.cause.message, "invalid encrypted catalog"); assert.equal( error.message, - `Desktop connection catalog protection failed during decrypt-catalog at ${baseDir}/userdata/connection-catalog.json.`, + `Desktop connection catalog protection failed during decrypt-catalog at ${path.join(baseDir, "userdata", "connection-catalog.json")}.`, ); assert.notEqual(error.message, decryptError.message); yield* Ref.set(failDecrypt, false); diff --git a/apps/desktop/src/app/DesktopEnvironment.test.ts b/apps/desktop/src/app/DesktopEnvironment.test.ts index 218e2c3e4ba2..89cc592831a7 100644 --- a/apps/desktop/src/app/DesktopEnvironment.test.ts +++ b/apps/desktop/src/app/DesktopEnvironment.test.ts @@ -1,3 +1,4 @@ +import * as NodePath from "@effect/platform-node/NodePath"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; @@ -26,7 +27,11 @@ const makeEnvironmentLayer = ( DesktopEnvironment.layer({ ...defaultInput, ...overrides, - }).pipe(Layer.provide(Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest(env)))); + }).pipe( + Layer.provide( + Layer.mergeAll(NodeServices.layer, NodePath.layerPosix, DesktopConfig.layerTest(env)), + ), + ); const makeEnvironment = ( overrides: Partial = {}, diff --git a/apps/desktop/src/electron/ElectronShell.test.ts b/apps/desktop/src/electron/ElectronShell.test.ts index 9ae6f502b000..17f3e06039b6 100644 --- a/apps/desktop/src/electron/ElectronShell.test.ts +++ b/apps/desktop/src/electron/ElectronShell.test.ts @@ -36,6 +36,20 @@ describe("ElectronShell", () => { }).pipe(Effect.provide(ElectronShell.layer)), ); + it.effect("opens the Full Disk Access settings anchor", () => + Effect.gen(function* () { + openExternalMock.mockResolvedValue(undefined); + + const electronShell = yield* ElectronShell.ElectronShell; + const result = yield* electronShell.openSystemSettings("full-disk-access"); + + assert.equal(result, true); + assert.deepEqual(openExternalMock.mock.calls, [ + ["x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_AllFiles"], + ]); + }).pipe(Effect.provide(ElectronShell.layer)), + ); + it.effect("opens remote SSH editor URLs", () => Effect.gen(function* () { openExternalMock.mockResolvedValue(undefined); diff --git a/apps/desktop/src/electron/ElectronShell.ts b/apps/desktop/src/electron/ElectronShell.ts index 2ed13bfebd0f..0ac4f8f9cc6a 100644 --- a/apps/desktop/src/electron/ElectronShell.ts +++ b/apps/desktop/src/electron/ElectronShell.ts @@ -1,4 +1,8 @@ -import { REMOTE_CAPABLE_EDITOR_IDS, remoteSchemeForEditor } from "@t3tools/contracts"; +import { + REMOTE_CAPABLE_EDITOR_IDS, + remoteSchemeForEditor, + type SystemSettingsPane, +} from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -6,6 +10,20 @@ import * as Option from "effect/Option"; import * as Electron from "electron"; +/** + * Deep links to individual System Settings panes. These are app-fixed, not + * renderer-supplied, so they skip `parseSafeExternalUrl` — which exists to keep + * arbitrary link schemes from reaching the OS handler — and open through their + * own path below. The pane rather than the URL crosses the IPC boundary, so a + * renderer can only ask for one of these known destinations. + * + * Full Disk Access uses the post-Ventura `PrivacySecurity.extension` anchor. + */ +const SYSTEM_SETTINGS_URLS: Record = { + "full-disk-access": + "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_AllFiles", +}; + // Remote open-in-editor deep links (`vscode://vscode-remote/ssh-remote+…`) // must reach the OS handler; every other non-web scheme stays blocked. const SAFE_WEB_PROTOCOLS = new Set(["http:", "https:"]); @@ -43,6 +61,8 @@ export class ElectronShell extends Context.Service< ElectronShell, { readonly openExternal: (rawUrl: unknown) => Effect.Effect; + /** Opens a known System Settings pane by identifier, not by URL. */ + readonly openSystemSettings: (pane: SystemSettingsPane) => Effect.Effect; readonly copyText: (text: string) => Effect.Effect; } >()("@t3tools/desktop/electron/ElectronShell") {} @@ -59,6 +79,13 @@ export const make = ElectronShell.of({ ), ), }), + openSystemSettings: (pane) => + Effect.promise(() => + Electron.shell.openExternal(SYSTEM_SETTINGS_URLS[pane]).then( + () => true, + () => false, + ), + ), copyText: (text) => Effect.sync(() => { Electron.clipboard.writeText(text); diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 2cdffbefb7ad..3e30083064af 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -38,6 +38,7 @@ import { getSystemLocale, getWindowFullscreenState, openExternal, + openSystemSettings, probeRemoteEditors, pickFolder, pickProjectFavicon, @@ -94,6 +95,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(setTheme); yield* ipc.handle(showContextMenu); yield* ipc.handle(openExternal); + yield* ipc.handle(openSystemSettings); yield* ipc.handle(probeRemoteEditors); yield* ipc.handle(getUpdateState); yield* ipc.handle(setUpdateChannel); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 81b50d165d24..5b2c815eaa42 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -4,6 +4,7 @@ export const PICK_THEME_FILES_CHANNEL = "desktop:pick-theme-files"; 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 OPEN_SYSTEM_SETTINGS_CHANNEL = "desktop:open-system-settings"; export const PROBE_REMOTE_EDITORS_CHANNEL = "desktop:probe-remote-editors"; export const MENU_ACTION_CHANNEL = "desktop:menu-action"; export const QUIT_SHORTCUT_CHANNEL = "desktop:quit-shortcut"; diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index edae8394302c..61de1361a311 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -9,6 +9,7 @@ import { PickFolderOptionsSchema, PRIMARY_LOCAL_ENVIRONMENT_ID, REMOTE_CAPABLE_EDITOR_IDS, + SystemSettingsPaneSchema, type DesktopEnvironmentBootstrap, type PickedThemeFile, } from "@t3tools/contracts"; @@ -298,6 +299,16 @@ export const openExternal = DesktopIpc.makeIpcMethod({ }), }); +export const openSystemSettings = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.OPEN_SYSTEM_SETTINGS_CHANNEL, + payload: SystemSettingsPaneSchema, + result: Schema.Boolean, + handler: Effect.fn("desktop.ipc.window.openSystemSettings")(function* (pane) { + const shell = yield* ElectronShell.ElectronShell; + return yield* shell.openSystemSettings(pane); + }), +}); + export const probeRemoteEditors = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PROBE_REMOTE_EDITORS_CHANNEL, payload: Schema.Undefined, diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 685a9b1204db..74001dd785d3 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -116,6 +116,8 @@ contextBridge.exposeInMainWorld("desktopBridge", { ...(position === undefined ? {} : { position }), }), openExternal: (url: string) => ipcRenderer.invoke(IpcChannels.OPEN_EXTERNAL_CHANNEL, url), + openSystemSettings: (pane: string) => + ipcRenderer.invoke(IpcChannels.OPEN_SYSTEM_SETTINGS_CHANNEL, pane), probeRemoteEditors: () => ipcRenderer.invoke(IpcChannels.PROBE_REMOTE_EDITORS_CHANNEL, undefined), onMenuAction: (listener) => { const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => { diff --git a/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts b/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts index 9b0a652f09f1..003461085376 100644 --- a/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts +++ b/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts @@ -14,6 +14,7 @@ import * as Ref from "effect/Ref"; import * as BrowserSession from "../BrowserSession.ts"; import * as BrowserImport from "./BrowserImport.ts"; import { BROWSER_IMPORT_SOURCES, sourcePathContext } from "./Sources.ts"; +import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; const helium = BROWSER_IMPORT_SOURCES.find((source) => source.id === "helium")!; @@ -105,28 +106,30 @@ describe("BrowserImport.importCookies", () => { }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), ); - it.effect("refuses to import while the source browser holds its profile", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const { importer, root } = yield* withImporter(); - // The lock Chromium leaves while it is running, dangling target and - // all. This must stop the import before it ever asks the keychain. - yield* fileSystem.symlink("host-that-does-not-exist-1234", `${root}/SingletonLock`); - - const error = yield* importer - .importCookies({ - input: { - sourceId: "helium", - sourceProfileDirectory: "Default", - targetProfileId: "default", - }, - scope: "persist:t3code-preview-test", - persistent: true, - }) - .pipe(Effect.flip); + it.effect.skipIf(!symlinksSupported)( + "refuses to import while the source browser holds its profile", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const { importer, root } = yield* withImporter(); + // The lock Chromium leaves while it is running, dangling target and + // all. This must stop the import before it ever asks the keychain. + yield* fileSystem.symlink("host-that-does-not-exist-1234", `${root}/SingletonLock`); + + const error = yield* importer + .importCookies({ + input: { + sourceId: "helium", + sourceProfileDirectory: "Default", + targetProfileId: "default", + }, + scope: "persist:t3code-preview-test", + persistent: true, + }) + .pipe(Effect.flip); - assert.equal(error.reason, "browserRunning"); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + assert.equal(error.reason, "browserRunning"); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), ); }); diff --git a/apps/desktop/src/preview/BrowserImport/BrowserImport.ts b/apps/desktop/src/preview/BrowserImport/BrowserImport.ts index e92f2f05e05c..386b3ef6f813 100644 --- a/apps/desktop/src/preview/BrowserImport/BrowserImport.ts +++ b/apps/desktop/src/preview/BrowserImport/BrowserImport.ts @@ -27,6 +27,7 @@ import * as BrowserSession from "../BrowserSession.ts"; import { ChromiumCookieReadError, readChromiumCookies } from "./ChromiumCookies.ts"; import type { CookieReadResult } from "./CookieDatabase.ts"; import { FirefoxCookieReadError, readFirefoxCookies } from "./FirefoxCookies.ts"; +import { readSafariCookies, safariAccessDenied, SafariCookieReadError } from "./SafariCookies.ts"; import { BROWSER_IMPORT_SOURCES, resolveCookieDatabase, @@ -92,6 +93,15 @@ const unavailableReason = Effect.fn("BrowserImport.unavailableReason")(function* if (!definition.platforms.includes(context.platform)) return "unsupportedPlatform"; if (!(yield* isSourceInstalled(definition, context))) return "notInstalled"; if (yield* isSourceRunning(definition, context)) return "browserRunning"; + // Safari's jar is found by `stat`, which TCC permits without Full Disk + // Access — so a Safari that lists as ready may still refuse the read. Probe + // the grant here, so the wizard can open on the permission step and a + // post-grant recheck can tell granted from still-denied, rather than only + // discovering it by attempting the import. + if (definition.engine === "safari") { + const jar = yield* resolveCookieDatabase(definition, context, "."); + if (jar !== undefined && (yield* safariAccessDenied(jar))) return "needsFullDiskAccess"; + } return undefined; }); @@ -254,25 +264,29 @@ export const make = Effect.gen(function* BrowserImportMake() { const userDataDirectory = definition.userDataDirectory(pathContext); const read: Effect.Effect< CookieReadResult, - ChromiumCookieReadError | FirefoxCookieReadError, + ChromiumCookieReadError | FirefoxCookieReadError | SafariCookieReadError, FileSystem.FileSystem | Path.Path | Scope.Scope | ChildProcessSpawner.ChildProcessSpawner > = - definition.engine === "firefox" - ? readFirefoxCookies(databasePath).pipe( + definition.engine === "safari" + ? readSafariCookies(databasePath).pipe( Effect.map((cookies) => ({ cookies, undecryptable: 0, undecryptableHosts: [] })), ) - : readChromiumCookies({ - cookieDatabasePath: databasePath, - keychainService: definition.keychainService, - keychainAccount: definition.keychainAccount, - linuxSecretApplication: definition.linuxSecretApplication, - ...(platform === "win32" && userDataDirectory !== undefined - ? { - windowsLocalStatePath: pathContext.path.join(userDataDirectory, "Local State"), - } - : {}), - platform, - }); + : definition.engine === "firefox" + ? readFirefoxCookies(databasePath).pipe( + Effect.map((cookies) => ({ cookies, undecryptable: 0, undecryptableHosts: [] })), + ) + : readChromiumCookies({ + cookieDatabasePath: databasePath, + keychainService: definition.keychainService, + keychainAccount: definition.keychainAccount, + linuxSecretApplication: definition.linuxSecretApplication, + ...(platform === "win32" && userDataDirectory !== undefined + ? { + windowsLocalStatePath: pathContext.path.join(userDataDirectory, "Local State"), + } + : {}), + platform, + }); const result = yield* read.pipe( Effect.scoped, @@ -289,6 +303,12 @@ export const make = Effect.gen(function* BrowserImportMake() { Effect.fail( new BrowserImportFailedError({ sourceId: definition.id, reason: "readFailed", cause }), ), + // Safari's reasons are already user-facing: a TCC refusal is the Full + // Disk Access prompt, anything else is a read failure. + SafariCookieReadError: (cause) => + Effect.fail( + new BrowserImportFailedError({ sourceId: definition.id, reason: cause.reason, cause }), + ), }), ); diff --git a/apps/desktop/src/preview/BrowserImport/SafariCookies.test.ts b/apps/desktop/src/preview/BrowserImport/SafariCookies.test.ts new file mode 100644 index 000000000000..f5d07f765943 --- /dev/null +++ b/apps/desktop/src/preview/BrowserImport/SafariCookies.test.ts @@ -0,0 +1,443 @@ +// @effect-diagnostics nodeBuiltinImport:off - Hand-builds Safari's binary jar +// format byte by byte. +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as PlatformError from "effect/PlatformError"; + +import { + isPermissionDenied, + parseBinaryCookies, + readSafariCookies, + safariAccessDenied, + SafariCookieReadError, +} from "./SafariCookies.ts"; + +const APPLE_EPOCH_OFFSET_SECONDS = 978_307_200; + +interface FixtureCookie { + readonly domain: string; + readonly name: string; + readonly path: string; + readonly value: string; + readonly flags: number; + /** Seconds since 2001-01-01, as Safari stores them. */ + readonly expiry: number; +} + +/** Encodes one cookie exactly as Safari lays it out. */ +function encodeCookie(cookie: FixtureCookie): Buffer { + const strings = [cookie.domain, cookie.name, cookie.path, cookie.value]; + const headerSize = 56; + const offsets: number[] = []; + let cursor = headerSize; + for (const value of strings) { + offsets.push(cursor); + cursor += Buffer.byteLength(value) + 1; + } + const size = cursor; + + const buffer = Buffer.alloc(size); + buffer.writeUInt32LE(size, 0); + buffer.writeUInt32LE(0, 4); + buffer.writeUInt32LE(cookie.flags, 8); + buffer.writeUInt32LE(0, 12); + buffer.writeUInt32LE(offsets[0]!, 16); + buffer.writeUInt32LE(offsets[1]!, 20); + buffer.writeUInt32LE(offsets[2]!, 24); + buffer.writeUInt32LE(offsets[3]!, 28); + buffer.writeUInt32LE(0, 32); + buffer.writeUInt32LE(0, 36); + buffer.writeDoubleLE(cookie.expiry, 40); + buffer.writeDoubleLE(0, 48); + strings.forEach((value, index) => { + buffer.write(value, offsets[index]!, "utf8"); + }); + return buffer; +} + +/** Builds a single-page `Cookies.binarycookies` file. */ +function encodeBinaryCookies(cookies: ReadonlyArray): Buffer { + const encoded = cookies.map(encodeCookie); + const headerSize = 12 + encoded.length * 4; + const offsets: number[] = []; + let cursor = headerSize; + for (const cookie of encoded) { + offsets.push(cursor); + cursor += cookie.length; + } + + const page = Buffer.alloc(cursor); + page.writeUInt32BE(0x0000_0100, 0); + page.writeUInt32LE(encoded.length, 4); + offsets.forEach((offset, index) => page.writeUInt32LE(offset, 8 + index * 4)); + encoded.forEach((cookie, index) => cookie.copy(page, offsets[index]!)); + + const header = Buffer.alloc(8 + 4); + header.write("cook", 0, "latin1"); + header.writeUInt32BE(1, 4); + header.writeUInt32BE(page.length, 8); + return Buffer.concat([header, page]); +} + +describe("parseBinaryCookies", () => { + it("reads Safari's format and rebases its 2001 epoch", () => { + const file = encodeBinaryCookies([ + { + domain: ".apple.com", + name: "session", + path: "/", + value: "abc", + // secure | httpOnly + flags: 0x1 | 0x4, + expiry: 800_000_000, + }, + { + domain: "example.test", + name: "plain", + path: "/app", + value: "v", + flags: 0, + expiry: 0, + }, + ]); + + expect(parseBinaryCookies(file)).toEqual([ + { + url: "https://apple.com/", + name: "session", + value: "abc", + domain: ".apple.com", + path: "/", + secure: true, + httpOnly: true, + // Safari counts from 2001-01-01, Electron from 1970. + expirationDate: 800_000_000 + APPLE_EPOCH_OFFSET_SECONDS, + // The format predates SameSite; Lax is the safe modern default. + sameSite: "lax", + }, + { + url: "http://example.test/app", + name: "plain", + value: "v", + // Host-only: no leading dot in the jar, so no `domain` for Electron, + // which would otherwise re-add the dot and widen it to subdomains. + domain: undefined, + path: "/app", + secure: false, + httpOnly: false, + expirationDate: undefined, + sameSite: "lax", + }, + ]); + }); + + it("keeps __Host- cookies host-only so Electron accepts them", () => { + const file = encodeBinaryCookies([ + { domain: "example.test", name: "__Host-id", path: "/", value: "v", flags: 0x1, expiry: 0 }, + ]); + + expect(parseBinaryCookies(file)[0]).toMatchObject({ + url: "https://example.test/", + name: "__Host-id", + domain: undefined, + }); + }); + + it("brackets IPv6 hosts in the cookie URL", () => { + const file = encodeBinaryCookies([ + { domain: "::1", name: "local", path: "/", value: "v", flags: 0, expiry: 0 }, + ]); + + expect(parseBinaryCookies(file)[0]).toMatchObject({ + url: "http://[::1]/", + domain: undefined, + }); + }); + + it("reads cookies spread across multiple pages", () => { + // Safari pages its cookie file, and a single-page reader would silently + // return only the first slice. + const first = encodeBinaryCookies([ + { domain: "a.test", name: "one", path: "/", value: "1", flags: 0, expiry: 1 }, + ]); + const second = encodeBinaryCookies([ + { domain: "b.test", name: "two", path: "/", value: "2", flags: 0, expiry: 1 }, + ]); + // Splice the two single-page files into one two-page file. + const firstPage = first.subarray(12); + const secondPage = second.subarray(12); + const header = Buffer.alloc(16); + header.write("cook", 0, "latin1"); + header.writeUInt32BE(2, 4); + header.writeUInt32BE(firstPage.length, 8); + header.writeUInt32BE(secondPage.length, 12); + + const parsed = parseBinaryCookies(Buffer.concat([header, firstPage, secondPage])); + + expect(parsed.map((cookie) => cookie.name)).toEqual(["one", "two"]); + }); + + it("rejects a page that runs past the end of the file", () => { + // `Buffer.subarray` clamps rather than throwing, so an overlong first page + // swallows the second one's bytes and advances the cursor past the end. + // Every cookie after the boundary then vanishes from a "successful" import. + const first = encodeBinaryCookies([ + { domain: "a.test", name: "one", path: "/", value: "1", flags: 0, expiry: 1 }, + ]); + const second = encodeBinaryCookies([ + { domain: "b.test", name: "two", path: "/", value: "2", flags: 0, expiry: 1 }, + ]); + const firstPage = first.subarray(12); + const secondPage = second.subarray(12); + const header = Buffer.alloc(16); + header.write("cook", 0, "latin1"); + header.writeUInt32BE(2, 4); + // Declares more bytes for page one than the file holds in total. + header.writeUInt32BE(firstPage.length + secondPage.length + 32, 8); + header.writeUInt32BE(secondPage.length, 12); + + expect(() => parseBinaryCookies(Buffer.concat([header, firstPage, secondPage]))).toThrow( + SafariCookieReadError, + ); + }); + + it("rejects a record whose declared size runs past its page", () => { + const valid = encodeBinaryCookies([ + { domain: "a.test", name: "n", path: "/", value: "v", expiry: 1_000, flags: 0 }, + ]); + // The record's own length is what bounds its string offsets; an inflated + // one lets them read the following record's bytes as this cookie's value. + const pageStart = 8 + 4; + const recordStart = pageStart + valid.readUInt32LE(pageStart + 8); + const corrupt = Buffer.from(valid); + corrupt.writeUInt32LE(0xffff, recordStart); + + expect(() => parseBinaryCookies(corrupt)).toThrow(SafariCookieReadError); + }); + + it("rejects records truncated inside the 56-byte header", () => { + const valid = encodeBinaryCookies([ + { domain: "a.test", name: "n", path: "/", value: "v", expiry: 1_000, flags: 0 }, + ]); + const pageStart = 8 + 4; + const recordStart = pageStart + valid.readUInt32LE(pageStart + 8); + + for (let size = 48; size < 56; size += 1) { + const corrupt = Buffer.from(valid); + corrupt.writeUInt32LE(size, recordStart); + expect(() => parseBinaryCookies(corrupt), `record size ${size}`).toThrow( + SafariCookieReadError, + ); + } + }); + + it("rejects record offsets that point into the page header or an earlier record", () => { + const valid = encodeBinaryCookies([ + { domain: "a.test", name: "n", path: "/", value: "v", expiry: 1_000, flags: 0 }, + { domain: "b.test", name: "m", path: "/", value: "w", expiry: 1_000, flags: 0 }, + ]); + const pageStart = 8 + 4; + const firstRecord = valid.readUInt32LE(pageStart + 8); + + // Pointing the second offset at the page's offset table would let those + // table bytes parse as a fabricated record. + const intoTable = Buffer.from(valid); + intoTable.writeUInt32LE(4, pageStart + 12); + expect(() => parseBinaryCookies(intoTable)).toThrow(SafariCookieReadError); + + // Pointing it back at the first record makes the same bytes count twice. + const overlapping = Buffer.from(valid); + overlapping.writeUInt32LE(firstRecord, pageStart + 12); + expect(() => parseBinaryCookies(overlapping)).toThrow(SafariCookieReadError); + + // And a well-formed two-record page still parses. + expect(parseBinaryCookies(valid)).toHaveLength(2); + }); + + it("rejects string offsets that point into the record header", () => { + const valid = encodeBinaryCookies([ + { domain: "a.test", name: "n", path: "/", value: "v", expiry: 1_000, flags: 0 }, + ]); + const pageStart = 8 + 4; + const recordStart = pageStart + valid.readUInt32LE(pageStart + 8); + + for (const offsetField of [16, 20, 24, 28]) { + const corrupt = Buffer.from(valid); + corrupt.writeUInt32LE(55, recordStart + offsetField); + expect(() => parseBinaryCookies(corrupt), `offset field ${offsetField}`).toThrow( + SafariCookieReadError, + ); + } + }); + + it("accepts the checksum and property-list trailer Safari writes", () => { + const file = encodeBinaryCookies([ + { domain: "a.test", name: "c", path: "/", value: "v", flags: 0, expiry: 0 }, + ]); + const checksum = Buffer.alloc(8); + const plist = Buffer.from("bplist00 stub"); + const plistLength = Buffer.alloc(4); + plistLength.writeUInt32BE(plist.length, 0); + + expect(parseBinaryCookies(Buffer.concat([file, checksum]))).toHaveLength(1); + expect(parseBinaryCookies(Buffer.concat([file, checksum, plistLength, plist]))).toHaveLength(1); + }); + + it("rejects a jar whose page table stops short of its contents", () => { + // A second, undeclared page after the first would be silently dropped — + // the cookies it holds vanish from the import with no error — so a file + // the header does not fully describe is refused instead. + const first = encodeBinaryCookies([ + { domain: "a.test", name: "c", path: "/", value: "v", flags: 0, expiry: 0 }, + ]); + const extraPage = encodeBinaryCookies([ + { domain: "b.test", name: "d", path: "/", value: "w", flags: 0, expiry: 0 }, + ]).subarray(12); + + expect(() => parseBinaryCookies(Buffer.concat([first, extraPage]))).toThrow( + SafariCookieReadError, + ); + // A trailer that claims a property list it doesn't contain is refused too. + const badLength = Buffer.alloc(4); + badLength.writeUInt32BE(99, 0); + expect(() => + parseBinaryCookies(Buffer.concat([first, Buffer.alloc(8), badLength, Buffer.from("x")])), + ).toThrow(SafariCookieReadError); + }); + + it("rejects a file that is not binarycookies", () => { + expect(() => parseBinaryCookies(Buffer.from("not a cookie jar"))).toThrow( + SafariCookieReadError, + ); + }); +}); + +describe("readSafariCookies", () => { + it.effect("adds the cookie path and parser cause to malformed jar failures", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-safari-" }); + const jar = `${directory}/Cookies.binarycookies`; + yield* fileSystem.writeFileString(jar, "not a cookie jar"); + + const error = yield* readSafariCookies(jar).pipe(Effect.flip); + + assert.equal(error.reason, "readFailed"); + assert.equal(error.cookieDatabasePath, jar); + assert.instanceOf(error.cause, SafariCookieReadError); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); + + it.effect("reports a TCC denial as a permission the user can grant", () => + Effect.gen(function* () { + // What Full Disk Access actually looks like: the file is there, the read + // is refused with EPERM. Effect tags that `Unknown`, not + // `PermissionDenied`, so the reader has to look at the errno. Reporting + // it as a generic failure would send the user looking for a missing + // browser instead of a checkbox. + const denied = PlatformError.systemError({ + _tag: "Unknown", + module: "FileSystem", + method: "readFile", + pathOrDescriptor: "/protected/Cookies.binarycookies", + cause: Object.assign(new Error("operation not permitted"), { code: "EPERM" }), + }); + + const error = yield* readSafariCookies("/protected/Cookies.binarycookies").pipe( + Effect.flip, + Effect.provide(FileSystem.layerNoop({ readFile: () => Effect.fail(denied) })), + ); + + assert.equal(error.reason, "needsFullDiskAccess"); + }), + ); + + it.effect("reports an ordinary permission failure as a plain read failure", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-safari-" }); + const jar = `${directory}/Cookies.binarycookies`; + yield* fileSystem.writeFile(jar, new Uint8Array([0x63, 0x6f, 0x6f, 0x6b])); + // A mode-bits refusal is EACCES: granting Full Disk Access cannot fix + // it, so it must not be routed to that grant. + yield* fileSystem.chmod(jar, 0o000); + + const error = yield* readSafariCookies(jar).pipe(Effect.flip); + + assert.equal(error.reason, "readFailed"); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); + + it.effect("reports a missing jar as a plain read failure", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-safari-" }); + + const error = yield* readSafariCookies(`${directory}/absent.binarycookies`).pipe(Effect.flip); + + assert.equal(error.reason, "readFailed"); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); +}); + +describe("safariAccessDenied", () => { + const eperm = PlatformError.systemError({ + _tag: "Unknown", + module: "FileSystem", + method: "open", + pathOrDescriptor: "/protected/Cookies.binarycookies", + cause: Object.assign(new Error("operation not permitted"), { code: "EPERM" }), + }); + const denied = (error: PlatformError.PlatformError) => + FileSystem.layerNoop({ open: () => Effect.fail(error) }); + + it.effect("reports TCC's EPERM as a missing Full Disk Access grant", () => + Effect.gen(function* () { + // `stat` finds the jar without the grant, so only an open tells the + // listing whether the import would actually be allowed. + assert.isTrue( + yield* safariAccessDenied("/protected/Cookies.binarycookies").pipe( + Effect.provide(denied(eperm)), + ), + ); + }), + ); + + it.effect("does not read a readable jar, or any other failure, as denied", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-safari-" }); + const jar = `${directory}/Cookies.binarycookies`; + yield* fileSystem.writeFile(jar, new Uint8Array([0x63, 0x6f, 0x6f, 0x6b])); + assert.isFalse(yield* safariAccessDenied(jar)); + // Missing entirely is "not installed", not "denied". + assert.isFalse(yield* safariAccessDenied(`${directory}/absent.binarycookies`)); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); +}); + +describe("isPermissionDenied", () => { + // Shapes taken from a real `FileSystem.readFile` failure on macOS — verified + // against Safari's TCC-protected jar, whose denial is EPERM, tagged + // `Unknown` rather than `PermissionDenied`. + const platformError = (reasonTag: string, code: string): PlatformError.PlatformError => + ({ _tag: "PlatformError", reason: { _tag: reasonTag, cause: { code } } }) as never; + + it("treats a TCC EPERM denial as permission denied", () => { + // The regression: EPERM is tagged `Unknown`, so checking the tag alone + // reported Safari's Full Disk Access refusal as a generic read failure. + expect(isPermissionDenied(platformError("Unknown", "EPERM"))).toBe(true); + }); + + it("does not send an ordinary EACCES failure to the Full Disk Access grant", () => { + // A POSIX permission or ACL refusal cannot be fixed by granting Full Disk + // Access, so it stays a plain read failure; only TCC's EPERM routes there. + expect(isPermissionDenied(platformError("PermissionDenied", "EACCES"))).toBe(false); + }); + + it("does not treat an unrelated failure as permission denied", () => { + expect(isPermissionDenied(platformError("Unknown", "EIO"))).toBe(false); + }); +}); diff --git a/apps/desktop/src/preview/BrowserImport/SafariCookies.ts b/apps/desktop/src/preview/BrowserImport/SafariCookies.ts new file mode 100644 index 000000000000..88aa856b83e9 --- /dev/null +++ b/apps/desktop/src/preview/BrowserImport/SafariCookies.ts @@ -0,0 +1,263 @@ +/** + * Safari cookie extraction. + * + * Safari does not encrypt its cookies; it stores them in a proprietary + * `Cookies.binarycookies` file inside its app container. The protection is + * TCC, not cryptography — the file lives under a path only apps with Full Disk + * Access may read, so the gate is a permission the user grants in System + * Settings rather than a key to obtain. + * + * The format, big-endian throughout except the page bodies: + * + * magic "cook", u32 pageCount, u32 pageSize[pageCount], then each page: + * u32 0x00000100, u32le cookieCount, u32le cookieOffset[cookieCount], + * then each cookie: + * u32le size, u32le unknown, u32le flags, u32le unknown, + * u32le urlOffset, nameOffset, pathOffset, valueOffset, + * u64 end-of-header, f64 expiry, f64 creation, then NUL-terminated + * strings at the offsets above (relative to the cookie start). + * + * @module SafariCookies + */ +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as PlatformError from "effect/PlatformError"; +import * as Schema from "effect/Schema"; + +import { cookieScope, type ImportedCookie } from "./CookieDatabase.ts"; + +/** Safari's timestamps count seconds from 2001-01-01, not the UNIX epoch. */ +const APPLE_EPOCH_OFFSET_SECONDS = 978_307_200; + +/** `u32 0x00000100`, `u32le cookieCount`, then one `u32le` offset per cookie. */ +const COOKIE_PAGE_HEADER_SIZE = 12; +/** Through the `f64 creation` field; string bytes follow. */ +const COOKIE_RECORD_HEADER_SIZE = 56; + +const FLAG_SECURE = 0x1; +const FLAG_HTTP_ONLY = 0x4; + +export const SafariCookieReadFailure = Schema.Literals(["needsFullDiskAccess", "readFailed"]); +export type SafariCookieReadFailure = typeof SafariCookieReadFailure.Type; + +export class SafariCookieReadError extends Schema.TaggedErrorClass()( + "SafariCookieReadError", + { + reason: SafariCookieReadFailure, + /** + * Which jar the read was for. The parser raises this before a path is in + * hand, so it is optional rather than required. + */ + cookieDatabasePath: Schema.optional(Schema.String), + /** Kept for the log; never surfaced to the user. */ + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return this.cookieDatabasePath === undefined + ? `Could not read Safari cookies: ${this.reason}.` + : `Could not read Safari cookies at ${this.cookieDatabasePath}: ${this.reason}.`; + } +} + +const isSafariCookieReadError = Schema.is(SafariCookieReadError); + +/** Reads a NUL-terminated ASCII string at an offset. */ +function readCString(buffer: Buffer, start: number): string { + const end = buffer.indexOf(0, start); + return buffer.toString("utf8", start, end === -1 ? buffer.length : end); +} + +export function parseBinaryCookies(buffer: Buffer): ReadonlyArray { + if (buffer.length < 8 || buffer.toString("latin1", 0, 4) !== "cook") { + throw new SafariCookieReadError({ reason: "readFailed" }); + } + + const pageCount = buffer.readUInt32BE(4); + // Every declared structure is bounds-checked against what the file actually + // contains, and a mismatch fails the read. `Buffer.subarray` clamps silently, + // so accepting a short page or an overlong record would return a cookie set + // that is quietly missing entries or carrying fields read out of the next + // record — a partial import the user has no way to notice. + if (8 + pageCount * 4 > buffer.length) { + throw new SafariCookieReadError({ reason: "readFailed" }); + } + const pageSizes: number[] = []; + for (let index = 0; index < pageCount; index += 1) { + pageSizes.push(buffer.readUInt32BE(8 + index * 4)); + } + + const cookies: ImportedCookie[] = []; + let pageStart = 8 + pageCount * 4; + + for (const pageSize of pageSizes) { + if (pageSize < COOKIE_PAGE_HEADER_SIZE || pageStart + pageSize > buffer.length) { + throw new SafariCookieReadError({ reason: "readFailed" }); + } + const page = buffer.subarray(pageStart, pageStart + pageSize); + pageStart += pageSize; + + // Page bodies switch to little-endian after the big-endian header. + const cookieCount = page.readUInt32LE(4); + const offsetTableEnd = COOKIE_PAGE_HEADER_SIZE + cookieCount * 4; + if (offsetTableEnd > page.length) { + throw new SafariCookieReadError({ reason: "readFailed" }); + } + // Every record accepted so far, so a later offset cannot point back into + // one of them: the page header, the offset table, and earlier records are + // all bytes that would otherwise parse as a fabricated cookie. + const accepted: Array = []; + for (let index = 0; index < cookieCount; index += 1) { + const cookieStart = page.readUInt32LE(8 + index * 4); + if (cookieStart < offsetTableEnd || cookieStart + COOKIE_RECORD_HEADER_SIZE > page.length) { + throw new SafariCookieReadError({ reason: "readFailed" }); + } + // Bounded by the record's own length so a string offset cannot run past + // it into the following record's bytes. + const recordSize = page.readUInt32LE(cookieStart); + const cookieEnd = cookieStart + recordSize; + if ( + recordSize < COOKIE_RECORD_HEADER_SIZE || + cookieEnd > page.length || + accepted.some(([start, end]) => cookieStart < end && cookieEnd > start) + ) { + throw new SafariCookieReadError({ reason: "readFailed" }); + } + accepted.push([cookieStart, cookieEnd]); + const cookie = page.subarray(cookieStart, cookieEnd); + + const flags = cookie.readUInt32LE(8); + const urlOffset = cookie.readUInt32LE(16); + const nameOffset = cookie.readUInt32LE(20); + const pathOffset = cookie.readUInt32LE(24); + const valueOffset = cookie.readUInt32LE(28); + const expiry = cookie.readDoubleLE(40); + + // Offsets are relative to the record; one pointing outside it would + // otherwise read a neighbouring cookie's bytes as this one's value. + if ( + [urlOffset, nameOffset, pathOffset, valueOffset].some( + (offset) => offset < COOKIE_RECORD_HEADER_SIZE || offset >= cookie.length, + ) + ) { + throw new SafariCookieReadError({ reason: "readFailed" }); + } + const domain = readCString(cookie, urlOffset); + const name = readCString(cookie, nameOffset); + const path = readCString(cookie, pathOffset); + const value = readCString(cookie, valueOffset); + if (domain === "" || name === "") continue; + + const secure = (flags & FLAG_SECURE) !== 0; + const expirationDate = + expiry > 0 ? Math.floor(expiry) + APPLE_EPOCH_OFFSET_SECONDS : undefined; + + cookies.push({ + // Safari marks domain cookies with a leading dot like the other + // engines, so the shared scope rule applies: host-only cookies keep + // `domain` undefined, or Electron widens them to every subdomain. + ...cookieScope(domain, path || "/", secure), + name, + value, + path: path || "/", + secure, + httpOnly: (flags & FLAG_HTTP_ONLY) !== 0, + expirationDate, + // Bits 3–5 of the flags carry something SameSite-shaped, but no public + // description of them agrees and real jars do not match any of them + // cleanly. Lax is the modern browser default; claiming "none" would + // widen every imported cookie's scope. + sameSite: "lax", + }); + } + } + + // Safari writes an 8-byte checksum after the pages, then an optional + // length-prefixed property list. Anything else past the declared pages — + // in particular whole extra pages — means the page table does not describe + // the file, and a jar the header lies about is refused rather than + // imported with cookies silently missing. + const trailer = buffer.length - pageStart; + // Legal shapes: nothing, the 8-byte checksum alone, or checksum + u32 + // length + exactly that many property-list bytes. + const validTrailer = + trailer === 0 || + trailer === 8 || + (trailer >= 12 && trailer === 8 + 4 + buffer.readUInt32BE(pageStart + 8)); + if (!validTrailer) { + throw new SafariCookieReadError({ reason: "readFailed" }); + } + + return cookies; +} + +/** + * Whether a filesystem error is the OS refusing access. + * + * A TCC denial arrives as EPERM, which Effect tags `Unknown` rather than + * `PermissionDenied` (reserved for EACCES), so the underlying errno is checked + * too — otherwise a Full Disk Access refusal is reported as a generic read + * failure and the user is never told what to grant. + */ +export const isPermissionDenied = (error: PlatformError.PlatformError): boolean => { + // TCC denies with EPERM, which Effect tags `Unknown` rather than + // `PermissionDenied` — so the errno is what identifies it. EACCES (and the + // `PermissionDenied` tag it maps to) is an ordinary POSIX permission or + // ACL failure that granting Full Disk Access cannot fix, so it stays a plain + // read failure rather than sending the user to a grant that won't help. + const code = (error.reason as { cause?: { code?: unknown } }).cause?.code; + return code === "EPERM"; +}; + +/** + * Whether reading the jar is refused by TCC. `stat` succeeds on the jar + * inside Safari's container even without Full Disk Access — that is what lets + * the listing find it — so presence alone cannot tell granted from denied. + * Opening it for read is what TCC gates: EPERM means the grant is missing. + * Anything else (including a missing jar) is not a permission answer. + */ +export const safariAccessDenied = Effect.fnUntraced(function* (cookiePath: string) { + const fileSystem = yield* FileSystem.FileSystem; + return yield* fileSystem.open(cookiePath, { flag: "r" }).pipe( + Effect.as(false), + Effect.catch((cause) => Effect.succeed(isPermissionDenied(cause))), + Effect.scoped, + ); +}); + +export const readSafariCookies = Effect.fn("SafariCookies.readSafariCookies")(function* ( + cookiePath: string, +) { + const fileSystem = yield* FileSystem.FileSystem; + const contents = yield* fileSystem.readFile(cookiePath).pipe( + Effect.mapError((cause) => { + // TCC denies the read even though the file exists — a permission the user + // grants in System Settings rather than a missing browser. macOS never + // prompts for Full Disk Access, so there is no dialog to wait on; the + // read just fails, and it fails with EPERM, which Effect surfaces as an + // `Unknown` system error rather than `PermissionDenied` (that is EACCES). + return new SafariCookieReadError({ + reason: isPermissionDenied(cause) ? "needsFullDiskAccess" : "readFailed", + cookieDatabasePath: cookiePath, + cause, + }); + }), + ); + // The parser throws on a malformed jar; catch it here so callers see a typed + // failure rather than a defect. + return yield* Effect.try({ + try: () => parseBinaryCookies(Buffer.from(contents)), + catch: (cause) => + isSafariCookieReadError(cause) + ? new SafariCookieReadError({ + reason: cause.reason, + cookieDatabasePath: cookiePath, + cause, + }) + : new SafariCookieReadError({ + reason: "readFailed", + cookieDatabasePath: cookiePath, + cause, + }), + }); +}); diff --git a/apps/desktop/src/preview/BrowserImport/Sources.test.ts b/apps/desktop/src/preview/BrowserImport/Sources.test.ts index feaac842cbef..a867f78497b4 100644 --- a/apps/desktop/src/preview/BrowserImport/Sources.test.ts +++ b/apps/desktop/src/preview/BrowserImport/Sources.test.ts @@ -1,5 +1,6 @@ // @effect-diagnostics nodeBuiltinImport:off - Builds a Chromium-shaped cookie // table with the same native bindings the source reads. +import * as NodePath from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import { @@ -32,6 +33,7 @@ import { sourcePathContext, windowsChromiumCookiesAreHeld, } from "./Sources.ts"; +import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; const helium = BROWSER_IMPORT_SOURCES.find((source) => source.id === "helium")!; @@ -125,7 +127,7 @@ const writeFirefoxCookieDatabase = ( }); describe("Helium on Linux", () => { - it.effect("discovers its profiles and checks the user-data lock", () => + it.effect.skipIf(!symlinksSupported)("discovers its profiles and checks the user-data lock", () => run( Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -221,48 +223,52 @@ describe("isSourceRunning", () => { ), ); - it.effect("reads Chromium's dangling SingletonLock symlink as a running browser", () => - run( - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const context = yield* withSourceHome(); - assert.isFalse(yield* isSourceRunning(helium, context)); - - // Chromium points the lock at `-`, a target that never - // exists on disk. A check that follows the link reports a running - // browser as closed, letting an import read a live, mid-write database. - yield* fileSystem.symlink( - "host-that-does-not-exist-1234", - `${userDataDirectory(context)}/SingletonLock`, - ); + it.effect.skipIf(!symlinksSupported)( + "reads Chromium's dangling SingletonLock symlink as a running browser", + () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const context = yield* withSourceHome(); + assert.isFalse(yield* isSourceRunning(helium, context)); + + // Chromium points the lock at `-`, a target that never + // exists on disk. A check that follows the link reports a running + // browser as closed, letting an import read a live, mid-write database. + yield* fileSystem.symlink( + "host-that-does-not-exist-1234", + `${userDataDirectory(context)}/SingletonLock`, + ); - assert.isTrue(yield* isSourceRunning(helium, context)); - }), - ), + assert.isTrue(yield* isSourceRunning(helium, context)); + }), + ), ); - it.effect("uses the provided hostname to classify Chromium locks", () => - run( - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const paths = yield* withSourceHome(); - yield* fileSystem.symlink( - "lock-owner-99999999", - `${helium.userDataDirectory(paths)}/SingletonLock`, - ); + it.effect.skipIf(!symlinksSupported)( + "uses the provided hostname to classify Chromium locks", + () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const paths = yield* withSourceHome(); + yield* fileSystem.symlink( + "lock-owner-99999999", + `${helium.userDataDirectory(paths)}/SingletonLock`, + ); - assert.isTrue( - yield* isSourceRunning(helium, paths).pipe( - Effect.provideService(HostProcessHostname, "another-host"), - ), - ); - assert.isFalse( - yield* isSourceRunning(helium, paths).pipe( - Effect.provideService(HostProcessHostname, "lock-owner"), - ), - ); - }), - ), + assert.isTrue( + yield* isSourceRunning(helium, paths).pipe( + Effect.provideService(HostProcessHostname, "another-host"), + ), + ); + assert.isFalse( + yield* isSourceRunning(helium, paths).pipe( + Effect.provideService(HostProcessHostname, "lock-owner"), + ), + ); + }), + ), ); }); @@ -385,25 +391,27 @@ describe("isSourceInstalled", () => { ), ); - it.effect("follows cookie database symlinks when detecting profiles", () => - run( - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const context = yield* withSourceHome(); - const root = userDataDirectory(context); - yield* fileSystem.makeDirectory(`${root}/Default`, { recursive: true }); - yield* fileSystem.symlink("missing-cookies", `${root}/Default/Cookies`); + it.effect.skipIf(!symlinksSupported)( + "follows cookie database symlinks when detecting profiles", + () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const context = yield* withSourceHome(); + const root = userDataDirectory(context); + yield* fileSystem.makeDirectory(`${root}/Default`, { recursive: true }); + yield* fileSystem.symlink("missing-cookies", `${root}/Default/Cookies`); - assert.deepEqual(yield* listSourceProfiles(helium, context), []); - assert.isFalse(yield* isSourceInstalled(helium, context)); + assert.deepEqual(yield* listSourceProfiles(helium, context), []); + assert.isFalse(yield* isSourceInstalled(helium, context)); - yield* fileSystem.writeFileString(`${root}/Default/missing-cookies`, "db"); - assert.deepEqual(yield* listSourceProfiles(helium, context), [ - { directory: "Default", name: "Default" }, - ]); - assert.isTrue(yield* isSourceInstalled(helium, context)); - }), - ), + yield* fileSystem.writeFileString(`${root}/Default/missing-cookies`, "db"); + assert.deepEqual(yield* listSourceProfiles(helium, context), [ + { directory: "Default", name: "Default" }, + ]); + assert.isTrue(yield* isSourceInstalled(helium, context)); + }), + ), ); }); @@ -524,7 +532,7 @@ Path=Profiles/wxyz.empty yield* fileSystem.makeDirectory(`${root}/Profiles/wxyz.empty`, { recursive: true }); assert.deepEqual(yield* listSourceProfiles(firefox, context), [ - { directory: "Profiles/abcd.default-release", name: "original" }, + { directory: context.path.join("Profiles", "abcd.default-release"), name: "original" }, ]); }), ), @@ -605,10 +613,16 @@ describe("cookieDatabaseCandidatePaths", () => { run( Effect.gen(function* () { const context = yield* withSourceHome(); - const profile = `${context.home}/Library/Application Support/net.imput.helium/Profile 1`; + const profile = context.path.join( + context.home, + "Library", + "Application Support", + "net.imput.helium", + "Profile 1", + ); assert.deepEqual(cookieDatabaseCandidatePaths(helium, context, "Profile 1"), [ - `${profile}/Network/Cookies`, - `${profile}/Cookies`, + context.path.join(profile, "Network", "Cookies"), + context.path.join(profile, "Cookies"), ]); }), ), @@ -619,7 +633,7 @@ describe("cookieDatabaseCandidatePaths", () => { Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const context = yield* withSourceHome(); - const root = helium.userDataDirectory(context); + const root = userDataDirectory(context); // Chromium 96+ keeps sessions in Network/; a root Cookies left behind // by the move is stale and must not be the one imported. yield* fileSystem.makeDirectory(`${root}/Default/Network`, { recursive: true }); @@ -628,7 +642,7 @@ describe("cookieDatabaseCandidatePaths", () => { assert.equal( yield* resolveCookieDatabase(helium, context, "Default"), - `${root}/Default/Network/Cookies`, + context.path.join(root, "Default", "Network", "Cookies"), ); // A fresh install with only the Network/ jar is installed, not hidden. yield* fileSystem.remove(`${root}/Default/Cookies`); @@ -660,44 +674,48 @@ describe("cookieDatabaseCandidatePaths", () => { const firefox = BROWSER_IMPORT_SOURCES.find((source) => source.id === "firefox")!; describe("Firefox Snap profiles", () => { - it.effect("finds Snap profiles with or without profiles.ini and checks their locks", () => - run( - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-firefox-snap-" }); - const context = yield* sourcePathContext.pipe( - Effect.provideService(HostProcessEnvironment, { HOME: home }), - Effect.provideService(HostProcessPlatform, "linux"), - ); - const root = `${home}/snap/firefox/common/.mozilla/firefox`; - const directory = `${root}/abcd.default`; - yield* fileSystem.makeDirectory(directory, { recursive: true }); - yield* writeFirefoxCookieDatabase(`${directory}/cookies.sqlite`, 2, 1); - yield* fileSystem.writeFileString( - `${root}/profiles.ini`, - "[Profile0]\nName=Personal\nIsRelative=1\nPath=abcd.default\n", - ); + it.effect.skipIf(!symlinksSupported)( + "finds Snap profiles with or without profiles.ini and checks their locks", + () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const home = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-firefox-snap-", + }); + const context = yield* sourcePathContext.pipe( + Effect.provideService(HostProcessEnvironment, { HOME: home }), + Effect.provideService(HostProcessPlatform, "linux"), + ); + const root = context.path.join(home, "snap", "firefox", "common", ".mozilla", "firefox"); + const directory = context.path.join(root, "abcd.default"); + yield* fileSystem.makeDirectory(directory, { recursive: true }); + yield* writeFirefoxCookieDatabase(`${directory}/cookies.sqlite`, 2, 1); + yield* fileSystem.writeFileString( + `${root}/profiles.ini`, + "[Profile0]\nName=Personal\nIsRelative=1\nPath=abcd.default\n", + ); - assert.isTrue(yield* isSourceInstalled(firefox, context)); - assert.deepEqual(yield* listSourceProfiles(firefox, context), [ - { directory, name: "Personal", cookieCount: 2 }, - ]); - assert.equal( - yield* resolveCookieDatabase(firefox, context, directory), - `${directory}/cookies.sqlite`, - ); - assert.isFalse(yield* isSourceRunning(firefox, context)); - yield* fileSystem.symlink("foreign-host:+4242", `${directory}/lock`); - assert.isTrue(yield* isSourceRunning(firefox, context)); - yield* fileSystem.remove(`${directory}/lock`); - assert.isFalse(yield* isSourceRunning(firefox, context)); + assert.isTrue(yield* isSourceInstalled(firefox, context)); + assert.deepEqual(yield* listSourceProfiles(firefox, context), [ + { directory, name: "Personal", cookieCount: 2 }, + ]); + assert.equal( + yield* resolveCookieDatabase(firefox, context, directory), + context.path.join(directory, "cookies.sqlite"), + ); + assert.isFalse(yield* isSourceRunning(firefox, context)); + yield* fileSystem.symlink("foreign-host:+4242", `${directory}/lock`); + assert.isTrue(yield* isSourceRunning(firefox, context)); + yield* fileSystem.remove(`${directory}/lock`); + assert.isFalse(yield* isSourceRunning(firefox, context)); - yield* fileSystem.remove(`${root}/profiles.ini`); - assert.deepEqual(yield* listSourceProfiles(firefox, context), [ - { directory, name: "abcd.default", cookieCount: 2 }, - ]); - }), - ), + yield* fileSystem.remove(`${root}/profiles.ini`); + assert.deepEqual(yield* listSourceProfiles(firefox, context), [ + { directory, name: "abcd.default", cookieCount: 2 }, + ]); + }), + ), ); it.effect("keeps matching profile names in native and Snap installs distinct", () => @@ -709,8 +727,8 @@ describe("Firefox Snap profiles", () => { Effect.provideService(HostProcessEnvironment, { HOME: home }), Effect.provideService(HostProcessPlatform, "linux"), ); - const native = `${home}/.mozilla/firefox`; - const snap = `${home}/snap/firefox/common/.mozilla/firefox`; + const native = context.path.join(home, ".mozilla", "firefox"); + const snap = context.path.join(home, "snap", "firefox", "common", ".mozilla", "firefox"); for (const root of [native, snap]) { yield* fileSystem.makeDirectory(`${root}/abcd.default`, { recursive: true }); yield* writeFirefoxCookieDatabase(`${root}/abcd.default/cookies.sqlite`, 1, 0); @@ -724,14 +742,14 @@ describe("Firefox Snap profiles", () => { const profiles = yield* listSourceProfiles(firefox, context); assert.deepEqual( profiles.map((profile) => profile.directory), - ["abcd.default", `${snap}/abcd.default`], + ["abcd.default", context.path.join(snap, "abcd.default")], ); const databases = yield* Effect.forEach(profiles, (profile) => resolveCookieDatabase(firefox, context, profile.directory), ); assert.deepEqual(databases, [ - `${native}/abcd.default/cookies.sqlite`, - `${snap}/abcd.default/cookies.sqlite`, + context.path.join(native, "abcd.default", "cookies.sqlite"), + context.path.join(snap, "abcd.default", "cookies.sqlite"), ]); }), ), @@ -741,8 +759,8 @@ describe("Firefox Snap profiles", () => { describe("listSourceProfiles Firefox fallback", () => { const cases = [ { platform: "linux" as const, profileDirectory: "linux.default" }, - { platform: "darwin" as const, profileDirectory: "Profiles/macos.default" }, - { platform: "win32" as const, profileDirectory: "Profiles/windows.default" }, + { platform: "darwin" as const, profileDirectory: NodePath.join("Profiles", "macos.default") }, + { platform: "win32" as const, profileDirectory: NodePath.join("Profiles", "windows.default") }, ]; for (const { platform, profileDirectory } of cases) { @@ -813,7 +831,11 @@ describe("listSourceProfiles Firefox fallback", () => { // Returning the empty declared list would hide the browser entirely. assert.deepEqual(yield* listSourceProfiles(firefox, context), [ - { directory: "Profiles/real.default", name: "real.default", cookieCount: 3 }, + { + directory: path.join("Profiles", "real.default"), + name: "real.default", + cookieCount: 3, + }, ]); assert.isTrue(yield* isSourceInstalled(firefox, context)); }), @@ -844,7 +866,11 @@ describe("listSourceProfiles Firefox fallback", () => { ); assert.deepEqual(yield* listSourceProfiles(firefox, context), [ - { directory: "Profiles/declared.default", name: "Declared", cookieCount: 2 }, + { + directory: path.join("Profiles", "declared.default"), + name: "Declared", + cookieCount: 2, + }, ]); yield* fileSystem.remove(path.join(root, "profiles.ini")); @@ -853,8 +879,16 @@ describe("listSourceProfiles Firefox fallback", () => { yield* writeFirefoxCookieDatabase(path.join(fallbackDirectory, "cookies.sqlite"), 1, 4); assert.deepEqual(yield* listSourceProfiles(firefox, context), [ - { directory: "Profiles/declared.default", name: "declared.default", cookieCount: 2 }, - { directory: "Profiles/fallback.default", name: "fallback.default", cookieCount: 1 }, + { + directory: path.join("Profiles", "declared.default"), + name: "declared.default", + cookieCount: 2, + }, + { + directory: path.join("Profiles", "fallback.default"), + name: "fallback.default", + cookieCount: 1, + }, ]); }), ), @@ -862,7 +896,7 @@ describe("listSourceProfiles Firefox fallback", () => { }); describe("isSourceRunning for Firefox", () => { - it.effect("finds the lock inside the profile, not at the root", () => + it.effect.skipIf(!symlinksSupported)("finds the lock inside the profile, not at the root", () => run( Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -917,53 +951,56 @@ describe("isSourceRunning for Firefox", () => { ), ); - it.effect("detects a live fcntl lock on .parentlock, as macOS Firefox leaves it", () => - run( - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-firefox-" }); - const context = yield* sourcePathContext.pipe( - Effect.provideService(HostProcessEnvironment, { HOME: home }), - Effect.provideService(HostProcessPlatform, "darwin"), - ); - const root = firefox.userDataDirectory(context)!; - const profile = `${root}/Profiles/abcd.default-release`; - yield* fileSystem.makeDirectory(profile, { recursive: true }); - yield* fileSystem.writeFileString(`${profile}/cookies.sqlite`, "db"); - const parentLock = `${profile}/.parentlock`; - yield* fileSystem.writeFileString(parentLock, ""); - - // Hold the lock from a child the way Firefox does (F_SETLK, write), - // and keep it until the scope closes. - const holder = yield* spawner.spawn( - ChildProcess.make( - "python3", - [ - "-c", - "import fcntl,os,sys,time\n" + - "fd=os.open(sys.argv[1],os.O_WRONLY)\n" + - "fcntl.lockf(fd,fcntl.LOCK_EX|fcntl.LOCK_NB)\n" + - "print('locked',flush=True)\n" + - "time.sleep(30)", - parentLock, - ], - { stdin: "ignore" }, - ), - ); - // Wait for the child to confirm it holds the lock before probing. - yield* holder.stdout.pipe( - Stream.decodeText(), - Stream.splitLines, - Stream.filter((line) => line.trim() === "locked"), - Stream.take(1), - Stream.runDrain, - ); + // Holds the lock with python3's fcntl, which does not exist on Windows. + it.effect.skipIf(HostProcessPlatform.defaultValue() === "win32")( + "detects a live fcntl lock on .parentlock, as macOS Firefox leaves it", + () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-firefox-" }); + const context = yield* sourcePathContext.pipe( + Effect.provideService(HostProcessEnvironment, { HOME: home }), + Effect.provideService(HostProcessPlatform, "darwin"), + ); + const root = firefox.userDataDirectory(context)!; + const profile = `${root}/Profiles/abcd.default-release`; + yield* fileSystem.makeDirectory(profile, { recursive: true }); + yield* fileSystem.writeFileString(`${profile}/cookies.sqlite`, "db"); + const parentLock = `${profile}/.parentlock`; + yield* fileSystem.writeFileString(parentLock, ""); + + // Hold the lock from a child the way Firefox does (F_SETLK, write), + // and keep it until the scope closes. + const holder = yield* spawner.spawn( + ChildProcess.make( + "python3", + [ + "-c", + "import fcntl,os,sys,time\n" + + "fd=os.open(sys.argv[1],os.O_WRONLY)\n" + + "fcntl.lockf(fd,fcntl.LOCK_EX|fcntl.LOCK_NB)\n" + + "print('locked',flush=True)\n" + + "time.sleep(30)", + parentLock, + ], + { stdin: "ignore" }, + ), + ); + // Wait for the child to confirm it holds the lock before probing. + yield* holder.stdout.pipe( + Stream.decodeText(), + Stream.splitLines, + Stream.filter((line) => line.trim() === "locked"), + Stream.take(1), + Stream.runDrain, + ); - assert.isTrue(yield* isSourceRunning(firefox, context)); - yield* holder.kill(); - }), - ), + assert.isTrue(yield* isSourceRunning(firefox, context)); + yield* holder.kill(); + }), + ), ); it.effect("reads a Firefox lock symlink's pid to tell live from crashed", () => @@ -1056,3 +1093,106 @@ describe("listSourceProfiles hardening", () => { ), ); }); + +describe("Safari profiles", () => { + const safari = BROWSER_IMPORT_SOURCES.find((source) => source.id === "safari")!; + const workUuid = "C561D071-67AD-4537-866F-54F65FB8E8DD"; + const otherUuid = "2875EB19-B938-4E38-BE92-5AE97C256BDD"; + + const fixture = Effect.fnUntraced(function* () { + const context = yield* withSourceHome(); + const fileSystem = yield* FileSystem.FileSystem; + const root = safari.userDataDirectory(context)!; + const library = context.path.dirname(root); + yield* fileSystem.makeDirectory(root, { recursive: true }); + yield* fileSystem.writeFileString(context.path.join(root, "Cookies.binarycookies"), "default"); + const store = (uuid: string) => + context.path.join(library, "WebKit", "WebsiteDataStore", uuid.toLowerCase(), "Cookies"); + for (const uuid of [workUuid, otherUuid]) { + yield* fileSystem.makeDirectory(store(uuid), { recursive: true }); + yield* fileSystem.writeFileString( + context.path.join(store(uuid), "Cookies.binarycookies"), + uuid, + ); + } + yield* fileSystem.makeDirectory(context.path.join(library, "Safari"), { recursive: true }); + const metadata = context.path.join(library, "Safari", "SafariTabs.db"); + return { context, root, store, metadata }; + }); + + it.effect("discovers named profiles and resolves only the selected profile's cookies", () => + run( + Effect.gen(function* () { + const { context, root, store, metadata } = yield* fixture(); + yield* Effect.sync(() => { + const database = new NodeSqlite.DatabaseSync(metadata); + try { + database.exec(`CREATE TABLE bookmarks ( + title TEXT, external_uuid TEXT, parent INTEGER DEFAULT 0, + type INTEGER DEFAULT 1, subtype INTEGER DEFAULT 2, + deleted INTEGER DEFAULT 0, order_index INTEGER DEFAULT 0 + )`); + const insert = database.prepare( + "INSERT INTO bookmarks (title, external_uuid, deleted) VALUES (?, ?, ?)", + ); + insert.run("", "DefaultProfile", 0); + insert.run("Ping", workUuid, 0); + insert.run("Deleted", otherUuid, 1); + insert.run("Unsafe", "../../outside", 0); + database.exec( + "INSERT INTO bookmarks (title, external_uuid, subtype) VALUES ('Tab group', 'group', 1)", + ); + } finally { + database.close(); + } + }); + const profiles = yield* listSourceProfiles(safari, context); + assert.deepEqual(profiles, [ + { directory: ".", name: "Personal" }, + { directory: store(workUuid), name: "Ping" }, + ]); + assert.strictEqual( + yield* resolveCookieDatabase(safari, context, "."), + context.path.join(root, "Cookies.binarycookies"), + ); + const selected = yield* resolveCookieDatabase(safari, context, profiles[1]!.directory); + assert.strictEqual(selected, context.path.join(store(workUuid), "Cookies.binarycookies")); + const fileSystem = yield* FileSystem.FileSystem; + assert.strictEqual(yield* fileSystem.readFileString(selected!), workUuid); + yield* fileSystem.remove(selected!); + assert.isUndefined(yield* resolveCookieDatabase(safari, context, profiles[1]!.directory)); + assert.deepEqual(yield* listSourceProfiles(safari, context), profiles); + }), + ), + ); + + for (const metadataState of ["missing", "corrupt"] as const) { + it.effect(`recovers separate cookie stores when metadata is ${metadataState}`, () => + run( + Effect.gen(function* () { + const { context, store, metadata } = yield* fixture(); + const fileSystem = yield* FileSystem.FileSystem; + if (metadataState === "corrupt") yield* fileSystem.writeFileString(metadata, "invalid"); + yield* fileSystem.remove(context.path.join(store(otherUuid), "Cookies.binarycookies")); + assert.deepEqual(yield* listSourceProfiles(safari, context), [ + { directory: ".", name: "Safari" }, + { directory: store(workUuid), name: workUuid.toLowerCase() }, + ]); + assert.isTrue(yield* isSourceInstalled(safari, context)); + }), + ), + ); + } + + it.effect("keeps Safari without profiles available", () => + run( + Effect.gen(function* () { + const context = yield* withSourceHome(); + assert.deepEqual(yield* listSourceProfiles(safari, context), [ + { directory: ".", name: "Safari" }, + ]); + assert.isFalse(yield* isSourceInstalled(safari, context)); + }), + ), + ); +}); diff --git a/apps/desktop/src/preview/BrowserImport/Sources.ts b/apps/desktop/src/preview/BrowserImport/Sources.ts index 702933a432b3..6075f0ad56a3 100644 --- a/apps/desktop/src/preview/BrowserImport/Sources.ts +++ b/apps/desktop/src/preview/BrowserImport/Sources.ts @@ -1,10 +1,11 @@ /** * Importable browser sources. * - * Two engines are modelled. Chromium-family browsers keep cookies in an + * Chromium-family browsers keep cookies in an * encrypted SQLite database whose key lives in an OS credential store; Firefox * keeps them in plain SQLite with no key at all, so it needs no keychain and - * works the same on every platform. + * works the same on every platform. Safari uses binary cookie files, with + * separate WebKit data stores for named profiles. * * Each entry pins its own paths and credential-store coordinates rather than * deriving them, because the forks do not agree. macOS uses service/account @@ -31,7 +32,7 @@ import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import * as SqlClient from "effect/unstable/sql/SqlClient"; -export type BrowserImportEngine = "chromium" | "firefox"; +export type BrowserImportEngine = "chromium" | "firefox" | "safari"; /** * Directory roots a definition builds its paths from. Passed in rather than @@ -177,6 +178,26 @@ export const BROWSER_IMPORT_SOURCES: ReadonlyArray + context.platform === "darwin" + ? context.path.join( + context.home, + "Library", + "Containers", + "com.apple.Safari", + "Data", + "Library", + "Cookies", + ) + : undefined, + }, { id: "firefox", name: "Firefox", @@ -220,6 +241,9 @@ export const cookieDatabaseCandidatePaths = ( if (definition.engine === "firefox") { return [context.path.join(profilePath, "cookies.sqlite")]; } + if (definition.engine === "safari") { + return [context.path.join(profilePath, "Cookies.binarycookies")]; + } // Chromium: pre-96 uses `Cookies`, 96+ use `Network/Cookies`. An upgrade // leaves the legacy file behind, so prefer the current one and fall back. return [ @@ -386,6 +410,64 @@ const withCookieCounts = ( ), ); +const SafariProfileRows = Schema.Array( + Schema.Struct({ title: Schema.NullOr(Schema.String), external_uuid: Schema.String }), +); +const decodeSafariProfiles = Schema.decodeUnknownEffect(SafariProfileRows); +const isSafariProfileUuid = (value: string) => + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value); + +const listSafariProfiles = Effect.fnUntraced(function* ( + context: BrowserImportPathContext, + root: string, +) { + const fileSystem = yield* FileSystem.FileSystem; + const library = context.path.dirname(root); + const metadata = context.path.join(library, "Safari", "SafariTabs.db"); + const declared = yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + return yield* decodeSafariProfiles( + yield* sql` + select title, external_uuid from bookmarks + where parent = 0 and type = 1 and subtype = 2 and deleted = 0 + order by order_index + `, + ); + }).pipe( + Effect.provide(NodeSqliteClient.layer({ filename: metadata, readonly: true })), + Effect.orElseSucceed(() => []), + ); + const defaultProfile = declared.find((profile) => profile.external_uuid === "DefaultProfile"); + const profiles: Array = [ + { + directory: ".", + name: defaultProfile ? defaultProfile.title?.trim() || "Personal" : "Safari", + }, + ]; + const stores = context.path.join(library, "WebKit", "WebsiteDataStore"); + const profileDirectory = (uuid: string) => + context.path.join(stores, uuid.toLowerCase(), "Cookies"); + for (const profile of declared) { + if (!isSafariProfileUuid(profile.external_uuid)) continue; + profiles.push({ + directory: profileDirectory(profile.external_uuid), + name: profile.title?.trim() || profile.external_uuid, + }); + } + // If Safari's metadata is unavailable, recover stores that have cookies. + // With readable metadata, avoid resurrecting deleted profiles left on disk. + if (declared.length === 0) { + const entries = yield* fileSystem.readDirectory(stores).pipe(Effect.orElseSucceed(() => [])); + for (const entry of entries.filter(isSafariProfileUuid).sort()) { + const directory = context.path.join(stores, entry, "Cookies"); + if (yield* databaseFileExists(context.path.join(directory, "Cookies.binarycookies"))) { + profiles.push({ directory, name: entry }); + } + } + } + return profiles; +}); + /** * Profiles the source browser knows about. * @@ -403,6 +485,10 @@ const listSourceProfilesInDirectory = Effect.fnUntraced(function* ( const root = definition.userDataDirectory(context); if (root === undefined) return []; + if (definition.engine === "safari") { + return yield* listSafariProfiles(context, root); + } + if (definition.engine === "firefox") { const declared = yield* fileSystem.readFileString(context.path.join(root, "profiles.ini")).pipe( Effect.map((ini) => parseFirefoxProfiles(ini, context.path, root)), @@ -773,11 +859,15 @@ export const isSourceRunning = Effect.fn("BrowserImportSources.isSourceRunning") const root = definition.userDataDirectory(context); if (root === undefined) return false; // Probe the source's own lock state rather than scanning the process table. + // Safari keeps no lock and writes its jar atomically, so a running instance + // is not a hazard there. + // // Chromium exposes its lock through the cookie jar on Windows and through a // user-data SingletonLock on POSIX. Firefox keeps its locks inside each // profile under three names across platforms (`lock` on macOS and Linux, // `.parentlock` beside it, `parent.lock` on Windows). Looking for Firefox's // at the root finds nothing and reports a running browser as importable. + if (definition.engine === "safari") return false; if (definition.engine !== "firefox") { if (context.platform === "win32") { return yield* windowsChromiumCookiesAreHeld(definition, context); diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index a7b3afabd3c3..1eb65f0c7400 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -272,6 +272,7 @@ const makeTestPreviewWebContents = ( ipc: { on: vi.fn(), off: vi.fn() }, send: webviewSend, navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setIgnoreMenuShortcuts: vi.fn(), setWindowOpenHandler: vi.fn(), debugger: { isAttached: () => false, @@ -382,6 +383,7 @@ const makeFaviconWebContents = (options?: { send: webviewSend, session: { fetch }, navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setIgnoreMenuShortcuts: vi.fn(), setWindowOpenHandler: vi.fn(), executeJavaScriptInIsolatedWorld, debugger: { @@ -474,6 +476,67 @@ describe("PreviewManager", () => { webviewSend.mockClear(); }); + effectIt.effect("keeps preview shortcuts out of the host window", () => + withManager((manager) => + Effect.gen(function* () { + const preview = makeFaviconWebContents(); + const sendInputEvent = vi.fn(); + const hostWebContents = { sendInputEvent }; + Object.assign(preview.webContents, { hostWebContents }); + fromId.mockReturnValue(preview.webContents); + yield* manager.setMainWindow({ + isDestroyed: () => false, + once: vi.fn(), + webContents: hostWebContents, + } as never); + yield* manager.createTab("tab_keys"); + yield* manager.registerWebview("tab_keys", 42); + + expect( + (preview.webContents as Electron.WebContents).setIgnoreMenuShortcuts, + ).toHaveBeenCalledWith(true); + const beforeInput = preview.listeners.get("before-input-event")!; + for (const control of [false, true]) { + for (const key of ["k", ",", "w", "j", "q", "+", "a", "c", "v", "x"]) { + for (const type of ["keyDown", "keyUp"]) { + const preventDefault = vi.fn(); + beforeInput( + { preventDefault } as never, + { type, key, meta: !control, control, shift: key === "j", alt: false } as never, + ); + yield* Effect.yieldNow; + expect(preventDefault).not.toHaveBeenCalled(); + } + } + } + expect(sendInputEvent).not.toHaveBeenCalled(); + + const preventDefault = vi.fn(); + beforeInput( + { preventDefault } as never, + { + type: "keyDown", + key: "r", + meta: true, + control: false, + shift: false, + alt: false, + } as never, + ); + yield* Effect.yieldNow; + expect(preventDefault).toHaveBeenCalledOnce(); + expect(preview.reload).toHaveBeenCalledOnce(); + expect(sendInputEvent).not.toHaveBeenCalled(); + + const setIgnoreMenuShortcuts = vi.fn(); + preview.listeners.get("did-create-window")!({ + webContents: { setIgnoreMenuShortcuts, setWindowOpenHandler: vi.fn() }, + } as never); + expect(setIgnoreMenuShortcuts).toHaveBeenCalledWith(true); + }), + ), + ); + effectIt.effect("reports an unregistered webview as temporarily unavailable", () => withManager((manager) => Effect.gen(function* () { @@ -617,6 +680,7 @@ describe("PreviewManager", () => { ipc: { on: vi.fn(), off: vi.fn() }, send: webviewSend, navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setIgnoreMenuShortcuts: vi.fn(), setWindowOpenHandler: vi.fn(), debugger: { isAttached: () => false, @@ -718,6 +782,7 @@ describe("PreviewManager", () => { ipc: { on: vi.fn(), off: vi.fn() }, send: webviewSend, navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setIgnoreMenuShortcuts: vi.fn(), setWindowOpenHandler: vi.fn(), get debugger() { if (destroyed) throw new Error("Object has been destroyed"); @@ -1222,6 +1287,7 @@ describe("PreviewManager", () => { ipc: { on: vi.fn(), off: vi.fn() }, send: webviewSend, navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setIgnoreMenuShortcuts: vi.fn(), setWindowOpenHandler: vi.fn(), debugger: { isAttached: () => false, @@ -1286,6 +1352,7 @@ describe("PreviewManager", () => { ipc: { on: vi.fn(), off: vi.fn() }, send: webviewSend, navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setIgnoreMenuShortcuts: vi.fn(), setWindowOpenHandler: vi.fn(), debugger: { isAttached: () => false, @@ -1326,6 +1393,7 @@ describe("PreviewManager", () => { ipc: { on: vi.fn(), off: vi.fn() }, send: webviewSend, navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setIgnoreMenuShortcuts: vi.fn(), setWindowOpenHandler: vi.fn(), debugger: { isAttached: () => false, @@ -1372,6 +1440,7 @@ describe("PreviewManager", () => { ipc: { on: vi.fn(), off: vi.fn() }, send: webviewSend, navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setIgnoreMenuShortcuts: vi.fn(), setWindowOpenHandler: vi.fn(), debugger: { isAttached: () => false, @@ -1427,6 +1496,7 @@ describe("PreviewManager", () => { ipc: { on: vi.fn(), off: vi.fn() }, send: webviewSend, navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setIgnoreMenuShortcuts: vi.fn(), setWindowOpenHandler: vi.fn(), debugger: { isAttached: () => false, @@ -1524,6 +1594,7 @@ describe("PreviewManager", () => { ipc: { on: vi.fn(), off: vi.fn() }, send: webviewSend, navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setIgnoreMenuShortcuts: vi.fn(), setWindowOpenHandler: vi.fn(), debugger: { isAttached: () => false, @@ -1859,6 +1930,7 @@ describe("PreviewManager", () => { ipc: { on: vi.fn(), off: vi.fn() }, send: webviewSend, navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setIgnoreMenuShortcuts: vi.fn(), setWindowOpenHandler: vi.fn(), debugger: { isAttached: () => false, @@ -1951,6 +2023,7 @@ describe("PreviewManager", () => { ipc: { on: vi.fn(), off: vi.fn() }, send: webviewSend, navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setIgnoreMenuShortcuts: vi.fn(), setWindowOpenHandler: vi.fn(), debugger: { isAttached: () => false, @@ -2368,6 +2441,7 @@ describe("PreviewManager", () => { ipc: { on: vi.fn(), off: vi.fn() }, send: webviewSend, navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setIgnoreMenuShortcuts: vi.fn(), setWindowOpenHandler: vi.fn(), debugger: { isAttached: () => false, @@ -2670,6 +2744,7 @@ describe("PreviewManager", () => { ipc: { on: vi.fn(), off: vi.fn() }, send: webviewSend, navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setIgnoreMenuShortcuts: vi.fn(), setWindowOpenHandler: vi.fn(), debugger: { isAttached: () => false, @@ -3173,6 +3248,7 @@ describe("PreviewManager", () => { ipc: { on: vi.fn(), off: vi.fn(), removeListener: vi.fn() }, send: webviewSend, navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setIgnoreMenuShortcuts: vi.fn(), setWindowOpenHandler: vi.fn(), debugger: { isAttached: () => false, @@ -3228,6 +3304,7 @@ describe("PreviewManager", () => { }, send: webviewSend, navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setIgnoreMenuShortcuts: vi.fn(), setWindowOpenHandler: vi.fn(), debugger: { isAttached: () => false, @@ -3304,6 +3381,7 @@ describe("PreviewManager", () => { }, send: webviewSend, navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setIgnoreMenuShortcuts: vi.fn(), setWindowOpenHandler: vi.fn(), debugger: { isAttached: () => false, @@ -3389,6 +3467,7 @@ describe("PreviewManager", () => { goBack, goForward, }, + setIgnoreMenuShortcuts: vi.fn(), setWindowOpenHandler: vi.fn(), debugger: { isAttached: () => false, @@ -3518,6 +3597,7 @@ describe("PreviewManager", () => { }, send: webviewSend, navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setIgnoreMenuShortcuts: vi.fn(), setWindowOpenHandler: vi.fn(), debugger: { isAttached: () => false, @@ -3618,6 +3698,7 @@ describe("PreviewManager", () => { }, send: webviewSend, navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setIgnoreMenuShortcuts: vi.fn(), setWindowOpenHandler: vi.fn(), debugger: { isAttached: () => false, @@ -3773,6 +3854,7 @@ describe("PreviewManager", () => { }, send: webviewSend, navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setIgnoreMenuShortcuts: vi.fn(), setWindowOpenHandler: vi.fn(), debugger: { isAttached: () => false, @@ -3837,6 +3919,7 @@ describe("PreviewManager", () => { ipc: { on: vi.fn(), off: vi.fn() }, send: webviewSend, navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setIgnoreMenuShortcuts: vi.fn(), setWindowOpenHandler: vi.fn(), debugger: { isAttached: () => false, diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 324b92034f36..aa149bc9ee1d 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -465,22 +465,6 @@ interface ExpectedAgentInput { readonly expiresAt: number; } -const APP_FORWARDED_SHORTCUTS: ReadonlyArray<{ - key: string; - meta: boolean; - shift: boolean; - control: boolean; -}> = Object.freeze([ - // mod+shift+J → preview.toggle - { key: "j", meta: true, shift: true, control: false }, - // mod+K → command palette - { key: "k", meta: true, shift: false, control: false }, - // mod+, → settings (macOS convention) - { key: ",", meta: true, shift: false, control: false }, - // mod+W → close tab/panel - { key: "w", meta: true, shift: false, control: false }, -]); - /** * Protocols a preview page may open in a real popup window. * @@ -1535,16 +1519,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function } }); - const isAppShortcut = (input: Electron.Input): boolean => - input.type === "keyDown" && - APP_FORWARDED_SHORTCUTS.some( - (shortcut) => - shortcut.key.toLowerCase() === input.key.toLowerCase() && - shortcut.meta === input.meta && - shortcut.shift === input.shift && - shortcut.control === input.control, - ); - const computeNavStatus = (wc: Electron.WebContents): PreviewNavStatus => { const url = wc.getURL(); const title = wc.getTitle(); @@ -1819,30 +1793,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }).pipe(Effect.ignore), ); }; - const forwardShortcut = Effect.fn("PreviewManager.forwardShortcut")(function* ( - event: Electron.Event, - input: Electron.Input, - ) { - const mainWindow = yield* Ref.get(mainWindowRef); - if (!isAppShortcut(input) || Option.isNone(mainWindow) || mainWindow.value.isDestroyed()) { - return; - } - event.preventDefault(); - mainWindow.value.webContents.sendInputEvent({ - type: "keyDown", - keyCode: input.key, - modifiers: [ - ...(input.meta ? (["meta"] as const) : []), - ...(input.shift ? (["shift"] as const) : []), - ...(input.control ? (["control"] as const) : []), - ...(input.alt ? (["alt"] as const) : []), - ], - }); - }); // A popup opens with Electron's default handler, so the page inside it could // otherwise spawn native windows without limit. Nothing in an OAuth flow // opens a second popup, so the chain stops at the first one. const windowCreated = (window: Electron.BrowserWindow): void => { + window.webContents.setIgnoreMenuShortcuts(true); window.webContents.setWindowOpenHandler(() => ({ action: "deny" })); }; const beforeInput = (event: Electron.Event, input: Electron.Input): void => { @@ -1855,7 +1810,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); return; } - runFork(forwardShortcut(event, input)); }; yield* Scope.addFinalizer( scope, @@ -1878,6 +1832,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); const install = Effect.fn("PreviewManager.installWebContentsListeners")(function* () { yield* attempt({ operation: "attachListeners", tabId, webContentsId: wc.id }, () => { + // Preview input belongs to the page, including keys injected through CDP. + // Never let it invoke the host application's menu accelerators. + wc.setIgnoreMenuShortcuts(true); wc.on("did-start-navigation", navigationStarted); wc.on("did-navigate", syncNavigation); wc.on("did-navigate-in-page", syncInPageNavigation); diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 28cce3cfb507..0d9ddc8fde91 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -26,7 +26,6 @@ const clientSettings: ClientSettings = { confirmThreadArchive: true, confirmThreadDelete: false, confirmThreadUnpin: false, - continueThreadsAfterServerUpdate: true, contextWindowMeterEnabled: false, composerCollapseOnBlur: false, composerCollapseOnScroll: true, diff --git a/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts b/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts index 05b1ca144444..348f6cb3843e 100644 --- a/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts +++ b/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts @@ -3,6 +3,7 @@ import { assert, describe, it } from "@effect/vitest"; import { EnvironmentId, type PersistedSavedEnvironmentRecord } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; @@ -400,11 +401,12 @@ describe("DesktopSavedEnvironments", () => { it.effect("reports saved environment filesystem reads separately from document decoding", () => Effect.gen(function* () { + const path = yield* Path.Path; const baseFileSystem = yield* FileSystem.FileSystem; const baseDir = yield* baseFileSystem.makeTempDirectoryScoped({ prefix: "t3-desktop-saved-environments-test-", }); - const registryPath = `${baseDir}/userdata/saved-environments.json`; + const registryPath = path.join(baseDir, "userdata", "saved-environments.json"); const permissionError = PlatformError.systemError({ _tag: "PermissionDenied", module: "FileSystem", @@ -433,6 +435,7 @@ describe("DesktopSavedEnvironments", () => { it.effect("reports the failed saved environment write operation and path", () => Effect.gen(function* () { const baseFileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const baseDir = yield* baseFileSystem.makeTempDirectoryScoped({ prefix: "t3-desktop-saved-environments-test-", }); @@ -440,7 +443,7 @@ describe("DesktopSavedEnvironments", () => { _tag: "PermissionDenied", module: "FileSystem", method: "makeDirectory", - pathOrDescriptor: `${baseDir}/userdata`, + pathOrDescriptor: path.join(baseDir, "userdata"), }); const fileSystemLayer = Layer.succeed( FileSystem.FileSystem, @@ -456,11 +459,11 @@ describe("DesktopSavedEnvironments", () => { const error = yield* savedEnvironments.setRegistry([savedRegistryRecord]).pipe(Effect.flip); assert.instanceOf(error, DesktopSavedEnvironments.DesktopSavedEnvironmentsWriteError); assert.equal(error.operation, "create-directory"); - assert.equal(error.path, `${baseDir}/userdata`); + assert.equal(error.path, path.join(baseDir, "userdata")); assert.strictEqual(error.cause, permissionError); assert.equal( error.message, - `Desktop saved-environment write failed during create-directory at ${baseDir}/userdata.`, + `Desktop saved-environment write failed during create-directory at ${path.join(baseDir, "userdata")}.`, ); assert.notEqual(error.message, permissionError.message); }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index abf6f220eca4..bdd03865c7bf 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -280,6 +280,7 @@ function makeTestLayer(input: { input.openedExternalUrls?.push(url); return true; }), + openSystemSettings: () => Effect.succeed(true), copyText: () => Effect.void, } satisfies ElectronShell.ElectronShell["Service"]), electronThemeLayer, @@ -380,6 +381,7 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n electronMenuLayer, Layer.succeed(ElectronShell.ElectronShell, { openExternal: () => Effect.succeed(true), + openSystemSettings: () => Effect.succeed(true), copyText: () => Effect.void, } satisfies ElectronShell.ElectronShell["Service"]), electronThemeLayer, diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index ce74cf58e0a3..294a02030a83 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -1,3 +1,4 @@ +import "vite-plus/test/config"; import { defineConfig } from "vite-plus"; import { loadRepoEnv } from "../../scripts/lib/public-config.ts"; @@ -83,4 +84,10 @@ export default defineConfig({ entry: ["src/preview-pip-preload.ts"], }, ], + test: { + // The Windows lane runs workspace suites concurrently; filesystem-heavy + // desktop integration tests can exceed Vitest's 5 second default there. + testTimeout: 15_000, + setupFiles: ["../../packages/shared/src/testing/longTempDir.ts"], + }, }); diff --git a/apps/mobile/README.md b/apps/mobile/README.md index ed95b060fe3a..a9a8177c4ece 100644 --- a/apps/mobile/README.md +++ b/apps/mobile/README.md @@ -38,12 +38,8 @@ vp run dev:client:reset Run that reset once after installing or changing the Uniwind dependency patch. Cached transforms can otherwise reference its previous pnpm package path. Ordinary Metro starts still keep the cache. -Component edits use Fast Refresh. Connection-runtime edits replace the active Effect layer through -a stable atom runtime, preserving navigation and existing atom subscribers. Replaced registries -and managed runtimes dispose their resources; the app does not force a JavaScript reload. The Uniwind patch -skips global style invalidation when generated styles and themes are unchanged, while real style -changes still refresh. See [mobile development lifecycle](../../docs/internals/mobile-development.md) -for the lifetime boundaries. +Component edits use Fast Refresh. See [mobile development lifecycle](../../docs/internals/mobile-development.md) +before changing runtime ownership or refresh behavior. Build and run the local iOS dev client: @@ -51,6 +47,10 @@ Build and run the local iOS dev client: vp run ios:dev ``` +After changing a native dependency patch, rerun CocoaPods before rebuilding an existing iOS +project. pnpm gives each patch hash a new package path; Pods can otherwise keep compiling the +previous directory. + If your Xcode account only has a Personal Team, use a bundle identifier you control and opt into the reduced-capability local build. Personal Team builds omit the widget and share extensions, push entitlement, and native Sign in with Apple entitlement; builds without this opt-in are unchanged. diff --git a/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts index 2b39ac201599..4c8a6c4d7cd5 100644 --- a/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts +++ b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts @@ -195,7 +195,8 @@ function appendRun( return runs; } -const SKILL_TOKEN_REGEX = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s|$)/g; +const SKILL_TOKEN_REGEX = + /(^|\s)\$(?![0-9][0-9_]*(?:[kKmMbBtT]|[eE][0-9]+)?(?:\s|$))(?=[a-zA-Z0-9:_-]*[a-zA-Z])([a-zA-Z0-9][a-zA-Z0-9:_-]*)(?=\s|$)/g; function formatSkillLabel(skill: SelectableMarkdownSkill): string { const displayName = skill.displayName?.trim(); diff --git a/apps/mobile/src/connection/runtime.ts b/apps/mobile/src/connection/runtime.ts index ee224ce9f6ed..662a4dcdf70c 100644 --- a/apps/mobile/src/connection/runtime.ts +++ b/apps/mobile/src/connection/runtime.ts @@ -30,7 +30,10 @@ type ConnectionLayerSource = | typeof mobileBackgroundActivityObserverLayer | typeof mobileBackgroundActivityReporterLayer; -const providedClientConnectionLayer = Layer.merge(Connection.layer, snapshotLoaderLayer).pipe( +const providedClientConnectionLayer = Layer.merge( + Connection.layerWithOptions({ usageLimitSources: true }), + snapshotLoaderLayer, +).pipe( Layer.provideMerge( Layer.mergeAll( runtimeContextLayer, diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index ab48046fbd96..360b980edc95 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -86,6 +86,7 @@ import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; import { downloadAndShareAttachment } from "../../lib/attachmentDownload"; import { hasWideMarkdownBlock } from "../../lib/wideMarkdownBlocks"; +import { faviconUrlForOrigin } from "@t3tools/shared/favicon"; import { hasNativeSelectableMarkdownText, SelectableMarkdownText, @@ -596,7 +597,8 @@ const MarkdownExternalLink = memo(function MarkdownExternalLink(props: { readonly href: string; readonly onPress: (href: string) => void; }) { - const [failed, setFailed] = useState(() => failedMarkdownFaviconHosts.has(props.host)); + const [failedHost, setFailedHost] = useState(null); + const faviconUrl = faviconUrlForOrigin(`https://${props.host}`); return ( - {!failed ? ( + {faviconUrl !== null && + failedHost !== props.host && + !failedMarkdownFaviconHosts.has(props.host) ? ( { failedMarkdownFaviconHosts.add(props.host); - setFailed(true); + setFailedHost(props.host); }} /> ) : ( diff --git a/apps/mobile/src/features/usage/UsageLimitsSection.tsx b/apps/mobile/src/features/usage/UsageLimitsSection.tsx index 08e939e9b4bd..0668efa30053 100644 --- a/apps/mobile/src/features/usage/UsageLimitsSection.tsx +++ b/apps/mobile/src/features/usage/UsageLimitsSection.tsx @@ -7,6 +7,7 @@ import type { ServerProviderResetCredits, ServerProviderUsageWindow, UsageLimitSourceAccount, + UsageProviderKind, } from "@t3tools/contracts"; import { collectLimitSources, @@ -22,30 +23,46 @@ import { type ReactNode, useState } from "react"; import { Alert, Pressable, View } from "react-native"; import { AppText as Text } from "../../components/AppText"; +import { ProviderIcon } from "../../components/ProviderIcon"; import { environmentPresentations } from "../../state/presentation"; import { serverEnvironment } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; import { SettingsSection } from "../settings/components/SettingsSection"; +import { useProviderColors } from "./usageProviders"; const PACE_LABEL = { ahead: "ahead of pace", on: "on pace", under: "under pace" } as const; const DRIVER_LABEL: Partial> = { codex: "Codex", claudeAgent: "Claude" }; +type Driver = ServerProvider["driver"]; + +/** The series colour the usage chart uses for this driver, so the two views read as one. */ +function useBarColor(driver: Driver): string | null { + const colors = useProviderColors(); + const kind: UsageProviderKind | null = + driver === "codex" ? "codex" : driver === "claudeAgent" ? "claude" : null; + return kind ? colors[kind] : null; +} + /** * One window as a bar spanning its whole duration: the fill is quota spent, - * the hairline is how far into the window the clock is. + * the hairline is how far into the window the clock is. Pace sits under the + * left edge, the countdown under the right, so a row reads in one glance. */ -function WindowBar(props: { readonly window: ServerProviderUsageWindow; readonly now: number }) { +function WindowRow(props: { + readonly window: ServerProviderUsageWindow; + readonly color: string | null; + readonly now: number; +}) { const { window, now } = props; const used = Math.round(Math.max(0, Math.min(100, window.usedPercent))); const elapsed = elapsedShare(window, now); const pace = paceOf(window, now); const resetsIn = formatResetsIn(window, now); - const detail = [pace ? PACE_LABEL[pace] : null, resetsIn].filter(Boolean).join(" · "); return ( - + - {window.label} - {used}% used + {window.label} + {used}% @@ -57,7 +74,10 @@ function WindowBar(props: { readonly window: ServerProviderUsageWindow; readonly ? "h-full rounded-full bg-warning" : "h-full rounded-full bg-foreground" } - style={{ flex: used }} + style={[ + { flex: used }, + used < 70 && props.color ? { backgroundColor: props.color } : null, + ]} /> @@ -68,12 +88,19 @@ function WindowBar(props: { readonly window: ServerProviderUsageWindow; readonly /> ) : null} - {detail ? {detail} : null} + {pace || resetsIn ? ( + + {pace ? PACE_LABEL[pace] : ""} + {resetsIn ?? ""} + + ) : null} ); } +/** One account: icon, name and plan on a single line, then its windows. */ function AccountLimits(props: { + readonly driver: Driver; readonly label: string; readonly instanceLabel: string; readonly detail: string | undefined; @@ -83,23 +110,35 @@ function AccountLimits(props: { readonly footer?: ReactNode; }) { const { limits, now } = props; + const color = useBarColor(props.driver); if (!limits) return null; const notice = limitsNotice(limits); return ( - - {props.label} - {props.instanceLabel !== props.label ? ( - · {props.instanceLabel} - ) : null} - {props.detail ? ( - · {props.detail} - ) : null} + + + + {props.label} + {props.instanceLabel !== props.label ? ( + + · {props.instanceLabel} + + ) : null} + {props.detail ? ( + + · {props.detail} + + ) : null} + {notice ? ( {notice} ) : ( - limits.windows.map((window) => ) + + {limits.windows.map((window) => ( + + ))} + )} {props.footer} @@ -170,7 +209,7 @@ function ResetCredits(props: { }; return ( - + {summary} {credits.availableCount > 0 ? ( {busy ? "Using credit…" : "Use a reset credit"} @@ -200,6 +239,7 @@ function ProviderLimits(props: { const credits = provider.usageLimits?.resetCredits; return ( DRIVER_LABEL[driver])} detail={provider.auth.label} @@ -229,6 +269,7 @@ function SourceAccountLimits(props: { const { account } = props; return ( Date.now()); + const [refreshing, setRefreshing] = useState(false); + const [failedLabels, setFailedLabels] = useState([]); + // Always toggles `refreshing`, even with nothing to probe: Android's + // RefreshControl keeps its spinner up until it sees true then false. + const refresh = async () => { + const connected = [...presentations].filter( + ([, presentation]) => presentation.connection.phase === "connected", + ); + setRefreshing(true); + try { + const results = await Promise.all( + connected.map(([environmentId]) => refreshProviders({ environmentId, input: {} })), + ); + setFailedLabels( + connected + .filter((_, index) => results[index]?._tag === "Failure") + .map(([, presentation]) => presentation.entry.target.label), + ); + } finally { + setNow(Date.now()); + setRefreshing(false); + } + }; + return { now, refreshing, failedLabels, refresh }; +} + /** * Subscription quota windows from every connected environment's providers, - * read from the config each environment already streams. Countdowns anchor to - * render time rather than ticking. + * read from the config each environment already streams. */ -export function UsageLimitsSection() { +export function UsageLimitsSection(props: { + readonly now: number; + readonly failedLabels: readonly string[]; +}) { + const { now } = props; const presentations = useAtomValue(environmentPresentations.presentationsAtom); const groups = collectLimitsGroups(presentations); const sources = collectLimitSources(presentations); - // Anchored once per mount on purpose: countdowns must not tick. - const [now] = useState(() => Date.now()); - if (groups.length === 0 && sources.length === 0) return null; + + if (groups.length === 0 && sources.length === 0) { + return ( + + No provider on a connected environment reports subscription limits. + + ); + } return ( <> + {props.failedLabels.length > 0 ? ( + + + {props.failedLabels.join(", ")} could not refresh limits. Showing the last known values. + + + ) : null} + {groups.map((group) => ( + + {group.providers.map((provider, index) => ( + + ))} + + ))} {sources.map((source) => ( {source.error ? ( @@ -276,23 +389,6 @@ export function UsageLimitsSection() { )} ))} - {groups.map((group) => ( - - {group.providers.map((provider, index) => ( - - ))} - - ))} ); } diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index 6e913b4999d8..be7c36173f15 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -11,32 +11,52 @@ import { formatUsd, makeWindow, } from "@t3tools/shared/usageFormat"; -import { useMemo, useState } from "react"; +import { useMemo, useRef, useState } from "react"; import { Platform, Pressable, RefreshControl, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; import { AppText as Text } from "../../components/AppText"; +import { cn } from "../../lib/cn"; import { NativeStackScreenOptions } from "../../native/StackHeader"; import { useUsage, type EnvironmentUsageStatus } from "../../state/usage"; import { SettingsSection } from "../settings/components/SettingsSection"; import { UsageDailyChart } from "./UsageDailyChart"; -import { UsageLimitsSection } from "./UsageLimitsSection"; +import { UsageLimitsSection, useRefreshLimits } from "./UsageLimitsSection"; import type { UsageChartMetric } from "./usageChartData"; import { PROVIDER_LABEL, useProviderColors } from "./usageProviders"; +type UsageTab = "usage" | "limits"; +const TAB_OPTIONS = [ + { value: "usage", label: "Usage" }, + { value: "limits", label: "Limits" }, +] as const satisfies readonly { value: UsageTab; label: string }[]; + +// Labels are abbreviated to share a row with the metric toggle; screen +// readers get the full phrase. const WINDOW_OPTIONS = [ - { days: 1, label: "Past 24h" }, - { days: 7, label: "7 days" }, - { days: 30, label: "30 days" }, - { days: 90, label: "90 days" }, + { value: 1, label: "24h", accessibilityLabel: "Past 24 hours" }, + { value: 7, label: "7d", accessibilityLabel: "Past 7 days" }, + { value: 30, label: "30d", accessibilityLabel: "Past 30 days" }, + { value: 90, label: "90d", accessibilityLabel: "Past 90 days" }, ] as const; +const METRIC_OPTIONS = [ + { value: "cost", label: "Cost" }, + { value: "tokens", label: "Tokens" }, +] as const satisfies readonly { value: UsageChartMetric; label: string }[]; + const CHART_HEIGHT = 180; +/** + * Two tabs over one screen. Usage is the transcript-derived spend for a + * period; Limits is the live subscription quota, which has no period. Both + * pull to refresh, each refreshing its own data. + */ export function UsageRouteScreen() { const navigation = useNavigation(); const insets = useSafeAreaInsets(); + const [tab, setTab] = useState("usage"); const [windowSelection, setWindowSelection] = useState(() => ({ days: 30, window: makeWindow(30), @@ -45,6 +65,7 @@ export function UsageRouteScreen() { const { days: windowDays, window } = windowSelection; const isPast24Hours = windowDays === 1; const { merged, environments, isPending, isPartial, refresh } = useUsage(window); + const limits = useRefreshLimits(); const days = useMemo( () => enumerateDays(window.sinceDay, window.untilDay), @@ -73,7 +94,16 @@ export function UsageRouteScreen() { // The pull spinner tracks re-scans of environments that have answered // before. The initial scan renders its own placeholder, and an unreachable // environment stays pending forever — neither may pin the spinner on. - const refreshing = environments.some((entry) => entry.isPending && entry.summary !== null); + const refreshingUsage = environments.some((entry) => entry.isPending && entry.summary !== null); + const showingLimits = tab === "limits"; + // One ScrollView serves both tabs, so the offset would otherwise carry over + // and a short Limits list could open scrolled past its own content. + const scrollRef = useRef(null); + const selectTab = (next: UsageTab) => { + if (next === tab) return; + setTab(next); + scrollRef.current?.scrollTo({ y: 0, animated: false }); + }; const selectWindow = (days: number) => { setWindowSelection({ days, @@ -103,46 +133,73 @@ export function UsageRouteScreen() { ) : null} } + refreshControl={ + void limits.refresh() : refreshWindow} + /> + } > - ({ value: option.days, label: option.label }))} - selected={windowDays} - onSelect={selectWindow} - /> - - + - {isPending ? ( - - Scanning provider transcripts… - - ) : environments.length === 0 ? ( - - Connect an environment to see usage. - + {showingLimits ? ( + ) : ( <> - + + + + - - - - + {isPending ? ( + + Scanning provider transcripts… + + ) : environments.length === 0 ? ( + + Connect an environment to see usage. + + ) : ( + <> + + + + + + )} )} @@ -151,30 +208,48 @@ export function UsageRouteScreen() { } function SegmentedControl(props: { - readonly options: readonly { readonly value: Value; readonly label: string }[]; + readonly options: readonly { + readonly value: Value; + readonly label: string; + readonly accessibilityLabel?: string; + }[]; readonly selected: Value; readonly onSelect: (value: Value) => void; + /** The tab bar is full height; filters under it are shorter so it stays primary. */ + readonly size?: "default" | "compact"; + /** "tab" for the view switcher; filters stay plain buttons. */ + readonly role?: "tab" | "button"; + readonly className?: string; }) { + const compact = props.size === "compact"; return ( - + {props.options.map((option) => { const active = option.value === props.selected; return ( props.onSelect(option.value)} - className={ - active - ? "flex-1 items-center rounded-full bg-subtle-strong py-2" - : "flex-1 items-center py-2" - } + className={cn( + "flex-1 items-center justify-center rounded-full", + compact ? "h-9" : "h-11", + active && "bg-subtle-strong", + )} > {option.label} @@ -191,7 +266,6 @@ function ChartCard(props: { readonly days: readonly string[]; readonly daily: readonly DailyTotals[]; readonly metric: UsageChartMetric; - readonly onMetricChange: (metric: UsageChartMetric) => void; readonly sinceDay: string; readonly untilDay: string; readonly isPast24Hours: boolean; @@ -203,21 +277,18 @@ function ChartCard(props: { return ( - - - - {metric === "cost" ? "Raw token cost" : "Processed tokens"} - - - {metric === "cost" ? `${formatUsd(merged.costUsd)}*` : formatTokens(merged.totalTokens)} - - - {metric === "cost" - ? "* if billed at full API rate" - : `Across ${formatCount(merged.sessions)} sessions`} - - - + + + {metric === "cost" ? "Raw token cost" : "Processed tokens"} + + + {metric === "cost" ? `${formatUsd(merged.costUsd)}*` : formatTokens(merged.totalTokens)} + + + {metric === "cost" + ? "* if billed at full API rate" + : `Across ${formatCount(merged.sessions)} sessions`} + {hasActivity ? ( @@ -262,38 +333,6 @@ function ChartCard(props: { ); } -function MetricToggle(props: { - readonly metric: UsageChartMetric; - readonly onChange: (metric: UsageChartMetric) => void; -}) { - return ( - - {(["cost", "tokens"] as const).map((option) => { - const active = option === props.metric; - return ( - props.onChange(option)} - className={active ? "rounded-full bg-subtle-strong px-3 py-1.5" : "px-3 py-1.5"} - > - - {option} - - - ); - })} - - ); -} - function ProviderSection(props: { readonly merged: MergedUsage; readonly metric: UsageChartMetric; diff --git a/apps/mobile/src/lib/nativeMarkdownText.test.ts b/apps/mobile/src/lib/nativeMarkdownText.test.ts index 1e7cb5f3164e..c3951c8d81f0 100644 --- a/apps/mobile/src/lib/nativeMarkdownText.test.ts +++ b/apps/mobile/src/lib/nativeMarkdownText.test.ts @@ -226,6 +226,29 @@ describe("nativeMarkdownDocumentRuns", () => { ]); }); + it("decorates known skill references that begin with a digit", () => { + const node: MarkdownNode = { + type: "document", + children: [ + { + type: "paragraph", + children: [{ type: "text", content: "Use $2spec for this." }], + }, + ], + }; + + expect(nativeMarkdownDocumentRuns(node, [{ name: "2spec", displayName: "2Spec" }])).toEqual([ + { text: "Use ", role: "body" }, + { + text: "$2spec", + role: "body", + skillName: "2spec", + skillLabel: "2Spec", + }, + { text: " for this.", role: "body" }, + ]); + }); + it("decorates known skill references inside blockquotes", () => { const node: MarkdownNode = { type: "blockquote", diff --git a/apps/server/integration/providerService.integration.test.ts b/apps/server/integration/providerService.integration.test.ts index 3ad85b1a68b9..0d041b36ecb8 100644 --- a/apps/server/integration/providerService.integration.test.ts +++ b/apps/server/integration/providerService.integration.test.ts @@ -100,7 +100,10 @@ const makeIntegrationFixture = (options?: { readonly analytics?: Layer.Layer { }), ); - it.effect("backs up isolated state before writes and refuses the shared home", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-sqlite-state-exec-" }); - yield* createFixtureDatabase(baseDir); + it.effect.skipIf(!symlinksSupported)( + "backs up isolated state before writes and refuses the shared home", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-sqlite-state-exec-" }); + yield* createFixtureDatabase(baseDir); - const mutation = yield* runSqliteState({ - operation: "exec", - baseDir, - sql: "INSERT INTO fixtures (id, label) VALUES (2, 'seeded')", - }); - assert.equal(mutation.operation, "exec"); - if (mutation.operation === "exec") { - assert.equal((yield* fs.stat(mutation.backup)).mode & 0o777, 0o600); - } - - const error = yield* runSqliteState( - { + const mutation = yield* runSqliteState({ operation: "exec", baseDir, - sql: "DELETE FROM fixtures", - }, - { sharedHome: baseDir }, - ).pipe(Effect.flip); - assert.equal(error._tag, "SqliteStateSharedHomeMutationError"); + sql: "INSERT INTO fixtures (id, label) VALUES (2, 'seeded')", + }); + assert.equal(mutation.operation, "exec"); + if (mutation.operation === "exec" && (yield* HostProcessPlatform) !== "win32") { + // NTFS has no POSIX mode bits to report. + assert.equal((yield* fs.stat(mutation.backup)).mode & 0o777, 0o600); + } - const aliasParent = yield* fs.makeTempDirectoryScoped({ - prefix: "t3-sqlite-state-alias-", - }); - const aliasBaseDir = path.join(aliasParent, "shared-home-alias"); - yield* fs.symlink(baseDir, aliasBaseDir); - const aliasError = yield* runSqliteState( - { - operation: "exec", - baseDir: aliasBaseDir, - sql: "DELETE FROM fixtures", - }, - { sharedHome: baseDir }, - ).pipe(Effect.flip); - assert.equal(aliasError._tag, "SqliteStateSharedHomeMutationError"); - }), + const error = yield* runSqliteState( + { + operation: "exec", + baseDir, + sql: "DELETE FROM fixtures", + }, + { sharedHome: baseDir }, + ).pipe(Effect.flip); + assert.equal(error._tag, "SqliteStateSharedHomeMutationError"); + + const aliasParent = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-sqlite-state-alias-", + }); + const aliasBaseDir = path.join(aliasParent, "shared-home-alias"); + yield* fs.symlink(baseDir, aliasBaseDir); + const aliasError = yield* runSqliteState( + { + operation: "exec", + baseDir: aliasBaseDir, + sql: "DELETE FROM fixtures", + }, + { sharedHome: baseDir }, + ).pipe(Effect.flip); + assert.equal(aliasError._tag, "SqliteStateSharedHomeMutationError"); + }), ); }); diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 251a4761705d..53e33cf7fd5b 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -24,6 +24,7 @@ import { assetFileResponse } from "../http.ts"; import { ASSET_ROUTE_PREFIX, issueAssetUrl, resolveAsset } from "./AssetAccess.ts"; import * as NativeAppIconResolver from "./NativeAppIconResolver.ts"; import { openMediaFile } from "./MediaFile.ts"; +import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; vi.mock("node:fs/promises", async (importOriginal) => { const actual = await importOriginal(); @@ -108,216 +109,241 @@ describe("AssetAccess", () => { }).pipe(Effect.provide(testLayer)), ); - it.effect("rejects non-previewable files, disguised targets, and directories", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-validation-" }); - for (const name of ["report.md", "secret.txt", "secret.%70ng", "secret.png#private.txt"]) { - const filePath = path.join(root, name); - yield* fs.writeFileString(filePath, "not media"); - const error = yield* issueAssetUrl({ - resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: filePath }, + it.effect.skipIf(!symlinksSupported)( + "rejects non-previewable files, disguised targets, and directories", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-validation-" }); + for (const name of ["report.md", "secret.txt", "secret.%70ng", "secret.png#private.txt"]) { + const filePath = path.join(root, name); + yield* fs.writeFileString(filePath, "not media"); + const error = yield* issueAssetUrl({ + resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: filePath }, + }).pipe(Effect.flip); + expect(error).toBeInstanceOf(AssetPreviewTypeValidationError); + } + const disguisedPath = path.join(root, "disguised.png"); + yield* fs.symlink(path.join(root, "secret.txt"), disguisedPath); + const disguisedError = yield* issueAssetUrl({ + resource: { + _tag: "media-file", + threadId: ThreadId.make("thread-1"), + path: disguisedPath, + }, }).pipe(Effect.flip); - expect(error).toBeInstanceOf(AssetPreviewTypeValidationError); - } - const disguisedPath = path.join(root, "disguised.png"); - yield* fs.symlink(path.join(root, "secret.txt"), disguisedPath); - const disguisedError = yield* issueAssetUrl({ - resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: disguisedPath }, - }).pipe(Effect.flip); - expect(disguisedError).toBeInstanceOf(AssetPreviewTypeValidationError); - const directoryPath = path.join(root, "directory.png"); - yield* fs.makeDirectory(directoryPath); - const directoryError = yield* issueAssetUrl({ - resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: directoryPath }, - }).pipe(Effect.flip); - expect(directoryError._tag).toBe("AssetWorkspaceAssetNotFoundError"); - }).pipe(Effect.provide(testLayer)), - ); - - it.effect("binds media URLs to the canonical target and rejects symlink substitution", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-symlink-" }); - const filePath = path.join(root, "actual.svg"); - const aliasPath = path.join(root, "alias.png"); - const replacementPath = path.join(root, "other.svg"); - yield* fs.writeFileString(filePath, ""); - yield* fs.writeFileString(replacementPath, "private"); - yield* fs.symlink(filePath, aliasPath); - const canonicalFile = yield* fs.realPath(filePath); - const result = yield* issueAssetUrl({ - resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: aliasPath }, - }); - const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); - const separator = suffix.indexOf("/"); - const token = suffix.slice(0, separator); - const name = suffix.slice(separator + 1); - const expected = { kind: "file", path: canonicalFile, mimeType: "image/svg+xml" }; - expect(yield* resolveAsset(token, name)).toMatchObject(expected); - yield* fs.remove(aliasPath); - yield* fs.symlink(replacementPath, aliasPath); - expect(yield* resolveAsset(token, name)).toMatchObject(expected); - yield* fs.remove(filePath); - yield* fs.symlink(replacementPath, filePath); - expect(yield* resolveAsset(token, name)).toBeNull(); - }).pipe(Effect.provide(testLayer)), + expect(disguisedError).toBeInstanceOf(AssetPreviewTypeValidationError); + const directoryPath = path.join(root, "directory.png"); + yield* fs.makeDirectory(directoryPath); + const directoryError = yield* issueAssetUrl({ + resource: { + _tag: "media-file", + threadId: ThreadId.make("thread-1"), + path: directoryPath, + }, + }).pipe(Effect.flip); + expect(directoryError._tag).toBe("AssetWorkspaceAssetNotFoundError"); + }).pipe(Effect.provide(testLayer)), ); - it.effect("keeps full and partial responses bound to the file opened during resolution", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-open-file-" }); - const filePath = path.join(root, "recording.mp4"); - const savedPath = path.join(root, "saved.mp4"); - const secretPath = path.join(root, "secret.txt"); - yield* fs.writeFileString(filePath, "0123456789"); - yield* fs.writeFileString(secretPath, "private information"); - const result = yield* issueAssetUrl({ - resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: filePath }, - }); - const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); - const separator = suffix.indexOf("/"); - for (const [range, expected, status] of [ - [undefined, "0123456789", 200], - ["bytes=2-5", "2345", 206], - ] as const) { - const asset = yield* resolveAsset(suffix.slice(0, separator), suffix.slice(separator + 1)); - if (!asset) throw new Error("Expected a resolved media file"); - - yield* fs.rename(filePath, savedPath); - yield* fs.symlink(secretPath, filePath); - const response = HttpServerResponse.toWeb(yield* assetFileResponse(asset, range)); - expect(response.status).toBe(status); - expect(response.headers.get("content-length")).toBe(String(expected.length)); - expect(yield* Effect.promise(() => response.text())).toBe(expected); + it.effect.skipIf(!symlinksSupported)( + "binds media URLs to the canonical target and rejects symlink substitution", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-symlink-" }); + const filePath = path.join(root, "actual.svg"); + const aliasPath = path.join(root, "alias.png"); + const replacementPath = path.join(root, "other.svg"); + yield* fs.writeFileString(filePath, ""); + yield* fs.writeFileString(replacementPath, "private"); + yield* fs.symlink(filePath, aliasPath); + const canonicalFile = yield* fs.realPath(filePath); + const result = yield* issueAssetUrl({ + resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: aliasPath }, + }); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separator = suffix.indexOf("/"); + const token = suffix.slice(0, separator); + const name = suffix.slice(separator + 1); + const expected = { kind: "file", path: canonicalFile, mimeType: "image/svg+xml" }; + expect(yield* resolveAsset(token, name)).toMatchObject(expected); + yield* fs.remove(aliasPath); + yield* fs.symlink(replacementPath, aliasPath); + expect(yield* resolveAsset(token, name)).toMatchObject(expected); yield* fs.remove(filePath); - yield* fs.rename(savedPath, filePath); - } - }).pipe(Effect.provide(testLayer)), + yield* fs.symlink(replacementPath, filePath); + expect(yield* resolveAsset(token, name)).toBeNull(); + }).pipe(Effect.provide(testLayer)), ); - it.effect("rejects a symlink swapped in after canonical validation but before open", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-open-race-" }); - const filePath = path.join(root, "recording.mp4"); - const secretPath = path.join(root, "secret.txt"); - yield* fs.writeFileString(filePath, "video"); - yield* fs.writeFileString(secretPath, "secret"); - const canonicalPath = yield* fs.realPath(filePath); - const result = yield* issueAssetUrl({ - resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: filePath }, - }); - const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); - const separator = suffix.indexOf("/"); - const swappingFileSystem = FileSystem.FileSystem.of({ - ...fs, - stat: Effect.fn(function* (requestedPath) { - const info = yield* fs.stat(requestedPath); - if (requestedPath === canonicalPath) { - yield* fs.remove(filePath); - yield* fs.symlink(secretPath, filePath); - } - return info; - }), - }); - expect( - yield* resolveAsset(suffix.slice(0, separator), suffix.slice(separator + 1)).pipe( - Effect.provideService(FileSystem.FileSystem, swappingFileSystem), - ), - ).toBeNull(); - }).pipe(Effect.provide(testLayer)), + it.effect.skipIf(!symlinksSupported)( + "keeps full and partial responses bound to the file opened during resolution", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-open-file-" }); + const filePath = path.join(root, "recording.mp4"); + const savedPath = path.join(root, "saved.mp4"); + const secretPath = path.join(root, "secret.txt"); + yield* fs.writeFileString(filePath, "0123456789"); + yield* fs.writeFileString(secretPath, "private information"); + const result = yield* issueAssetUrl({ + resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: filePath }, + }); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separator = suffix.indexOf("/"); + for (const [range, expected, status] of [ + [undefined, "0123456789", 200], + ["bytes=2-5", "2345", 206], + ] as const) { + const asset = yield* resolveAsset( + suffix.slice(0, separator), + suffix.slice(separator + 1), + ); + if (!asset) throw new Error("Expected a resolved media file"); + + yield* fs.rename(filePath, savedPath); + yield* fs.symlink(secretPath, filePath); + const response = HttpServerResponse.toWeb(yield* assetFileResponse(asset, range)); + expect(response.status).toBe(status); + expect(response.headers.get("content-length")).toBe(String(expected.length)); + expect(yield* Effect.promise(() => response.text())).toBe(expected); + yield* fs.remove(filePath); + yield* fs.rename(savedPath, filePath); + } + }).pipe(Effect.provide(testLayer)), ); - it.effect("closes a descriptor rejected when its path changes during open", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-open-rejected-" }); - const filePath = path.join(root, "recording.mp4"); - const secretPath = path.join(root, "secret.txt"); - yield* fs.writeFileString(filePath, "video"); - yield* fs.writeFileString(secretPath, "secret"); - const canonicalPath = yield* fs.realPath(filePath); - const originalOpen = (yield* Effect.promise(() => - vi.importActual("node:fs/promises"), - )).open; - let opened: NodeFSP.FileHandle | undefined; - const openSpy = vi.mocked(NodeFSP.open).mockImplementation(async (target, flags, mode) => { - const handle = await originalOpen(target, flags, mode); - if (target === canonicalPath) { - opened = handle; - await NodeFSP.unlink(filePath); - await NodeFSP.symlink(secretPath, filePath); - } - return handle; - }); - yield* Effect.addFinalizer(() => Effect.sync(() => openSpy.mockImplementation(originalOpen))); - expect(yield* openMediaFile(canonicalPath)).toBeNull(); - expect(opened).toBeDefined(); - expect(opened?.fd).toBe(-1); - }).pipe(Effect.provide(testLayer)), + it.effect.skipIf(!symlinksSupported)( + "rejects a symlink swapped in after canonical validation but before open", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-open-race-" }); + const filePath = path.join(root, "recording.mp4"); + const secretPath = path.join(root, "secret.txt"); + yield* fs.writeFileString(filePath, "video"); + yield* fs.writeFileString(secretPath, "secret"); + const canonicalPath = yield* fs.realPath(filePath); + const result = yield* issueAssetUrl({ + resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: filePath }, + }); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separator = suffix.indexOf("/"); + const swappingFileSystem = FileSystem.FileSystem.of({ + ...fs, + stat: Effect.fn(function* (requestedPath) { + const info = yield* fs.stat(requestedPath); + if (requestedPath === canonicalPath) { + yield* fs.remove(filePath); + yield* fs.symlink(secretPath, filePath); + } + return info; + }), + }); + expect( + yield* resolveAsset(suffix.slice(0, separator), suffix.slice(separator + 1)).pipe( + Effect.provideService(FileSystem.FileSystem, swappingFileSystem), + ), + ).toBeNull(); + }).pipe(Effect.provide(testLayer)), ); - it.effect("rejects an ancestor symlink race even when canonical path rechecks would pass", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-parent-race-" }); - const publicDirectory = path.join(root, "public"); - const privateDirectory = path.join(root, "private"); - yield* fs.makeDirectory(publicDirectory); - yield* fs.makeDirectory(privateDirectory); - const filePath = path.join(publicDirectory, "recording.mp4"); - yield* fs.writeFileString(filePath, "public video"); - yield* fs.writeFileString(path.join(privateDirectory, "recording.mp4"), "private video"); - const canonicalPath = yield* fs.realPath(filePath); - const result = yield* issueAssetUrl({ - resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: filePath }, - }); - const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); - const separator = suffix.indexOf("/"); - const native = yield* Effect.promise(() => - vi.importActual("node:fs/promises"), - ); - const savedDirectory = path.join(root, "saved"); - const realpathSpy = vi.mocked(NodeFSP.realpath).mockImplementationOnce(async () => { - // A pathname-only guard can see the original parents during realpath, - // but the private file during both lstat calls and open. - await native.unlink(publicDirectory); - await native.rename(savedDirectory, publicDirectory); - const canonical = await native.realpath(canonicalPath); - await native.rename(publicDirectory, savedDirectory); - await native.symlink(privateDirectory, publicDirectory, "junction"); - return canonical; - }); - yield* Effect.addFinalizer(() => - Effect.sync(() => realpathSpy.mockReset().mockImplementation(native.realpath)), - ); - const swappingFileSystem = FileSystem.FileSystem.of({ - ...fs, - realPath: Effect.fn(function* (requestedPath) { - const canonical = yield* fs.realPath(requestedPath); - if (requestedPath === canonicalPath) { - yield* fs.rename(publicDirectory, savedDirectory); - yield* Effect.promise(() => - NodeFSP.symlink(privateDirectory, publicDirectory, "junction"), - ); + it.effect.skipIf(!symlinksSupported)( + "closes a descriptor rejected when its path changes during open", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-open-rejected-" }); + const filePath = path.join(root, "recording.mp4"); + const secretPath = path.join(root, "secret.txt"); + yield* fs.writeFileString(filePath, "video"); + yield* fs.writeFileString(secretPath, "secret"); + const canonicalPath = yield* fs.realPath(filePath); + const originalOpen = (yield* Effect.promise(() => + vi.importActual("node:fs/promises"), + )).open; + let opened: NodeFSP.FileHandle | undefined; + const openSpy = vi.mocked(NodeFSP.open).mockImplementation(async (target, flags, mode) => { + const handle = await originalOpen(target, flags, mode); + if (target === canonicalPath) { + opened = handle; + await NodeFSP.unlink(filePath); + await NodeFSP.symlink(secretPath, filePath); } + return handle; + }); + yield* Effect.addFinalizer(() => + Effect.sync(() => openSpy.mockImplementation(originalOpen)), + ); + expect(yield* openMediaFile(canonicalPath)).toBeNull(); + expect(opened).toBeDefined(); + expect(opened?.fd).toBe(-1); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect.skipIf(!symlinksSupported)( + "rejects an ancestor symlink race even when canonical path rechecks would pass", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-parent-race-" }); + const publicDirectory = path.join(root, "public"); + const privateDirectory = path.join(root, "private"); + yield* fs.makeDirectory(publicDirectory); + yield* fs.makeDirectory(privateDirectory); + const filePath = path.join(publicDirectory, "recording.mp4"); + yield* fs.writeFileString(filePath, "public video"); + yield* fs.writeFileString(path.join(privateDirectory, "recording.mp4"), "private video"); + const canonicalPath = yield* fs.realPath(filePath); + const result = yield* issueAssetUrl({ + resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: filePath }, + }); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separator = suffix.indexOf("/"); + const native = yield* Effect.promise(() => + vi.importActual("node:fs/promises"), + ); + const savedDirectory = path.join(root, "saved"); + const realpathSpy = vi.mocked(NodeFSP.realpath).mockImplementationOnce(async () => { + // A pathname-only guard can see the original parents during realpath, + // but the private file during both lstat calls and open. + await native.unlink(publicDirectory); + await native.rename(savedDirectory, publicDirectory); + const canonical = await native.realpath(canonicalPath); + await native.rename(publicDirectory, savedDirectory); + await native.symlink(privateDirectory, publicDirectory, "junction"); return canonical; - }), - }); - expect( - yield* resolveAsset(suffix.slice(0, separator), suffix.slice(separator + 1)).pipe( - Effect.provideService(FileSystem.FileSystem, swappingFileSystem), - ), - ).toBeNull(); - }).pipe(Effect.provide(testLayer)), + }); + yield* Effect.addFinalizer(() => + Effect.sync(() => realpathSpy.mockReset().mockImplementation(native.realpath)), + ); + const swappingFileSystem = FileSystem.FileSystem.of({ + ...fs, + realPath: Effect.fn(function* (requestedPath) { + const canonical = yield* fs.realPath(requestedPath); + if (requestedPath === canonicalPath) { + yield* fs.rename(publicDirectory, savedDirectory); + yield* Effect.promise(() => + NodeFSP.symlink(privateDirectory, publicDirectory, "junction"), + ); + } + return canonical; + }), + }); + expect( + yield* resolveAsset(suffix.slice(0, separator), suffix.slice(separator + 1)).pipe( + Effect.provideService(FileSystem.FileSystem, swappingFileSystem), + ), + ).toBeNull(); + }).pipe(Effect.provide(testLayer)), ); it.effect("keeps in-place edits readable but requires a new URL after atomic replacement", () => @@ -728,7 +754,7 @@ describe("AssetAccess", () => { projectFaviconPath: "brand/custom.svg", }); - expect(result.sourcePath).toBe("brand/custom.svg"); + expect(result.sourcePath).toBe(path.join("brand", "custom.svg")); expect(result.relativeUrl).toMatch(/\/v[0-9a-f]{64}-custom\.svg$/); }).pipe(Effect.provide(testLayer)), ); @@ -787,7 +813,7 @@ describe("AssetAccess", () => { projectFaviconPath: "brand/saved.svg", }); - expect(result.sourcePath).toBe("brand/saved.svg"); + expect(result.sourcePath).toBe(path.join("brand", "saved.svg")); expect(result.relativeUrl).toMatch(/\/v[0-9a-f]{64}-saved\.svg$/); }).pipe(Effect.provide(testLayer)), ); diff --git a/apps/server/src/assets/MediaFile.ts b/apps/server/src/assets/MediaFile.ts index f1b63bb659e3..e1053555b052 100644 --- a/apps/server/src/assets/MediaFile.ts +++ b/apps/server/src/assets/MediaFile.ts @@ -37,6 +37,11 @@ export interface OpenMediaFile { readonly info: NodeFS.BigIntStats; } +const realpathLikeFileSystem = (filePath: string) => + new Promise((resolve, reject) => { + NodeFS.realpath(filePath, (error, resolved) => (error ? reject(error) : resolve(resolved))); + }); + /** Opens a canonical media path once. Replacements cannot change the response's source. */ export const openMediaFile = Effect.fn("openMediaFile")(function* ( filePath: string, @@ -71,7 +76,11 @@ export const openMediaFile = Effect.fn("openMediaFile")(function* ( ) { return null; } - if ((await NodeFSP.realpath(filePath)) !== filePath) return null; + // Callers canonicalise with Effect's FileSystem.realPath, which is + // Node's JS realpath. fs/promises.realpath is the native binding and + // on Windows also expands 8.3 short names, so a path that is already + // canonical by the caller's rules would still look swapped here. + if ((await realpathLikeFileSystem(filePath)) !== filePath) return null; const after = await NodeFSP.lstat(filePath, { bigint: true }); if (!after.isFile() || info.dev !== after.dev || info.ino !== after.ino) return null; accepted = true; diff --git a/apps/server/src/auth/ServerSecretStore.test.ts b/apps/server/src/auth/ServerSecretStore.test.ts index d4411fb9f3b7..6b7850aa363b 100644 --- a/apps/server/src/auth/ServerSecretStore.test.ts +++ b/apps/server/src/auth/ServerSecretStore.test.ts @@ -109,7 +109,7 @@ const ConcurrentReadMissFileSystemLayer = Layer.effect( return { ...fileSystem, readFile: (path) => - String(path).endsWith("/session-signing-key.bin") + /[\\/]session-signing-key\.bin$/.test(String(path)) ? Ref.updateAndGet(readCountRef, (count) => count + 1).pipe( Effect.flatMap((count) => { if (count > 2) { @@ -219,7 +219,7 @@ it.layer(NodeServices.layer)("ServerSecretStore.layer", (it) => { yield* secretStore.set("session-signing-key", Uint8Array.from([1, 2, 3])); assert.isTrue( - chmodCalls.some((call) => call.mode === 0o700 && call.path.endsWith("/secrets")), + chmodCalls.some((call) => call.mode === 0o700 && /[\\/]secrets$/.test(call.path)), ); assert.isAtLeast(chmodCalls.filter((call) => call.mode === 0o600).length, 2); }).pipe(Effect.provide(NodeServices.layer)), diff --git a/apps/server/src/bootstrap.test.ts b/apps/server/src/bootstrap.test.ts index 05155f32ec4c..d532f71c9fb8 100644 --- a/apps/server/src/bootstrap.test.ts +++ b/apps/server/src/bootstrap.test.ts @@ -55,6 +55,24 @@ vi.mock("node:fs", async (importOriginal) => { }; }); +const windowsHost = HostProcessPlatform.defaultValue() === "win32"; +const nullDevice = windowsHost ? "\\\\.\\NUL" : "/dev/null"; +const closeIfOpen = (fd: number) => { + try { + NodeFS.closeSync(fd); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EBADF") throw error; + } +}; + +// A successful Windows read streams the inherited fd with autoClose. POSIX +// reopens the fd through /proc or /dev, so the test still owns the original. +const openBootstrapInputFd = (filePath: string) => + Effect.acquireRelease( + Effect.sync(() => NodeFS.openSync(filePath, "r")), + (fd) => (windowsHost ? Effect.void : Effect.sync(() => closeIfOpen(fd))), + ); + const TestEnvelopeSchema = Schema.Struct({ mode: Schema.String }); const encodeTestEnvelopeSchema = Schema.encodeEffect(Schema.fromJsonString(TestEnvelopeSchema)); @@ -69,10 +87,7 @@ it.layer(NodeServices.layer)("readBootstrapEnvelope", (it) => { `${yield* encodeTestEnvelopeSchema({ mode: "desktop" })}\n`, ); - const fd = yield* Effect.acquireRelease( - Effect.sync(() => NodeFS.openSync(filePath, "r")), - (fd) => Effect.sync(() => NodeFS.closeSync(fd)), - ); + const fd = yield* openBootstrapInputFd(filePath); const payload = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, { timeoutMs: 100 }); assertSome(payload, { @@ -117,7 +132,7 @@ it.layer(NodeServices.layer)("readBootstrapEnvelope", (it) => { const filePath = yield* fs.makeTempFileScoped({ prefix: "t3-bootstrap-", suffix: ".ndjson" }); const fd = yield* Effect.acquireRelease( Effect.sync(() => NodeFS.openSync(filePath, "r")), - (fd) => Effect.sync(() => NodeFS.closeSync(fd)), + (fd) => Effect.sync(() => closeIfOpen(fd)), ); const fdPath = `/proc/self/fd/${fd}`; @@ -146,7 +161,7 @@ it.layer(NodeServices.layer)("readBootstrapEnvelope", (it) => { it.effect("returns none when the fd is unavailable", () => Effect.gen(function* () { - const fd = NodeFS.openSync("/dev/null", "r"); + const fd = NodeFS.openSync(nullDevice, "r"); NodeFS.closeSync(fd); const payload = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, { timeoutMs: 100 }); @@ -157,8 +172,8 @@ it.layer(NodeServices.layer)("readBootstrapEnvelope", (it) => { it.effect("preserves fd and cause when stat fails for a non-availability reason", () => Effect.gen(function* () { const fd = yield* Effect.acquireRelease( - Effect.sync(() => NodeFS.openSync("/dev/null", "r")), - (fd) => Effect.sync(() => NodeFS.closeSync(fd)), + Effect.sync(() => NodeFS.openSync(nullDevice, "r")), + (fd) => Effect.sync(() => closeIfOpen(fd)), ); fstatSyncInterceptor.failFd = fd; @@ -183,10 +198,7 @@ it.layer(NodeServices.layer)("readBootstrapEnvelope", (it) => { const filePath = yield* fs.makeTempFileScoped({ prefix: "t3-bootstrap-", suffix: ".ndjson" }); yield* fs.writeFileString(filePath, '{"mode":42}\n'); - const fd = yield* Effect.acquireRelease( - Effect.sync(() => NodeFS.openSync(filePath, "r")), - (fd) => Effect.sync(() => NodeFS.closeSync(fd)), - ); + const fd = yield* openBootstrapInputFd(filePath); const error = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, { timeoutMs: 100, }).pipe(Effect.flip); @@ -201,40 +213,43 @@ it.layer(NodeServices.layer)("readBootstrapEnvelope", (it) => { }), ); - it.effect("returns none when the bootstrap read times out before any value arrives", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-bootstrap-" }); - const fifoPath = NodePath.join(tempDir, "bootstrap.pipe"); - - yield* Effect.sync(() => NodeChildProcess.execFileSync("mkfifo", [fifoPath])); - - const _writer = yield* Effect.acquireRelease( - Effect.sync(() => - NodeChildProcess.spawn("sh", ["-c", 'exec 3>"$1"; sleep 60', "sh", fifoPath], { - stdio: ["ignore", "ignore", "ignore"], - }), - ), - (writer) => - Effect.sync(() => { - writer.kill("SIGKILL"); - }), - ); + // Needs a FIFO, which mkfifo creates; Windows has neither. + it.effect.skipIf(windowsHost)( + "returns none when the bootstrap read times out before any value arrives", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-bootstrap-" }); + const fifoPath = NodePath.join(tempDir, "bootstrap.pipe"); + + yield* Effect.sync(() => NodeChildProcess.execFileSync("mkfifo", [fifoPath])); + + const _writer = yield* Effect.acquireRelease( + Effect.sync(() => + NodeChildProcess.spawn("sh", ["-c", 'exec 3>"$1"; sleep 60', "sh", fifoPath], { + stdio: ["ignore", "ignore", "ignore"], + }), + ), + (writer) => + Effect.sync(() => { + writer.kill("SIGKILL"); + }), + ); - const fd = yield* Effect.acquireRelease( - Effect.sync(() => NodeFS.openSync(fifoPath, "r")), - (fd) => Effect.sync(() => NodeFS.closeSync(fd)), - ); + const fd = yield* Effect.acquireRelease( + Effect.sync(() => NodeFS.openSync(fifoPath, "r")), + (fd) => Effect.sync(() => closeIfOpen(fd)), + ); - const fiber = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, { - timeoutMs: 100, - }).pipe(Effect.forkScoped); + const fiber = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, { + timeoutMs: 100, + }).pipe(Effect.forkScoped); - yield* Effect.yieldNow; - yield* TestClock.adjust(Duration.millis(100)); + yield* Effect.yieldNow; + yield* TestClock.adjust(Duration.millis(100)); - const payload = yield* Fiber.join(fiber); - assertNone(payload); - }).pipe(Effect.provide(TestClock.layer())), + const payload = yield* Fiber.join(fiber); + assertNone(payload); + }).pipe(Effect.provide(TestClock.layer())), ); }); diff --git a/apps/server/src/cli/config.test.ts b/apps/server/src/cli/config.test.ts index def63b61fafe..2267a5cb1cc9 100644 --- a/apps/server/src/cli/config.test.ts +++ b/apps/server/src/cli/config.test.ts @@ -60,7 +60,16 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { yield* fs.writeFileString(filePath, `${encoded}\n`); return yield* Effect.acquireRelease( Effect.sync(() => NodeFS.openSync(filePath, "r")), - (fd) => Effect.sync(() => NodeFS.closeSync(fd)), + // Without a /proc or /dev/fd path to reopen, the reader consumes the fd + // itself (autoClose), so on Windows it is already closed here. + (fd) => + Effect.sync(() => { + try { + NodeFS.closeSync(fd); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EBADF") throw error; + } + }), ); }); @@ -281,13 +290,15 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { it.effect("uses bootstrap envelope values as fallbacks when flags and env are absent", () => Effect.gen(function* () { - const { join } = yield* Path.Path; - const baseDir = "/tmp/t3-bootstrap-home"; + const { join, resolve } = yield* Path.Path; + // The resolver absolutises the configured home, so the expectation must + // carry the host's drive on Windows. + const baseDir = resolve("/tmp/t3-bootstrap-home"); const fd = yield* openBootstrapFd( makeDesktopBootstrap({ port: 4888, host: "127.0.0.2", - t3Home: baseDir, + t3Home: "/tmp/t3-bootstrap-home", noBrowser: true, desktopBootstrapToken: "desktop-token", desktopTelemetryFd: 4, diff --git a/apps/server/src/cli/theme.test.ts b/apps/server/src/cli/theme.test.ts index d3dd69b94727..7935f5e51b42 100644 --- a/apps/server/src/cli/theme.test.ts +++ b/apps/server/src/cli/theme.test.ts @@ -13,6 +13,12 @@ import * as TestConsole from "effect/testing/TestConsole"; import { Command } from "effect/unstable/cli"; import { cli } from "../bin.ts"; +import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; + +// These force a failure with chmod, which Windows ignores for directories and +// cannot use to make a file unreadable, so the failure never happens there. +const windowsHost = HostProcessPlatform.defaultValue() === "win32"; const runCli = (args: ReadonlyArray) => Command.runWith(cli, { version: "0.0.0" })(args).pipe( @@ -160,7 +166,7 @@ describe("t3 theme", () => { // rolled back rather than left mutating the environment's theme set. The // userdata directory is made read-only while themes stays writable, so the // failure lands after the publish -- the case the rollback exists for. - it.effect("rolls back a publish when the default cannot be written", () => + it.effect.skipIf(windowsHost)("rolls back a publish when the default cannot be written", () => Effect.gen(function* () { const baseDir = makeBaseDir(); writeSettings(baseDir, {}); @@ -185,27 +191,29 @@ describe("t3 theme", () => { // A symlink is a normal way to hand this command a theme -- desktop hooks // symlink the current palette -- so the source is resolved, not refused. - it.effect("publishes a theme file through a symlinked source path", () => - Effect.gen(function* () { - const baseDir = makeBaseDir(); - const realFile = NodePath.join(baseDir, "real-nightfall.json"); - NodeFS.writeFileSync(realFile, NIGHTFALL_THEME_JSON); - const linkPath = NodePath.join(baseDir, "nightfall.json"); - NodeFS.symlinkSync(realFile, linkPath); - - yield* runCli(["theme", "set", linkPath, "--base-dir", baseDir]); - - assert.equal( - NodeFS.existsSync(NodePath.join(baseDir, "userdata", "themes", "nightfall.json")), - true, - ); - assert.equal(readSettings(baseDir).defaultTheme, "nightfall"); - }), + it.effect.skipIf(!symlinksSupported)( + "publishes a theme file through a symlinked source path", + () => + Effect.gen(function* () { + const baseDir = makeBaseDir(); + const realFile = NodePath.join(baseDir, "real-nightfall.json"); + NodeFS.writeFileSync(realFile, NIGHTFALL_THEME_JSON); + const linkPath = NodePath.join(baseDir, "nightfall.json"); + NodeFS.symlinkSync(realFile, linkPath); + + yield* runCli(["theme", "set", linkPath, "--base-dir", baseDir]); + + assert.equal( + NodeFS.existsSync(NodePath.join(baseDir, "userdata", "themes", "nightfall.json")), + true, + ); + assert.equal(readSettings(baseDir).defaultTheme, "nightfall"); + }), ); // The staging entry is created fresh with O_EXCL, so a symlink planted at // its predictable name is cleared, never followed and written through. - it.effect("never writes through a symlink at the staging path", () => + it.effect.skipIf(!symlinksSupported)("never writes through a symlink at the staging path", () => Effect.gen(function* () { const baseDir = makeBaseDir(); const themesDir = NodePath.join(baseDir, "userdata", "themes"); @@ -226,31 +234,33 @@ describe("t3 theme", () => { // Rollback moves the previous directory entry aside and back, so even an // entry the watcher would never publish -- here a symlink -- comes back // exactly as it was when the set fails. - it.effect("restores a non-theme destination entry when the set fails", () => - Effect.gen(function* () { - const baseDir = makeBaseDir(); - writeSettings(baseDir, {}); - const userdataDir = NodePath.dirname(settingsPathFor(baseDir)); - const themesDir = NodePath.join(userdataDir, "themes"); - NodeFS.mkdirSync(themesDir, { recursive: true }); - const outside = NodePath.join(baseDir, "outside.json"); - NodeFS.writeFileSync(outside, NIGHTFALL_THEME_JSON); - const destination = NodePath.join(themesDir, "nightfall.json"); - NodeFS.symlinkSync(outside, destination); - const themeFile = NodePath.join(baseDir, "nightfall.json"); - NodeFS.writeFileSync(themeFile, NIGHTFALL_THEME_JSON); - - NodeFS.chmodSync(userdataDir, 0o555); - try { - yield* runCli(["theme", "set", themeFile, "--base-dir", baseDir]).pipe(Effect.flip); - assert.equal(NodeFS.lstatSync(destination).isSymbolicLink(), true); - } finally { - NodeFS.chmodSync(userdataDir, 0o755); - } - }), + it.effect.skipIf(!symlinksSupported || windowsHost)( + "restores a non-theme destination entry when the set fails", + () => + Effect.gen(function* () { + const baseDir = makeBaseDir(); + writeSettings(baseDir, {}); + const userdataDir = NodePath.dirname(settingsPathFor(baseDir)); + const themesDir = NodePath.join(userdataDir, "themes"); + NodeFS.mkdirSync(themesDir, { recursive: true }); + const outside = NodePath.join(baseDir, "outside.json"); + NodeFS.writeFileSync(outside, NIGHTFALL_THEME_JSON); + const destination = NodePath.join(themesDir, "nightfall.json"); + NodeFS.symlinkSync(outside, destination); + const themeFile = NodePath.join(baseDir, "nightfall.json"); + NodeFS.writeFileSync(themeFile, NIGHTFALL_THEME_JSON); + + NodeFS.chmodSync(userdataDir, 0o555); + try { + yield* runCli(["theme", "set", themeFile, "--base-dir", baseDir]).pipe(Effect.flip); + assert.equal(NodeFS.lstatSync(destination).isSymbolicLink(), true); + } finally { + NodeFS.chmodSync(userdataDir, 0o755); + } + }), ); - it.effect("restores the previous theme when a re-publish fails to set", () => + it.effect.skipIf(windowsHost)("restores the previous theme when a re-publish fails to set", () => Effect.gen(function* () { const baseDir = makeBaseDir(); writeSettings(baseDir, {}); @@ -347,7 +357,7 @@ describe("t3 theme", () => { // An unreadable settings file must never read as "no settings": writing a // fresh sparse file over it would discard every key the user had. - it.effect("refuses to write when the settings file cannot be read", () => + it.effect.skipIf(windowsHost)("refuses to write when the settings file cannot be read", () => Effect.gen(function* () { const baseDir = makeBaseDir(); writeSettings(baseDir, { enableProviderUpdateChecks: false }); diff --git a/apps/server/src/cloud/bootService.test.ts b/apps/server/src/cloud/bootService.test.ts index c820f93cd212..688617440500 100644 --- a/apps/server/src/cloud/bootService.test.ts +++ b/apps/server/src/cloud/bootService.test.ts @@ -529,11 +529,14 @@ it.layer(NodeServices.layer)("boot service install", (it) => { it.effect("installs, reports current state, and uninstalls on macOS", () => Effect.gen(function* () { const { service, fs, statePath, commands, timeouts } = yield* makeHarness("darwin"); + const path = yield* Path.Path; const plan = yield* service.install(); - expect(plan.unitPath.endsWith("Library/LaunchAgents/com.t3tools.t3code.service.plist")).toBe( - true, - ); + expect( + plan.unitPath.endsWith( + path.join("Library", "LaunchAgents", "com.t3tools.t3code.service.plist"), + ), + ).toBe(true); expect(yield* fs.readFileString(plan.unitPath)).toContain( ` PATH\n ${macInstallerPath}:/usr/local/bin:/usr/sbin:/sbin`, ); diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts index 50ffcedf74b8..a0bd6f30c6ca 100644 --- a/apps/server/src/cloud/bootService.ts +++ b/apps/server/src/cloud/bootService.ts @@ -555,9 +555,16 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { yield* fs.makeDirectory(directory, { recursive: true }); const tempPath = yield* fs.makeTempFileScoped({ directory, prefix: ".service-write-" }); yield* fs.writeFileString(tempPath, contents, { mode: 0o600 }); - yield* (yield* fs.open(tempPath, { flag: "r" })).sync; + // Opened read-write: Windows refuses to flush a handle without write access. + yield* (yield* fs.open(tempPath, { flag: "r+" })).sync; yield* fs.rename(tempPath, filePath); - yield* (yield* fs.open(directory, { flag: "r" })).sync; + // Windows has no directory fsync (EPERM); NTFS journals the rename. + yield* (yield* fs.open(directory, { flag: "r" })).sync.pipe( + Effect.catchIf( + (error) => (error.reason.cause as NodeJS.ErrnoException | undefined)?.code === "EPERM", + () => Effect.void, + ), + ); }), ).pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); const plan: BootServicePlan = { diff --git a/apps/server/src/entrypoint.test.ts b/apps/server/src/entrypoint.test.ts index 56f2c119764a..bf8247fd4295 100644 --- a/apps/server/src/entrypoint.test.ts +++ b/apps/server/src/entrypoint.test.ts @@ -7,6 +7,7 @@ import * as NodeURL from "node:url"; import { describe, expect, it } from "vite-plus/test"; import { isEntrypoint } from "./entrypoint.ts"; +import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; const makeTempDir = () => NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-entrypoint-test-")); @@ -44,21 +45,24 @@ describe("isEntrypoint", () => { ).toBe(true); }); - it("matches through a symlinked entrypoint, as npm and npx install it", () => { - const dir = makeTempDir(); - const real = NodePath.join(dir, "bin.mjs"); - const link = NodePath.join(dir, "t3"); - NodeFS.writeFileSync(real, ""); - NodeFS.symlinkSync(real, link); + it.skipIf(!symlinksSupported)( + "matches through a symlinked entrypoint, as npm and npx install it", + () => { + const dir = makeTempDir(); + const real = NodePath.join(dir, "bin.mjs"); + const link = NodePath.join(dir, "t3"); + NodeFS.writeFileSync(real, ""); + NodeFS.symlinkSync(real, link); - expect( - isEntrypoint({ - moduleUrl: NodeURL.pathToFileURL(real).href, - entryPath: link, - runtimeMain: undefined, - }), - ).toBe(true); - }); + expect( + isEntrypoint({ + moduleUrl: NodeURL.pathToFileURL(real).href, + entryPath: link, + runtimeMain: undefined, + }), + ).toBe(true); + }, + ); it("stays false for an imported module that is not the entrypoint", () => { // This is what keeps `bin.test.ts` from launching the CLI on import. diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index 91895fd5dcfc..141aa405af21 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -166,6 +166,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { expect(second.capabilities.attachmentUploads).toBe(true); expect(second.capabilities.fileAttachments).toEqual({ maxUploadBytes: 50 * 1024 * 1024 }); expect(second.capabilities.pullRequests).toBe(true); + expect(second.capabilities.usagePriceOverrides).toBe(true); expect(second.capabilities.threadTitleRegeneration).toBe(true); expect(second.capabilities.threadPullRequestLinking).toBe(true); expect(second.capabilities.agentActivityPublishing).toBe(false); diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index d050bde88565..cfecfc00c86d 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -222,6 +222,7 @@ export const make = Effect.gen(function* () { threadSnooze: true, environmentThemes: true, usageLimitSources: true, + usagePriceOverrides: true, threadPinning: true, threadPinReorder: true, threadTitleRegeneration: true, diff --git a/apps/server/src/environmentTheme.test.ts b/apps/server/src/environmentTheme.test.ts index 0d50e020098c..3c3f650b4c3c 100644 --- a/apps/server/src/environmentTheme.test.ts +++ b/apps/server/src/environmentTheme.test.ts @@ -13,6 +13,7 @@ import * as Stream from "effect/Stream"; import * as ServerConfig from "./config.ts"; import * as EnvironmentTheme from "./environmentTheme.ts"; +import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; const encodeThemeFile = Schema.encodeSync(Schema.fromJsonString(EnvironmentThemeFile)); @@ -188,7 +189,7 @@ it.layer(NodeServices.layer)("environment theme", (it) => { // A symlinked themes directory stays usable, but a symlinked file inside it // must not publish whatever it points at. - it.effect("ignores a symlinked theme file", () => + it.effect.skipIf(!symlinksSupported)("ignores a symlinked theme file", () => withEnvironmentThemes( {}, Effect.gen(function* () { diff --git a/apps/server/src/environmentTheme.ts b/apps/server/src/environmentTheme.ts index c038af065bdc..b7eb11940cea 100644 --- a/apps/server/src/environmentTheme.ts +++ b/apps/server/src/environmentTheme.ts @@ -95,13 +95,23 @@ export class EnvironmentThemeService extends Context.Service< * usable, a symlinked file inside it does not), O_NONBLOCK keeps a FIFO from * blocking the open, and the fstat type and size gate examines the open * descriptor. Returns null for anything that is not a small regular file. + * + * Windows has neither flag (the constants are undefined, and OR-ing them in + * is a no-op), so the symlink check there is an lstat before the open. That + * leaves a window a swap could slip through, which the descriptor-bound + * checks below then narrow to "a regular file at that path". */ export const readThemeFileGuarded = (filePath: string, maxBytes: number): string | null => { let fd: number; try { + if (NodeFS.constants.O_NOFOLLOW === undefined && NodeFS.lstatSync(filePath).isSymbolicLink()) { + return null; + } fd = NodeFS.openSync( filePath, - NodeFS.constants.O_RDONLY | NodeFS.constants.O_NOFOLLOW | NodeFS.constants.O_NONBLOCK, + NodeFS.constants.O_RDONLY | + (NodeFS.constants.O_NOFOLLOW ?? 0) | + (NodeFS.constants.O_NONBLOCK ?? 0), ); } catch { return null; diff --git a/apps/server/src/git/Utils.ts b/apps/server/src/git/Utils.ts deleted file mode 100644 index e4a703f44540..000000000000 --- a/apps/server/src/git/Utils.ts +++ /dev/null @@ -1,7 +0,0 @@ -// @effect-diagnostics nodeBuiltinImport:off -import * as NodeFS from "node:fs"; -import * as NodePath from "node:path"; - -export function isGitRepository(cwd: string): boolean { - return NodeFS.existsSync(NodePath.join(cwd, ".git")); -} diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index 6c32235c27f8..160755a8f9b2 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -12,6 +12,7 @@ import * as Schema from "effect/Schema"; import * as ServerConfig from "./config.ts"; import * as Keybindings from "./keybindings.ts"; import { KeybindingsConfigError } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; const KeybindingsConfigJson = Schema.fromJsonString(KeybindingsConfig); const encodeKeybindingsConfigJson = Schema.encodeEffect(KeybindingsConfigJson); @@ -511,31 +512,34 @@ it.layer(NodeServices.layer)("keybindings", (it) => { }).pipe(Effect.provide(makeKeybindingsLayer())), ); - it.effect("fails when config directory is not writable", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig; - const { dirname } = yield* Path.Path; - yield* writeKeybindingsConfig(keybindingsConfigPath, [ - { key: "mod+j", command: "terminal.toggle" }, - ]); - yield* fs.chmod(dirname(keybindingsConfigPath), 0o500); + // chmod cannot make a directory unwritable on Windows, so the write succeeds. + it.effect.skipIf(HostProcessPlatform.defaultValue() === "win32")( + "fails when config directory is not writable", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig; + const { dirname } = yield* Path.Path; + yield* writeKeybindingsConfig(keybindingsConfigPath, [ + { key: "mod+j", command: "terminal.toggle" }, + ]); + yield* fs.chmod(dirname(keybindingsConfigPath), 0o500); - const result = yield* Effect.gen(function* () { - const keybindings = yield* Keybindings.Keybindings; - return yield* keybindings.upsertKeybindingRule({ - key: "mod+shift+r", - command: "script.run-tests.run", - }); - }).pipe(toDetailResult); - assertFailure(result, "failed to write keybindings config"); + const result = yield* Effect.gen(function* () { + const keybindings = yield* Keybindings.Keybindings; + return yield* keybindings.upsertKeybindingRule({ + key: "mod+shift+r", + command: "script.run-tests.run", + }); + }).pipe(toDetailResult); + assertFailure(result, "failed to write keybindings config"); - yield* fs.chmod(dirname(keybindingsConfigPath), 0o700); + yield* fs.chmod(dirname(keybindingsConfigPath), 0o700); - const persisted = yield* readKeybindingsConfig(keybindingsConfigPath); - const persistedView = persisted.map(({ key, command }) => ({ key, command })); - assert.deepEqual(persistedView, [{ key: "mod+j", command: "terminal.toggle" }]); - }).pipe(Effect.provide(makeKeybindingsLayer())), + const persisted = yield* readKeybindingsConfig(keybindingsConfigPath); + const persistedView = persisted.map(({ key, command }) => ({ key, command })); + assert.deepEqual(persistedView, [{ key: "mod+j", command: "terminal.toggle" }]); + }).pipe(Effect.provide(makeKeybindingsLayer())), ); it.effect("caches loaded resolved config across repeated reads", () => diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index a01a938187f2..2f2c4b30525b 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -590,6 +590,83 @@ describe("CheckpointReactor", () => { }), ); + effectIt.effect("captures and reverts checkpoints from a nested Git workspace", () => + Effect.gen(function* () { + const repositoryRoot = createGitRepository(); + tempDirs.push(repositoryRoot); + const workspaceRoot = NodePath.join(repositoryRoot, "apps", "server"); + NodeFS.mkdirSync(workspaceRoot, { recursive: true }); + const filePath = NodePath.join(workspaceRoot, "index.ts"); + NodeFS.writeFileSync(filePath, "export const value = 1;\n"); + runGit(repositoryRoot, ["add", "."]); + runGit(repositoryRoot, ["commit", "-m", "Add nested workspace"]); + const harness = yield* Effect.promise(() => + createHarness({ + seedFilesystemCheckpoints: false, + projectWorkspaceRoot: workspaceRoot, + threadWorktreePath: workspaceRoot, + providerSessionCwd: workspaceRoot, + }), + ); + const threadId = ThreadId.make("thread-1"); + const turnId = asTurnId("turn-nested"); + const createdAt = "2026-01-01T00:00:00.000Z"; + harness.provider.emit({ + type: "turn.started", + eventId: EventId.make("evt-nested-start"), + provider: ProviderDriverKind.make("codex"), + createdAt, + threadId, + turnId, + }); + yield* Effect.promise(harness.drain); + expect(gitRefExists(repositoryRoot, checkpointRefForThreadTurn(threadId, 0))).toBe(true); + expect(yield* harness.nextReceipt).toMatchObject({ + type: "checkpoint.baseline.captured", + }); + + NodeFS.writeFileSync(filePath, "export const value = 2;\n"); + harness.provider.emit({ + type: "turn.completed", + eventId: EventId.make("evt-nested-complete"), + provider: ProviderDriverKind.make("codex"), + createdAt, + threadId, + turnId, + payload: { state: "completed" }, + }); + yield* Effect.promise(harness.drain); + const thread = (yield* Effect.promise(harness.readModel)).threads.find( + (entry) => entry.id === threadId, + ); + expect(thread?.checkpoints[0]).toMatchObject({ + status: "ready", + files: [{ path: "apps/server/index.ts", additions: 1, deletions: 1 }], + }); + expect(yield* harness.nextReceipt).toMatchObject({ + type: "checkpoint.diff.finalized", + turnId, + }); + expect(yield* harness.nextReceipt).toMatchObject({ type: "turn.processing.quiesced" }); + + yield* harness.engine.dispatch({ + type: "thread.checkpoint.revert", + commandId: CommandId.make("cmd-nested-revert"), + threadId, + turnCount: 0, + createdAt, + }); + yield* Effect.promise(harness.drain); + expect(NodeFS.readFileSync(filePath, "utf8")).toBe("export const value = 1;\n"); + expect(harness.provider.rollbackConversation).toHaveBeenCalledWith({ threadId, numTurns: 1 }); + expect(gitRefExists(repositoryRoot, checkpointRefForThreadTurn(threadId, 1))).toBe(false); + const reverted = (yield* Effect.promise(harness.readModel)).threads.find( + (entry) => entry.id === threadId, + ); + expect(reverted?.checkpoints).toEqual([]); + }), + ); + it("refreshes local git status state on turn completion using the session cwd", async () => { const gitStatusRefreshCalls: string[] = []; const harness = await createHarness({ diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.ts index 0a56eb840960..108abd5d06bb 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.ts @@ -35,7 +35,6 @@ import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts" import { RuntimeReceiptBus } from "../Services/RuntimeReceiptBus.ts"; import type { CheckpointStoreError } from "../../checkpointing/Errors.ts"; import type { OrchestrationDispatchError } from "../Errors.ts"; -import { isGitRepository } from "../../git/Utils.ts"; import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; import * as WorkspaceEntries from "../../workspace/WorkspaceEntries.ts"; import * as PullRequestService from "../../pullRequest/PullRequestService.ts"; @@ -181,8 +180,6 @@ const make = Effect.gen(function* () { return project ? [project] : []; }); - const isGitWorkspace = (cwd: string) => isGitRepository(cwd); - // Resolves the workspace CWD for checkpoint operations, preferring the // active provider session CWD and falling back to the thread/project config. // Returns undefined when no CWD can be determined or the workspace is not @@ -192,7 +189,7 @@ const make = Effect.gen(function* () { readonly thread: { readonly projectId: ProjectId; readonly worktreePath: string | null }; readonly projects: ReadonlyArray<{ readonly id: ProjectId; readonly workspaceRoot: string }>; readonly preferSessionRuntime: boolean; - }): Effect.fn.Return { + }): Effect.fn.Return { const fromSession = yield* resolveSessionRuntimeForThread(input.threadId); const fromThread = resolveThreadWorkspaceCwd({ thread: input.thread, @@ -213,7 +210,7 @@ const make = Effect.gen(function* () { if (!cwd) { return undefined; } - if (!isGitWorkspace(cwd)) { + if (!(yield* checkpointStore.isGitRepository(cwd))) { return undefined; } return cwd; @@ -751,7 +748,7 @@ const make = Effect.gen(function* () { }).pipe(Effect.catch(() => Effect.void)); return; } - if (!isGitWorkspace(sessionRuntime.value.cwd)) { + if (!(yield* checkpointStore.isGitRepository(sessionRuntime.value.cwd))) { yield* appendRevertFailureActivity({ threadId: event.payload.threadId, turnCount: event.payload.turnCount, diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 38970e45421d..4d35c5b04dd4 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -39,7 +39,11 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { deriveServerPaths, ServerConfig } from "../../config.ts"; import { TextGenerationError } from "@t3tools/contracts"; -import { ProviderAdapterRequestError } from "../../provider/Errors.ts"; +import { + ProviderAdapterRequestError, + ProviderWorkspaceMissingError, + type ProviderServiceError, +} from "../../provider/Errors.ts"; import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts"; import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; @@ -181,7 +185,7 @@ describe("ProviderCommandReactor", () => { readonly stopSessionEffect?: () => Effect.Effect; readonly startSessionEffect?: ( session: ProviderSession, - ) => Effect.Effect; + ) => Effect.Effect; readonly tryHandlePromptCommandEffect?: ProviderAuthService["Service"]["tryHandlePromptCommand"]; }) { const now = "2026-01-01T00:00:00.000Z"; @@ -1217,6 +1221,58 @@ describe("ProviderCommandReactor", () => { }), ); + effectIt.effect("shows the missing workspace message without a provider stack trace", () => + Effect.gen(function* () { + const attempted = yield* Deferred.make(); + const missingCwd = "/missing/project/worktree"; + const missingWorkspace = new ProviderWorkspaceMissingError({ + threadId: ThreadId.make("thread-1"), + cwd: missingCwd, + }); + const harness = yield* Effect.promise(() => + createHarness({ + startSessionEffect: () => + Deferred.succeed(attempted, undefined).pipe( + Effect.andThen(Effect.fail(missingWorkspace)), + ), + }), + ); + + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-missing-workspace"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-missing-workspace"), + role: "user", + text: "continue", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: "2026-01-01T00:00:00.000Z", + }); + yield* Deferred.await(attempted); + yield* Effect.promise(() => harness.drain()); + + const thread = (yield* Effect.promise(() => harness.readModel())).threads.find( + (entry) => entry.id === ThreadId.make("thread-1"), + ); + expect(thread?.session).toMatchObject({ + status: "error", + activeTurnId: null, + lastError: missingWorkspace.message, + }); + const failure = thread?.activities.find( + (activity) => activity.kind === "provider.turn.start.failed", + ); + expect(failure?.payload).toMatchObject({ detail: missingWorkspace.message }); + expect(harness.runtimeSessions).toEqual([]); + expect(harness.sendTurn).not.toHaveBeenCalled(); + expect(yield* Effect.promise(() => harness.readPendingTurnStarts())).toEqual([]); + }), + ); + effectIt.effect("settles a failed provider startup and allows a clean retry", () => Effect.gen(function* () { let failStartup = true; diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 1589bc94b78b..b8e457e34ebb 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -34,6 +34,7 @@ import { increment, orchestrationEventsProcessedTotal } from "../../observabilit import { ProviderAdapterRequestError, ProviderAdapterValidationError, + ProviderWorkspaceMissingError, } from "../../provider/Errors.ts"; import type { ProviderServiceError } from "../../provider/Errors.ts"; import { TextGeneration } from "../../textGeneration/TextGeneration.ts"; @@ -56,6 +57,7 @@ import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; import { GitWorkflowService } from "../../git/GitWorkflowService.ts"; const isProviderAdapterRequestError = Schema.is(ProviderAdapterRequestError); const isProviderAdapterValidationError = Schema.is(ProviderAdapterValidationError); +const isProviderWorkspaceMissingError = Schema.is(ProviderWorkspaceMissingError); const isProviderDriverKind = Schema.is(ProviderDriverKind); type ProviderIntentEvent = Extract< @@ -394,6 +396,9 @@ const make = Effect.gen(function* () { if (isProviderAdapterValidationError(failReason?.error)) { return failReason.error.issue; } + if (isProviderWorkspaceMissingError(failReason?.error)) { + return failReason.error.message; + } return Cause.pretty(cause); }; diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 923e8c16f540..827e14734dc6 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -2,6 +2,7 @@ import * as NodeFS from "node:fs"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; +import * as NodeChildProcess from "node:child_process"; import { OrchestrationReadModel, @@ -44,6 +45,9 @@ import { type ProviderServiceShape, } from "../../provider/Services/ProviderService.ts"; import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts"; +import * as CheckpointStore from "../../checkpointing/CheckpointStore.ts"; +import * as VcsDriverRegistry from "../../vcs/VcsDriverRegistry.ts"; +import * as VcsProcess from "../../vcs/VcsProcess.ts"; import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; @@ -257,9 +261,15 @@ describe("ProviderRuntimeIngestion", () => { async function createHarness(options?: { serverSettings?: Partial; threadTitle?: string; + workspaceSubdirectory?: string; }) { - const workspaceRoot = makeTempDir("t3-provider-project-"); - NodeFS.mkdirSync(NodePath.join(workspaceRoot, ".git")); + const repositoryRoot = makeTempDir("t3-provider-project-"); + NodeChildProcess.execFileSync("git", ["init", "--initial-branch=main"], { + cwd: repositoryRoot, + stdio: "ignore", + }); + const workspaceRoot = NodePath.join(repositoryRoot, options?.workspaceSubdirectory ?? ""); + NodeFS.mkdirSync(workspaceRoot, { recursive: true }); const provider = createProviderServiceHarness(); const sqlCounter = makeSqlStatementCounter(); const orchestrationLayer = OrchestrationEngineLive.pipe( @@ -284,6 +294,8 @@ describe("ProviderRuntimeIngestion", () => { Layer.provideMerge(SqlitePersistenceMemory), Layer.provideMerge(Layer.succeed(ProviderService, provider.service)), Layer.provideMerge(makeTestServerSettingsLayer(options?.serverSettings)), + Layer.provideMerge(CheckpointStore.layer.pipe(Layer.provide(VcsDriverRegistry.layer))), + Layer.provideMerge(VcsProcess.layer), Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), Layer.provideMerge(NodeServices.layer), Layer.provideMerge(Layer.succeed(Tracer.Tracer, sqlCounter.tracer)), @@ -3177,6 +3189,33 @@ describe("ProviderRuntimeIngestion", () => { }); }); + effectIt.effect("tracks provider diff updates from a nested Git workspace", () => + Effect.gen(function* () { + const harness = yield* Effect.promise(() => + createHarness({ workspaceSubdirectory: "apps/server" }), + ); + yield* Effect.promise(() => + harness.emitAndDrain([ + { + type: "turn.diff.updated", + eventId: asEventId("evt-nested-diff"), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", + threadId: asThreadId("thread-1"), + turnId: asTurnId("nested-turn"), + payload: { + unifiedDiff: "diff --git a/apps/server/file.ts b/apps/server/file.ts\n+new\n", + }, + }, + ]), + ); + const snapshot = yield* Effect.promise(harness.readModel); + expect(snapshot.threads[0]?.checkpoints).toEqual([ + expect.objectContaining({ turnId: "nested-turn", status: "missing" }), + ]); + }), + ); + it("consumes P1 runtime events into thread metadata, diff checkpoints, and activities", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 449123d2e9da..a9be5d5b6e1f 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -36,7 +36,7 @@ import { formatTokens } from "@t3tools/shared/usageFormat"; import { ProviderService } from "../../provider/Services/ProviderService.ts"; import { ProjectionTurnRepository } from "../../persistence/Services/ProjectionTurns.ts"; import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/ProjectionTurns.ts"; -import { isGitRepository } from "../../git/Utils.ts"; +import * as CheckpointStore from "../../checkpointing/CheckpointStore.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { ThreadBackgroundLivenessService } from "../ThreadBackgroundLiveness.ts"; import { ThreadPlanProgressService } from "../ThreadPlanProgress.ts"; @@ -956,6 +956,7 @@ const make = Effect.gen(function* () { const providerService = yield* ProviderService; const projectionTurnRepository = yield* ProjectionTurnRepository; const serverSettingsService = yield* ServerSettingsService; + const checkpointStore = yield* CheckpointStore.CheckpointStore; const providerCommandId = (event: ProviderRuntimeEvent, tag: string) => crypto.randomUUIDv4.pipe( Effect.map((uuid) => CommandId.make(`provider:${event.eventId}:${tag}:${uuid}`)), @@ -2013,7 +2014,12 @@ const make = Effect.gen(function* () { : undefined; const workspaceCwd = checkpointContext?.worktreePath ?? checkpointContext?.workspaceRoot ?? undefined; - if (turnId && checkpointContext && workspaceCwd && isGitRepository(workspaceCwd)) { + if ( + turnId && + checkpointContext && + workspaceCwd && + (yield* checkpointStore.isGitRepository(workspaceCwd)) + ) { // Skip if a checkpoint already exists for this turn. A real // (non-placeholder) capture from CheckpointReactor should not // be clobbered, and dispatching a duplicate placeholder for the diff --git a/apps/server/src/orchestration/workflowScriptQuery.test.ts b/apps/server/src/orchestration/workflowScriptQuery.test.ts index 47fe888de70c..311b89427697 100644 --- a/apps/server/src/orchestration/workflowScriptQuery.test.ts +++ b/apps/server/src/orchestration/workflowScriptQuery.test.ts @@ -5,6 +5,7 @@ import * as NodePath from "node:path"; import { it as effectIt } from "@effect/vitest"; import * as Effect from "effect/Effect"; import { afterAll, assert, describe } from "vite-plus/test"; +import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; import { readWorkflowScript } from "./workflowScriptQuery.ts"; const root = NodePath.join(NodeOS.homedir(), ".claude", "projects", "__wf_script_test__"); @@ -14,19 +15,15 @@ NodeFS.writeFileSync(scriptPath, "export const meta = {};\n"); const outside = NodePath.join(NodeOS.tmpdir(), "wf-outside.js"); NodeFS.writeFileSync(outside, "evil\n"); const link = NodePath.join(root, "sneaky.js"); -try { +// Planted only where the host allows it; the escape test is skipped +// otherwise rather than passing vacuously on "not-found". +if (symlinksSupported) { + NodeFS.rmSync(link, { force: true }); NodeFS.symlinkSync(outside, link); -} catch (error) { - // Tolerate only "already exists" from a prior run — any other failure - // (EPERM etc.) must fail setup, or the escape test below would pass - // vacuously on "not-found" without testing containment. - if ((error as NodeJS.ErrnoException).code !== "EEXIST") { - throw error; + if (!NodeFS.lstatSync(link).isSymbolicLink()) { + throw new Error("test setup: sneaky.js must be a symlink"); } } -if (!NodeFS.lstatSync(link).isSymbolicLink()) { - throw new Error("test setup: sneaky.js must be a symlink"); -} afterAll(() => { NodeFS.rmSync(root, { recursive: true, force: true }); @@ -53,23 +50,25 @@ describe("readWorkflowScript containment", () => { }), ); - effectIt.effect("rejects paths outside the root and symlink escapes", () => - Effect.gen(function* () { - const escaped = yield* Effect.exit(readWorkflowScript({ scriptPath: outside })); - assert.equal(escaped._tag, "Failure"); - // A symlink INSIDE the root pointing outside must fail specifically on - // realpath re-containment — a "not-found" would mean the link was - // never exercised and the assertion proves nothing. - const sneaky = yield* Effect.exit( - readWorkflowScript({ scriptPath: link }).pipe( - Effect.flip, - Effect.map((error) => error.reason), - ), - ); - assert.equal(sneaky._tag, "Success"); - if (sneaky._tag === "Success") { - assert.equal(sneaky.value, "outside-root"); - } - }), + effectIt.effect.skipIf(!symlinksSupported)( + "rejects paths outside the root and symlink escapes", + () => + Effect.gen(function* () { + const escaped = yield* Effect.exit(readWorkflowScript({ scriptPath: outside })); + assert.equal(escaped._tag, "Failure"); + // A symlink INSIDE the root pointing outside must fail specifically on + // realpath re-containment — a "not-found" would mean the link was + // never exercised and the assertion proves nothing. + const sneaky = yield* Effect.exit( + readWorkflowScript({ scriptPath: link }).pipe( + Effect.flip, + Effect.map((error) => error.reason), + ), + ); + assert.equal(sneaky._tag, "Success"); + if (sneaky._tag === "Success") { + assert.equal(sneaky.value, "outside-root"); + } + }), ); }); diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index 030453e2f06f..2ae1684b67d4 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -20,6 +20,12 @@ import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { SpawnExecutableResolution } from "@t3tools/shared/shell"; import * as ExternalLauncher from "./externalLauncher.ts"; +// Tests below write `#!/bin/sh` stubs into a real temp dir and hand that +// directory to a posix-mocked resolver as PATH. On a Windows host the temp +// path carries a drive letter, so the posix `:` split shatters it; there is +// no posix executable to find there anyway. +const windowsHost = HostProcessPlatform.defaultValue() === "win32"; + interface MockSpawnResult { readonly exitCode?: number; readonly stdout?: string; @@ -153,7 +159,7 @@ it.effect("launches an installed editor with platform-safe arguments", () => }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); -it.effect("reveals a file in Finder with open -R on macOS", () => +it.effect.skipIf(windowsHost)("reveals a file in Finder with open -R on macOS", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -336,196 +342,208 @@ it.effect("does not advertise reveal on Windows when PowerShell is missing", () }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); -it.effect("reveals a WSL file in Windows File Explorer through its UNC path", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); - for (const name of ["explorer.exe", "powershell.exe", "xdg-open"]) { - const filePath = path.join(binDir, name); - yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); - yield* fileSystem.chmod(filePath, 0o755); - } +it.effect.skipIf(windowsHost)( + "reveals a WSL file in Windows File Explorer through its UNC path", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + for (const name of ["explorer.exe", "powershell.exe", "xdg-open"]) { + const filePath = path.join(binDir, name); + yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); + yield* fileSystem.chmod(filePath, 0o755); + } - let spawned: ChildProcess.StandardCommand | undefined; - const result = yield* Effect.gen(function* () { - const launcher = yield* ExternalLauncher.ExternalLauncher; - const kind = yield* launcher.resolveFileManagerRevealKind(); - const editors = yield* launcher.resolveAvailableEditors(); - yield* launcher.launchEditor({ - editor: "file-manager", - cwd: "/home/t3/workspace/media/clip.mp4", - reveal: true, - }); - return { kind, editors }; - }).pipe( - Effect.provide( - testLayer({ - platform: "linux", - env: { - PATH: binDir, - WSL_DISTRO_NAME: "Ubuntu-24.04", - WSL_INTEROP: "/run/WSL/1_interop", - }, - onSpawn: (command) => { - spawned = command; - }, - }), - ), - ); + let spawned: ChildProcess.StandardCommand | undefined; + const result = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + const kind = yield* launcher.resolveFileManagerRevealKind(); + const editors = yield* launcher.resolveAvailableEditors(); + yield* launcher.launchEditor({ + editor: "file-manager", + cwd: "/home/t3/workspace/media/clip.mp4", + reveal: true, + }); + return { kind, editors }; + }).pipe( + Effect.provide( + testLayer({ + platform: "linux", + env: { + PATH: binDir, + WSL_DISTRO_NAME: "Ubuntu-24.04", + WSL_INTEROP: "/run/WSL/1_interop", + }, + onSpawn: (command) => { + spawned = command; + }, + }), + ), + ); - assert.equal(result.kind, "file-explorer"); - assert.equal(result.editors.includes("file-manager"), true); - assert.ok(spawned); - // The reveal routes through interop PowerShell so Explorer receives its - // raw `/select,""` switch even for spaced paths. - assert.equal(spawned.command, "powershell.exe"); - const encodedCommand = spawned.args[spawned.args.length - 1] ?? ""; - const decodedCommand = Buffer.from(encodedCommand, "base64").toString("utf16le"); - assert.equal( - decodedCommand, - "$ProgressPreference = 'SilentlyContinue'; Start-Process 'explorer.exe' -ArgumentList ('/select,\"' + '\\\\wsl.localhost\\Ubuntu-24.04\\home\\t3\\workspace\\media\\clip.mp4' + '\"')", - ); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + assert.equal(result.kind, "file-explorer"); + assert.equal(result.editors.includes("file-manager"), true); + assert.ok(spawned); + // The reveal routes through interop PowerShell so Explorer receives its + // raw `/select,""` switch even for spaced paths. + assert.equal(spawned.command, "powershell.exe"); + const encodedCommand = spawned.args[spawned.args.length - 1] ?? ""; + const decodedCommand = Buffer.from(encodedCommand, "base64").toString("utf16le"); + assert.equal( + decodedCommand, + "$ProgressPreference = 'SilentlyContinue'; Start-Process 'explorer.exe' -ArgumentList ('/select,\"' + '\\\\wsl.localhost\\Ubuntu-24.04\\home\\t3\\workspace\\media\\clip.mp4' + '\"')", + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); -it.effect("does not advertise reveal from WSL when interop PowerShell is missing", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); - const explorerPath = path.join(binDir, "explorer.exe"); - yield* fileSystem.writeFileString(explorerPath, ""); - yield* fileSystem.chmod(explorerPath, 0o755); +it.effect.skipIf(windowsHost)( + "does not advertise reveal from WSL when interop PowerShell is missing", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + const explorerPath = path.join(binDir, "explorer.exe"); + yield* fileSystem.writeFileString(explorerPath, ""); + yield* fileSystem.chmod(explorerPath, 0o755); - const result = yield* Effect.gen(function* () { - const launcher = yield* ExternalLauncher.ExternalLauncher; - return { - kind: yield* launcher.resolveFileManagerRevealKind(), - editors: yield* launcher.resolveAvailableEditors(), - }; - }).pipe( - Effect.provide( - testLayer({ - platform: "linux", - env: { - PATH: binDir, - WSL_DISTRO_NAME: "Ubuntu-24.04", - WSL_INTEROP: "/run/WSL/1_interop", - }, - }), - ), - ); + const result = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + return { + kind: yield* launcher.resolveFileManagerRevealKind(), + editors: yield* launcher.resolveAvailableEditors(), + }; + }).pipe( + Effect.provide( + testLayer({ + platform: "linux", + env: { + PATH: binDir, + WSL_DISTRO_NAME: "Ubuntu-24.04", + WSL_INTEROP: "/run/WSL/1_interop", + }, + }), + ), + ); - assert.equal(result.editors.includes("file-manager"), true); - assert.isUndefined(result.kind); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + assert.equal(result.editors.includes("file-manager"), true); + assert.isUndefined(result.kind); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); // When interop PowerShell is missing the capability advertises the Linux // "files" kind (or nothing), so the reveal must open the Linux file manager // the label promised even though plain open still prefers File Explorer. -it.effect("reveals through the Linux file manager when WSL lacks interop PowerShell", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); - for (const name of ["explorer.exe", "xdg-open", "xdg-mime"]) { - const filePath = path.join(binDir, name); - yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); - yield* fileSystem.chmod(filePath, 0o755); - } +it.effect.skipIf(windowsHost)( + "reveals through the Linux file manager when WSL lacks interop PowerShell", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + for (const name of ["explorer.exe", "xdg-open", "xdg-mime"]) { + const filePath = path.join(binDir, name); + yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); + yield* fileSystem.chmod(filePath, 0o755); + } - const spawnedCommands: ChildProcess.StandardCommand[] = []; - const kind = yield* Effect.gen(function* () { - const launcher = yield* ExternalLauncher.ExternalLauncher; - const revealKind = yield* launcher.resolveFileManagerRevealKind(); - yield* launcher.launchEditor({ - editor: "file-manager", - cwd: "/home/t3/workspace/media/clip.mp4", - reveal: true, - }); - return revealKind; - }).pipe( - Effect.provide( - testLayer({ - platform: "linux", - env: { - PATH: binDir, - WSL_DISTRO_NAME: "Ubuntu-24.04", - WSL_INTEROP: "/run/WSL/1_interop", - DISPLAY: ":0", - }, - onSpawn: (command) => { - spawnedCommands.push(command); - }, - spawnResult: (command) => - command.command === "xdg-mime" ? { stdout: "org.gnome.Nautilus.desktop\n" } : undefined, - }), - ), - ); + const spawnedCommands: ChildProcess.StandardCommand[] = []; + const kind = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + const revealKind = yield* launcher.resolveFileManagerRevealKind(); + yield* launcher.launchEditor({ + editor: "file-manager", + cwd: "/home/t3/workspace/media/clip.mp4", + reveal: true, + }); + return revealKind; + }).pipe( + Effect.provide( + testLayer({ + platform: "linux", + env: { + PATH: binDir, + WSL_DISTRO_NAME: "Ubuntu-24.04", + WSL_INTEROP: "/run/WSL/1_interop", + DISPLAY: ":0", + }, + onSpawn: (command) => { + spawnedCommands.push(command); + }, + spawnResult: (command) => + command.command === "xdg-mime" + ? { stdout: "org.gnome.Nautilus.desktop\n" } + : undefined, + }), + ), + ); - assert.equal(kind, "files"); - const launch = spawnedCommands.find((command) => command.command === "xdg-open"); - assert.ok(launch); - assert.deepEqual(launch.args, ["/home/t3/workspace/media"]); - assert.isUndefined(spawnedCommands.find((command) => command.command === "explorer.exe")); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + assert.equal(kind, "files"); + const launch = spawnedCommands.find((command) => command.command === "xdg-open"); + assert.ok(launch); + assert.deepEqual(launch.args, ["/home/t3/workspace/media"]); + assert.isUndefined(spawnedCommands.find((command) => command.command === "explorer.exe")); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); // Interop can exist without `explorer.exe` on PATH (appendWindowsPath=false) // while WSLg still provides a working Linux file manager; the host must keep // the Linux open/reveal path instead of losing the editor entirely. -it.effect("falls back to the Linux file manager when WSL lacks the Explorer bridge", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); - for (const name of ["xdg-open", "xdg-mime"]) { - const filePath = path.join(binDir, name); - yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); - yield* fileSystem.chmod(filePath, 0o755); - } +it.effect.skipIf(windowsHost)( + "falls back to the Linux file manager when WSL lacks the Explorer bridge", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + for (const name of ["xdg-open", "xdg-mime"]) { + const filePath = path.join(binDir, name); + yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); + yield* fileSystem.chmod(filePath, 0o755); + } - const spawnedCommands: ChildProcess.StandardCommand[] = []; - const result = yield* Effect.gen(function* () { - const launcher = yield* ExternalLauncher.ExternalLauncher; - const editors = yield* launcher.resolveAvailableEditors(); - const kind = yield* launcher.resolveFileManagerRevealKind(); - yield* launcher.launchEditor({ - editor: "file-manager", - cwd: "/home/t3/workspace/media/clip.mp4", - reveal: true, - }); - return { editors, kind }; - }).pipe( - Effect.provide( - testLayer({ - platform: "linux", - env: { - PATH: binDir, - WSL_DISTRO_NAME: "Ubuntu-24.04", - WSL_INTEROP: "/run/WSL/1_interop", - DISPLAY: ":0", - }, - onSpawn: (command) => { - spawnedCommands.push(command); - }, - spawnResult: (command) => - command.command === "xdg-mime" ? { stdout: "org.gnome.Nautilus.desktop\n" } : undefined, - }), - ), - ); + const spawnedCommands: ChildProcess.StandardCommand[] = []; + const result = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + const editors = yield* launcher.resolveAvailableEditors(); + const kind = yield* launcher.resolveFileManagerRevealKind(); + yield* launcher.launchEditor({ + editor: "file-manager", + cwd: "/home/t3/workspace/media/clip.mp4", + reveal: true, + }); + return { editors, kind }; + }).pipe( + Effect.provide( + testLayer({ + platform: "linux", + env: { + PATH: binDir, + WSL_DISTRO_NAME: "Ubuntu-24.04", + WSL_INTEROP: "/run/WSL/1_interop", + DISPLAY: ":0", + }, + onSpawn: (command) => { + spawnedCommands.push(command); + }, + spawnResult: (command) => + command.command === "xdg-mime" + ? { stdout: "org.gnome.Nautilus.desktop\n" } + : undefined, + }), + ), + ); - assert.equal(result.editors.includes("file-manager"), true); - assert.equal(result.kind, "files"); - const launch = spawnedCommands.find((command) => command.command === "xdg-open"); - assert.ok(launch); - assert.deepEqual(launch.args, ["/home/t3/workspace/media"]); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + assert.equal(result.editors.includes("file-manager"), true); + assert.equal(result.kind, "files"); + const launch = spawnedCommands.find((command) => command.command === "xdg-open"); + assert.ok(launch); + assert.deepEqual(launch.args, ["/home/t3/workspace/media"]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); -it.effect( +it.effect.skipIf(windowsHost)( "falls back to opening the containing directory for WSL paths Explorer cannot select", () => Effect.gen(function* () { @@ -570,7 +588,7 @@ it.effect( }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); -it.effect("reveals by opening the containing directory on Linux", () => +it.effect.skipIf(windowsHost)("reveals by opening the containing directory on Linux", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -609,127 +627,137 @@ it.effect("reveals by opening the containing directory on Linux", () => }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); -it.effect("does not advertise a Linux file manager without a graphical session", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); - const xdgOpenPath = path.join(binDir, "xdg-open"); - yield* fileSystem.writeFileString(xdgOpenPath, "#!/bin/sh\n"); - yield* fileSystem.chmod(xdgOpenPath, 0o755); +it.effect.skipIf(windowsHost)( + "does not advertise a Linux file manager without a graphical session", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + const xdgOpenPath = path.join(binDir, "xdg-open"); + yield* fileSystem.writeFileString(xdgOpenPath, "#!/bin/sh\n"); + yield* fileSystem.chmod(xdgOpenPath, 0o755); - const editors = yield* Effect.gen(function* () { - const launcher = yield* ExternalLauncher.ExternalLauncher; - return yield* launcher.resolveAvailableEditors(); - }).pipe(Effect.provide(testLayer({ platform: "linux", env: { PATH: binDir } }))); + const editors = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + return yield* launcher.resolveAvailableEditors(); + }).pipe(Effect.provide(testLayer({ platform: "linux", env: { PATH: binDir } }))); - assert.equal(editors.includes("file-manager"), false); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + assert.equal(editors.includes("file-manager"), false); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); -it.effect("advertises a Linux file manager when a directory handler is installed", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); - for (const name of ["xdg-open", "xdg-mime"]) { - const filePath = path.join(binDir, name); - yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); - yield* fileSystem.chmod(filePath, 0o755); - } +it.effect.skipIf(windowsHost)( + "advertises a Linux file manager when a directory handler is installed", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + for (const name of ["xdg-open", "xdg-mime"]) { + const filePath = path.join(binDir, name); + yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); + yield* fileSystem.chmod(filePath, 0o755); + } - let probe: ChildProcess.StandardCommand | undefined; - const editors = yield* Effect.gen(function* () { - const launcher = yield* ExternalLauncher.ExternalLauncher; - return yield* launcher.resolveAvailableEditors(); - }).pipe( - Effect.provide( - testLayer({ - platform: "linux", - env: { PATH: binDir, DISPLAY: ":0" }, - onSpawn: (command) => { - probe = command; - }, - spawnResult: (command) => - command.command === "xdg-mime" ? { stdout: "org.gnome.Nautilus.desktop\n" } : undefined, - }), - ), - ); + let probe: ChildProcess.StandardCommand | undefined; + const editors = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + return yield* launcher.resolveAvailableEditors(); + }).pipe( + Effect.provide( + testLayer({ + platform: "linux", + env: { PATH: binDir, DISPLAY: ":0" }, + onSpawn: (command) => { + probe = command; + }, + spawnResult: (command) => + command.command === "xdg-mime" + ? { stdout: "org.gnome.Nautilus.desktop\n" } + : undefined, + }), + ), + ); - assert.equal(editors.includes("file-manager"), true); - assert.ok(probe); - assert.equal(probe.command, "xdg-mime"); - assert.deepEqual(probe.args, ["query", "default", "inode/directory"]); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + assert.equal(editors.includes("file-manager"), true); + assert.ok(probe); + assert.equal(probe.command, "xdg-mime"); + assert.deepEqual(probe.args, ["query", "default", "inode/directory"]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); // `xdg-open` with a display variable but no `inode/directory` handler exits // nonzero after the launch has already detached: without this gate the server // advertises a reveal that is a silent no-op. -it.effect("does not advertise a Linux file manager without a directory handler", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); - for (const name of ["xdg-open", "xdg-mime"]) { - const filePath = path.join(binDir, name); - yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); - yield* fileSystem.chmod(filePath, 0o755); - } +it.effect.skipIf(windowsHost)( + "does not advertise a Linux file manager without a directory handler", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + for (const name of ["xdg-open", "xdg-mime"]) { + const filePath = path.join(binDir, name); + yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); + yield* fileSystem.chmod(filePath, 0o755); + } - const editors = yield* Effect.gen(function* () { - const launcher = yield* ExternalLauncher.ExternalLauncher; - return yield* launcher.resolveAvailableEditors(); - }).pipe( - Effect.provide( - testLayer({ - platform: "linux", - env: { PATH: binDir, DISPLAY: ":0" }, - spawnResult: (command) => (command.command === "xdg-mime" ? { stdout: "" } : undefined), - }), - ), - ); + const editors = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + return yield* launcher.resolveAvailableEditors(); + }).pipe( + Effect.provide( + testLayer({ + platform: "linux", + env: { PATH: binDir, DISPLAY: ":0" }, + spawnResult: (command) => (command.command === "xdg-mime" ? { stdout: "" } : undefined), + }), + ), + ); - assert.equal(editors.includes("file-manager"), false); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + assert.equal(editors.includes("file-manager"), false); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); -it.effect("does not advertise a Linux file manager when the handler query fails", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); - for (const name of ["xdg-open", "xdg-mime"]) { - const filePath = path.join(binDir, name); - yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); - yield* fileSystem.chmod(filePath, 0o755); - } +it.effect.skipIf(windowsHost)( + "does not advertise a Linux file manager when the handler query fails", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + for (const name of ["xdg-open", "xdg-mime"]) { + const filePath = path.join(binDir, name); + yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); + yield* fileSystem.chmod(filePath, 0o755); + } - const editors = yield* Effect.gen(function* () { - const launcher = yield* ExternalLauncher.ExternalLauncher; - return yield* launcher.resolveAvailableEditors(); - }).pipe( - Effect.provide( - testLayer({ - platform: "linux", - env: { PATH: binDir, DISPLAY: ":0" }, - spawnResult: (command) => - command.command === "xdg-mime" - ? { exitCode: 47, stdout: "org.gnome.Nautilus.desktop\n" } - : undefined, - }), - ), - ); + const editors = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + return yield* launcher.resolveAvailableEditors(); + }).pipe( + Effect.provide( + testLayer({ + platform: "linux", + env: { PATH: binDir, DISPLAY: ":0" }, + spawnResult: (command) => + command.command === "xdg-mime" + ? { exitCode: 47, stdout: "org.gnome.Nautilus.desktop\n" } + : undefined, + }), + ), + ); - assert.equal(editors.includes("file-manager"), false); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + assert.equal(editors.includes("file-manager"), false); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); // The handler probe carries its own timeout because the editor scan's outer // timeout in server.getConfig degrades to an EMPTY editor list: a wedged // xdg-mime must cost only the file manager, never the other editors. Runs on // the live clock so the probe's real timeout fires. -it.live("a stalled handler probe drops only the file manager", () => +it.live.skipIf(windowsHost)("a stalled handler probe drops only the file manager", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -758,22 +786,26 @@ it.live("a stalled handler probe drops only the file manager", () => }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); -it.effect("does not advertise a Linux file manager when xdg-mime is missing", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); - const xdgOpenPath = path.join(binDir, "xdg-open"); - yield* fileSystem.writeFileString(xdgOpenPath, "#!/bin/sh\n"); - yield* fileSystem.chmod(xdgOpenPath, 0o755); +it.effect.skipIf(windowsHost)( + "does not advertise a Linux file manager when xdg-mime is missing", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + const xdgOpenPath = path.join(binDir, "xdg-open"); + yield* fileSystem.writeFileString(xdgOpenPath, "#!/bin/sh\n"); + yield* fileSystem.chmod(xdgOpenPath, 0o755); - const editors = yield* Effect.gen(function* () { - const launcher = yield* ExternalLauncher.ExternalLauncher; - return yield* launcher.resolveAvailableEditors(); - }).pipe(Effect.provide(testLayer({ platform: "linux", env: { PATH: binDir, DISPLAY: ":0" } }))); + const editors = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + return yield* launcher.resolveAvailableEditors(); + }).pipe( + Effect.provide(testLayer({ platform: "linux", env: { PATH: binDir, DISPLAY: ":0" } })), + ); - assert.equal(editors.includes("file-manager"), false); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + assert.equal(editors.includes("file-manager"), false); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); it.effect("discovers editors through the service API", () => diff --git a/apps/server/src/project/ProjectFaviconResolver.test.ts b/apps/server/src/project/ProjectFaviconResolver.test.ts index 2c7b0f7bdc62..f86e8f8aa399 100644 --- a/apps/server/src/project/ProjectFaviconResolver.test.ts +++ b/apps/server/src/project/ProjectFaviconResolver.test.ts @@ -54,11 +54,12 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => { it.effect("serves repeated resolves from cache instead of re-walking candidates", () => Effect.gen(function* () { const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const path = yield* Path.Path; const cwd = yield* makeTempDir; yield* writeTextFile(cwd, "public/favicon.svg", "public"); const resolved = yield* resolver.resolvePath(cwd); - expect(resolved?.endsWith("public/favicon.svg")).toBe(true); + expect(resolved?.endsWith(path.join("public", "favicon.svg"))).toBe(true); // `favicon.svg` outranks `public/favicon.svg`, so a resolver that walked // the candidate list again would switch to it. Staying on the original @@ -71,7 +72,7 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => { yield* TestClock.adjust(Duration.minutes(11)); - expect((yield* resolver.resolvePath(cwd))?.endsWith("/favicon.svg")).toBe(true); + expect(yield* resolver.resolvePath(cwd)).toBe(path.join(cwd, "favicon.svg")); expect(yield* resolver.resolvePath(cwd)).not.toBe(resolved); }).pipe(Effect.provide(TestClock.layer())), ); @@ -125,6 +126,7 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => { it.effect("prefers a t3.json iconPath over well-known files", () => Effect.gen(function* () { const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const path = yield* Path.Path; const cwd = yield* makeTempDir; yield* writeTextFile(cwd, "t3.json", '{ "iconPath": "brand/mark.svg" }'); yield* writeTextFile(cwd, "brand/mark.svg", "mark"); @@ -133,13 +135,14 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => { const resolved = yield* resolver.resolvePath(cwd); expect(resolved).not.toBeNull(); - expect(resolved).toContain("brand/mark.svg"); + expect(resolved).toBe(path.join(cwd, "brand", "mark.svg")); }), ); it.effect("uses a saved project favicon override", () => Effect.gen(function* () { const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const path = yield* Path.Path; const cwd = yield* makeTempDir; yield* writeTextFile(cwd, "brand/custom.svg", "custom"); yield* writeTextFile(cwd, "favicon.svg", "automatic"); @@ -147,7 +150,7 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => { const resolved = yield* resolver.resolvePath(cwd, "brand/custom.svg"); expect(resolved).not.toBeNull(); - expect(resolved).toContain("brand/custom.svg"); + expect(resolved).toBe(path.join(cwd, "brand", "custom.svg")); }), ); @@ -231,7 +234,7 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => { const resolved = yield* resolver.resolvePath(cwd); expect(resolved).not.toBeNull(); - expect(resolved).toContain("public/brand/logo.svg"); + expect(resolved).toBe((yield* Path.Path).join(cwd, "public", "brand", "logo.svg")); }), ); @@ -256,7 +259,7 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => { const resolved = yield* resolver.resolvePath(cwd); expect(resolved).not.toBeNull(); - expect(resolved).toContain("public/brand/logo.svg"); + expect(resolved).toBe((yield* Path.Path).join(cwd, "public", "brand", "logo.svg")); }), ); @@ -274,7 +277,7 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => { const resolved = yield* resolver.resolvePath(cwd); expect(resolved).not.toBeNull(); - expect(resolved).toContain("public/brand/logo.svg"); + expect(resolved).toBe((yield* Path.Path).join(cwd, "public", "brand", "logo.svg")); }), ); @@ -292,7 +295,7 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => { const resolved = yield* resolver.resolvePath(cwd); expect(resolved).not.toBeNull(); - expect(resolved).toContain("public/brand/logo.svg"); + expect(resolved).toBe((yield* Path.Path).join(cwd, "public", "brand", "logo.svg")); }), ); @@ -310,7 +313,7 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => { const resolved = yield* resolver.resolvePath(cwd); expect(resolved).not.toBeNull(); - expect(resolved).toContain("public/brand/logo.svg"); + expect(resolved).toBe((yield* Path.Path).join(cwd, "public", "brand", "logo.svg")); }), ); @@ -458,7 +461,7 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => { const resolved = yield* resolver.resolvePath(cwd); expect(resolved).not.toBeNull(); - expect(resolved).toContain("public/brand/logo.svg"); + expect(resolved).toBe((yield* Path.Path).join(cwd, "public", "brand", "logo.svg")); }), ); }); diff --git a/apps/server/src/project/RepositoryIdentityResolver.test.ts b/apps/server/src/project/RepositoryIdentityResolver.test.ts index 72232a78b689..7c73752ee831 100644 --- a/apps/server/src/project/RepositoryIdentityResolver.test.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.test.ts @@ -1,3 +1,5 @@ +// @effect-diagnostics nodeBuiltinImport:off - realpathSync.native resolves Windows 8.3 short names, which the Effect realPath does not. +import * as NodeFS from "node:fs"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; import * as Duration from "effect/Duration"; @@ -131,9 +133,11 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; const identity = yield* resolver.resolve(cwd); + // Native realpath, since git reports the long form of a directory the + // temp dir may name by its 8.3 short form on Windows. const resolvedIdentityRoot = - identity?.rootPath === undefined ? "" : yield* fileSystem.realPath(identity.rootPath); - const resolvedCwd = yield* fileSystem.realPath(cwd); + identity?.rootPath === undefined ? "" : NodeFS.realpathSync.native(identity.rootPath); + const resolvedCwd = NodeFS.realpathSync.native(cwd); expect(identity).not.toBeNull(); expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code"); @@ -161,8 +165,8 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; const identity = yield* resolver.resolve(nestedWorkspace); const resolvedIdentityRoot = - identity?.rootPath === undefined ? "" : yield* fileSystem.realPath(identity.rootPath); - const resolvedRepoRoot = yield* fileSystem.realPath(repoRoot); + identity?.rootPath === undefined ? "" : NodeFS.realpathSync.native(identity.rootPath); + const resolvedRepoRoot = NodeFS.realpathSync.native(repoRoot); expect(identity).not.toBeNull(); expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code"); diff --git a/apps/server/src/provider/AntigravityInstallation.test.ts b/apps/server/src/provider/AntigravityInstallation.test.ts index 8d6b62731cfd..0528cb4972c9 100644 --- a/apps/server/src/provider/AntigravityInstallation.test.ts +++ b/apps/server/src/provider/AntigravityInstallation.test.ts @@ -55,9 +55,24 @@ const zipFixtures = { "UEsDBBQAAAAIAAAAIl1zEy/oFAAAABQAAAASAAAAYWd5X2FjcF9zZXJ2ZXIuZXhlS8wryUwvSizLLKlUKCoFcnJTuQBQSwMEFAAAAAgAAAAiXV9yAykQAAAADgAAABkAAABsb2NhbGhhcm5lc3NfZXh0ZXJuYWwuZXhly8lPTsxRyEgsykstLuYCAFBLAQIUAxQAAAAIAAAAIl1zEy/oFAAAABQAAAASAAAAAAAAAAAAAADtgQAAAABhZ3lfYWNwX3NlcnZlci5leGVQSwECFAMUAAAACAAAACJdX3IDKRAAAAAOAAAAGQAAAAAAAAAAAAAA7YFEAAAAbG9jYWxoYXJuZXNzX2V4dGVybmFsLmV4ZVBLBQYAAAAAAgACAIcAAACLAAAAAAA=", }; -const completeArchive = Buffer.from(zipFixtures.complete, "base64"); +// The installation checks POSIX exec bits off the real filesystem unless the +// platform is win32, so a linux platform mock cannot pass on NTFS. Default to +// the host and let the fixture names follow; the suite is about install +// mechanics, which are the same on every platform. +const hostPlatform: NodeJS.Platform = + HostProcessPlatform.defaultValue() === "win32" ? "win32" : "linux"; +const completeArchive = Buffer.from( + hostPlatform === "win32" ? zipFixtures.windows : zipFixtures.complete, + "base64", +); +const executableName = hostPlatform === "win32" ? "agy_acp_server.exe" : "agy_acp_server.par"; +const harnessName = + hostPlatform === "win32" ? "localharness_external.exe" : "localharness_external"; -function releaseAsset(archive: Uint8Array = completeArchive, platform: NodeJS.Platform = "linux") { +function releaseAsset( + archive: Uint8Array = completeArchive, + platform: NodeJS.Platform = hostPlatform, +) { return { version: "fixture-new", url: "https://dl.google.com/antigravity-test.zip", @@ -128,7 +143,7 @@ const makeHarness = Effect.fn("test.makeAntigravityInstallation")(function* ( const path = yield* Path.Path; const baseDir = options.baseDir ?? (yield* fs.makeTempDirectoryScoped({ prefix: "t3-agy-test-" })); - const platform = options.platform ?? "linux"; + const platform = options.platform ?? hostPlatform; const archive = options.archive ?? completeArchive; const asset = options.asset === undefined ? releaseAsset(archive, platform) : options.asset; const managedDirectory = path.join(baseDir, "tools", "antigravity-acp", `${platform}-x64`); @@ -472,7 +487,7 @@ it.layer(NodeServices.layer)("Antigravity installation", (it) => { ...fs, sink: (target, options) => (stage === "download" && target.endsWith("download.zip")) || - (stage === "extract" && target.endsWith("agy_acp_server.par")) + (stage === "extract" && target.endsWith(executableName)) ? fs.sink(target, options).pipe(Sink.mapInputEffect(() => Effect.fail(noSpace))) : fs.sink(target, options), writeFileString: (target, content, options) => @@ -525,7 +540,7 @@ it.layer(NodeServices.layer)("Antigravity installation", (it) => { fileSystem: FileSystem.FileSystem.of({ ...fs, sink: (target, options) => - phase === "extracting" && target.endsWith("agy_acp_server.par") + phase === "extracting" && target.endsWith(executableName) ? fs .sink(target, options) .pipe( @@ -681,65 +696,69 @@ it.layer(NodeServices.layer)("Antigravity installation", (it) => { }), ); - it.effect("honors explicit paths and reports invalid overrides without falling back", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-agy-path-test-" }); - const externalDirectory = path.join(baseDir, "external"); - const externalExecutable = path.join(externalDirectory, "agy_acp_server.par"); - const externalHarness = path.join(externalDirectory, "localharness_external"); - yield* fs.makeDirectory(externalDirectory); - yield* fs.writeFileString(externalExecutable, "external server", { mode: 0o755 }); - yield* fs.writeFileString(externalHarness, "external harness", { mode: 0o755 }); - const { installation } = yield* makeHarness({ - baseDir, - path: externalDirectory, - previous: true, - }); - yield* expectPreviousRelease(installation); - expect(yield* installation.resolve(undefined, { PATH: externalDirectory })).toMatchObject({ - source: "managed", - version: previousVersion, - }); - expect(yield* installation.resolve(externalExecutable)).toMatchObject({ - executablePath: externalExecutable, - source: "override", - managedVersionDirectory: null, - }); - expect(yield* installation.resolve("agy_acp_server.par")).toMatchObject({ - source: "override", - }); - yield* fs.remove(externalHarness); - expect(yield* installation.resolve(externalExecutable).pipe(Effect.flip)).toMatchObject({ - operation: "resolve", - }); - expect( - yield* installation.resolve(path.join(baseDir, "missing")).pipe(Effect.flip), - ).toMatchObject({ - operation: "resolve", - }); - yield* expectPreviousRelease(installation); - yield* fs.writeFileString(externalHarness, "external harness", { mode: 0o755 }); - yield* installation.remove(); - expect(yield* installation.resolve()).toMatchObject({ - source: "path", - executablePath: externalExecutable, - }); - const isolated = yield* makeHarness({ baseDir }); - expect(yield* isolated.installation.resolve().pipe(Effect.flip)).toMatchObject({ - operation: "resolve", - }); - expect( - yield* isolated.installation.resolve(undefined, { PATH: externalDirectory }), - ).toMatchObject({ - source: "path", - executablePath: externalExecutable, - }); - expect( - yield* isolated.installation.resolve("agy_acp_server.par", { PATH: externalDirectory }), - ).toMatchObject({ source: "override", executablePath: externalExecutable }); - }), + // Real posix executables in a real temp dir, resolved by a linux-mocked + // PATH walk; a Windows temp path cannot be split on `:`. + it.effect.skipIf(HostProcessPlatform.defaultValue() === "win32")( + "honors explicit paths and reports invalid overrides without falling back", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-agy-path-test-" }); + const externalDirectory = path.join(baseDir, "external"); + const externalExecutable = path.join(externalDirectory, executableName); + const externalHarness = path.join(externalDirectory, harnessName); + yield* fs.makeDirectory(externalDirectory); + yield* fs.writeFileString(externalExecutable, "external server", { mode: 0o755 }); + yield* fs.writeFileString(externalHarness, "external harness", { mode: 0o755 }); + const { installation } = yield* makeHarness({ + baseDir, + path: externalDirectory, + previous: true, + }); + yield* expectPreviousRelease(installation); + expect(yield* installation.resolve(undefined, { PATH: externalDirectory })).toMatchObject({ + source: "managed", + version: previousVersion, + }); + expect(yield* installation.resolve(externalExecutable)).toMatchObject({ + executablePath: externalExecutable, + source: "override", + managedVersionDirectory: null, + }); + expect(yield* installation.resolve(executableName)).toMatchObject({ + source: "override", + }); + yield* fs.remove(externalHarness); + expect(yield* installation.resolve(externalExecutable).pipe(Effect.flip)).toMatchObject({ + operation: "resolve", + }); + expect( + yield* installation.resolve(path.join(baseDir, "missing")).pipe(Effect.flip), + ).toMatchObject({ + operation: "resolve", + }); + yield* expectPreviousRelease(installation); + yield* fs.writeFileString(externalHarness, "external harness", { mode: 0o755 }); + yield* installation.remove(); + expect(yield* installation.resolve()).toMatchObject({ + source: "path", + executablePath: externalExecutable, + }); + const isolated = yield* makeHarness({ baseDir }); + expect(yield* isolated.installation.resolve().pipe(Effect.flip)).toMatchObject({ + operation: "resolve", + }); + expect( + yield* isolated.installation.resolve(undefined, { PATH: externalDirectory }), + ).toMatchObject({ + source: "path", + executablePath: externalExecutable, + }); + expect( + yield* isolated.installation.resolve(executableName, { PATH: externalDirectory }), + ).toMatchObject({ source: "override", executablePath: externalExecutable }); + }), ); it.effect("keeps leased releases available while new sessions resolve the new release", () => @@ -770,20 +789,12 @@ it.layer(NodeServices.layer)("Antigravity installation", (it) => { yield* fs.remove(previous.harnessPath); const externalDirectory = path.join(baseDir, "external"); yield* fs.makeDirectory(externalDirectory); - yield* fs.writeFileString( - path.join(externalDirectory, "agy_acp_server.par"), - "external server", - { - mode: 0o755, - }, - ); - yield* fs.writeFileString( - path.join(externalDirectory, "localharness_external"), - "external harness", - { - mode: 0o755, - }, - ); + yield* fs.writeFileString(path.join(externalDirectory, executableName), "external server", { + mode: 0o755, + }); + yield* fs.writeFileString(path.join(externalDirectory, harnessName), "external harness", { + mode: 0o755, + }); const restarted = yield* makeHarness({ baseDir, path: externalDirectory }); expect(yield* restarted.installation.state).toMatchObject({ phase: "failed", @@ -820,8 +831,8 @@ it.layer(NodeServices.layer)("Antigravity installation", (it) => { const profileDirectory = path.join(baseDir, "providers", "antigravity", "profile"); yield* fs.makeDirectory(externalDirectory); yield* fs.makeDirectory(profileDirectory, { recursive: true }); - const externalExecutable = path.join(externalDirectory, "agy_acp_server.par"); - const externalHarness = path.join(externalDirectory, "localharness_external"); + const externalExecutable = path.join(externalDirectory, executableName); + const externalHarness = path.join(externalDirectory, harnessName); const profilePath = path.join(profileDirectory, "preferences.json"); yield* fs.writeFileString(externalExecutable, "external server", { mode: 0o755 }); yield* fs.writeFileString(externalHarness, "external harness", { mode: 0o755 }); diff --git a/apps/server/src/provider/ClaudeModelCatalog.test.ts b/apps/server/src/provider/ClaudeModelCatalog.test.ts index b370c8e24d3d..d5c9d8f53d1e 100644 --- a/apps/server/src/provider/ClaudeModelCatalog.test.ts +++ b/apps/server/src/provider/ClaudeModelCatalog.test.ts @@ -7,9 +7,11 @@ import { formatClaudeVersionUpgradeMessage, normalizeClaudeCatalogEffort, resolveClaudeCatalogApiModelId, + resolveClaudeCatalogEffort, resolveClaudeModelCatalog, resolveClaudeModelsForVersion, resolveClaudeModelSlug, + scopeClaudeModelCatalog, } from "./ClaudeModelCatalog.ts"; /** @@ -134,4 +136,58 @@ describe("Claude model catalog", () => { }; assert.isFalse(hasValidClaudeManifestAdapters(malformed)); }); + + it("appends custom models with their own descriptors and keeps bare slugs opaque", () => { + const catalog = scopeClaudeModelCatalog(resolveClaudeModelCatalog(manifest()), [ + "synthetic", + { + slug: "claude-custom-tuned", + name: "Tuned", + capabilities: { + optionDescriptors: [ + { + id: "effort", + label: "Reasoning", + type: "select", + options: [ + { id: "gentle", label: "Gentle", isDefault: true }, + { id: "brutal", label: "Brutal" }, + ], + }, + ], + }, + }, + ]); + + // The bare custom slug shadows the built-in alias, so it no longer resolves to it. + assert.strictEqual(resolveClaudeModelSlug(catalog, "synthetic"), "synthetic"); + assert.strictEqual(resolveClaudeCatalogEffort(catalog, "synthetic", "extreme"), undefined); + + // The entry with descriptors resolves user-defined effort ids and passes + // them through untouched (no effortMap, no model suffix). + assert.strictEqual( + resolveClaudeCatalogEffort(catalog, "claude-custom-tuned", "brutal"), + "brutal", + ); + assert.strictEqual( + resolveClaudeCatalogEffort(catalog, "claude-custom-tuned", "bogus"), + "gentle", + ); + assert.strictEqual( + normalizeClaudeCatalogEffort(catalog, "brutal", "claude-custom-tuned"), + "brutal", + ); + assert.strictEqual( + resolveClaudeCatalogApiModelId(catalog, { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-custom-tuned", + options: [{ id: "effort", value: "brutal" }], + }), + "claude-custom-tuned", + ); + assert.deepStrictEqual( + resolveClaudeModelsForVersion(catalog, "3.2.0").map((model) => model.slug), + ["claude-synthetic-next", "claude-custom-tuned"], + ); + }); }); diff --git a/apps/server/src/provider/ClaudeModelCatalog.ts b/apps/server/src/provider/ClaudeModelCatalog.ts index bd554f042f0b..b1fcd6a92bbf 100644 --- a/apps/server/src/provider/ClaudeModelCatalog.ts +++ b/apps/server/src/provider/ClaudeModelCatalog.ts @@ -1,4 +1,5 @@ import { + type CustomModelSetting, type ModelCapabilities, type ModelSelection, ProviderDriverKind, @@ -9,7 +10,7 @@ import { getModelSelectionStringOptionValue, getProviderOptionCurrentValue, getProviderOptionDescriptors, - normalizeCustomModelSlug, + readCustomModelEntries, } from "@t3tools/shared/model"; import { compareSemverVersions } from "@t3tools/shared/semver"; @@ -70,33 +71,51 @@ export function resolveClaudeModelCatalog(manifest: ModelManifestData): ClaudeMo export const BUNDLED_CLAUDE_MODEL_CATALOG = resolveClaudeModelCatalog(BUNDLED_MODEL_MANIFEST); -/** Keeps custom model aliases opaque while preserving canonical built-in models and capabilities. */ +/** + * Scope the catalog to one instance's settings: custom model slugs stay opaque + * (a built-in alias they shadow is dropped, canonical slugs and capabilities + * are preserved), and custom entries that declare their own capabilities are + * appended so the adapter resolves effort / fast mode / thinking against the + * user's descriptors instead of the empty default. Custom entries carry no + * runtime profile, so option values pass through to Claude Code verbatim. + */ export function scopeClaudeModelCatalog( catalog: ClaudeModelCatalog, - customModels: ReadonlyArray, + customModels: ReadonlyArray, ): ClaudeModelCatalog { - const customAliases = new Set( - customModels.flatMap((model) => { - const slug = normalizeCustomModelSlug(model); - return slug ? [slug.toLowerCase()] : []; - }), - ); - if (customAliases.size === 0) return catalog; + const customEntries = readCustomModelEntries(customModels); + if (customEntries.length === 0) return catalog; + const customAliases = new Set(customEntries.map((entry) => entry.slug.toLowerCase())); - return { - models: catalog.models.map((entry) => { - if (!entry.model.aliases?.some((alias) => customAliases.has(alias.toLowerCase()))) { - return entry; - } - return { - ...entry, - model: { - ...entry.model, - aliases: entry.model.aliases.filter((alias) => !customAliases.has(alias.toLowerCase())), - }, - }; - }), - }; + const builtInModels = catalog.models.map((entry) => { + if (!entry.model.aliases?.some((alias) => customAliases.has(alias.toLowerCase()))) { + return entry; + } + return { + ...entry, + model: { + ...entry.model, + aliases: entry.model.aliases.filter((alias) => !customAliases.has(alias.toLowerCase())), + }, + }; + }); + const builtInSlugs = new Set(builtInModels.map((entry) => entry.model.slug)); + const customCatalogModels: Array = []; + for (const entry of customEntries) { + if (!entry.capabilities || builtInSlugs.has(entry.slug)) continue; + customCatalogModels.push({ + model: { + slug: entry.slug, + name: entry.name, + isCustom: true, + capabilities: entry.capabilities, + }, + runtime: {}, + compatibility: {}, + }); + } + + return { models: [...builtInModels, ...customCatalogModels] }; } export function resolveClaudeCatalogModel( diff --git a/apps/server/src/provider/Drivers/AntigravitySkills.test.ts b/apps/server/src/provider/Drivers/AntigravitySkills.test.ts index c5e1faacf237..bb3381216d72 100644 --- a/apps/server/src/provider/Drivers/AntigravitySkills.test.ts +++ b/apps/server/src/provider/Drivers/AntigravitySkills.test.ts @@ -5,6 +5,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import { discoverAntigravitySkills } from "./AntigravitySkills.ts"; +import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; const writeSkill = Effect.fn("writeSkill")(function* (directory: string, contents: string) { const fileSystem = yield* FileSystem.FileSystem; @@ -239,27 +240,29 @@ it.layer(NodeServices.layer)("discoverAntigravitySkills", (it) => { }), ); - it.effect("follows directory symlinks used to install shared skills", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const input = yield* makeWorkspace(); - const sourceDirectory = path.join(input.profileDirectory, "shared-review"); - yield* writeSkill(sourceDirectory, "---\nname: review\n---\n"); - const root = path.join(input.cwd, ".agents", "skills"); - const linkedDirectory = path.join(root, "review"); - yield* fileSystem.makeDirectory(root, { recursive: true }); - yield* fileSystem.symlink(sourceDirectory, linkedDirectory); + it.effect.skipIf(!symlinksSupported)( + "follows directory symlinks used to install shared skills", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const input = yield* makeWorkspace(); + const sourceDirectory = path.join(input.profileDirectory, "shared-review"); + yield* writeSkill(sourceDirectory, "---\nname: review\n---\n"); + const root = path.join(input.cwd, ".agents", "skills"); + const linkedDirectory = path.join(root, "review"); + yield* fileSystem.makeDirectory(root, { recursive: true }); + yield* fileSystem.symlink(sourceDirectory, linkedDirectory); - assert.deepEqual(yield* discoverAntigravitySkills(input), [ - { - name: "review", - path: path.join(linkedDirectory, "SKILL.md"), - scope: "project", - enabled: true, - }, - ]); - }), + assert.deepEqual(yield* discoverAntigravitySkills(input), [ + { + name: "review", + path: path.join(linkedDirectory, "SKILL.md"), + scope: "project", + enabled: true, + }, + ]); + }), ); it.effect("rejects an oversized skill instead of returning an incomplete catalog", () => diff --git a/apps/server/src/provider/Drivers/ClaudeSkillDispatch.test.ts b/apps/server/src/provider/Drivers/ClaudeSkillDispatch.test.ts index 99074c8b07ed..e6e6da9d5e9e 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkillDispatch.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkillDispatch.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; import { planClaudeSkillDispatch } from "./ClaudeSkillDispatch.ts"; -const SKILLS = new Set(["implement", "review", "re-release-version"]); +const SKILLS = new Set(["2spec", "implement", "review", "re-release-version"]); describe("planClaudeSkillDispatch", () => { it("leaves a prompt without a known skill untouched", () => { @@ -27,6 +27,14 @@ describe("planClaudeSkillDispatch", () => { }); }); + it("dispatches a known skill whose name begins with a digit", () => { + expect(planClaudeSkillDispatch("use $2spec for this", SKILLS)).toEqual({ + leadingText: "use", + commandText: "/2spec for this", + skillName: "2spec", + }); + }); + it("dispatches the last mention and rewrites earlier ones inline", () => { expect(planClaudeSkillDispatch("$review the diff, then $implement the fixes", SKILLS)).toEqual({ leadingText: "/review the diff, then", @@ -38,4 +46,10 @@ describe("planClaudeSkillDispatch", () => { it("ignores a dollar token glued to other text", () => { expect(planClaudeSkillDispatch("cost is 5$implement", SKILLS)).toBeUndefined(); }); + + it("ignores currency amounts and compact monetary expressions", () => { + const skillsWithCurrency = new Set([...SKILLS, "20", "20k", "100M"]); + expect(planClaudeSkillDispatch("pay $20 tomorrow", skillsWithCurrency)).toBeUndefined(); + expect(planClaudeSkillDispatch("budget is $20k tomorrow", skillsWithCurrency)).toBeUndefined(); + }); }); diff --git a/apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts b/apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts index a008e0f9ec9b..27e7dcf68bdf 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts @@ -29,7 +29,8 @@ * (`packages/shared/src/composerInlineTokens.ts`), so a rendered chip and a * dispatched skill are always the same set. */ -const SKILL_MENTION_PATTERN = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s|$)/g; +const SKILL_MENTION_PATTERN = + /(^|\s)\$(?![0-9][0-9_]*(?:[kKmMbBtT]|[eE][0-9]+)?(?:\s|$))(?=[a-zA-Z0-9:_-]*[a-zA-Z])([a-zA-Z0-9][a-zA-Z0-9:_-]*)(?=\s|$)/g; export interface ClaudeSkillDispatch { /** Text before the dispatched mention, or `undefined` when it opens the prompt. */ diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.test.ts b/apps/server/src/provider/Drivers/ClaudeSkills.test.ts index 3e46ba94df03..d126a15c12b8 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.test.ts @@ -1,3 +1,4 @@ +import * as NodePath from "@effect/platform-node/NodePath"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; @@ -492,7 +493,11 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { it.effect("lets the administrator's managed policy outrank every other settings file", () => Effect.gen(function* () { - const path = yield* Path.Path; + // The function is pure on its `path` argument, so hand it the + // implementation matching each platform under test rather than the + // host's. + const path = yield* Path.Path.pipe(Effect.provide(NodePath.layerPosix)); + const win32Path = yield* Path.Path.pipe(Effect.provide(NodePath.layerWin32)); for (const [platform, expected] of [ ["darwin", "/Library/Application Support/ClaudeCode/managed-settings.json"], @@ -508,14 +513,15 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { } assert.deepEqual( - skillOverrideSettingsPaths(path, "/home/.claude", undefined, "win32", { - PROGRAMDATA: "C:/ProgramData", + skillOverrideSettingsPaths(win32Path, "C:\\Users\\me\\.claude", undefined, "win32", { + PROGRAMDATA: "C:\\ProgramData", }).at(-1), - "C:/ProgramData/ClaudeCode/managed-settings.json", + "C:\\ProgramData\\ClaudeCode\\managed-settings.json", + ); + assert.deepEqual( + skillOverrideSettingsPaths(win32Path, "C:\\Users\\me\\.claude", undefined, "win32", {}), + ["C:\\Users\\me\\.claude\\settings.json"], ); - assert.deepEqual(skillOverrideSettingsPaths(path, "/home/.claude", undefined, "win32", {}), [ - "/home/.claude/settings.json", - ]); // Only the repository root's local file joins in, after the // workspace's own local file so it wins. diff --git a/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts b/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts index 03c717abb14d..2c78ffd18662 100644 --- a/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts +++ b/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts @@ -13,6 +13,7 @@ import { materializeCodexShadowHome, resolveCodexHomeLayout, } from "./CodexHomeLayout.ts"; +import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; const decodeCodexSettingsValue = Schema.decodeSync(CodexSettings); const decodeCodexSettings = (input: { @@ -84,130 +85,139 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => { }); describe("materializeCodexShadowHome", () => { - it.effect("materializes a shadow home with shared state links and private auth", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const sharedHome = yield* makeTempDir("t3code-codex-shared-"); - const shadowRoot = yield* makeTempDir("t3code-codex-shadow-root-"); - const shadowHome = path.join(shadowRoot, "shadow"); - - yield* fileSystem.makeDirectory(path.join(sharedHome, "sessions")); - yield* writeTextFile(path.join(sharedHome, "config.toml"), 'model = "gpt-5-codex"\n'); - yield* writeTextFile(path.join(sharedHome, "models_cache.json"), '{"models":["shared"]}\n'); - yield* writeTextFile(path.join(sharedHome, "auth.json"), '{"shared":true}\n'); - yield* fileSystem.makeDirectory(shadowHome, { recursive: true }); - yield* writeTextFile(path.join(shadowHome, "auth.json"), '{"shadow":true}\n'); - yield* fileSystem.symlink( - path.join(sharedHome, "models_cache.json"), - path.join(shadowHome, "models_cache.json"), - ); - - const layout = yield* resolveCodexHomeLayout( - decodeCodexSettings({ - homePath: sharedHome, - shadowHomePath: shadowHome, - }), - ); - - yield* materializeCodexShadowHome(layout); - - const sessionsTarget = yield* fileSystem.readLink(path.join(shadowHome, "sessions")); - const configTarget = yield* fileSystem.readLink(path.join(shadowHome, "config.toml")); - const mcpOauthLocksTarget = yield* fileSystem.readLink( - path.join(shadowHome, "mcp-oauth-locks"), - ); - const modelsCacheExists = yield* fileSystem.exists( - path.join(shadowHome, "models_cache.json"), - ); - const authLinkResult = yield* fileSystem - .readLink(path.join(shadowHome, "auth.json")) - .pipe(Effect.result); - const authContents = yield* fileSystem.readFileString(path.join(shadowHome, "auth.json")); - - expect(sessionsTarget).toBe(path.join(sharedHome, "sessions")); - expect(configTarget).toBe(path.join(sharedHome, "config.toml")); - expect(mcpOauthLocksTarget).toBe(path.join(sharedHome, "mcp-oauth-locks")); - expect(modelsCacheExists).toBe(false); - expect(authLinkResult._tag).toBe("Failure"); - expect(authContents).toContain("shadow"); - }), + it.effect.skipIf(!symlinksSupported)( + "materializes a shadow home with shared state links and private auth", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sharedHome = yield* makeTempDir("t3code-codex-shared-"); + const shadowRoot = yield* makeTempDir("t3code-codex-shadow-root-"); + const shadowHome = path.join(shadowRoot, "shadow"); + + yield* fileSystem.makeDirectory(path.join(sharedHome, "sessions")); + yield* writeTextFile(path.join(sharedHome, "config.toml"), 'model = "gpt-5-codex"\n'); + yield* writeTextFile( + path.join(sharedHome, "models_cache.json"), + '{"models":["shared"]}\n', + ); + yield* writeTextFile(path.join(sharedHome, "auth.json"), '{"shared":true}\n'); + yield* fileSystem.makeDirectory(shadowHome, { recursive: true }); + yield* writeTextFile(path.join(shadowHome, "auth.json"), '{"shadow":true}\n'); + yield* fileSystem.symlink( + path.join(sharedHome, "models_cache.json"), + path.join(shadowHome, "models_cache.json"), + ); + + const layout = yield* resolveCodexHomeLayout( + decodeCodexSettings({ + homePath: sharedHome, + shadowHomePath: shadowHome, + }), + ); + + yield* materializeCodexShadowHome(layout); + + const sessionsTarget = yield* fileSystem.readLink(path.join(shadowHome, "sessions")); + const configTarget = yield* fileSystem.readLink(path.join(shadowHome, "config.toml")); + const mcpOauthLocksTarget = yield* fileSystem.readLink( + path.join(shadowHome, "mcp-oauth-locks"), + ); + const modelsCacheExists = yield* fileSystem.exists( + path.join(shadowHome, "models_cache.json"), + ); + const authLinkResult = yield* fileSystem + .readLink(path.join(shadowHome, "auth.json")) + .pipe(Effect.result); + const authContents = yield* fileSystem.readFileString(path.join(shadowHome, "auth.json")); + + expect(sessionsTarget).toBe(path.join(sharedHome, "sessions")); + expect(configTarget).toBe(path.join(sharedHome, "config.toml")); + expect(mcpOauthLocksTarget).toBe(path.join(sharedHome, "mcp-oauth-locks")); + expect(modelsCacheExists).toBe(false); + expect(authLinkResult._tag).toBe("Failure"); + expect(authContents).toContain("shadow"); + }), ); - it.effect("replaces Codex-created local MCP OAuth locks with the shared lock directory", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const sharedHome = yield* makeTempDir("t3code-codex-shared-"); - const shadowRoot = yield* makeTempDir("t3code-codex-shadow-root-"); - const shadowHome = path.join(shadowRoot, "shadow"); - const sharedLocks = path.join(sharedHome, "mcp-oauth-locks"); - const shadowLocks = path.join(shadowHome, "mcp-oauth-locks"); - - yield* writeTextFile(path.join(sharedLocks, "file-store.lock"), ""); - yield* writeTextFile(path.join(shadowLocks, "file-store.lock"), ""); - - const layout = yield* resolveCodexHomeLayout( - decodeCodexSettings({ - homePath: sharedHome, - shadowHomePath: shadowHome, - }), - ); - - yield* materializeCodexShadowHome(layout); - - const locksTarget = yield* fileSystem.readLink(shadowLocks); - const sharedLockExists = yield* fileSystem.exists( - path.join(sharedLocks, "file-store.lock"), - ); - - expect(locksTarget).toBe(sharedLocks); - expect(sharedLockExists).toBe(true); - }), + it.effect.skipIf(!symlinksSupported)( + "replaces Codex-created local MCP OAuth locks with the shared lock directory", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sharedHome = yield* makeTempDir("t3code-codex-shared-"); + const shadowRoot = yield* makeTempDir("t3code-codex-shadow-root-"); + const shadowHome = path.join(shadowRoot, "shadow"); + const sharedLocks = path.join(sharedHome, "mcp-oauth-locks"); + const shadowLocks = path.join(shadowHome, "mcp-oauth-locks"); + + yield* writeTextFile(path.join(sharedLocks, "file-store.lock"), ""); + yield* writeTextFile(path.join(shadowLocks, "file-store.lock"), ""); + + const layout = yield* resolveCodexHomeLayout( + decodeCodexSettings({ + homePath: sharedHome, + shadowHomePath: shadowHome, + }), + ); + + yield* materializeCodexShadowHome(layout); + + const locksTarget = yield* fileSystem.readLink(shadowLocks); + const sharedLockExists = yield* fileSystem.exists( + path.join(sharedLocks, "file-store.lock"), + ); + + expect(locksTarget).toBe(sharedLocks); + expect(sharedLockExists).toBe(true); + }), ); - it.effect("accepts Codex-created shadow-local runtime directories", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const sharedHome = yield* makeTempDir("t3code-codex-shared-"); - const shadowRoot = yield* makeTempDir("t3code-codex-shadow-root-"); - const shadowHome = path.join(shadowRoot, "shadow"); - - yield* fileSystem.makeDirectory(path.join(sharedHome, "log")); - yield* fileSystem.makeDirectory(path.join(sharedHome, "memories")); - yield* fileSystem.makeDirectory(path.join(sharedHome, "tmp")); - yield* writeTextFile(path.join(sharedHome, "config.toml"), 'model = "gpt-5-codex"\n'); - yield* writeTextFile(path.join(shadowHome, "auth.json"), '{"shadow":true}\n'); - yield* fileSystem.makeDirectory(path.join(shadowHome, "log"), { recursive: true }); - yield* fileSystem.makeDirectory(path.join(shadowHome, "memories"), { recursive: true }); - yield* fileSystem.makeDirectory(path.join(shadowHome, "tmp"), { recursive: true }); - - const layout = yield* resolveCodexHomeLayout( - decodeCodexSettings({ - homePath: sharedHome, - shadowHomePath: shadowHome, - }), - ); - - yield* materializeCodexShadowHome(layout); - - const configTarget = yield* fileSystem.readLink(path.join(shadowHome, "config.toml")); - const logLinkResult = yield* fileSystem - .readLink(path.join(shadowHome, "log")) - .pipe(Effect.result); - const memoriesLinkResult = yield* fileSystem - .readLink(path.join(shadowHome, "memories")) - .pipe(Effect.result); - const tmpLinkResult = yield* fileSystem - .readLink(path.join(shadowHome, "tmp")) - .pipe(Effect.result); - - expect(configTarget).toBe(path.join(sharedHome, "config.toml")); - expect(logLinkResult._tag).toBe("Failure"); - expect(memoriesLinkResult._tag).toBe("Failure"); - expect(tmpLinkResult._tag).toBe("Failure"); - }), + it.effect.skipIf(!symlinksSupported)( + "accepts Codex-created shadow-local runtime directories", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sharedHome = yield* makeTempDir("t3code-codex-shared-"); + const shadowRoot = yield* makeTempDir("t3code-codex-shadow-root-"); + const shadowHome = path.join(shadowRoot, "shadow"); + + yield* fileSystem.makeDirectory(path.join(sharedHome, "log")); + yield* fileSystem.makeDirectory(path.join(sharedHome, "memories")); + yield* fileSystem.makeDirectory(path.join(sharedHome, "tmp")); + yield* writeTextFile(path.join(sharedHome, "config.toml"), 'model = "gpt-5-codex"\n'); + yield* writeTextFile(path.join(shadowHome, "auth.json"), '{"shadow":true}\n'); + yield* fileSystem.makeDirectory(path.join(shadowHome, "log"), { recursive: true }); + yield* fileSystem.makeDirectory(path.join(shadowHome, "memories"), { recursive: true }); + yield* fileSystem.makeDirectory(path.join(shadowHome, "tmp"), { recursive: true }); + + const layout = yield* resolveCodexHomeLayout( + decodeCodexSettings({ + homePath: sharedHome, + shadowHomePath: shadowHome, + }), + ); + + yield* materializeCodexShadowHome(layout); + + const configTarget = yield* fileSystem.readLink(path.join(shadowHome, "config.toml")); + const logLinkResult = yield* fileSystem + .readLink(path.join(shadowHome, "log")) + .pipe(Effect.result); + const memoriesLinkResult = yield* fileSystem + .readLink(path.join(shadowHome, "memories")) + .pipe(Effect.result); + const tmpLinkResult = yield* fileSystem + .readLink(path.join(shadowHome, "tmp")) + .pipe(Effect.result); + + expect(configTarget).toBe(path.join(sharedHome, "config.toml")); + expect(logLinkResult._tag).toBe("Failure"); + expect(memoriesLinkResult._tag).toBe("Failure"); + expect(tmpLinkResult._tag).toBe("Failure"); + }), ); it.effect("rejects shadow homes that point at the shared home", () => @@ -233,36 +243,38 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => { }), ); - it.effect("rejects shared entries that already exist in the shadow home as real files", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const sharedHome = yield* makeTempDir("t3code-codex-shared-"); - const shadowRoot = yield* makeTempDir("t3code-codex-shadow-root-"); - const shadowHome = path.join(shadowRoot, "shadow"); - yield* writeTextFile(path.join(sharedHome, "config.toml"), 'model = "gpt-5-codex"\n'); - yield* writeTextFile(path.join(shadowHome, "config.toml"), 'model = "local"\n'); - - const layout = yield* resolveCodexHomeLayout( - decodeCodexSettings({ - homePath: sharedHome, - shadowHomePath: shadowHome, - }), - ); - - const error = yield* materializeCodexShadowHome(layout).pipe(Effect.flip); - - expect(error).toBeInstanceOf(CodexShadowHomeEntryConflictError); - expect(error).toMatchObject({ - sharedHomePath: sharedHome, - effectiveHomePath: shadowHome, - entryName: "config.toml", - linkPath: path.join(shadowHome, "config.toml"), - targetPath: path.join(sharedHome, "config.toml"), - }); - expect(error.message).toBe( - `Cannot create Codex shadow home entry 'config.toml' because '${path.join(shadowHome, "config.toml")}' already exists and is not a symlink.`, - ); - }), + it.effect.skipIf(!symlinksSupported)( + "rejects shared entries that already exist in the shadow home as real files", + () => + Effect.gen(function* () { + const path = yield* Path.Path; + const sharedHome = yield* makeTempDir("t3code-codex-shared-"); + const shadowRoot = yield* makeTempDir("t3code-codex-shadow-root-"); + const shadowHome = path.join(shadowRoot, "shadow"); + yield* writeTextFile(path.join(sharedHome, "config.toml"), 'model = "gpt-5-codex"\n'); + yield* writeTextFile(path.join(shadowHome, "config.toml"), 'model = "local"\n'); + + const layout = yield* resolveCodexHomeLayout( + decodeCodexSettings({ + homePath: sharedHome, + shadowHomePath: shadowHome, + }), + ); + + const error = yield* materializeCodexShadowHome(layout).pipe(Effect.flip); + + expect(error).toBeInstanceOf(CodexShadowHomeEntryConflictError); + expect(error).toMatchObject({ + sharedHomePath: sharedHome, + effectiveHomePath: shadowHome, + entryName: "config.toml", + linkPath: path.join(shadowHome, "config.toml"), + targetPath: path.join(sharedHome, "config.toml"), + }); + expect(error.message).toBe( + `Cannot create Codex shadow home entry 'config.toml' because '${path.join(shadowHome, "config.toml")}' already exists and is not a symlink.`, + ); + }), ); it.effect("preserves filesystem operation, paths, and cause", () => diff --git a/apps/server/src/provider/Drivers/CursorSkills.ts b/apps/server/src/provider/Drivers/CursorSkills.ts index 5113fd3d0ca6..599dc712d46a 100644 --- a/apps/server/src/provider/Drivers/CursorSkills.ts +++ b/apps/server/src/provider/Drivers/CursorSkills.ts @@ -19,8 +19,9 @@ import * as Schema from "effect/Schema"; import { parse as parseYamlDocument } from "yaml"; const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/; -const SKILL_MENTION_PATTERN = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s|$)/g; -const HAS_SKILL_MENTION_PATTERN = /(^|\s)\$[a-zA-Z][a-zA-Z0-9:_-]*(?=\s|$)/; +const SKILL_MENTION_PATTERN = + /(^|\s)\$(?![0-9][0-9_]*(?:[kKmMbBtT]|[eE][0-9]+)?(?:\s|$))(?=[a-zA-Z0-9:_-]*[a-zA-Z])([a-zA-Z0-9][a-zA-Z0-9:_-]*)(?=\s|$)/g; +const HAS_SKILL_MENTION_PATTERN = new RegExp(SKILL_MENTION_PATTERN.source); const MAX_SKILL_DEPTH = 10; const MAX_SKILL_BYTES = FileSystem.Size(1_000_000); const MAX_SKILL_SCAN_ENTRIES = 10_000; @@ -147,16 +148,18 @@ const discoverSkillsInRoot = Effect.fn("discoverCursorSkillsInRoot")(function* ( if (!resolvedDirectory) { return; } - if ( - visitedDirectories.has(resolvedDirectory) || - (resolvedDirectory !== rootDirectory && - !resolvedDirectory.startsWith(`${rootDirectory}${path.sep}`)) - ) { + if (visitedDirectories.has(resolvedDirectory)) { return; } visitedDirectories.add(resolvedDirectory); + // A symlink whose target lives outside the root is a skill package + // boundary: read its own SKILL.md so linked skill libraries show up, but + // never walk the target tree. + const insideRoot = + resolvedDirectory === rootDirectory || + resolvedDirectory.startsWith(`${rootDirectory}${path.sep}`); - const skillPath = path.join(resolvedDirectory, "SKILL.md"); + const skillPath = path.join(directory, "SKILL.md"); const skillInfo = yield* orUndefined(fileSystem.stat(skillPath), input.budget); if (skillInfo?.type === "File") { let frontmatter: CursorSkillFrontmatter | undefined = { cliVisible: true }; @@ -167,7 +170,7 @@ const discoverSkillsInRoot = Effect.fn("discoverCursorSkillsInRoot")(function* ( frontmatter = parseSkillFrontmatter(contents); } } - const name = path.basename(resolvedDirectory).trim(); + const name = path.basename(directory).trim(); if (frontmatter?.cliVisible && name) { skills.push({ name, @@ -184,7 +187,10 @@ const discoverSkillsInRoot = Effect.fn("discoverCursorSkillsInRoot")(function* ( } } - const entries = yield* orUndefined(fileSystem.readDirectory(resolvedDirectory), input.budget); + if (!insideRoot) { + return; + } + const entries = yield* orUndefined(fileSystem.readDirectory(directory), input.budget); if (!entries) { return; } @@ -194,7 +200,7 @@ const discoverSkillsInRoot = Effect.fn("discoverCursorSkillsInRoot")(function* ( return; } input.budget.remainingEntries -= 1; - const child = path.join(resolvedDirectory, entry); + const child = path.join(directory, entry); const info = yield* orUndefined(fileSystem.stat(child), input.budget); if (info?.type !== "Directory") continue; if (depth >= MAX_SKILL_DEPTH) { diff --git a/apps/server/src/provider/Errors.ts b/apps/server/src/provider/Errors.ts index 0cf1522399b4..4abb10554462 100644 --- a/apps/server/src/provider/Errors.ts +++ b/apps/server/src/provider/Errors.ts @@ -85,6 +85,22 @@ export class ProviderAdapterProcessError extends Schema.TaggedErrorClass()( + "ProviderWorkspaceMissingError", + { + threadId: Schema.String, + cwd: Schema.String, + }, +) { + override get message(): string { + return `This thread's workspace folder no longer exists or is not a directory: ${this.cwd}. Restore the folder at this path before retrying.`; + } +} + /** * ProviderValidationError - Invalid provider API input. */ @@ -197,6 +213,7 @@ export type ProviderAdapterError = export type ProviderServiceError = | ProviderValidationError | ProviderUnsupportedError + | ProviderWorkspaceMissingError | ProviderInstanceNotFoundError | ProviderSessionNotFoundError | ProviderSessionDirectoryPersistenceError diff --git a/apps/server/src/provider/Layers/AntigravityAdapter.test.ts b/apps/server/src/provider/Layers/AntigravityAdapter.test.ts index 6020d688cbd4..4bd0bb3e1010 100644 --- a/apps/server/src/provider/Layers/AntigravityAdapter.test.ts +++ b/apps/server/src/provider/Layers/AntigravityAdapter.test.ts @@ -421,7 +421,8 @@ it.layer(layer)("AntigravityAdapter", (it) => { modelSelection: { instanceId, model: nativeAlternative }, }); expect(second.model).toBe(nativeAlternative); - expect(second.cwd).toBe("/tmp"); + // The adapter resolves the cwd it was given through the host Path. + expect(second.cwd).toBe((yield* Path.Path).resolve("/tmp")); expect(h.launches[1]?.resumeSessionId).toBe(nativeSessionId); expect(h.calls).toEqual([ "start", diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 6b02c95f598f..afea9a605084 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -3449,10 +3449,14 @@ describe("ClaudeAdapterLive", () => { const harness = makeHarness(); return Effect.gen(function* () { const adapter = yield* ClaudeAdapter; - const runtimeEvents: Array = []; - const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => - Effect.sync(() => runtimeEvents.push(event)), - ).pipe(Effect.forkChild); + const runtimeEventsFiber = yield* adapter.streamEvents.pipe( + Stream.takeUntil( + (event) => + event.type === "session.state.changed" && event.payload.reason === "api_retry:3/10", + ), + Stream.runCollect, + Effect.forkChild, + ); yield* adapter.startSession({ threadId: THREAD_ID, @@ -3498,7 +3502,6 @@ describe("ClaudeAdapterLive", () => { uuid: "tu", }, { type: "system", subtype: "commands_changed", session_id: "session", uuid: "cc" }, - { type: "system", subtype: "model_refusal_fallback", session_id: "session", uuid: "mrf" }, { type: "system", subtype: "local_command_output", session_id: "session", uuid: "lco" }, { type: "system", subtype: "plugin_install", session_id: "session", uuid: "pi" }, { type: "system", subtype: "memory_recall", session_id: "session", uuid: "mr" }, @@ -3545,6 +3548,21 @@ describe("ClaudeAdapterLive", () => { ]) { harness.query.emit(message as unknown as SDKMessage); } + // Safety model-fallback notices DO surface as a warning row. + harness.query.emit({ + type: "system", + subtype: "model_refusal_fallback", + trigger: "refusal", + direction: "retry", + original_model: "claude-fable-5", + fallback_model: "claude-opus-4-8", + request_id: "req_test", + api_refusal_category: "cyber", + api_refusal_explanation: null, + content: "Safeguards flagged this message. Switched to Opus 4.8.", + session_id: "session", + uuid: "mrf", + } as unknown as SDKMessage); // High-priority notifications DO surface as a warning row. harness.query.emit({ type: "system", @@ -3602,15 +3620,15 @@ describe("ClaudeAdapterLive", () => { session_id: "session", uuid: "retry", } as unknown as SDKMessage); - yield* Effect.yieldNow; - yield* Effect.yieldNow; + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); const warnings = runtimeEvents.filter((event) => event.type === "runtime.warning"); - // Exactly three warnings: the high-priority notification, the + // Exactly four warnings: the fallback notice, high-priority notification, // warning-level informational note, and the refusal. Nothing else. assert.deepEqual( warnings.map((event) => event.payload.message), [ + "Safeguards flagged this message. Switched to Opus 4.8.", "context window nearly full", "Stop hook prevented continuation", "The request was declined by the API.", @@ -3638,7 +3656,6 @@ describe("ClaudeAdapterLive", () => { event.payload.reason.startsWith("api_retry:"), ); assert.equal(heartbeat?.type, "session.state.changed"); - runtimeEventsFiber.interruptUnsafe(); }).pipe( Effect.provideService(Random.Random, makeDeterministicRandomService()), Effect.provide(harness.layer), diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 6096fd7cbef7..d295821d8dfa 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -3655,6 +3655,14 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( yield* emitRuntimeWarning(context, message.text, message); } return; + case "model_refusal_fallback": + // A safety fallback switched the model mid-session (e.g. Fable 5 + // retried on Opus 4.8 after a flagged request). The CLI ships the + // user-facing notice in `content`; surface it like high-priority + // notifications so the rest of the session isn't silently served + // by a different model. + yield* emitRuntimeWarning(context, message.content, message); + return; // Inner protocol/UX details with no T3 surface today — consumed // deliberately so they don't masquerade as unknown-subtype warnings. // `background_tasks_changed` is a roster snapshot ({tasks: [...]}); the @@ -3663,7 +3671,6 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( // source. `control_request_progress` is a liveness heartbeat for an // in-flight control request. `worker_shutting_down` is a Remote // Control worker notice; the session close path reports the outcome. - case "model_refusal_fallback": case "local_command_output": case "plugin_install": case "commands_changed": diff --git a/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts index 253118299820..232b8cc02d00 100644 --- a/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts +++ b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts @@ -1,4 +1,11 @@ +// @effect-diagnostics nodeBuiltinImport:off - cleanup uses Node's retrying rm, which the FileSystem service does not expose. +import * as ClaudeSdk from "@anthropic-ai/claude-agent-sdk"; +import { vi } from "vite-plus/test"; +import * as Deferred from "effect/Deferred"; +import * as Fiber from "effect/Fiber"; +import * as TestClock from "effect/testing/TestClock"; import { ClaudeSettings } from "@t3tools/contracts"; +import * as NodeFSP from "node:fs/promises"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; @@ -12,6 +19,8 @@ import { probeClaudeCapabilities, } from "./ClaudeProvider.ts"; +vi.mock("@anthropic-ai/claude-agent-sdk", { spy: true }); + const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); it("isolates Claude capability probes without dropping workspace setting sources", () => { @@ -51,8 +60,25 @@ it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => { const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-probe-sdk-" }); const executablePath = path.join(tempDir, "fake-claude.mjs"); const invocationPath = path.join(tempDir, "invocation.json"); - const workspaceCwd = path.join(tempDir, "workspace"); - yield* fs.makeDirectory(workspaceCwd, { recursive: true }); + // The probe aborts the SDK without awaiting the child's exit, and on + // Windows a directory that is still some process's cwd cannot be + // removed. Keep the workspace outside the scoped directory and let it + // go with a retrying removal once the child has gone. + const workspaceCwd = yield* fs.makeTempDirectory({ prefix: "t3-claude-probe-cwd-" }); + // Node's own retry rather than an Effect schedule: it.effect runs on a + // TestClock, so a scheduled retry would wait for time nobody advances. + // If the child still holds the directory after that, an empty temp + // directory is left behind rather than failing the test for it. + yield* Effect.addFinalizer(() => + Effect.promise(() => + NodeFSP.rm(workspaceCwd, { + recursive: true, + force: true, + maxRetries: 20, + retryDelay: 250, + }).catch(() => undefined), + ), + ); yield* fs.writeFileString( executablePath, @@ -162,3 +188,38 @@ it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => { }).pipe(Effect.scoped), ); }); + +it.effect("preserves initialized capabilities when optional usage times out", () => + Effect.gen(function* () { + const usageStarted = yield* Deferred.make(); + let abortSignal: AbortSignal | undefined; + const query = vi.spyOn(ClaudeSdk, "query").mockImplementation(({ options }) => { + abortSignal = options?.abortController?.signal; + return { + initializationResult: async () => ({ + account: { email: "dev@example.com", subscriptionType: "pro", tokenSource: "oauth" }, + commands: [{ name: "review", description: "Review changes", argumentHint: "[path]" }], + }), + usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET: () => { + Deferred.doneUnsafe(usageStarted, Effect.void); + return new Promise(() => {}); + }, + } as ReturnType; + }); + yield* Effect.addFinalizer(() => Effect.sync(() => query.mockRestore())); + const probe = yield* probeClaudeCapabilities( + decodeClaudeSettings({ binaryPath: "claude" }), + ).pipe(Effect.forkChild); + yield* Deferred.await(usageStarted); + yield* TestClock.adjust("4 seconds"); + const capabilities = yield* Fiber.join(probe); + assert.equal(capabilities?.email, "dev@example.com"); + assert.equal(capabilities?.subscriptionType, "pro"); + assert.equal(capabilities?.tokenSource, "oauth"); + assert.deepEqual(capabilities?.slashCommands, [ + { name: "review", description: "Review changes", input: { hint: "[path]" } }, + ]); + assert.equal(capabilities?.usage, undefined); + assert.equal(abortSignal?.aborted, true); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index e4ec8c522da7..e3d2c6ab565d 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -355,44 +355,47 @@ const probeClaudeCapabilities = ( }), }); const init = await q.initializationResult(); - // Usage is a second control round trip on the same process; a failure - // there must not cost the slash commands and account we already have. - const usage = await q.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET().then( - (response) => ({ - rate_limits_available: response.rate_limits_available, - rate_limits: response.rate_limits, - }), - () => undefined, - ); - const account = init.account as - | { - readonly email?: string; - readonly subscriptionType?: string; - readonly tokenSource?: string; - readonly apiProvider?: string; - } - | undefined; - return { - email: account?.email, - subscriptionType: account?.subscriptionType, - tokenSource: account?.tokenSource, - apiProvider: account?.apiProvider, - slashCommands: parseClaudeInitializationCommands(init.commands), - ...(usage ? { usage } : {}), - } satisfies ClaudeCapabilitiesProbe; + return { q, init }; }); }).pipe( + Effect.timeout(CAPABILITIES_PROBE_TIMEOUT_MS), + Effect.flatMap(({ q, init }) => + Effect.gen(function* () { + // Usage has its own deadline so a slow optional request cannot discard initialization. + const usageResult = yield* Effect.tryPromise(() => + q.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET(), + ).pipe(Effect.timeout(DEFAULT_TIMEOUT_MS), Effect.result); + const usage = Result.isSuccess(usageResult) + ? { + rate_limits_available: usageResult.success.rate_limits_available, + rate_limits: usageResult.success.rate_limits, + } + : undefined; + const account = init.account as + | { + readonly email?: string; + readonly subscriptionType?: string; + readonly tokenSource?: string; + readonly apiProvider?: string; + } + | undefined; + return { + email: account?.email, + subscriptionType: account?.subscriptionType, + tokenSource: account?.tokenSource, + apiProvider: account?.apiProvider, + slashCommands: parseClaudeInitializationCommands(init.commands), + ...(usage ? { usage } : {}), + } satisfies ClaudeCapabilitiesProbe; + }), + ), Effect.ensuring( Effect.sync(() => { if (!abort.signal.aborted) abort.abort(); }), ), - Effect.timeoutOption(CAPABILITIES_PROBE_TIMEOUT_MS), Effect.result, - Effect.map((result) => { - if (Result.isFailure(result)) return undefined; - return Option.isSome(result.success) ? result.success.value : undefined; - }), + Effect.map((result) => (Result.isSuccess(result) ? result.success : undefined)), ); }; diff --git a/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts b/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts index 5bc5940fe539..02b7a45f33ad 100644 --- a/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts +++ b/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts @@ -9,6 +9,7 @@ */ // @effect-diagnostics nodeBuiltinImport:off import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -23,6 +24,7 @@ import { assert, describe } from "vite-plus/test"; import wireFixture from "../testFixtures/codexMultiAgentWire.json" with { type: "json" }; import { makeCodexSessionRuntime } from "./CodexSessionRuntime.ts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; const ROOT = wireFixture.rootThreadId; const [CHILD_A, CHILD_B] = wireFixture.childThreadIds as [string, string]; @@ -157,7 +159,11 @@ function readRecordedRequests() { } const scriptPath = NodePath.join(import.meta.dirname, "../testFixtures/.collab-script.json"); -const peerPath = NodePath.join(import.meta.dirname, "../testFixtures/codexCollabMockPeer.sh"); +// Windows cannot run the shebang wrapper; the .cmd sibling does the same job. +const peerPath = NodePath.join( + import.meta.dirname, + `../testFixtures/codexCollabMockPeer.${HostProcessPlatform.defaultValue() === "win32" ? "cmd" : "sh"}`, +); describe("CodexSessionRuntime collab integration", () => { it.effect("looks up child model metadata once after activity registration", () => @@ -195,7 +201,7 @@ describe("CodexSessionRuntime collab integration", () => { const runtime = yield* makeCodexSessionRuntime({ threadId: ThreadId.make("thread-collab-model-activity"), binaryPath: peerPath, - cwd: "/tmp", + cwd: NodeOS.tmpdir(), runtimeMode: "full-access", environment: { ...process.env, T3_CODEX_COLLAB_SCRIPT: scriptPath }, }); @@ -287,7 +293,7 @@ describe("CodexSessionRuntime collab integration", () => { const runtime = yield* makeCodexSessionRuntime({ threadId: ThreadId.make("thread-collab-model-spawn"), binaryPath: peerPath, - cwd: "/tmp", + cwd: NodeOS.tmpdir(), runtimeMode: "full-access", environment: { ...process.env, T3_CODEX_COLLAB_SCRIPT: scriptPath }, }); @@ -366,7 +372,7 @@ describe("CodexSessionRuntime collab integration", () => { const runtime = yield* makeCodexSessionRuntime({ threadId: ThreadId.make(`thread-collab-model-${name}`), binaryPath: peerPath, - cwd: "/tmp", + cwd: NodeOS.tmpdir(), runtimeMode: "full-access", environment: { ...process.env, T3_CODEX_COLLAB_SCRIPT: scriptPath }, }); @@ -405,7 +411,7 @@ describe("CodexSessionRuntime collab integration", () => { const runtime = yield* makeCodexSessionRuntime({ threadId: ThreadId.make("thread-collab-integration"), binaryPath: peerPath, - cwd: "/tmp", + cwd: NodeOS.tmpdir(), runtimeMode: "full-access", environment: { ...process.env, T3_CODEX_COLLAB_SCRIPT: scriptPath }, }); @@ -547,7 +553,7 @@ describe("CodexSessionRuntime collab integration", () => { const runtime = yield* makeCodexSessionRuntime({ threadId: ThreadId.make("thread-collab-stop"), binaryPath: peerPath, - cwd: "/tmp", + cwd: NodeOS.tmpdir(), runtimeMode: "full-access", environment: { ...process.env, T3_CODEX_COLLAB_SCRIPT: scriptPath }, }); @@ -625,7 +631,7 @@ describe("CodexSessionRuntime collab integration", () => { const runtime = yield* makeCodexSessionRuntime({ threadId: ThreadId.make("thread-codex-queued-stop"), binaryPath: peerPath, - cwd: "/tmp", + cwd: NodeOS.tmpdir(), runtimeMode: "full-access", environment: { ...process.env, T3_CODEX_COLLAB_SCRIPT: scriptPath }, }); @@ -722,7 +728,7 @@ describe("CodexSessionRuntime collab integration", () => { const runtime = yield* makeCodexSessionRuntime({ threadId: ThreadId.make("thread-codex-mcp-elicitation"), binaryPath: peerPath, - cwd: "/tmp", + cwd: NodeOS.tmpdir(), runtimeMode: "auto", environment: { ...process.env, T3_CODEX_COLLAB_SCRIPT: scriptPath }, }); diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index ac35ee138a8b..d65a09c6d4f9 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -15,6 +15,7 @@ import * as CodexErrors from "effect-codex-app-server/errors"; import type { CodexSettings, + CustomModelSetting, ServerProvider, ServerProviderState, ModelCapabilities, @@ -24,7 +25,7 @@ import type { } from "@t3tools/contracts"; import { PREFERRED_DEFAULT_CODEX_MODELS, ServerSettingsError } from "@t3tools/contracts"; -import { createModelCapabilities } from "@t3tools/shared/model"; +import { createModelCapabilities, readCustomModelEntries } from "@t3tools/shared/model"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { codexAppServerArgs, resolveCodexLaunchArgs } from "./codexLaunchArgs.ts"; import { @@ -244,9 +245,14 @@ export function applyPreferredCodexDefaultModel( }); } +/** + * Codex has no static default capability set, so a bare custom slug borrows + * the first built-in's descriptors; an entry with its own capabilities keeps + * them. + */ function appendCustomCodexModels( models: ReadonlyArray, - customModels: ReadonlyArray, + customModels: ReadonlyArray, ): ReadonlyArray { if (customModels.length === 0) { return models; @@ -255,17 +261,16 @@ function appendCustomCodexModels( const seen = new Set(models.map((model) => model.slug)); const fallbackCapabilities = models.find((model) => model.capabilities)?.capabilities ?? null; const customEntries: ServerProviderModel[] = []; - for (const rawModel of customModels) { - const slug = rawModel.trim(); - if (!slug || seen.has(slug)) { + for (const entry of readCustomModelEntries(customModels)) { + if (seen.has(entry.slug)) { continue; } - seen.add(slug); + seen.add(entry.slug); customEntries.push({ - slug, - name: slug, + slug: entry.slug, + name: entry.name, isCustom: true, - capabilities: fallbackCapabilities, + capabilities: entry.capabilities ?? fallbackCapabilities, }); } return customEntries.length === 0 ? models : [...models, ...customEntries]; @@ -399,7 +404,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun readonly homePath?: string; readonly launchArgs?: string; readonly cwd: string; - readonly customModels?: ReadonlyArray; + readonly customModels?: ReadonlyArray; readonly environment?: NodeJS.ProcessEnv; }) { const { client, initialize } = yield* withCodexAppServerClient(input); @@ -470,21 +475,8 @@ export const probeCodexSkillsForCwd = Effect.fn("probeCodexSkillsForCwd")(functi return parseCodexSkillsListResponse(skillsResponse, input.cwd); }); -const emptyCodexModelsFromSettings = (codexSettings: CodexSettings): ServerProvider["models"] => { - const models = new Set(); - for (const model of codexSettings.customModels) { - const trimmed = model.trim(); - if (trimmed.length > 0) { - models.add(trimmed); - } - } - return Array.from(models, (model) => ({ - slug: model, - name: model, - isCustom: true, - capabilities: null, - })); -}; +const emptyCodexModelsFromSettings = (codexSettings: CodexSettings): ServerProvider["models"] => + appendCustomCodexModels([], codexSettings.customModels); const makePendingCodexProvider = ( codexSettings: CodexSettings, @@ -562,7 +554,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu readonly homePath?: string; readonly launchArgs?: string; readonly cwd: string; - readonly customModels: ReadonlyArray; + readonly customModels: ReadonlyArray; readonly environment?: NodeJS.ProcessEnv; }) => Effect.Effect< CodexAppServerProviderSnapshot, diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts index b952c97ffac8..9881710135ce 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.test.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -30,6 +30,8 @@ import { buildRuntimeInstructions } from "../RuntimeInstructions.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import type { CursorAdapterShape } from "../Services/CursorAdapter.ts"; import { makeCursorAdapter } from "./CursorAdapter.ts"; +import { execScriptSource, writeFakeCli } from "../../testUtils/fakeCli.ts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; const decodeCursorSettings = Schema.decodeSync(CursorSettings); // Test-local service tag so the rest of the file can keep using `yield* CursorAdapter`. @@ -39,26 +41,25 @@ class CursorAdapter extends Context.Service() const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); const mockAgentPath = NodePath.join(__dirname, "../../../scripts/acp-mock-agent.ts"); -const mockAgentCommand = "node"; -const mockAgentArgs = [mockAgentPath] as const; - +// Stopping a session kills the agent with SIGTERM; Windows terminates the +// process instead, so the mock never sees a signal to log. +const windowsHost = HostProcessPlatform.defaultValue() === "win32"; async function makeMockAgentWrapper( extraEnv?: Record, options?: { initialDelaySeconds?: number }, ) { const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cursor-acp-mock-")); - const wrapperPath = NodePath.join(dir, "fake-agent.sh"); - const envExports = Object.entries(extraEnv ?? {}) - .map(([key, value]) => `export ${key}=${JSON.stringify(value)}`) - .join("\n"); - const script = `#!/bin/sh -${envExports} -${options?.initialDelaySeconds ? `sleep ${JSON.stringify(String(options.initialDelaySeconds))}` : ""} -exec ${JSON.stringify(mockAgentCommand)} ${mockAgentArgs.map((arg) => JSON.stringify(arg)).join(" ")} "$@" -`; - await NodeFSP.writeFile(wrapperPath, script, "utf8"); - await NodeFSP.chmod(wrapperPath, 0o755); - return wrapperPath; + return writeFakeCli({ + directory: dir, + name: "fake-agent", + env: extraEnv ?? {}, + source: execScriptSource({ + scriptPath: mockAgentPath, + ...(options?.initialDelaySeconds === undefined + ? {} + : { delayMs: Math.round(options.initialDelaySeconds * 1000) }), + }), + }); } async function makeProbeWrapper( @@ -67,20 +68,12 @@ async function makeProbeWrapper( extraEnv?: Record, ) { const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cursor-acp-probe-")); - const wrapperPath = NodePath.join(dir, "fake-agent.sh"); - const envExports = Object.entries(extraEnv ?? {}) - .map(([key, value]) => `export ${key}=${JSON.stringify(value)}`) - .join("\n"); - const script = `#!/bin/sh -printf '%s\t' "$@" >> ${JSON.stringify(argvLogPath)} -printf '\n' >> ${JSON.stringify(argvLogPath)} -export T3_ACP_REQUEST_LOG_PATH=${JSON.stringify(requestLogPath)} -${envExports} -exec ${JSON.stringify(mockAgentCommand)} ${mockAgentArgs.map((arg) => JSON.stringify(arg)).join(" ")} "$@" -`; - await NodeFSP.writeFile(wrapperPath, script, "utf8"); - await NodeFSP.chmod(wrapperPath, 0o755); - return wrapperPath; + return writeFakeCli({ + directory: dir, + name: "fake-agent", + env: { T3_ACP_REQUEST_LOG_PATH: requestLogPath, ...extraEnv }, + source: execScriptSource({ scriptPath: mockAgentPath, argvLogPath }), + }); } async function readArgvLog(filePath: string) { @@ -390,7 +383,7 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { }), ); - it.effect("closes the ACP child process when a session stops", () => + it.effect.skipIf(windowsHost)("closes the ACP child process when a session stops", () => Effect.gen(function* () { const adapter = yield* CursorAdapter; const settings = yield* ServerSettingsService; @@ -422,7 +415,7 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { }), ); - it.effect( + it.effect.skipIf(windowsHost)( "serializes concurrent startSession calls for the same thread and closes the replaced ACP session", () => Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/CursorProvider.test.ts b/apps/server/src/provider/Layers/CursorProvider.test.ts index 98983b4b4434..78edd8acbd45 100644 --- a/apps/server/src/provider/Layers/CursorProvider.test.ts +++ b/apps/server/src/provider/Layers/CursorProvider.test.ts @@ -30,6 +30,8 @@ import { probeCursorSkills, rewriteCursorSkillMentions, } from "../Drivers/CursorSkills.ts"; +import { execScriptSource, writeFakeCli } from "../../testUtils/fakeCli.ts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; const runNode = ( effect: Effect.Effect< @@ -39,6 +41,10 @@ const runNode = ( >, ): Promise => Effect.runPromise(effect.pipe(Effect.provide(NodeServices.layer))); +// Closing the probe kills the agent with SIGTERM; Windows terminates the +// process instead, so the mock never sees a signal to log. +const windowsHost = HostProcessPlatform.defaultValue() === "win32"; + const resolveMockAgentPath = Effect.fn("resolveMockAgentPath")(function* () { const path = yield* Path.Path; return yield* path.fromFileUrl(new URL("../../../scripts/acp-mock-agent.ts", import.meta.url)); @@ -73,47 +79,38 @@ const makeMockAgentWrapper = Effect.fn("makeMockAgentWrapper")(function* ( extraEnv?: Record, ) { const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; const mockAgentPath = yield* resolveMockAgentPath(); const dir = yield* fileSystem.makeTempDirectory({ directory: NodeOS.tmpdir(), prefix: "cursor-provider-mock-", }); - const wrapperPath = path.join(dir, "fake-agent.sh"); - const mockAgentCommand = ["node", mockAgentPath].map((arg) => JSON.stringify(arg)).join(" "); - const envExports = Object.entries(extraEnv ?? {}) - .map(([key, value]) => `export ${key}=${JSON.stringify(value)}`) - .join("\n"); - const script = `#!/bin/sh -${envExports} -exec ${mockAgentCommand} "$@" -`; - yield* fileSystem.writeFileString(wrapperPath, script); - yield* fileSystem.chmod(wrapperPath, 0o755); - return wrapperPath; + return writeFakeCli({ + directory: dir, + name: "fake-agent", + env: extraEnv ?? {}, + source: execScriptSource({ scriptPath: mockAgentPath }), + }); }); const makeMockAgentWithAboutWrapper = Effect.fn("makeMockAgentWithAboutWrapper")(function* () { const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; const mockAgentPath = yield* resolveMockAgentPath(); const dir = yield* fileSystem.makeTempDirectory({ directory: NodeOS.tmpdir(), prefix: "cursor-provider-about-mock-", }); - const wrapperPath = path.join(dir, "fake-agent.sh"); - const mockAgentCommand = ["node", mockAgentPath].map((arg) => JSON.stringify(arg)).join(" "); - const script = `#!/bin/sh -if [ "$1" = "about" ]; then - printf 'CLI Version 2026.04.09-f2b0fcd\\n' - printf 'User Email cursor@example.com\\n' - exit 0 -fi -exec ${mockAgentCommand} "$@" -`; - yield* fileSystem.writeFileString(wrapperPath, script); - yield* fileSystem.chmod(wrapperPath, 0o755); - return wrapperPath; + return writeFakeCli({ + directory: dir, + name: "fake-agent", + source: [ + 'if (process.argv[2] === "about") {', + ' process.stdout.write("CLI Version 2026.04.09-f2b0fcd\\n");', + ' process.stdout.write("User Email cursor@example.com\\n");', + " process.exit(0);", + "}", + execScriptSource({ scriptPath: mockAgentPath }), + ].join("\n"), + }); }); const waitForFileContent = Effect.fn("waitForFileContent")(function* ( @@ -398,6 +395,56 @@ describe("Cursor skills", () => { }), )); + it("treats a symlinked skill outside the root as a package boundary", async () => + await runNode( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const userHome = yield* fileSystem.makeTempDirectory({ + directory: NodeOS.tmpdir(), + prefix: "cursor-skills-home-", + }); + const workspace = yield* fileSystem.makeTempDirectory({ + directory: NodeOS.tmpdir(), + prefix: "cursor-skills-workspace-", + }); + const library = yield* fileSystem.makeTempDirectory({ + directory: NodeOS.tmpdir(), + prefix: "cursor-skills-library-", + }); + const writeSkill = Effect.fn("writeCursorSkill")(function* ( + directory: string, + contents: string, + ) { + yield* fileSystem.makeDirectory(directory, { recursive: true }); + yield* fileSystem.writeFileString(path.join(directory, "SKILL.md"), contents); + }); + + // A skill package managed in a config repo and installed by symlink. + // Its own SKILL.md must be discovered under the link name, but nothing + // below the target may be walked. + yield* writeSkill(path.join(library, "shared-review"), "---\ndescription: shared\n---\n"); + yield* writeSkill(path.join(library, "shared-review", "hidden"), "---\n---\n"); + const root = path.join(workspace, ".cursor", "skills"); + yield* fileSystem.makeDirectory(root, { recursive: true }); + yield* fileSystem.symlink(path.join(library, "shared-review"), path.join(root, "review")); + + const skills = yield* discoverCursorSkills(workspace, { HOME: userHome }); + expect(skills).toEqual([ + { + name: "review", + description: "shared", + path: path.join(root, "review", "SKILL.md"), + scope: "project", + enabled: true, + }, + ]); + expect( + (yield* probeCursorSkills(workspace, { HOME: userHome }).pipe(Effect.result))._tag, + ).toBe("Success"); + }), + )); + it("rewrites only discovered skill mentions into Cursor slash invocations", () => { expect(hasCursorSkillMention("use $Review_Pr:V2 here")).toBe(true); expect(hasCursorSkillMention("please $review this")).toBe(true); @@ -408,6 +455,24 @@ describe("Cursor skills", () => { "please /review this", ); }); + + it("detects and invokes digit-leading Cursor skills without rewriting money", () => { + const names = new Set(["2spec", "20k", "100M", "1e6"]); + // Repeated presence checks must not carry a global-regex cursor. + expect(hasCursorSkillMention("use $2spec here")).toBe(true); + expect(hasCursorSkillMention("use $2spec here")).toBe(true); + expect(rewriteCursorSkillMentions("use $2spec here", names)).toBe("use /2spec here"); + expect(rewriteCursorSkillMentions("use $2spec here", new Set())).toBe("use $2spec here"); + for (const text of [ + "pay $20 tomorrow", + "budget $20k here", + "cost $100M total", + "limit $1e6 here", + ]) { + expect(hasCursorSkillMention(text)).toBe(false); + expect(rewriteCursorSkillMentions(text, names)).toBe(text); + } + }); }); describe("getCursorFallbackModels", () => { @@ -593,7 +658,7 @@ describe("discoverCursorModelsViaAcp", () => { ]); }); - it("closes the ACP probe runtime after discovery completes", async () => { + it.skipIf(windowsHost)("closes the ACP probe runtime after discovery completes", async () => { const { exitLogPath, wrapperPath } = await runNode( makeExitLogFixture("cursor-provider-exit-log-"), ); diff --git a/apps/server/src/provider/Layers/GrokAdapter.test.ts b/apps/server/src/provider/Layers/GrokAdapter.test.ts index 44a0c7343d22..41fa6ed0f60a 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.test.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.test.ts @@ -33,25 +33,24 @@ import { nextGrokPlanModeActive, selectGrokPermissionOptionId, } from "./GrokAdapter.ts"; +import { execScriptSource, writeFakeCli } from "../../testUtils/fakeCli.ts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; const decodeGrokSettings = Schema.decodeSync(GrokSettings); const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); const mockAgentPath = NodePath.join(__dirname, "../../../scripts/acp-mock-agent.ts"); -const mockAgentCommand = process.execPath; +// Stopping a session kills the agent with SIGTERM; Windows terminates the +// process instead, so the mock never sees a signal to log. +const windowsHost = HostProcessPlatform.defaultValue() === "win32"; async function makeMockGrokWrapper(extraEnv?: Record) { const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "grok-acp-mock-")); - const wrapperPath = NodePath.join(dir, "fake-grok.sh"); - const envExports = Object.entries(extraEnv ?? {}) - .map(([key, value]) => `export ${key}=${JSON.stringify(value)}`) - .join("\n"); - const script = `#!/bin/sh -${envExports} -exec ${JSON.stringify(mockAgentCommand)} ${JSON.stringify(mockAgentPath)} "$@" -`; - await NodeFSP.writeFile(wrapperPath, script, "utf8"); - await NodeFSP.chmod(wrapperPath, 0o755); - return wrapperPath; + return writeFakeCli({ + directory: dir, + name: "fake-grok", + env: extraEnv ?? {}, + source: execScriptSource({ scriptPath: mockAgentPath }), + }); } function waitForFileContent( @@ -340,7 +339,7 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { }), ); - it.effect("closes the ACP child process when a session stops", () => + it.effect.skipIf(windowsHost)("closes the ACP child process when a session stops", () => Effect.gen(function* () { const threadId = ThreadId.make("grok-stop-session-close"); const tempDir = yield* Effect.promise(() => diff --git a/apps/server/src/provider/Layers/GrokProvider.test.ts b/apps/server/src/provider/Layers/GrokProvider.test.ts index e7c62f4cbf77..c751919b189a 100644 --- a/apps/server/src/provider/Layers/GrokProvider.test.ts +++ b/apps/server/src/provider/Layers/GrokProvider.test.ts @@ -6,7 +6,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; 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 { GrokSettings } from "@t3tools/contracts"; @@ -17,6 +16,7 @@ import { checkGrokProviderStatus, parseGrokModelsCliOutput, } from "./GrokProvider.ts"; +import { execScriptSource, writeFakeCli } from "../../testUtils/fakeCli.ts"; const decodeGrokSettings = Schema.decodeSync(GrokSettings); const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); @@ -308,14 +308,17 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { const snapshot = yield* Effect.scoped( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-grok-version-" }); - const grokPath = path.join(dir, "grok"); - yield* fs.writeFileString( - grokPath, - ["#!/bin/sh", `printf "%s\\n" "${secretStderr}" >&2`, "exit 2", ""].join("\n"), - ); - yield* fs.chmod(grokPath, 0o755); + const grokPath = writeFakeCli({ + directory: dir, + name: "grok", + source: [ + // @effect-diagnostics-next-line preferSchemaOverJson:off + `process.stderr.write(${JSON.stringify(`${secretStderr}\n`)});`, + "process.exit(2);", + "", + ].join("\n"), + }); return yield* checkGrokProviderStatus( decodeGrokSettings({ enabled: true, binaryPath: grokPath }), @@ -331,37 +334,31 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { }), ); - // Single-quotes a path for /bin/sh. Temp dirs and execPath never contain quotes. - const shellQuote = (value: string) => `'${value.replaceAll("'", `'\\''`)}'`; - - // A shell stand-in for the Grok CLI: `--version` and `models` print canned text, + // A stand-in for the Grok CLI: `--version` and `models` print canned text, // and `agent stdio` execs the mock ACP agent so `initialize` returns model metadata. const writeFakeGrokCli = (input: { readonly modelsOutput: string; readonly acp: boolean }) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-grok-probe-" }); - const modelsPath = path.join(dir, "models.txt"); - yield* fs.writeFileString(modelsPath, input.modelsOutput); - const grokPath = path.join(dir, "grok"); - const mockAgentPath = path.resolve(__dirname, "../../../scripts/acp-mock-agent.ts"); - yield* fs.writeFileString( - grokPath, - [ - "#!/bin/sh", - 'case "$1" in', - ' --version) printf "grok 1.0.13\\n"; exit 0;;', - ` models) cat ${shellQuote(modelsPath)}; exit 0;;`, - input.acp - ? ` agent) exec ${shellQuote(process.execPath)} ${shellQuote(mockAgentPath)};;` - : " agent) exit 3;;", - "esac", - "exit 1", + const mockAgentPath = NodePath.resolve(__dirname, "../../../scripts/acp-mock-agent.ts"); + return writeFakeCli({ + directory: dir, + name: "grok", + source: [ + 'if (process.argv[2] === "--version") {', + ' process.stdout.write("grok 1.0.13\\n");', + " process.exit(0);", + "}", + 'if (process.argv[2] === "models") {', + // @effect-diagnostics-next-line preferSchemaOverJson:off + ` process.stdout.write(${JSON.stringify(input.modelsOutput)});`, + " process.exit(0);", + "}", + 'if (process.argv[2] !== "agent") process.exit(1);', + ...(input.acp ? [execScriptSource({ scriptPath: mockAgentPath })] : ["process.exit(3);"]), "", ].join("\n"), - ); - yield* fs.chmod(grokPath, 0o755); - return grokPath; + }); }); it.effect("reports ready with ACP-discovered models when logged in", () => diff --git a/apps/server/src/provider/Layers/GrokProvider.ts b/apps/server/src/provider/Layers/GrokProvider.ts index 50a881f38897..493e46d44352 100644 --- a/apps/server/src/provider/Layers/GrokProvider.ts +++ b/apps/server/src/provider/Layers/GrokProvider.ts @@ -1,4 +1,5 @@ import { + type CustomModelSetting, type GrokSettings, type ModelCapabilities, type ServerProvider, @@ -104,7 +105,7 @@ export function buildInitialGrokProviderSnapshot( } function grokModelsFromSettings( - customModels: ReadonlyArray | undefined, + customModels: ReadonlyArray | undefined, builtInModels: ReadonlyArray = GROK_BUILT_IN_MODELS, ): ReadonlyArray { return providerModelsFromSettings(builtInModels, customModels ?? [], EMPTY_CAPABILITIES); diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 93382f842231..fb0e9aa9ef2d 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -49,6 +49,7 @@ import { makeOpenCodeAdapter, mergeOpenCodeAssistantText, } from "./OpenCodeAdapter.ts"; +import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; // Test-local service tag so the rest of the file can keep using `yield* OpenCodeAdapter`. class OpenCodeAdapter extends Context.Service()( @@ -6401,30 +6402,32 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }), ); - it.effect("treats lexically or physically identical directories as the same", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const sameDirectory = (left: string, right: string) => - isSameOpenCodeDirectory(fileSystem, path, left, right); - - // Lexical-only differences (trailing slash, dot segments) short-circuit - // without touching the filesystem — the paths need not exist. - NodeAssert.equal(yield* sameDirectory("/repo/project/", "/repo/project"), true); - NodeAssert.equal(yield* sameDirectory("/repo/nested/../project", "/repo/project"), true); - // Nonexistent paths degrade to the lexical comparison instead of failing. - NodeAssert.equal(yield* sameDirectory("/repo/project", "/repo/other"), false); - - // A symlinked cwd (the macOS `/tmp` → `/private/tmp` shape) resolves to - // the directory it points at, so the two spellings compare equal. - const base = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-opencode-dir-" }); - const real = path.join(base, "real"); - const link = path.join(base, "link"); - yield* fileSystem.makeDirectory(real); - yield* fileSystem.symlink(real, link); - NodeAssert.equal(yield* sameDirectory(link, real), true); - NodeAssert.equal(yield* sameDirectory(link, path.join(base, "other")), false); - }).pipe(Effect.scoped), + it.effect.skipIf(!symlinksSupported)( + "treats lexically or physically identical directories as the same", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sameDirectory = (left: string, right: string) => + isSameOpenCodeDirectory(fileSystem, path, left, right); + + // Lexical-only differences (trailing slash, dot segments) short-circuit + // without touching the filesystem — the paths need not exist. + NodeAssert.equal(yield* sameDirectory("/repo/project/", "/repo/project"), true); + NodeAssert.equal(yield* sameDirectory("/repo/nested/../project", "/repo/project"), true); + // Nonexistent paths degrade to the lexical comparison instead of failing. + NodeAssert.equal(yield* sameDirectory("/repo/project", "/repo/other"), false); + + // A symlinked cwd (the macOS `/tmp` → `/private/tmp` shape) resolves to + // the directory it points at, so the two spellings compare equal. + const base = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-opencode-dir-" }); + const real = path.join(base, "real"); + const link = path.join(base, "link"); + yield* fileSystem.makeDirectory(real); + yield* fileSystem.symlink(real, link); + NodeAssert.equal(yield* sameDirectory(link, real), true); + NodeAssert.equal(yield* sameDirectory(link, path.join(base, "other")), false); + }).pipe(Effect.scoped), ); it.effect("appends raw assistant text deltas and reconciles part update snapshots", () => diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts index d750789fde02..af43e039e652 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts @@ -24,6 +24,7 @@ */ import { describe, expect, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Path from "effect/Path"; import { type ClaudeSettings, type CodexSettings, @@ -214,15 +215,19 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { expect(personalSnapshot.instanceId).toBe(personalId); expect(personalSnapshot.driver).toBe(codexDriverKind); expect(personalSnapshot.enabled).toBe(false); + // The layout resolves the configured home through the host Path. + const path = yield* Path.Path; expect(personalSnapshot.continuation?.groupKey).toBe( - "codex:home:/home/julius/.codex_personal", + `codex:home:${path.resolve("/home/julius/.codex_personal")}`, ); const workSnapshot = yield* work!.snapshot.getSnapshot; expect(workSnapshot.instanceId).toBe(workId); expect(workSnapshot.driver).toBe(codexDriverKind); expect(workSnapshot.enabled).toBe(false); - expect(workSnapshot.continuation?.groupKey).toBe("codex:home:/home/julius/.codex"); + expect(workSnapshot.continuation?.groupKey).toBe( + `codex:home:${path.resolve("/home/julius/.codex")}`, + ); // Nothing goes to the unavailable bucket — both drivers are registered. const unavailable = yield* registry.listUnavailable; @@ -451,13 +456,17 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { expect(codexSnapshot.instanceId).toBe(codexId); expect(codexSnapshot.driver).toBe(codexDriverKind); expect(codexSnapshot.enabled).toBe(false); - expect(codexSnapshot.continuation?.groupKey).toBe("codex:home:/home/julius/.codex"); + expect(codexSnapshot.continuation?.groupKey).toBe( + `codex:home:${(yield* Path.Path).resolve("/home/julius/.codex")}`, + ); const claudeSnapshot = yield* claude!.snapshot.getSnapshot; expect(claudeSnapshot.instanceId).toBe(claudeId); expect(claudeSnapshot.driver).toBe(claudeDriverKind); expect(claudeSnapshot.enabled).toBe(false); - expect(claudeSnapshot.continuation?.groupKey).toBe("claude:home:/home/julius/.claude-work"); + expect(claudeSnapshot.continuation?.groupKey).toBe( + `claude:home:${(yield* Path.Path).resolve("/home/julius/.claude-work")}`, + ); const cursorSnapshot = yield* cursor!.snapshot.getSnapshot; expect(cursorSnapshot.instanceId).toBe(cursorId); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index b961032a7082..c687fc2fe988 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -3,6 +3,7 @@ import { describe, it, assert } from "@effect/vitest"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Path from "effect/Path"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; @@ -49,7 +50,11 @@ import { } from "./ProviderRegistry.ts"; import * as ServerConfig from "../../config.ts"; import * as ServerSettingsModule from "../../serverSettings.ts"; -import { readProviderStatusCache, resolveProviderStatusCachePath } from "../providerStatusCache.ts"; +import { + readProviderStatusCache, + resolveProviderStatusCachePath, + writeProviderStatusCache, +} from "../providerStatusCache.ts"; import { COMPACT_SLASH_COMMAND } from "../providerSnapshot.ts"; import type { ProviderInstance } from "../ProviderDriver.ts"; import * as ProviderInstanceRegistry from "../Services/ProviderInstanceRegistry.ts"; @@ -342,6 +347,21 @@ function makeMutableServerSettingsService( }); } +// The registry writes the status cache and only then publishes the change, so +// a subscriber that sees `checkedAt` on the stream knows the file is on disk. +// Subscribed before the publish that triggers it; a spin on the file would +// race the write and lose on a slow host. +const awaitPersistedProvider = ( + registry: ProviderRegistry.ProviderRegistry["Service"], + checkedAt: string, +) => + registry.streamChanges.pipe( + Stream.filter((providers) => providers.some((provider) => provider.checkedAt === checkedAt)), + Stream.take(1), + Stream.runDrain, + Effect.forkScoped, + ); + it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), TestHttpClientLive))( "ProviderRegistry", (it) => { @@ -900,6 +920,183 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te assert.deepStrictEqual(afterFailure.models, [authoritativeProvider.models[0]!]); }); + describe("Codex model inventories", () => { + const cachedProvider = { + instanceId: ProviderInstanceId.make("codex-personal"), + driver: ProviderDriverKind.make("codex"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-09-04T19:00:00.000Z", + version: "0.153.3", + models: [ + "vega-alpha", + "joule-alpha", + "kindle-alpha", + "ultima-alpha", + "solstice-alpha", + ].map((slug) => ({ slug, name: slug, isCustom: false, capabilities: null })), + slashCommands: [], + skills: [], + } satisfies ServerProvider; + const customModel = { + slug: "custom-model", + name: "Custom model", + isCustom: true, + capabilities: null, + } as const; + const refreshedProvider = { + ...cachedProvider, + checkedAt: "2026-09-04T19:01:00.000Z", + models: [ + { slug: "gpt-6-astra", name: "GPT 6 Astra", isCustom: false, capabilities: null }, + cachedProvider.models[0]!, + customModel, + ], + } satisfies ServerProvider; + const pendingProvider = { + ...cachedProvider, + status: "warning", + installed: false, + auth: { status: "unknown" }, + models: [customModel], + } satisfies ServerProvider; + const failedProvider = { + ...pendingProvider, + checkedAt: "2026-09-04T19:02:00.000Z", + status: "error", + installed: true, + } satisfies ServerProvider; + + it("drops retired alpha models after discovery, including without OpenAI authentication", () => { + for (const authStatus of ["authenticated", "unknown"] as const) { + assert.deepStrictEqual( + mergeProviderSnapshot(cachedProvider, { + ...refreshedProvider, + auth: { status: authStatus }, + }).models, + refreshedProvider.models, + ); + } + }); + + it("keeps discovered models during startup and failed probes without restoring removed custom models", () => { + for (const provider of [pendingProvider, failedProvider]) { + assert.deepStrictEqual( + mergeProviderSnapshot( + { + ...cachedProvider, + models: [...cachedProvider.models, { ...customModel, slug: "removed-custom" }], + }, + provider, + ).models, + [customModel, ...cachedProvider.models], + ); + } + }); + + it("clears discovered models after sign-out, disable, uninstall, or empty discovery", () => { + const emptyProvider = { ...refreshedProvider, models: [customModel] }; + const clearedProviders = [ + { ...emptyProvider, status: "error", auth: { status: "unauthenticated" } }, + { ...emptyProvider, status: "disabled", enabled: false }, + { ...emptyProvider, status: "error", installed: false, auth: { status: "unknown" } }, + emptyProvider, + { ...emptyProvider, models: [] }, + ] satisfies ReadonlyArray; + + for (const provider of clearedProviders) { + assert.deepStrictEqual( + mergeProviderSnapshot(cachedProvider, provider).models, + provider.models, + ); + } + }); + + it.effect("persists removals across failed refreshes and registry restarts", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const filePath = yield* resolveProviderStatusCachePath({ + cacheDir: config.providerStatusCacheDir, + instanceId: cachedProvider.instanceId, + }); + yield* writeProviderStatusCache({ filePath, provider: cachedProvider }); + const nextProvider = yield* Ref.make(refreshedProvider); + const instance = { + instanceId: cachedProvider.instanceId, + driverKind: cachedProvider.driver, + continuationIdentity: { + driverKind: cachedProvider.driver, + continuationKey: "codex:instance:codex-personal", + }, + displayName: undefined, + enabled: true, + snapshot: { + maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({ + provider: cachedProvider.driver, + packageName: null, + }), + getSnapshot: Effect.succeed(pendingProvider), + refresh: Ref.get(nextProvider), + streamChanges: Stream.empty, + applyUsageLimits: () => Effect.void, + }, + adapter: {} as ProviderInstance["adapter"], + textGeneration: {} as ProviderInstance["textGeneration"], + } satisfies ProviderInstance; + const instanceRegistryLayer = Layer.succeed( + ProviderInstanceRegistry.ProviderInstanceRegistry, + { + getInstance: (id) => + Effect.succeed(id === instance.instanceId ? instance : undefined), + listInstances: Effect.succeed([instance]), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.empty, + subscribeChanges: Effect.flatMap(PubSub.unbounded(), PubSub.subscribe), + }, + ); + const retainedModels = [ + customModel, + ...refreshedProvider.models.filter((model) => !model.isCustom), + ]; + + for (const restarted of [false, true]) { + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry.ProviderRegistry; + const expectedModels = restarted + ? retainedModels + : [customModel, ...cachedProvider.models]; + assert.deepStrictEqual((yield* registry.getProviders)[0]?.models, expectedModels); + + yield* registry.refreshInstance(instance.instanceId); + assert.deepStrictEqual( + (yield* readProviderStatusCache(filePath))?.models, + restarted ? retainedModels : refreshedProvider.models, + ); + + yield* Ref.set(nextProvider, failedProvider); + const afterFailure = yield* registry.refreshInstance(instance.instanceId); + assert.deepStrictEqual(afterFailure[0]?.models, retainedModels); + assert.deepStrictEqual( + (yield* readProviderStatusCache(filePath))?.models, + retainedModels, + ); + }).pipe( + Effect.provide(ProviderRegistryLive.pipe(Layer.provide(instanceRegistryLayer))), + Effect.scoped, + ); + } + }).pipe( + Effect.provide( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-codex-retired-model-cache-", + }).pipe(Layer.provideMerge(NodeServices.layer)), + ), + ), + ); + }); + describe("Antigravity model inventories", () => { const previousProvider = { instanceId: ProviderInstanceId.make("antigravity-personal"), @@ -1571,18 +1768,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te assert.deepStrictEqual((yield* registry.getProviders)[0]?.models, [ ...initialProvider.models, ]); + const persisted = yield* awaitPersistedProvider(registry, refreshedProvider.checkedAt); 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); - } + yield* Fiber.join(persisted); + const cachedProvider = yield* readProviderStatusCache(filePath); assert.deepStrictEqual(cachedProvider, { ...refreshedProvider, @@ -1697,31 +1886,23 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te instanceId: openCodeInstanceId, }); + const authoritativePersisted = yield* awaitPersistedProvider( + registry, + authoritativeProvider.checkedAt, + ); yield* PubSub.publish(changes, authoritativeProvider); - + yield* Fiber.join(authoritativePersisted); 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); - } assert.deepStrictEqual(cachedProvider?.models, [authoritativeProvider.models[0]!]); + const failedPersisted = yield* awaitPersistedProvider( + registry, + failedProvider.checkedAt, + ); 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); - } + yield* Fiber.join(failedPersisted); + cachedProvider = yield* readProviderStatusCache(filePath); assert.deepStrictEqual(cachedProvider?.models, [authoritativeProvider.models[0]!]); assert.deepStrictEqual((yield* registry.getProviders)[0]?.models, [ @@ -2534,9 +2715,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te claudeCapabilities(), ); assert.strictEqual(status.status, "ready"); + // The home is resolved through the host Path before it reaches the env. assert.deepStrictEqual( recorded.commands.map((command) => command.env?.CLAUDE_CONFIG_DIR), - [claudeConfigDir], + [(yield* Path.Path).resolve(claudeConfigDir)], ); }).pipe(Effect.provide(recorded.layer)); }); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index 4c8658e38afc..bc972d7a6559 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -102,15 +102,19 @@ export function upsertProviderWorkspaceSnapshot( const shouldRetainMissingProviderModels = (provider: ServerProvider): boolean => { const isAntigravity = provider.driver === ProviderDriverKind.make("antigravity"); - if (!isAntigravity && provider.driver !== ProviderDriverKind.make("opencode")) { + const isCodex = provider.driver === ProviderDriverKind.make("codex"); + if (!isAntigravity && !isCodex && provider.driver !== ProviderDriverKind.make("opencode")) { return true; } - if (isAntigravity && (!provider.enabled || provider.auth.status === "unauthenticated")) { + if ( + (isAntigravity || isCodex) && + (!provider.enabled || provider.auth.status === "unauthenticated") + ) { return false; } - // Both drivers replace their inventories after successful catalog discovery. + // Successful discovery replaces these inventories so cached retired models disappear. // Antigravity's local health check does not authenticate or discover models. const isPendingAntigravityAuthentication = isAntigravity && provider.status === "warning" && provider.auth.status === "unknown"; diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 5e59960685a7..f5b9be91650f 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -32,6 +32,7 @@ import { } from "@t3tools/shared/assistantCitations"; import { createModelSelection } from "@t3tools/shared/model"; import { it, assert, describe, vi } from "@effect/vitest"; +import { afterAll } from "vite-plus/test"; import * as Cause from "effect/Cause"; import * as Deferred from "effect/Deferred"; @@ -54,6 +55,7 @@ import { ProviderAdapterSessionNotFoundError, ProviderUnsupportedError, ProviderValidationError, + ProviderWorkspaceMissingError, type ProviderAdapterError, } from "../Errors.ts"; import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; @@ -79,6 +81,16 @@ const serverConfigTestLayer = ServerConfig.layerTest(process.cwd(), process.cwd( Layer.provide(NodeServices.layer), ); +// startSession verifies the workspace folder exists before dispatching to an +// adapter, so session cwd fixtures must be real directories. +const fixtureCwdRoot = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "provider-service-test-")); +afterAll(() => NodeFS.rmSync(fixtureCwdRoot, { recursive: true, force: true })); +function fixtureCwd(name: string): string { + const dir = NodePath.join(fixtureCwdRoot, name); + NodeFS.mkdirSync(dir, { recursive: true }); + return dir; +} + const asRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.make(value); const asEventId = (value: string): EventId => EventId.make(value); const asThreadId = (value: string): ThreadId => ThreadId.make(value); @@ -425,6 +437,7 @@ function makeProviderServiceLayer( const layer = it.layer( Layer.mergeAll( makeProviderServiceLive().pipe( + Layer.provide(NodeServices.layer), Layer.provide(providerAdapterLayer), Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), @@ -452,6 +465,126 @@ function makeProviderServiceLayer( }; } +for (const [enabled, completed] of [ + [false, false], + [true, false], + [true, true], +] as const) { + it.effect( + `persists shutdown recovery before stopping providers when enabled=${enabled}, completed=${completed}`, + () => + Effect.gen(function* () { + const codex = makeFakeCodexAdapter(); + const persistence = yield* Layer.build( + ProviderSessionDirectoryLive.pipe( + Layer.provide( + ProviderSessionRuntime.layer.pipe(Layer.provide(SqlitePersistenceMemory)), + ), + ), + ); + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory.pipe( + Effect.provide(persistence), + ); + const threadId = asThreadId("shutdown-recovery"); + const turnId = asTurnId("shutdown-recovery-turn"); + const scope = yield* Scope.make(); + const services = yield* Layer.build( + makeProviderServiceLive().pipe( + Layer.provide(NodeServices.layer), + Layer.provide( + Layer.succeed(ProviderSessionDirectory.ProviderSessionDirectory, directory), + ), + Layer.provide( + Layer.succeed( + ProviderAdapterRegistry.ProviderAdapterRegistry, + makeStaticInstanceRegistry([[codexInstanceId, codex.adapter]]), + ), + ), + Layer.provide(ServerSettings.layerTest({ continueThreadsAfterServerUpdate: enabled })), + Layer.provide(serverConfigTestLayer), + Layer.provide(AnalyticsService.layerTest), + Layer.provide( + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + ), + ), + ).pipe(Scope.provide(scope)); + const provider = yield* ProviderService.ProviderService.pipe(Effect.provide(services)); + const session = yield* provider.startSession(threadId, { + provider: CODEX_DRIVER, + providerInstanceId: codexInstanceId, + threadId, + runtimeMode: "full-access", + }); + codex.listSessions.mockReturnValue( + Effect.succeed([ + { + ...session, + status: completed ? "ready" : "running", + activeTurnId: completed ? undefined : turnId, + }, + ]), + ); + const pending = yield* directory.getBinding(threadId); + assert(Option.isSome(pending)); + yield* directory.upsert({ + ...pending.value, + runtimePayload: { activeTurnId: null, continueAfterServerUpdate: turnId }, + }); + const accepted = yield* provider.sendTurn({ threadId, continuation: true }); + const admitted = yield* directory.getBinding(threadId); + assert(Option.isSome(admitted)); + assert.propertyVal(admitted.value.runtimePayload, "activeTurnId", accepted.turnId); + assert.propertyVal(admitted.value.runtimePayload, "continueAfterServerUpdate", null); + if (completed) { + // Updates can mark an already-admitted turn immediately before it finishes. + yield* directory.upsert({ + ...admitted.value, + runtimePayload: { + continueAfterServerUpdate: accepted.turnId, + continueAfterServerUpdatePrepared: null, + }, + }); + } + const markers: unknown[] = []; + codex.stopAll.mockImplementation(() => + Effect.gen(function* () { + const binding = yield* directory.getBinding(threadId); + assert(Option.isSome(binding)); + markers.push(binding.value.runtimePayload); + }).pipe(Effect.orDie), + ); + yield* Scope.close(scope, Exit.void); + const binding = yield* directory.getBinding(threadId); + assert(Option.isSome(binding)); + assert.equal(codex.stopAll.mock.calls.length, 1); + assert.deepStrictEqual(binding.value.resumeCursor, session.resumeCursor); + assert.equal(binding.value.status, "stopped"); + assert.propertyVal(markers[0], "activeTurnId", completed ? null : turnId); + if (enabled && !completed) { + assert.propertyVal(markers[0], "continueAfterServerUpdate", turnId); + assert.propertyVal(binding.value.runtimePayload, "continueAfterServerUpdate", turnId); + } else if (completed) { + assert.propertyVal( + binding.value.runtimePayload, + "continueAfterServerUpdate", + accepted.turnId, + ); + assert.propertyVal( + binding.value.runtimePayload, + "continueAfterServerUpdatePrepared", + null, + ); + } else { + assert.propertyVal(markers[0], "continueAfterServerUpdate", null); + assert.propertyVal(binding.value.runtimePayload, "continueAfterServerUpdate", null); + } + }).pipe(Effect.provide(NodeServices.layer)), + ); +} + it.effect("ProviderServiceLive catches stopAll failures during shutdown", () => Effect.gen(function* () { const codex = makeFakeCodexAdapter(); @@ -477,6 +610,7 @@ it.effect("ProviderServiceLive catches stopAll failures during shutdown", () => const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)); const providerLayer = Layer.mergeAll( makeProviderServiceLive().pipe( + Layer.provide(NodeServices.layer), Layer.provide(providerAdapterLayer), Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), @@ -519,6 +653,7 @@ it.effect("ProviderServiceLive flushes deferred completions during shutdown", () const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)); const providerLayer = Layer.mergeAll( makeProviderServiceLive().pipe( + Layer.provide(NodeServices.layer), Layer.provide(providerAdapterLayer), Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), @@ -655,6 +790,7 @@ it.effect("ProviderServiceLive rejects new sessions for disabled providers", () ); const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)); const providerLayer = makeProviderServiceLive().pipe( + Layer.provide(NodeServices.layer), Layer.provide(providerAdapterLayer), Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), @@ -738,6 +874,7 @@ it.effect( Layer.provide(runtimeRepositoryLayer), ); const providerLayer = makeProviderServiceLive().pipe( + Layer.provide(NodeServices.layer), Layer.provide(providerAdapterLayer), Layer.provide(directoryLayer), Layer.provide(serverSettingsLayer), @@ -807,6 +944,7 @@ it.effect("ProviderServiceLive rejects new sessions for disabled custom instance ); const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)); const providerLayer = makeProviderServiceLive().pipe( + Layer.provide(NodeServices.layer), Layer.provide(providerAdapterLayer), Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), @@ -925,7 +1063,7 @@ unsupportedRollback.layer("ProviderServiceLive unsupported rewind", (it) => { yield* provider.startSession(threadId, { providerInstanceId: codexInstanceId, threadId, - cwd: "/tmp/project", + cwd: fixtureCwd("project"), runtimeMode: "approval-required", }); if (!active) { @@ -979,6 +1117,7 @@ it.effect( Layer.provide(runtimeRepositoryLayer), ); const providerLayer = makeProviderServiceLive().pipe( + Layer.provide(NodeServices.layer), Layer.provide(Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, registry)), Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), @@ -1099,6 +1238,7 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", ( }).pipe(Effect.provide(directoryLayer)); const providerLayer = makeProviderServiceLive().pipe( + Layer.provide(NodeServices.layer), Layer.provide(Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, registry)), Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), @@ -1164,6 +1304,7 @@ it.effect( Layer.provide(runtimeRepositoryLayer), ); const firstProviderLayer = makeProviderServiceLive().pipe( + Layer.provide(NodeServices.layer), Layer.provide( Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, firstRegistry), ), @@ -1191,7 +1332,7 @@ it.effect( const session = yield* provider.startSession(threadId, { provider: ProviderDriverKind.make("codex"), providerInstanceId: codexInstanceId, - cwd: "/tmp/project", + cwd: fixtureCwd("project"), runtimeMode: "full-access", threadId, }); @@ -1224,6 +1365,7 @@ it.effect( Layer.provide(runtimeRepositoryLayer), ); const secondProviderLayer = makeProviderServiceLive().pipe( + Layer.provide(NodeServices.layer), Layer.provide( Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, secondRegistry), ), @@ -1261,7 +1403,7 @@ it.effect( threadId?: string; }; assert.equal(startPayload.provider, "codex"); - assert.equal(startPayload.cwd, "/tmp/project"); + assert.equal(startPayload.cwd, fixtureCwd("project")); assert.deepEqual(startPayload.resumeCursor, updatedResumeCursor); assert.equal(startPayload.threadId, startedSession.threadId); } @@ -1275,6 +1417,61 @@ it.effect( ); routing.layer("ProviderServiceLive routing", (it) => { + it.effect.each([CODEX_DRIVER, CLAUDE_AGENT_DRIVER, CURSOR_DRIVER])( + "rejects missing, file, and saved workspace paths before starting %s", + (driver) => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + const adapter = + driver === CODEX_DRIVER + ? routing.codex + : driver === CLAUDE_AGENT_DRIVER + ? routing.claude + : routing.cursor; + const cwd = fixtureCwd(`missing-workspace-${driver}`); + const movedCwd = `${cwd}-moved`; + const threadId = asThreadId(`missing-workspace-${driver}`); + const input = { + provider: driver, + providerInstanceId: ProviderInstanceId.make(driver), + threadId, + runtimeMode: "full-access" as const, + cwd, + }; + + yield* provider.startSession(threadId, input); + yield* provider.stopSession({ threadId }); + adapter.startSession.mockClear(); + NodeFS.renameSync(cwd, movedCwd); + + const failure = yield* provider.startSession(threadId, input).pipe(Effect.flip); + assert.instanceOf(failure, ProviderWorkspaceMissingError); + assert.include(failure.message, cwd); + assert.equal(adapter.startSession.mock.calls.length, 0); + + const { cwd: _cwd, ...savedInput } = input; + const savedFailure = yield* provider.startSession(threadId, savedInput).pipe(Effect.flip); + assert.instanceOf(savedFailure, ProviderWorkspaceMissingError); + assert.include(savedFailure.message, cwd); + assert.equal(adapter.startSession.mock.calls.length, 0); + + NodeFS.writeFileSync(cwd, "not a directory"); + const fileFailure = yield* provider.startSession(threadId, input).pipe(Effect.flip); + assert.instanceOf(fileFailure, ProviderWorkspaceMissingError); + assert.include(fileFailure.message, cwd); + assert.equal(adapter.startSession.mock.calls.length, 0); + + NodeFS.unlinkSync(cwd); + NodeFS.renameSync(movedCwd, cwd); + const restored = yield* provider.startSession(threadId, savedInput); + assert.equal(restored.cwd, cwd); + assert.equal(adapter.startSession.mock.calls.length, 1); + yield* provider.stopSession({ threadId }); + adapter.startSession.mockClear(); + adapter.stopSession.mockClear(); + }), + ); + it.effect("allows promptless continuation only for capable providers", () => Effect.gen(function* () { const provider = yield* ProviderService.ProviderService; @@ -1333,7 +1530,7 @@ routing.layer("ProviderServiceLive routing", (it) => { provider: ProviderDriverKind.make("codex"), providerInstanceId: codexInstanceId, threadId: asThreadId("thread-1"), - cwd: "/tmp/project", + cwd: fixtureCwd("project"), runtimeMode: "full-access", }); assert.equal(session.provider, "codex"); @@ -1403,7 +1600,7 @@ routing.layer("ProviderServiceLive routing", (it) => { threadId?: string; }; assert.equal(startPayload.provider, "codex"); - assert.equal(startPayload.cwd, "/tmp/project"); + assert.equal(startPayload.cwd, fixtureCwd("project")); assert.deepEqual(startPayload.resumeCursor, session.resumeCursor); assert.equal(startPayload.threadId, session.threadId); } @@ -1680,7 +1877,7 @@ routing.layer("ProviderServiceLive routing", (it) => { provider: CODEX_DRIVER, providerInstanceId: codexInstanceId, threadId, - cwd: "/tmp/feedback-project", + cwd: fixtureCwd("feedback-project"), runtimeMode: "full-access", }); yield* routing.codex.stopSession(threadId); @@ -1743,7 +1940,7 @@ routing.layer("ProviderServiceLive routing", (it) => { provider: ProviderDriverKind.make("codex"), providerInstanceId: codexInstanceId, threadId: asThreadId("thread-attach"), - cwd: "/tmp/project", + cwd: fixtureCwd("project"), runtimeMode: "full-access", }); @@ -1818,7 +2015,7 @@ routing.layer("ProviderServiceLive routing", (it) => { provider: ProviderDriverKind.make("codex"), providerInstanceId: codexInstanceId, threadId: asThreadId("thread-1"), - cwd: "/tmp/project", + cwd: fixtureCwd("project"), runtimeMode: "full-access", }); yield* routing.codex.stopSession(initial.threadId); @@ -1844,7 +2041,7 @@ routing.layer("ProviderServiceLive routing", (it) => { threadId?: string; }; assert.equal(startPayload.provider, "codex"); - assert.equal(startPayload.cwd, "/tmp/project"); + assert.equal(startPayload.cwd, fixtureCwd("project")); assert.deepEqual(startPayload.resumeCursor, initial.resumeCursor); assert.equal(startPayload.threadId, initial.threadId); } @@ -1863,7 +2060,7 @@ routing.layer("ProviderServiceLive routing", (it) => { provider: ProviderDriverKind.make("codex"), providerInstanceId: codexInstanceId, threadId: asThreadId("thread-reap-preserve"), - cwd: "/tmp/project-reap-preserve", + cwd: fixtureCwd("project-reap-preserve"), runtimeMode: "full-access", }); @@ -1898,7 +2095,7 @@ routing.layer("ProviderServiceLive routing", (it) => { threadId?: string; }; assert.equal(startPayload.provider, "codex"); - assert.equal(startPayload.cwd, "/tmp/project-reap-preserve"); + assert.equal(startPayload.cwd, fixtureCwd("project-reap-preserve")); assert.deepEqual(startPayload.resumeCursor, initial.resumeCursor); assert.equal(startPayload.threadId, initial.threadId); } @@ -1914,7 +2111,7 @@ routing.layer("ProviderServiceLive routing", (it) => { provider: ProviderDriverKind.make("claudeAgent"), providerInstanceId: claudeAgentInstanceId, threadId: asThreadId("thread-claude"), - cwd: "/tmp/project-claude", + cwd: fixtureCwd("project-claude"), runtimeMode: "full-access", }); @@ -1930,7 +2127,7 @@ routing.layer("ProviderServiceLive routing", (it) => { }; assert.equal(startPayload.provider, "claudeAgent"); assert.equal(startPayload.providerInstanceId, claudeAgentInstanceId); - assert.equal(startPayload.cwd, "/tmp/project-claude"); + assert.equal(startPayload.cwd, fixtureCwd("project-claude")); } }), ); @@ -1945,7 +2142,7 @@ routing.layer("ProviderServiceLive routing", (it) => { provider: ProviderDriverKind.make("codex"), providerInstanceId: codexInstanceId, threadId, - cwd: "/tmp/project-binding-mismatch", + cwd: fixtureCwd("project-binding-mismatch"), runtimeMode: "full-access", }); yield* directory.upsert({ @@ -1975,7 +2172,7 @@ routing.layer("ProviderServiceLive routing", (it) => { provider: ProviderDriverKind.make("codex"), providerInstanceId: codexInstanceId, threadId, - cwd: "/tmp/project-provider-replacement", + cwd: fixtureCwd("project-provider-replacement"), runtimeMode: "full-access", }); @@ -1986,7 +2183,7 @@ routing.layer("ProviderServiceLive routing", (it) => { provider: ProviderDriverKind.make("claudeAgent"), providerInstanceId: claudeAgentInstanceId, threadId, - cwd: "/tmp/project-provider-replacement", + cwd: fixtureCwd("project-provider-replacement"), runtimeMode: "full-access", }); @@ -2013,7 +2210,7 @@ routing.layer("ProviderServiceLive routing", (it) => { provider: ProviderDriverKind.make("codex"), providerInstanceId: codexInstanceId, threadId: asThreadId("thread-1"), - cwd: "/tmp/project-send-turn", + cwd: fixtureCwd("project-send-turn"), runtimeMode: "full-access", }); @@ -2038,7 +2235,7 @@ routing.layer("ProviderServiceLive routing", (it) => { threadId?: string; }; assert.equal(startPayload.provider, "codex"); - assert.equal(startPayload.cwd, "/tmp/project-send-turn"); + assert.equal(startPayload.cwd, fixtureCwd("project-send-turn")); assert.deepEqual(startPayload.resumeCursor, initial.resumeCursor); assert.equal(startPayload.threadId, initial.threadId); } @@ -2054,7 +2251,7 @@ routing.layer("ProviderServiceLive routing", (it) => { provider: ProviderDriverKind.make("claudeAgent"), providerInstanceId: claudeAgentInstanceId, threadId: asThreadId("thread-claude-send-turn"), - cwd: "/tmp/project-claude-send-turn", + cwd: fixtureCwd("project-claude-send-turn"), modelSelection: createModelSelection( ProviderInstanceId.make("claudeAgent"), "claude-opus-4-6", @@ -2085,7 +2282,7 @@ routing.layer("ProviderServiceLive routing", (it) => { threadId?: string; }; assert.equal(startPayload.provider, "claudeAgent"); - assert.equal(startPayload.cwd, "/tmp/project-claude-send-turn"); + assert.equal(startPayload.cwd, fixtureCwd("project-claude-send-turn")); assert.deepEqual( startPayload.modelSelection, createModelSelection(ProviderInstanceId.make("claudeAgent"), "claude-opus-4-6", [ @@ -2249,6 +2446,7 @@ routing.layer("ProviderServiceLive routing", (it) => { Layer.provide(runtimeRepositoryLayer), ); const firstProviderLayer = makeProviderServiceLive().pipe( + Layer.provide(NodeServices.layer), Layer.provide( Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, firstRegistry), ), @@ -2270,7 +2468,7 @@ routing.layer("ProviderServiceLive routing", (it) => { provider: ProviderDriverKind.make("claudeAgent"), providerInstanceId: claudeAgentInstanceId, threadId: asThreadId("thread-claude-start"), - cwd: "/tmp/project-claude-start", + cwd: fixtureCwd("project-claude-start"), runtimeMode: "full-access", }); }).pipe(Effect.provide(firstProviderLayer)); @@ -2288,6 +2486,7 @@ routing.layer("ProviderServiceLive routing", (it) => { Layer.provide(runtimeRepositoryLayer), ); const secondProviderLayer = makeProviderServiceLive().pipe( + Layer.provide(NodeServices.layer), Layer.provide( Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, secondRegistry), ), @@ -2311,7 +2510,7 @@ routing.layer("ProviderServiceLive routing", (it) => { provider: ProviderDriverKind.make("claudeAgent"), providerInstanceId: claudeAgentInstanceId, threadId: initial.threadId, - cwd: "/tmp/project-claude-start", + cwd: fixtureCwd("project-claude-start"), runtimeMode: "full-access", }); }).pipe(Effect.provide(secondProviderLayer)); @@ -2327,7 +2526,7 @@ routing.layer("ProviderServiceLive routing", (it) => { threadId?: string; }; assert.equal(startPayload.provider, "claudeAgent"); - assert.equal(startPayload.cwd, "/tmp/project-claude-start"); + assert.equal(startPayload.cwd, fixtureCwd("project-claude-start")); assert.deepEqual(startPayload.resumeCursor, initial.resumeCursor); assert.equal(startPayload.threadId, initial.threadId); } @@ -2357,6 +2556,7 @@ routing.layer("ProviderServiceLive routing", (it) => { Layer.provide(runtimeRepositoryLayer), ); const firstProviderLayer = makeProviderServiceLive().pipe( + Layer.provide(NodeServices.layer), Layer.provide( Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, firstRegistry), ), @@ -2378,7 +2578,7 @@ routing.layer("ProviderServiceLive routing", (it) => { provider: ProviderDriverKind.make("claudeAgent"), providerInstanceId: claudeAgentInstanceId, threadId: asThreadId("thread-claude-cwd"), - cwd: "/tmp/project-claude-cwd", + cwd: fixtureCwd("project-claude-cwd"), runtimeMode: "full-access", }); }).pipe(Effect.provide(firstProviderLayer)); @@ -2391,6 +2591,7 @@ routing.layer("ProviderServiceLive routing", (it) => { Layer.provide(runtimeRepositoryLayer), ); const secondProviderLayer = makeProviderServiceLive().pipe( + Layer.provide(NodeServices.layer), Layer.provide( Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, secondRegistry), ), @@ -2429,7 +2630,7 @@ routing.layer("ProviderServiceLive routing", (it) => { threadId?: string; }; assert.equal(startPayload.provider, "claudeAgent"); - assert.equal(startPayload.cwd, "/tmp/project-claude-cwd"); + assert.equal(startPayload.cwd, fixtureCwd("project-claude-cwd")); assert.deepEqual(startPayload.resumeCursor, initial.resumeCursor); assert.equal(startPayload.threadId, initial.threadId); } @@ -2623,7 +2824,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { provider: ProviderDriverKind.make("claudeAgent"), providerInstanceId: claudeAgentInstanceId, threadId: asThreadId("thread-metrics"), - cwd: "/tmp/project", + cwd: fixtureCwd("project"), runtimeMode: "full-access", }); @@ -2701,7 +2902,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { provider: ProviderDriverKind.make("claudeAgent"), providerInstanceId: claudeAgentInstanceId, threadId: asThreadId("thread-send-metrics"), - cwd: "/tmp/project-send-metrics", + cwd: fixtureCwd("project-send-metrics"), runtimeMode: "full-access", }); @@ -4036,7 +4237,7 @@ validation.layer("ProviderServiceLive validation", (it) => { provider: ProviderDriverKind.make("codex"), providerInstanceId: codexInstanceId, threadId: asThreadId("thread-missing"), - cwd: "/tmp/project", + cwd: fixtureCwd("project"), runtimeMode: "full-access", }); @@ -4085,7 +4286,7 @@ boundedListing.layer("ProviderServiceLive session listing", (it) => { provider: CODEX_DRIVER, providerInstanceId: codexInstanceId, threadId: activeSessionThreadId, - cwd: "/tmp/project-active-session", + cwd: fixtureCwd("project-active-session"), runtimeMode: "full-access", }); listThreadIds.mockClear(); diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 2098cacda5eb..b853d779763c 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -35,6 +35,7 @@ import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; 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 PubSub from "effect/PubSub"; @@ -55,8 +56,12 @@ import { providerTurnMetricAttributes, withMetrics, } from "../../observability/Metrics.ts"; -import { ProviderAdapterRequestError } from "../Errors.ts"; -import { type ProviderAdapterError, ProviderValidationError } from "../Errors.ts"; +import { + ProviderAdapterRequestError, + type ProviderAdapterError, + ProviderValidationError, + ProviderWorkspaceMissingError, +} from "../Errors.ts"; import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; import * as ProviderAdapterRegistry from "../Services/ProviderAdapterRegistry.ts"; import * as ProviderService from "../Services/ProviderService.ts"; @@ -223,6 +228,7 @@ function toRuntimePayloadFromSession( session: ProviderSession, extra?: { readonly modelSelection?: unknown; + readonly continueAfterServerUpdate?: TurnId; readonly lastRuntimeEvent?: string; readonly lastRuntimeEventAt?: string; }, @@ -232,6 +238,9 @@ function toRuntimePayloadFromSession( model: session.model ?? null, activeTurnId: session.activeTurnId ?? null, lastError: session.lastError ?? null, + ...(extra?.continueAfterServerUpdate !== undefined + ? { continueAfterServerUpdate: extra.continueAfterServerUpdate } + : {}), ...(extra?.modelSelection !== undefined ? { modelSelection: extra.modelSelection } : {}), ...(extra?.lastRuntimeEvent !== undefined ? { lastRuntimeEvent: extra.lastRuntimeEvent } : {}), ...(extra?.lastRuntimeEventAt !== undefined @@ -318,6 +327,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( options?.issueMcpCredential ?? McpSessionRegistry.issueActiveMcpCredential; const revokeMcpCredential = options?.revokeMcpCredential ?? McpSessionRegistry.revokeActiveMcpThread; + const fileSystem = yield* FileSystem.FileSystem; const runtimeEventPubSub = yield* PubSub.unbounded(); const pendingCompactions = new Map(); const timedOutNativeCompactions = new Set(); @@ -831,6 +841,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( threadId: ThreadId, extra?: { readonly modelSelection?: unknown; + readonly continueAfterServerUpdate?: TurnId; readonly lastRuntimeEvent?: string; readonly lastRuntimeEventAt?: string; }, @@ -1230,6 +1241,20 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( : "none", "provider.cwd.effective": effectiveCwd ?? "", }); + if (effectiveCwd !== undefined) { + // Fail fast with an actionable error when the workspace folder is + // gone (e.g. moved, deleted, or replaced by a plain file). + // Otherwise every adapter surfaces this as a misleading "failed to + // spawn " process error. Stat failures other than "missing" + // fall through to the adapter. + const workspaceIsDirectory = yield* fileSystem.stat(effectiveCwd).pipe( + Effect.map((workspaceStat) => workspaceStat.type === "Directory"), + Effect.catch((statError) => Effect.succeed(statError.reason._tag !== "NotFound")), + ); + if (!workspaceIsDirectory) { + return yield* new ProviderWorkspaceMissingError({ threadId, cwd: effectiveCwd }); + } + } const adapter = yield* registry.getByInstance(resolvedInstanceId); yield* clearTurnAnalyticsSession(resolvedInstanceId, threadId); yield* prepareMcpSession(threadId, resolvedInstanceId); @@ -1433,6 +1458,9 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( runtimePayload: { ...(input.modelSelection !== undefined ? { modelSelection: input.modelSelection } : {}), activeTurnId: turn.turnId, + // Admission and marker consumption must survive the same restart. + continueAfterServerUpdate: null, + continueAfterServerUpdatePrepared: null, lastRuntimeEvent: "provider.sendTurn", lastRuntimeEventAt: yield* nowIso, }, @@ -1733,6 +1761,8 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( status: "stopped", runtimePayload: { activeTurnId: null, + continueAfterServerUpdate: null, + continueAfterServerUpdatePrepared: null, }, }); yield* analytics.record("provider.session.stopped", { @@ -1942,6 +1972,10 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ); const runStopAll = Effect.fn("runStopAll")(function* () { + const continueAfterRestart = yield* serverSettings.getSettings.pipe( + Effect.map((settings) => settings.continueThreadsAfterServerUpdate), + Effect.orElseSucceed(() => false), + ); const properties = yield* Ref.modify(turnAnalytics, (state) => { const completed: Array>> = []; for (const [sessionKey, session] of state.sessions) { @@ -1969,6 +2003,9 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( yield* Effect.forEach(activeSessions, (session) => Effect.flatMap(nowIso, (lastRuntimeEventAt) => upsertSessionBinding(session, session.threadId, { + ...(continueAfterRestart && session.status === "running" && session.activeTurnId + ? { continueAfterServerUpdate: session.activeTurnId } + : {}), lastRuntimeEvent: "provider.stopAll", lastRuntimeEventAt, }), diff --git a/apps/server/src/provider/providerMaintenance.test.ts b/apps/server/src/provider/providerMaintenance.test.ts index 5683da2c1a82..54ff1b599560 100644 --- a/apps/server/src/provider/providerMaintenance.test.ts +++ b/apps/server/src/provider/providerMaintenance.test.ts @@ -1,6 +1,7 @@ // @effect-diagnostics nodeBuiltinImport:off import { expect, it } from "@effect/vitest"; import * as NodeFS from "node:fs"; +import * as NodeChildProcess from "node:child_process"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; @@ -8,6 +9,7 @@ import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3 import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import { HttpClient } from "effect/unstable/http"; import { createProviderVersionAdvisory, @@ -20,8 +22,13 @@ import { resolveLatestProviderVersion, resolveProviderMaintenanceCapabilitiesEffect, } from "./providerMaintenance.ts"; +import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; const driver = (value: string) => ProviderDriverKind.make(value); +// These write `#!/bin/sh` stubs and resolve them through a darwin-mocked +// PATH walk; a Windows temp path cannot be split on `:`. +const windowsHost = HostProcessPlatform.defaultValue() === "win32"; + const makeTempDir = (name: string) => Crypto.Crypto.pipe( Effect.flatMap((crypto) => crypto.randomUUIDv4), @@ -199,7 +206,7 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { }); }); - it.effect( + it.effect.skipIf(windowsHost)( "switches package-managed providers to vite-plus updates when the resolved binary lives in vite-plus global bin", () => Effect.gen(function* () { @@ -272,7 +279,7 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { }), ); - it.effect( + it.effect.skipIf(windowsHost)( "switches package-managed providers to pnpm updates when the resolved binary lives in pnpm's global bin", () => Effect.gen(function* () { @@ -332,7 +339,7 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { }); }); - it.effect( + it.effect.skipIf(windowsHost)( "switches native-package-tool to native updates when the binary resolves through the native installer", () => Effect.gen(function* () { @@ -357,9 +364,9 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { provider: driver("nativePackageTool"), packageName: "@example/native-package-tool", update: { - command: "native-package-tool update", + command: `${nativePackageToolPath} update`, - executable: "native-package-tool", + executable: nativePackageToolPath, args: ["update"], @@ -369,7 +376,7 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { }), ); - it.effect( + it.effect.skipIf(windowsHost)( "switches scoped-package-tool to native upgrades when the binary resolves through the standalone installer", () => Effect.gen(function* () { @@ -394,9 +401,9 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { provider: driver("scopedPackageTool"), packageName: "@example/scoped-package-tool", update: { - command: "scoped-package-tool upgrade", + command: `${scopedPackageToolPath} upgrade`, - executable: "scoped-package-tool", + executable: scopedPackageToolPath, args: ["upgrade"], @@ -406,6 +413,50 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { }), ); + it.effect.skipIf(windowsHost)("runs an explicit native updater outside PATH", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-native-update-" }); + const nativeBinDir = NodePath.join(tempDir, "with spaces", ".scoped-package-tool", "bin"); + yield* fs.makeDirectory(nativeBinDir, { recursive: true }); + const binaryPath = NodePath.join(nativeBinDir, "scoped-package-tool"); + yield* fs.writeFileString(binaryPath, "#!/bin/sh\nprintf '%s' \"$1\"\n"); + yield* fs.chmod(binaryPath, 0o755); + + const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect( + scopedPackageToolUpdate, + { binaryPath, env: { PATH: "" } }, + ); + const update = capabilities.update; + expect(update).not.toBeNull(); + if (!update) return; + const result = NodeChildProcess.spawnSync(update.executable, update.args, { + env: { PATH: "" }, + encoding: "utf8", + }); + expect(result.error).toBeUndefined(); + expect(result.status).toBe(0); + expect(result.stdout).toBe("upgrade"); + }).pipe(Effect.scoped), + ); + + it.each([ + "/Users/example/.local/bin/native-package-tool", + "C:\\Users\\Example User\\.local\\bin\\native-package-tool.exe", + ])("preserves a configured native executable path: %s", (binaryPath) => { + expect(nativePackageToolUpdate.resolve({ binaryPath }).update?.executable).toBe(binaryPath); + }); + + it("uses the resolved launcher when its symlink target identifies a native install", () => { + const launcher = "/custom tools/native-launcher"; + const capabilities = nativePackageToolUpdate.resolve({ + binaryPath: launcher, + resolvedCommandPath: launcher, + realCommandPath: "/Users/example/.local/bin/native-package-tool", + }); + expect(capabilities.update?.executable).toBe(launcher); + }); + it("switches native-package-tool to Homebrew updates when the binary resolves through Homebrew", () => { expect( nativePackageToolUpdate.resolve({ @@ -452,100 +503,110 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { }); }); - it.effect("keeps npm updates for binaries symlinked into npm's global node_modules tree", () => - Effect.gen(function* () { - const tempDir = yield* makeTempDir("t3-npm-capabilities"); - const binDir = NodePath.join(tempDir, "bin"); - const packageBinDir = NodePath.join( - tempDir, - "lib", - "node_modules", - "@example", - "package-tool", - "bin", - ); - NodeFS.mkdirSync(binDir, { recursive: true }); - NodeFS.mkdirSync(packageBinDir, { recursive: true }); - const packageBinPath = NodePath.join(packageBinDir, "package-tool.js"); - const symlinkPath = NodePath.join(binDir, "package-tool"); - NodeFS.writeFileSync(packageBinPath, "#!/usr/bin/env node\n"); - NodeFS.chmodSync(packageBinPath, 0o755); - NodeFS.symlinkSync(packageBinPath, symlinkPath); - - const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(packageToolUpdate, { - binaryPath: symlinkPath, - env: { - PATH: "", - }, - }); + it.effect.skipIf(!symlinksSupported)( + "keeps npm updates for binaries symlinked into npm's global node_modules tree", + () => + Effect.gen(function* () { + const tempDir = yield* makeTempDir("t3-npm-capabilities"); + const binDir = NodePath.join(tempDir, "bin"); + const packageBinDir = NodePath.join( + tempDir, + "lib", + "node_modules", + "@example", + "package-tool", + "bin", + ); + NodeFS.mkdirSync(binDir, { recursive: true }); + NodeFS.mkdirSync(packageBinDir, { recursive: true }); + const packageBinPath = NodePath.join(packageBinDir, "package-tool.js"); + const symlinkPath = NodePath.join(binDir, "package-tool"); + NodeFS.writeFileSync(packageBinPath, "#!/usr/bin/env node\n"); + NodeFS.chmodSync(packageBinPath, 0o755); + NodeFS.symlinkSync(packageBinPath, symlinkPath); + + const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect( + packageToolUpdate, + { + binaryPath: symlinkPath, + env: { + PATH: "", + }, + }, + ); - expect(capabilities).toEqual({ - provider: driver("packageTool"), - packageName: "@example/package-tool", - update: { - command: - "npm install -g --allow-scripts=@example/package-tool @example/package-tool@latest", + expect(capabilities).toEqual({ + provider: driver("packageTool"), + packageName: "@example/package-tool", + update: { + command: + "npm install -g --allow-scripts=@example/package-tool @example/package-tool@latest", - executable: "npm", + executable: "npm", - args: [ - "install", - "-g", - "--allow-scripts=@example/package-tool", - "@example/package-tool@latest", - ], + args: [ + "install", + "-g", + "--allow-scripts=@example/package-tool", + "@example/package-tool@latest", + ], - lockKey: "npm-global", - }, - }); - }), + lockKey: "npm-global", + }, + }); + }), ); - it.effect("uses Effect FileSystem realPath when detecting pnpm global symlinks", () => - Effect.gen(function* () { - const tempDir = yield* makeTempDir("t3-pnpm-realpath-capabilities"); - const binDir = NodePath.join(tempDir, "bin"); - const packageBinDir = NodePath.join( - tempDir, - ".local", - "share", - "pnpm", - "global", - "5", - "node_modules", - "@example", - "package-tool", - "bin", - ); - NodeFS.mkdirSync(binDir, { recursive: true }); - NodeFS.mkdirSync(packageBinDir, { recursive: true }); - const packageBinPath = NodePath.join(packageBinDir, "package-tool.js"); - const symlinkPath = NodePath.join(binDir, "package-tool"); - NodeFS.writeFileSync(packageBinPath, "#!/usr/bin/env node\n"); - NodeFS.chmodSync(packageBinPath, 0o755); - NodeFS.symlinkSync(packageBinPath, symlinkPath); - - const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(packageToolUpdate, { - binaryPath: symlinkPath, - env: { - PATH: "", - }, - }); + it.effect.skipIf(!symlinksSupported)( + "uses Effect FileSystem realPath when detecting pnpm global symlinks", + () => + Effect.gen(function* () { + const tempDir = yield* makeTempDir("t3-pnpm-realpath-capabilities"); + const binDir = NodePath.join(tempDir, "bin"); + const packageBinDir = NodePath.join( + tempDir, + ".local", + "share", + "pnpm", + "global", + "5", + "node_modules", + "@example", + "package-tool", + "bin", + ); + NodeFS.mkdirSync(binDir, { recursive: true }); + NodeFS.mkdirSync(packageBinDir, { recursive: true }); + const packageBinPath = NodePath.join(packageBinDir, "package-tool.js"); + const symlinkPath = NodePath.join(binDir, "package-tool"); + NodeFS.writeFileSync(packageBinPath, "#!/usr/bin/env node\n"); + NodeFS.chmodSync(packageBinPath, 0o755); + NodeFS.symlinkSync(packageBinPath, symlinkPath); - expect(capabilities).toEqual({ - provider: driver("packageTool"), - packageName: "@example/package-tool", - update: { - command: "pnpm add -g @example/package-tool@latest", + const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect( + packageToolUpdate, + { + binaryPath: symlinkPath, + env: { + PATH: "", + }, + }, + ); + + expect(capabilities).toEqual({ + provider: driver("packageTool"), + packageName: "@example/package-tool", + update: { + command: "pnpm add -g @example/package-tool@latest", - executable: "pnpm", + executable: "pnpm", - args: ["add", "-g", "@example/package-tool@latest"], + args: ["add", "-g", "@example/package-tool@latest"], - lockKey: "pnpm-global", - }, - }); - }), + lockKey: "pnpm-global", + }, + }); + }), ); it("allows the package's own install scripts in npm global updates", () => { diff --git a/apps/server/src/provider/providerMaintenance.ts b/apps/server/src/provider/providerMaintenance.ts index 14d17cf365c3..c7a0bb112b69 100644 --- a/apps/server/src/provider/providerMaintenance.ts +++ b/apps/server/src/provider/providerMaintenance.ts @@ -209,6 +209,7 @@ function makeHomebrewProviderMaintenanceCapabilities( function makeNativeProviderMaintenanceCapabilities( definition: PackageManagedProviderMaintenanceDefinition, + commandPath: string, ): ProviderMaintenanceCapabilities | null { if (!definition.nativeUpdate) { return null; @@ -217,7 +218,7 @@ function makeNativeProviderMaintenanceCapabilities( return makeProviderMaintenanceCapabilities({ provider: definition.provider, packageName: definition.npmPackageName, - updateExecutable: definition.nativeUpdate.executable, + updateExecutable: commandPath, updateArgs: definition.nativeUpdate.args, updateLockKey: definition.nativeUpdate.lockKey, }); @@ -297,7 +298,7 @@ export function resolvePackageManagedProviderMaintenance( commandPaths.some((commandPath) => nativeUpdate.isCommandPath(commandPath)) ) { return ( - makeNativeProviderMaintenanceCapabilities(definition) ?? + makeNativeProviderMaintenanceCapabilities(definition, resolvedCommandPath) ?? makeNpmGlobalProviderMaintenanceCapabilities(definition) ); } diff --git a/apps/server/src/provider/providerSnapshot.test.ts b/apps/server/src/provider/providerSnapshot.test.ts index 011572780666..399d86f7a133 100644 --- a/apps/server/src/provider/providerSnapshot.test.ts +++ b/apps/server/src/provider/providerSnapshot.test.ts @@ -52,6 +52,27 @@ describe("providerModelsFromSettings", () => { ]); }); + it("keeps an entry's own name and capabilities over the driver default", () => { + const capabilities = createModelCapabilities({ + optionDescriptors: [{ id: "fastMode", label: "Fast Mode", type: "boolean" }], + }); + const models = providerModelsFromSettings( + [], + ["bare", { slug: "named", name: "Named", capabilities }], + OPENCODE_CUSTOM_MODEL_CAPABILITIES, + ); + + expect(models).toEqual([ + { + slug: "bare", + name: "bare", + isCustom: true, + capabilities: OPENCODE_CUSTOM_MODEL_CAPABILITIES, + }, + { slug: "named", name: "Named", isCustom: true, capabilities }, + ]); + }); + it("preserves a custom slug that collides with a provider alias", () => { const capabilities = createModelCapabilities({ optionDescriptors: [] }); const models = providerModelsFromSettings( diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index 55534629d3eb..9663aeacb63f 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -1,4 +1,5 @@ import type { + CustomModelSetting, ProviderDriverKind, ModelCapabilities, ServerProvider, @@ -14,7 +15,7 @@ import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { normalizeCustomModelSlug } from "@t3tools/shared/model"; +import { readCustomModelEntries } from "@t3tools/shared/model"; import { isWindowsCommandNotFound } from "../processRunner.ts"; import { createProviderVersionAdvisory } from "./providerMaintenance.ts"; import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; @@ -145,26 +146,30 @@ export function parseGenericCliVersion(output: string): string | null { return match?.[1] ?? null; } +/** + * Append the user's custom models after the built-ins. A custom entry that + * declares its own capabilities keeps them; a bare slug gets the driver's + * default set. Slugs that collide with a built-in are dropped. + */ export function providerModelsFromSettings( builtInModels: ReadonlyArray, - customModels: ReadonlyArray, + customModels: ReadonlyArray, customModelCapabilities: ModelCapabilities, ): ReadonlyArray { const resolvedBuiltInModels = [...builtInModels]; const seen = new Set(resolvedBuiltInModels.map((model) => model.slug)); const customEntries: ServerProviderModel[] = []; - for (const candidate of customModels) { - const normalized = normalizeCustomModelSlug(candidate); - if (!normalized || seen.has(normalized)) { + for (const entry of readCustomModelEntries(customModels)) { + if (seen.has(entry.slug)) { continue; } - seen.add(normalized); + seen.add(entry.slug); customEntries.push({ - slug: normalized, - name: normalized, + slug: entry.slug, + name: entry.name, isCustom: true, - capabilities: customModelCapabilities, + capabilities: entry.capabilities ?? customModelCapabilities, }); } diff --git a/apps/server/src/provider/testFixtures/codexCollabMockPeer.cmd b/apps/server/src/provider/testFixtures/codexCollabMockPeer.cmd new file mode 100644 index 000000000000..18220e2c7f69 --- /dev/null +++ b/apps/server/src/provider/testFixtures/codexCollabMockPeer.cmd @@ -0,0 +1,8 @@ +@echo off +rem Wrapper so CodexSessionRuntime can spawn the mock peer on Windows: the +rem runtime always passes "app-server" as the first argument; drop it and +rem run the .mjs peer with node. "shift /1" leaves %0 alone so %~dp0 still +rem names this file's directory. +shift /1 +node "%~dp0codexCollabMockPeer.mjs" %1 %2 %3 %4 %5 %6 %7 %8 %9 +exit /b %ERRORLEVEL% diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 1f2ff59a94d3..eb2f913c9206 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -1005,10 +1005,12 @@ it.effect("refuses an action the host never claimed it could run", () => }), ); -it.effect("publishes a successful merge for immediate settlement", () => +it.effect("publishes a merge for immediate settlement only after host confirmation", () => Effect.scoped( Effect.gen(function* () { const mergedAt = "2026-09-03T02:00:00.000Z"; + let state: "open" | "merged" = "open"; + let confirmationFails = false; const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; const service = yield* makeService({ projects: [ @@ -1016,7 +1018,17 @@ it.effect("publishes a successful merge for immediate settlement", () => ], providers: [ fakeProvider("github", { - runAction: () => TestClock.setTime(Date.parse(mergedAt)), + getChangeRequestSummary: () => + confirmationFails + ? Effect.fail( + new PullRequestProviderError({ + provider: "github", + operation: "getChangeRequestSummary", + reason: "failed", + detail: "HTTP 504", + }), + ) + : Effect.succeed({ ...changeRequest(1, mergedAt), state }), }), ], }); @@ -1025,6 +1037,13 @@ it.effect("publishes a successful merge for immediate settlement", () => Effect.forkChild({ startImmediately: true }), ); + // Queueing succeeds while the host still reports an open PR. + yield* service.runAction({ ...reference, action: "merge" }); + confirmationFails = true; + yield* service.runAction({ ...reference, action: "merge" }); + confirmationFails = false; + state = "merged"; + yield* TestClock.setTime(Date.parse(mergedAt)); yield* service.runAction({ ...reference, repository: " ACME/WEB ", @@ -1965,6 +1984,7 @@ it.effect("refuses a merge strategy the host does not offer", () => review: FULL_REVIEW, reviewers: FULL_REVIEWERS, }, + getChangeRequestSummary: () => Effect.succeed(changeRequest(1, "2026-07-02T00:00:00Z")), runAction: (input) => { ranWith = input.mergeMethod ?? "merge"; return Effect.void; @@ -3037,6 +3057,114 @@ it.effect("fills in the line counts for the rows it is given", () => ]); }), ); +it.effect( + "reuses counts across overlapping pages until expiry, explicit invalidation, or a reference changes", + () => + Effect.gen(function* () { + const asked: number[][] = []; + const ref = (number: number) => ({ + projectId: "p1" as ProjectId, + repository: "acme/web", + number, + }); + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), + ], + providers: [ + fakeProvider("github", { + listChangeRequestStats: (input) => { + asked.push(input.changeRequests.map((ref) => ref.number)); + return Effect.succeed( + input.changeRequests.map((ref) => ({ ...ref, additions: 12, deletions: 3 })), + ); + }, + }), + ], + }); + + yield* service.listStats({ refs: [ref(1), ref(2)] }); + const overlapping = yield* service.listStats({ refs: [ref(2), ref(3)] }); + assert.deepStrictEqual( + overlapping.stats.map((stat) => stat.number), + [2, 3], + ); + yield* service.listStats({ refs: [ref(1), ref(2), ref(3)] }); + assert.deepStrictEqual(asked, [[1, 2], [3]]); + + yield* service.invalidate({ reference: ref(2) }); + yield* service.listStats({ refs: [ref(1), ref(2), ref(3)] }); + assert.deepStrictEqual(asked, [[1, 2], [3], [2]]); + + yield* TestClock.adjust("61 seconds"); + yield* service.listStats({ refs: [ref(1), ref(2), ref(3)] }); + assert.deepStrictEqual(asked, [[1, 2], [3], [2], [1, 2, 3]]); + + yield* service.refreshAfterTurn; + yield* service.listStats({ refs: [ref(1)] }); + assert.deepStrictEqual(asked, [[1, 2], [3], [2], [1, 2, 3], [1]]); + + yield* service.invalidate({}); + yield* service.listStats({ refs: [ref(1)] }); + assert.deepStrictEqual(asked, [[1, 2], [3], [2], [1, 2, 3], [1], [1]]); + }), +); + +it.effect("reads the fresh diff when detail or summary discovers a changed revision", () => + Effect.gen(function* () { + const summaryStarted = yield* Deferred.make(); + const releaseSummary = yield* Deferred.make(); + let revision = "2026-07-02T00:00:00Z"; + let patch = "old patch"; + let diffCalls = 0; + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + getChangeRequest: () => + Effect.sync(() => ({ ...hostedChangeRequest("body"), updatedAt: revision })), + getChangeRequestSummary: () => + Effect.gen(function* () { + const result = changeRequest(1, revision); + yield* Deferred.succeed(summaryStarted, undefined); + yield* Deferred.await(releaseSummary); + return result; + }), + getDiff: () => + Effect.sync(() => { + diffCalls += 1; + return { patch, truncated: false, nextCursor: null }; + }), + }), + ], + }); + + const coldSummary = yield* service.summary(reference).pipe(Effect.forkChild()); + yield* Deferred.await(summaryStarted); + yield* service.detail(reference); + assert.strictEqual((yield* service.diff(reference)).patch, "old patch"); + revision = "2026-07-02T00:01:00Z"; + patch = "new patch"; + yield* TestClock.adjust("16 seconds"); + yield* service.detail(reference); + yield* Effect.yieldNow; + assert.strictEqual((yield* service.detail(reference)).updatedAt, revision); + yield* Deferred.succeed(releaseSummary, undefined); + yield* Fiber.join(coldSummary); + yield* service.summary(reference, { recoverTransientFailure: false }); + assert.strictEqual((yield* service.diff(reference)).patch, "new patch"); + assert.strictEqual(diffCalls, 2); + + revision = "2026-07-02T00:02:00Z"; + patch = "summary-discovered patch"; + yield* TestClock.adjust("61 seconds"); + yield* service.summary(reference, { recoverTransientFailure: false }); + assert.strictEqual((yield* service.diff(reference)).patch, patch); + assert.strictEqual(diffCalls, 3); + }), +); + it.effect("keeps the rows when the line counts cannot be read", () => Effect.gen(function* () { const service = yield* makeService({ @@ -3060,6 +3188,7 @@ it.effect( Effect.gen(function* () { let coreCalls = 0; let activityCalls = 0; + let statsCalls = 0; const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; const service = yield* makeService({ projects: [ @@ -3067,6 +3196,10 @@ it.effect( ], providers: [ fakeProvider("github", { + listChangeRequestStats: () => { + statsCalls += 1; + return Effect.succeed([]); + }, getChangeRequest: () => { coreCalls += 1; return Effect.succeed({ @@ -3106,6 +3239,16 @@ it.effect( assert.strictEqual(coreCalls, 1); assert.strictEqual(activityCalls, 0); + const counts = yield* service.listStats({ refs: [reference] }); + assert.strictEqual(statsCalls, 0); + assert.deepStrictEqual(counts.stats, [ + { + ...reference, + additions: core.additions, + deletions: core.deletions, + }, + ]); + yield* Effect.all([service.activity(reference), service.activity(reference)], { concurrency: 2, }); diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 6a37ed935848..a8d3dc2fdeaf 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -2136,6 +2136,21 @@ export const make = Effect.gen(function* () { Math.max(turnRefreshEpoch, refEpochs.get(refScope(ref)) ?? 0); const refCacheKey = (ref: PullRequestRef) => JSON.stringify([refEpoch(ref), ref.projectId, ref.repository, ref.number]); + // Counts belong to a PR, not a filtered page. Background reads and filter changes reuse + // them; explicit refreshes, mutations, and turns strand old and in-flight results. + const statsCacheKey = (key: string) => JSON.stringify([listingsEpoch, key]); + const recentStats = new Map< + string, + { readonly at: number; readonly value: PullRequestDiffStat } + >(); + const recordStats = (key: string, value: PullRequestDiffStat, at: number) => { + recentStats.delete(key); + recentStats.set(key, { at, value }); + if (recentStats.size > REF_EPOCH_CAPACITY) { + const oldest = recentStats.keys().next().value; + if (oldest !== undefined) recentStats.delete(oldest); + } + }; const bumpRefEpoch = (ref: PullRequestRef) => { const scope = refScope(ref); if (!refEpochs.has(scope) && refEpochs.size >= REF_EPOCH_CAPACITY) { @@ -2175,13 +2190,15 @@ export const make = Effect.gen(function* () { const summary: PullRequestService["Service"]["summary"] = (input, options) => { const key = refCacheKey(input); const cached = Cache.get(summaryCache, key); - if (options?.recoverTransientFailure !== false) { - return lastGoodSummary.serveHeld(key, cached, "reuse"); - } const held = lastGoodSummary.peek(key); - return held?.state === "merged" + return held !== undefined && + (options?.recoverTransientFailure !== false || held.state === "merged") ? Effect.succeed(held) - : cached.pipe(Effect.tap((value) => lastGoodSummary.record(key, value))); + : cached.pipe( + Effect.tap((value) => + shouldReplaceHeldSummary(key, value) ? lastGoodSummary.record(key, value) : Effect.void, + ), + ); }; // Keys serialize positionally and parse back in the lookup, so the cache is the only holder @@ -2263,8 +2280,25 @@ export const make = Effect.gen(function* () { const detailCache = yield* Cache.makeWith( (key: string) => { + const statsKey = statsCacheKey(key); const [, projectId, repository, number] = JSON.parse(key) as [number, string, string, number]; - return detailUncached({ projectId, repository, number } as PullRequestRef); + return detailUncached({ projectId, repository, number } as PullRequestRef).pipe( + Effect.tap( + Effect.fn("PullRequestService.recordDetailStats")(function* (value: PullRequestDetail) { + recordStats( + statsKey, + { + projectId: value.projectId, + repository: value.repository, + number: value.number, + additions: value.additions, + deletions: value.deletions, + }, + yield* Clock.currentTimeMillis, + ); + }), + ), + ); }, { capacity: DETAIL_CACHE_CAPACITY, @@ -2360,38 +2394,69 @@ export const make = Effect.gen(function* () { input.number, input.cursor ?? null, input.commit ?? null, + input.commit === undefined + ? (lastGoodSummary.peek(refCacheKey(input))?.updatedAt ?? null) + : null, ]); return staleDiff(key, Cache.get(diffCache, key)); }; const listStatsCache = yield* Cache.makeWith( (key: string) => { - const [, refs] = JSON.parse(key) as [number, ReadonlyArray<[string, string, number]>]; + const [, refs] = JSON.parse(key) as [number, ReadonlyArray<[string, string, number, number]>]; return listStatsUncached({ refs: refs.map(([projectId, repository, number]) => ({ projectId, repository, number })), - } as unknown as PullRequestListStatsInput); + } as unknown as PullRequestListStatsInput).pipe( + Effect.flatMap((result) => + Clock.currentTimeMillis.pipe(Effect.map((at) => ({ result, at }))), + ), + ); }, { capacity: LIST_STATS_CACHE_CAPACITY, timeToLive: (exit) => (Exit.isSuccess(exit) ? LIST_STATS_CACHE_TTL : Duration.zero), }, ); - // The stats read leans on the host's search API — the scarcest limit of them all — so it - // shares between clients like every other read. Refs are sorted so one page's worth of rows - // is one key however the client assembled them, and the listings epoch rides along so the - // refresh that forgets the listing forgets its decorations with it. - const listStats: PullRequestService["Service"]["listStats"] = (input) => { - if (input.refs.length === 0) return Effect.succeed({ stats: [] }); - const key = JSON.stringify([ + const statsBatchKey = (refs: Iterable) => + JSON.stringify([ listingsEpoch, - input.refs - .map((ref) => [ref.projectId, ref.repository, ref.number] as const) + [...refs] + .map((ref) => [ref.projectId, ref.repository, ref.number, refEpoch(ref)] as const) .toSorted((left, right) => `${left[0]} ${left[1]} ${left[2]}`.localeCompare(`${right[0]} ${right[1]} ${right[2]}`), ), ]); - return Cache.get(listStatsCache, key); - }; + // Exact batches share in-flight reads; overlapping pages reuse each row already fetched. + const listStats: PullRequestService["Service"]["listStats"] = Effect.fn( + "PullRequestService.listStats", + )(function* (input: PullRequestListStatsInput) { + if (input.refs.length === 0) return { stats: [] }; + const now = yield* Clock.currentTimeMillis; + const held: PullRequestDiffStat[] = []; + const missing = new Map(); + for (const ref of input.refs) { + const key = statsCacheKey(refCacheKey(ref)); + const cached = recentStats.get(key); + if (cached !== undefined && now - cached.at < Duration.toMillis(LIST_STATS_CACHE_TTL)) { + held.push(cached.value); + } else { + missing.set(key, ref); + } + } + if (missing.size === 0) return { stats: held }; + const key = statsBatchKey(missing.values()); + const { result, at } = yield* Cache.get(listStatsCache, key); + for (const [key, ref] of missing) { + const stat = result.stats.find( + (stat) => + stat.projectId === ref.projectId && + stat.repository.toLowerCase() === ref.repository.toLowerCase() && + stat.number === ref.number, + ); + if (stat !== undefined) recordStats(key, stat, at); + } + return { stats: [...held, ...result.stats] }; + }); const invalidate: PullRequestService["Service"]["invalidate"] = (input) => { const reference = input.reference; @@ -2432,6 +2497,15 @@ export const make = Effect.gen(function* () { bumpRefEpoch({ ...input, repository }); listingsEpoch = ++epochCounter; if (input.action === "merge") { + // A successful merge action can merely enqueue the PR or enable auto-merge. + const confirmed = yield* summaryUncached({ ...input, repository }).pipe( + Effect.catch((error) => + Effect.logWarning("failed to confirm pull request merge", { error }).pipe( + Effect.as(null), + ), + ), + ); + if (confirmed?.state !== "merged") return; yield* PubSub.publish(mergedPullRequests, { projectId: input.projectId, repository, diff --git a/apps/server/src/resourceTelemetry/ResourceMonitorBinary.test.ts b/apps/server/src/resourceTelemetry/ResourceMonitorBinary.test.ts index 243556b6e3b0..7fa23aac3908 100644 --- a/apps/server/src/resourceTelemetry/ResourceMonitorBinary.test.ts +++ b/apps/server/src/resourceTelemetry/ResourceMonitorBinary.test.ts @@ -11,6 +11,10 @@ import * as FileSystem from "effect/FileSystem"; import { ServerConfig } from "../config.ts"; import * as ResourceMonitorBinary from "./ResourceMonitorBinary.ts"; +// The override checks POSIX exec bits on a real file under a linux platform +// mock; NTFS never reports those bits, so the check cannot be satisfied there. +const windowsHost = HostProcessPlatform.defaultValue() === "win32"; + describe("ResourceMonitorBinary", () => { afterEach(() => { vi.restoreAllMocks(); @@ -42,7 +46,7 @@ describe("ResourceMonitorBinary", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); - it.effect("resolves an executable override", () => + it.effect.skipIf(windowsHost)("resolves an executable override", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const baseDir = yield* fileSystem.makeTempDirectoryScoped({ @@ -66,7 +70,7 @@ describe("ResourceMonitorBinary", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); - it.effect("resolves an executable override on an unsupported platform", () => + it.effect.skipIf(windowsHost)("resolves an executable override on an unsupported platform", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const baseDir = yield* fileSystem.makeTempDirectoryScoped({ diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index c98bc07fcd28..92def1774e74 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -199,6 +199,7 @@ import { type TransferBudgetRun, transferBudgetViolations, } from "../integration/TransferBudgetReport.integration.ts"; +import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; const defaultProjectId = ProjectId.make("project-default"); const defaultThreadId = ThreadId.make("thread-default"); @@ -1854,12 +1855,18 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const staticDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-static-replace-" }); const beforeOpenPath = path.join(staticDir, "before-open.txt"); const afterOpenPath = path.join(staticDir, "after-open.txt"); + const afterOpenSnapshotPath = path.join(staticDir, "after-open-snapshot.txt"); + const windowsHost = HostProcessPlatform.defaultValue() === "win32"; const original = "original bytes"; const replacement = "replacement bytes with a different size"; for (const filePath of [beforeOpenPath, afterOpenPath]) { yield* fileSystem.writeFileString(filePath, original); yield* fileSystem.writeFileString(`${filePath}.next`, replacement); } + if (windowsHost) { + // Windows cannot replace an open destination, so model the race with its original handle. + yield* fileSystem.writeFileString(afterOpenSnapshotPath, original); + } const replaced = new Set(); const replaceOnce = Effect.fnUntraced(function* (filePath: string) { if (replaced.has(filePath)) return; @@ -1876,7 +1883,10 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ), open: (filePath, options) => fileSystem - .open(filePath, options) + .open( + filePath === afterOpenPath && windowsHost ? afterOpenSnapshotPath : filePath, + options, + ) .pipe( Effect.tap(() => (filePath === afterOpenPath ? replaceOnce(filePath) : Effect.void)), ), @@ -6311,7 +6321,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive), ); - it.effect("preserves structured workspace rpc failures", () => + it.effect.skipIf(!symlinksSupported)("preserves structured workspace rpc failures", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; diff --git a/apps/server/src/serverRuntimeStartup.reconcile.test.ts b/apps/server/src/serverRuntimeStartup.reconcile.test.ts index 39c284330d46..2c95a6163acd 100644 --- a/apps/server/src/serverRuntimeStartup.reconcile.test.ts +++ b/apps/server/src/serverRuntimeStartup.reconcile.test.ts @@ -1,6 +1,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { type OrchestrationCommand, + type OrchestrationSessionStatus, ProviderDriverKind, ProviderInstanceId, type ProviderSendTurnInput, @@ -10,15 +11,21 @@ import { import { assert, it } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Stream from "effect/Stream"; import { OrchestrationCommandInvariantError } from "./orchestration/Errors.ts"; import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; -import { ProviderSessionDirectoryPersistenceError } from "./provider/Errors.ts"; +import { + ProviderSessionDirectoryPersistenceError, + ProviderSessionNotFoundError, +} from "./provider/Errors.ts"; import * as ProviderService from "./provider/Services/ProviderService.ts"; import * as ProviderSessionDirectory from "./provider/Services/ProviderSessionDirectory.ts"; +import { ServerActivation } from "./serverActivation.ts"; +import * as ServerSettings from "./serverSettings.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; const providerInstanceId = ProviderInstanceId.make("codex"); @@ -26,7 +33,7 @@ const updatedAt = "2026-08-20T12:00:00.000Z"; const makeThread = ( id: string, - status: "starting" | "running" | "ready" | "stopped" | "error", + status: OrchestrationSessionStatus, activeTurnId: TurnId | null = null, archivedAt: string | null = null, deletedAt: string | null = null, @@ -73,6 +80,7 @@ const queryWithThreads = (threads: ReadonlyArray>) const runReconciliation = (input: { readonly threads: ReadonlyArray>; + readonly continueAfterRestart?: boolean; readonly liveThreadIds?: ReadonlyArray; readonly providerService?: ProviderService.ProviderService["Service"]; readonly directory: ProviderSessionDirectory.ProviderSessionDirectory["Service"]; @@ -97,7 +105,14 @@ const runReconciliation = (input: { subscribeDomainEvents: Effect.succeed(Stream.empty), latestSequence: Effect.succeed(0), }), - Effect.provide(NodeServices.layer), + Effect.provide( + Layer.mergeAll( + ServerSettings.layerTest({ + continueThreadsAfterServerUpdate: input.continueAfterRestart ?? false, + }), + NodeServices.layer, + ), + ), ); it.effect("marks active running sessions that have persisted resume state", () => { @@ -138,7 +153,7 @@ it.effect("marks active running sessions that have persisted resume state", () = upsert: (binding) => Effect.sync(() => upserts.push(binding)), getProvider: () => Effect.die("unused"), listThreadIds: () => Effect.die("unused"), - listBindings: () => Effect.die("unused"), + listBindings: () => Effect.succeed([]), }), Effect.tap((marked) => Effect.sync(() => { @@ -147,169 +162,186 @@ it.effect("marks active running sessions that have persisted resume state", () = assert.deepStrictEqual(upserts[0]?.runtimePayload, { activeTurnId: "turn-mark-active", continueAfterServerUpdate: active.session.activeTurnId, + continueAfterServerUpdatePrepared: null, }); }), ), ); }); -it.effect("continues marked sessions after activation with provider-specific input", () => - Effect.gen(function* () { - const codex = makeThread( - "thread-continue-codex", - "running", - TurnId.make("turn-continue-codex"), - ); - const fallback = makeThread("thread-continue-fallback", "starting"); - const fallbackContinuationTurnId = TurnId.make("turn-continue-fallback"); - const fallbackProviderInstanceId = ProviderInstanceId.make("claudeAgent"); - const continuationSent = yield* Deferred.make(); - const continuationCleared = yield* Deferred.make(); - const sends: ProviderSendTurnInput[] = []; - const dispatched: OrchestrationCommand[] = []; - const upserts: ProviderSessionDirectory.ProviderRuntimeBinding[] = []; - const bindings = new Map( - [codex, fallback].map((thread) => [ - thread.id, - { - threadId: thread.id, - provider: - thread.id === codex.id - ? ProviderDriverKind.make("codex") - : ProviderDriverKind.make("claudeAgent"), - providerInstanceId: - thread.id === codex.id ? providerInstanceId : fallbackProviderInstanceId, - status: "running" as const, - runtimePayload: { - continueAfterServerUpdate: - thread.id === codex.id ? codex.session.activeTurnId : fallbackContinuationTurnId, +it.effect.each(["marked update", "opt-in restart"] as const)( + "continues %s sessions after activation with provider-specific input", + (recovery) => + Effect.gen(function* () { + const codex = makeThread( + "thread-continue-codex", + "running", + TurnId.make("turn-continue-codex"), + ); + const fallbackContinuationTurnId = TurnId.make("turn-continue-fallback"); + const fallback = makeThread( + "thread-continue-fallback", + recovery === "marked update" ? "starting" : "running", + recovery === "marked update" ? null : fallbackContinuationTurnId, + ); + const fallbackProviderInstanceId = ProviderInstanceId.make("claudeAgent"); + const continuationSent = yield* Deferred.make(); + const continuationCleared = yield* Deferred.make(); + const sends: ProviderSendTurnInput[] = []; + const dispatched: OrchestrationCommand[] = []; + const upserts: ProviderSessionDirectory.ProviderRuntimeBinding[] = []; + const bindings = new Map( + [codex, fallback].map((thread) => [ + thread.id, + { + threadId: thread.id, + provider: + thread.id === codex.id + ? ProviderDriverKind.make("codex") + : ProviderDriverKind.make("claudeAgent"), + providerInstanceId: + thread.id === codex.id ? providerInstanceId : fallbackProviderInstanceId, + status: "running" as const, + resumeCursor: { threadId: thread.id }, + runtimePayload: + recovery === "marked update" + ? { + continueAfterServerUpdate: + thread.id === codex.id + ? codex.session.activeTurnId + : fallbackContinuationTurnId, + } + : { activeTurnId: thread.session.activeTurnId }, }, - }, - ]), - ); - const providerService: ProviderService.ProviderService["Service"] = { - ...makeProviderService(), - getCapabilities: (instanceId) => - Effect.succeed({ - sessionModelSwitch: "in-session", - ...(instanceId === providerInstanceId ? { promptlessTurnContinuation: true } : {}), - }), - sendTurn: (input) => - Effect.gen(function* () { - sends.push(input); - if (sends.length === 2) { - yield* Deferred.succeed(continuationSent, undefined); - } - return { - threadId: input.threadId, - turnId: TurnId.make(`continued-${String(input.threadId)}`), - }; - }), - }; - - yield* runReconciliation({ - threads: [codex, fallback], - providerService, - directory: { - getBinding: (threadId) => - Effect.sync(() => { - const binding = bindings.get(threadId); - return binding === undefined ? Option.none() : Option.some(binding); + ]), + ); + const providerService: ProviderService.ProviderService["Service"] = { + ...makeProviderService(), + getCapabilities: (instanceId) => + Effect.succeed({ + sessionModelSwitch: "in-session", + ...(instanceId === providerInstanceId ? { promptlessTurnContinuation: true } : {}), }), - upsert: (binding) => - Effect.sync(() => { - bindings.set(binding.threadId, binding); - upserts.push(binding); - const clearedCount = upserts.filter((candidate) => { - const payload = candidate.runtimePayload; - return ( - payload !== null && - typeof payload === "object" && - !Array.isArray(payload) && - "continueAfterServerUpdate" in payload && - payload.continueAfterServerUpdate === null - ); - }).length; - return clearedCount === 1; - }).pipe( - Effect.flatMap((firstMarkerCleared) => - firstMarkerCleared ? Deferred.succeed(continuationCleared, undefined) : Effect.void, + sendTurn: (input) => + Effect.gen(function* () { + sends.push(input); + if (sends.length === 2) { + yield* Deferred.succeed(continuationSent, undefined); + } + return { + threadId: input.threadId, + turnId: TurnId.make(`continued-${String(input.threadId)}`), + }; + }), + }; + + yield* runReconciliation({ + threads: [codex, fallback], + continueAfterRestart: recovery === "opt-in restart", + providerService, + directory: { + getBinding: (threadId) => + Effect.sync(() => { + const binding = bindings.get(threadId); + return binding === undefined ? Option.none() : Option.some(binding); + }), + upsert: (binding) => + Effect.sync(() => { + bindings.set(binding.threadId, binding); + upserts.push(binding); + const clearedCount = upserts.filter((candidate) => { + const payload = candidate.runtimePayload; + return ( + payload !== null && + typeof payload === "object" && + !Array.isArray(payload) && + "continueAfterServerUpdate" in payload && + payload.continueAfterServerUpdate === null + ); + }).length; + return clearedCount === 1; + }).pipe( + Effect.flatMap((firstMarkerCleared) => + firstMarkerCleared ? Deferred.succeed(continuationCleared, undefined) : Effect.void, + ), ), + getProvider: () => Effect.die("unused"), + listThreadIds: () => Effect.die("unused"), + listBindings: () => Effect.succeed([]), + }, + dispatch: (command) => + Effect.sync(() => dispatched.push(command)).pipe( + Effect.as({ sequence: dispatched.length }), ), - getProvider: () => Effect.die("unused"), - listThreadIds: () => Effect.die("unused"), - listBindings: () => Effect.die("unused"), - }, - dispatch: (command) => - Effect.sync(() => dispatched.push(command)).pipe( - Effect.as({ sequence: dispatched.length }), - ), - }); - yield* Deferred.await(continuationSent); - yield* Deferred.await(continuationCleared); + }); + yield* Deferred.await(continuationSent); + yield* Deferred.await(continuationCleared); - assert.deepStrictEqual( - sends.toSorted((left, right) => String(left.threadId).localeCompare(String(right.threadId))), - [ - { threadId: codex.id, continuation: true, interactionMode: "default" }, - { - threadId: fallback.id, - input: "Continue where you left off.", - interactionMode: "default", - }, - ], - ); - assert.deepStrictEqual( - dispatched.map((command) => - command.type === "thread.session.set" - ? { - threadId: command.threadId, - status: command.session.status, - activeTurnId: command.session.activeTurnId, - } - : null, - ), - [ - { - threadId: codex.id, - status: "starting", - activeTurnId: null, - }, - { - threadId: fallback.id, - status: "starting", - activeTurnId: fallback.session.activeTurnId, - }, - ], - ); - for (const [thread, continuationTurnId] of [ - [codex, codex.session.activeTurnId], - [fallback, fallbackContinuationTurnId], - ] as const) { assert.deepStrictEqual( - upserts - .filter((binding) => binding.threadId === thread.id) - .map((binding) => binding.runtimePayload)[0], - { - continueAfterServerUpdate: continuationTurnId, - activeTurnId: null, - }, + sends.toSorted((left, right) => + String(left.threadId).localeCompare(String(right.threadId)), + ), + [ + { threadId: codex.id, continuation: true, interactionMode: "default" }, + { + threadId: fallback.id, + input: "Continue where you left off.", + interactionMode: "default", + }, + ], + ); + assert.deepStrictEqual( + dispatched.map((command) => + command.type === "thread.session.set" + ? { + threadId: command.threadId, + status: command.session.status, + activeTurnId: command.session.activeTurnId, + } + : null, + ), + [ + { + threadId: codex.id, + status: "starting", + activeTurnId: null, + }, + { + threadId: fallback.id, + status: "starting", + activeTurnId: null, + }, + ], ); - } - assert.equal( - upserts.some((binding) => { - const payload = binding.runtimePayload; - return ( - payload !== null && - typeof payload === "object" && - !Array.isArray(payload) && - "continueAfterServerUpdate" in payload && - payload.continueAfterServerUpdate === null + for (const [thread, continuationTurnId] of [ + [codex, codex.session.activeTurnId], + [fallback, fallbackContinuationTurnId], + ] as const) { + assert.deepStrictEqual( + upserts + .filter((binding) => binding.threadId === thread.id) + .map((binding) => binding.runtimePayload)[0], + { + continueAfterServerUpdate: continuationTurnId, + continueAfterServerUpdatePrepared: true, + activeTurnId: null, + }, ); - }), - true, - ); - }), + } + assert.equal( + upserts.some((binding) => { + const payload = binding.runtimePayload; + return ( + payload !== null && + typeof payload === "object" && + !Array.isArray(payload) && + "continueAfterServerUpdate" in payload && + payload.continueAfterServerUpdate === null + ); + }), + true, + ); + }), ); it.effect("does not continue archived or deleted marked sessions", () => { @@ -361,7 +393,7 @@ it.effect("does not continue archived or deleted marked sessions", () => { upsert: () => Effect.void, getProvider: () => Effect.die("unused"), listThreadIds: () => Effect.die("unused"), - listBindings: () => Effect.die("unused"), + listBindings: () => Effect.succeed([]), }, dispatch: (command) => Effect.sync(() => dispatched.push(command)).pipe(Effect.as({ sequence: dispatched.length })), @@ -416,7 +448,7 @@ it.effect("retries continuation preparation before settling a persistent failure upsert: () => Effect.void, getProvider: () => Effect.die("unused"), listThreadIds: () => Effect.die("unused"), - listBindings: () => Effect.die("unused"), + listBindings: () => Effect.succeed([]), }, dispatch: (command) => { if (command.type !== "thread.session.set") { @@ -487,7 +519,7 @@ it.effect("reconciles multiple active and archived orphans but skips live sessio upsert: (binding) => Effect.sync(() => upserts.push(binding)), getProvider: () => Effect.die("unused"), listThreadIds: () => Effect.die("unused"), - listBindings: () => Effect.die("unused"), + listBindings: () => Effect.succeed([]), }, dispatch: (command) => Effect.sync(() => dispatched.push(command)).pipe(Effect.as({ sequence: dispatched.length })), @@ -521,6 +553,7 @@ it.effect("reconciles multiple active and archived orphans but skips live sessio activeTurnId: null, unrelated: binding.threadId, continueAfterServerUpdate: null, + continueAfterServerUpdatePrepared: null, } : { activeTurnId: null, unrelated: binding.threadId }, ); @@ -565,7 +598,7 @@ it.effect( upsert: () => Effect.fail(writeFailure), getProvider: () => Effect.die("unused"), listThreadIds: () => Effect.die("unused"), - listBindings: () => Effect.die("unused"), + listBindings: () => Effect.succeed([]), }, dispatch: (command) => Effect.sync(() => dispatched.push(command)).pipe( @@ -602,7 +635,7 @@ it.effect("retries failed projections and continues after a persistent failure", upsert: () => Effect.void, getProvider: () => Effect.die("unused"), listThreadIds: () => Effect.die("unused"), - listBindings: () => Effect.die("unused"), + listBindings: () => Effect.succeed([]), }, dispatch: (command) => { if (command.type !== "thread.session.set") { @@ -651,7 +684,7 @@ it.effect("does not fail startup when the live provider session inventory cannot upsert: () => Effect.die("unused"), getProvider: () => Effect.die("unused"), listThreadIds: () => Effect.die("unused"), - listBindings: () => Effect.die("unused"), + listBindings: () => Effect.succeed([]), }), Effect.provideService(OrchestrationEngine.OrchestrationEngineService, { readEvents: () => Stream.empty, @@ -662,7 +695,283 @@ it.effect("does not fail startup when the live provider session inventory cannot subscribeDomainEvents: Effect.succeed(Stream.empty), latestSequence: Effect.succeed(0), }), - Effect.provide(NodeServices.layer), + Effect.provide(Layer.mergeAll(NodeServices.layer, ServerSettings.layerTest())), Effect.tap(() => Effect.sync(() => assert.equal(queried, false))), ); }); + +for (const scenario of [ + "disabled", + "stopped projection", + "finished projection", + "stopped binding", + "finished binding", + "missing cursor", + "mismatched turn", + "marked without cursor", + "marked stopped projection", + "marked superseded turn", +] as const) { + it.effect(`does not recover an interrupted session with ${scenario}`, () => { + const turnId = TurnId.make("turn-excluded-recovery"); + const thread = makeThread( + "thread-excluded-recovery", + scenario.includes("stopped projection") + ? "stopped" + : scenario === "finished projection" + ? "ready" + : scenario === "marked superseded turn" + ? "starting" + : "running", + scenario === "marked superseded turn" ? null : turnId, + ); + const dispatched: OrchestrationCommand[] = []; + const upserts: ProviderSessionDirectory.ProviderRuntimeBinding[] = []; + return runReconciliation({ + threads: [thread], + continueAfterRestart: scenario !== "disabled", + directory: { + getBinding: () => + Effect.succeed( + Option.some({ + threadId: thread.id, + provider: ProviderDriverKind.make("codex"), + providerInstanceId, + status: scenario === "stopped binding" ? "stopped" : "running", + ...(scenario.includes("cursor") ? {} : { resumeCursor: { threadId: thread.id } }), + runtimePayload: { + activeTurnId: + scenario === "finished binding" + ? null + : scenario === "mismatched turn" || scenario === "marked superseded turn" + ? "another-turn" + : turnId, + ...(scenario.startsWith("marked") ? { continueAfterServerUpdate: turnId } : {}), + }, + }), + ), + upsert: (binding) => + Effect.sync(() => { + upserts.push(binding); + }), + getProvider: () => Effect.die("unused"), + listThreadIds: () => Effect.die("unused"), + listBindings: () => Effect.succeed([]), + }, + dispatch: (command) => + Effect.sync(() => { + dispatched.push(command); + return { sequence: dispatched.length }; + }), + }).pipe( + Effect.tap(() => + Effect.sync(() => { + assert.deepStrictEqual( + dispatched.map( + (command) => command.type === "thread.session.set" && command.session.status, + ), + ["error"], + ); + assert.deepStrictEqual( + upserts.map((binding) => binding.status), + ["stopped"], + ); + }), + ), + ); + }); +} + +for (const preparedStatus of [ + "starting", + "ready", + "ready with failed scan", + "completed after update marking", +] as const) { + it.effect(`recovers again if startup exits with a prepared ${preparedStatus} session`, () => + Effect.gen(function* () { + const turnId = TurnId.make("turn-interrupted-startup"); + const thread = makeThread("thread-interrupted-startup", "running", turnId); + const activation = yield* Deferred.make(); + const cleared = yield* Deferred.make(); + const sends: ProviderSendTurnInput[] = []; + let binding: ProviderSessionDirectory.ProviderRuntimeBinding = { + threadId: thread.id, + provider: ProviderDriverKind.make("codex"), + providerInstanceId, + status: "running", + resumeCursor: { threadId: thread.id }, + runtimePayload: { activeTurnId: turnId }, + }; + const input = { + threads: [thread], + continueAfterRestart: true, + providerService: { + ...makeProviderService(), + getCapabilities: () => + Effect.succeed({ + sessionModelSwitch: "in-session" as const, + promptlessTurnContinuation: true, + }), + sendTurn: (input: ProviderSendTurnInput) => + Effect.sync(() => { + sends.push(input); + return { threadId: input.threadId, turnId: TurnId.make("turn-recovered") }; + }), + }, + directory: { + getBinding: () => Effect.sync(() => Option.some(binding)), + upsert: (next: ProviderSessionDirectory.ProviderRuntimeBinding) => + Effect.gen(function* () { + binding = next; + if (binding.status !== "starting" || sends.length === 0) return; + yield* Deferred.succeed(cleared, undefined); + }), + getProvider: () => Effect.die("unused"), + listThreadIds: () => Effect.die("unused"), + listBindings: () => + preparedStatus === "ready with failed scan" + ? Effect.fail( + new ProviderSessionDirectoryPersistenceError({ + operation: "listBindings", + detail: "unreadable unrelated binding", + }), + ) + : Effect.sync(() => [{ ...binding, lastSeenAt: "2026-01-01T00:00:00.000Z" }]), + }, + dispatch: (command: OrchestrationCommand) => + Effect.sync(() => { + if (command.type === "thread.session.set") { + thread.session.status = command.session.status; + thread.session.activeTurnId = command.session.activeTurnId; + } + return { sequence: 1 }; + }), + }; + + yield* runReconciliation(input).pipe( + Effect.provideService(ServerActivation, Deferred.await(activation)), + Effect.scoped, + ); + assert.deepStrictEqual(sends, []); + assert.equal(thread.session.status, "starting"); + assert.equal(thread.session.activeTurnId, null); + assert.deepStrictEqual(binding.runtimePayload, { + activeTurnId: null, + continueAfterServerUpdate: turnId, + continueAfterServerUpdatePrepared: true, + }); + + if (preparedStatus === "completed after update marking") { + thread.session.status = "ready"; + binding = { + ...binding, + status: "stopped", + runtimePayload: { + activeTurnId: null, + continueAfterServerUpdate: turnId, + continueAfterServerUpdatePrepared: null, + }, + }; + yield* runReconciliation(input); + assert.deepStrictEqual(sends, []); + assert.equal(thread.session.status, "ready"); + return; + } + thread.session.status = + preparedStatus === "ready with failed scan" ? "ready" : preparedStatus; + yield* runReconciliation(input); + yield* Deferred.await(cleared); + assert.deepStrictEqual(sends, [ + { threadId: thread.id, continuation: true, interactionMode: "default" }, + ]); + assert.deepStrictEqual(binding.runtimePayload, { + activeTurnId: null, + continueAfterServerUpdate: null, + continueAfterServerUpdatePrepared: null, + }); + }), + ); +} + +it.effect("settles failed opt-in recovery without retrying the provider turn", () => + Effect.gen(function* () { + const turnId = TurnId.make("turn-failed-recovery"); + const thread = makeThread("thread-failed-recovery", "running", turnId); + const settled = yield* Deferred.make(); + const sends: ProviderSendTurnInput[] = []; + const dispatched: OrchestrationCommand[] = []; + const preparedPayloads: unknown[] = []; + let binding: ProviderSessionDirectory.ProviderRuntimeBinding = { + threadId: thread.id, + provider: ProviderDriverKind.make("codex"), + providerInstanceId, + status: "running", + resumeCursor: { threadId: thread.id }, + runtimePayload: { activeTurnId: turnId }, + }; + yield* runReconciliation({ + threads: [thread], + continueAfterRestart: true, + providerService: { + ...makeProviderService(), + getCapabilities: () => + Effect.succeed({ sessionModelSwitch: "in-session", promptlessTurnContinuation: true }), + sendTurn: (input) => + Effect.gen(function* () { + sends.push(input); + preparedPayloads.push(binding.runtimePayload); + return yield* Effect.fail( + new ProviderSessionNotFoundError({ threadId: input.threadId }), + ); + }), + }, + directory: { + getBinding: () => Effect.sync(() => Option.some(binding)), + upsert: (next) => + Effect.sync(() => { + binding = next; + }), + getProvider: () => Effect.die("unused"), + listThreadIds: () => Effect.die("unused"), + listBindings: () => Effect.succeed([]), + }, + dispatch: (command) => + Effect.gen(function* () { + dispatched.push(command); + if (command.type === "thread.session.set" && command.session.status === "error") { + yield* Deferred.succeed(settled, undefined); + } + return { sequence: dispatched.length }; + }), + }); + yield* Deferred.await(settled); + assert.equal(sends.length, 1); + assert.deepStrictEqual(preparedPayloads, [ + { + activeTurnId: null, + continueAfterServerUpdate: turnId, + continueAfterServerUpdatePrepared: true, + }, + ]); + assert.deepStrictEqual( + dispatched.map( + (command) => + command.type === "thread.session.set" && { + status: command.session.status, + activeTurnId: command.session.activeTurnId, + }, + ), + [ + { status: "starting", activeTurnId: null }, + { status: "error", activeTurnId: null }, + ], + ); + assert.equal(binding.status, "stopped"); + assert.deepStrictEqual(binding.runtimePayload, { + activeTurnId: null, + continueAfterServerUpdate: null, + continueAfterServerUpdatePrepared: null, + }); + }), +); diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 064796b2810b..a34d1bdbde91 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -394,6 +394,7 @@ export const markRunningProviderSessionsForContinuation = Effect.gen(function* ( runtimePayload: { ...readRuntimePayload(binding.value.runtimePayload), [SERVER_UPDATE_CONTINUATION_KEY]: activeTurnId, + continueAfterServerUpdatePrepared: null, }, }); marked.push(thread.id); @@ -423,6 +424,7 @@ const clearContinuationMarkers = ( runtimePayload: { ...readRuntimePayload(binding.runtimePayload), [SERVER_UPDATE_CONTINUATION_KEY]: null, + continueAfterServerUpdatePrepared: null, }, }), }), @@ -443,17 +445,56 @@ export const reconcileProviderSessions = Effect.gen(function* () { const orchestrationEngine = yield* OrchestrationEngine.OrchestrationEngineService; const providerService = yield* ProviderService.ProviderService; const query = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const settings = yield* ServerSettings.ServerSettingsService; + const continueAfterRestart = yield* settings.getSettings.pipe( + Effect.map((value) => value.continueThreadsAfterServerUpdate), + Effect.catch((cause) => + Effect.logWarning("could not read restart continuation preference", { cause }).pipe( + Effect.as(false), + ), + ), + ); const liveThreadIds = new Set( (yield* providerService.listSessions()).map((session) => session.threadId), ); const { threads } = yield* query.getCommandReadModel(); + // Provider startup can report ready before the continuation is submitted. + // Find those markers in one read rather than querying every idle thread. + const preparedThreadIds = new Set( + (yield* directory.listBindings().pipe( + Effect.catch((cause) => + Effect.logWarning("failed to read prepared provider continuations", { cause }).pipe( + Effect.andThen( + Effect.forEach( + threads.filter( + (thread) => thread.session?.status === "ready" && !liveThreadIds.has(thread.id), + ), + (thread) => + directory.getBinding(thread.id).pipe(Effect.orElseSucceed(() => Option.none())), + ), + ), + Effect.map((bindings) => + bindings.flatMap((binding) => (Option.isSome(binding) ? [binding.value] : [])), + ), + ), + ), + )) + .filter( + (binding) => + readServerUpdateContinuationTurnId(binding.runtimePayload) !== null && + readRuntimePayload(binding.runtimePayload).activeTurnId === null && + readRuntimePayload(binding.runtimePayload).continueAfterServerUpdatePrepared === true, + ) + .map((binding) => binding.threadId), + ); const orphanedThreads = threads.filter( (thread) => thread.session !== null && (thread.session.status === "starting" || thread.session.status === "running" || - thread.session.activeTurnId !== null) && + thread.session.activeTurnId !== null || + (thread.session.status === "ready" && preparedThreadIds.has(thread.id))) && !liveThreadIds.has(thread.id), ); @@ -479,7 +520,27 @@ export const reconcileProviderSessions = Effect.gen(function* () { : null; const continuationMarked = continuationTurnId !== null && - (session.activeTurnId === null || continuationTurnId === session.activeTurnId); + (session.activeTurnId === null || continuationTurnId === session.activeTurnId) && + Option.isSome(binding) && + (readRuntimePayload(binding.value.runtimePayload).activeTurnId == null || + readRuntimePayload(binding.value.runtimePayload).activeTurnId === continuationTurnId); + const preparedWhileReady = + session.status === "ready" && + session.activeTurnId === null && + continuationMarked && + Option.isSome(binding) && + readRuntimePayload(binding.value.runtimePayload).activeTurnId === null && + readRuntimePayload(binding.value.runtimePayload).continueAfterServerUpdatePrepared === true; + // Abrupt shutdowns cannot write an update marker. Require both durable + // records to agree on an unfinished turn before recovering one implicitly. + const interruptedByRestart = + continueAfterRestart && + session.status === "running" && + session.activeTurnId !== null && + Option.isSome(binding) && + binding.value.status === "running" && + binding.value.resumeCursor != null && + readRuntimePayload(binding.value.runtimePayload).activeTurnId === session.activeTurnId; const settleAsError = (lastError: string) => Effect.gen(function* () { yield* Effect.gen(function* () { @@ -490,7 +551,12 @@ export const reconcileProviderSessions = Effect.gen(function* () { runtimePayload: { ...readRuntimePayload(binding.value.runtimePayload), activeTurnId: null, - ...(continuationMarkerPresent ? { [SERVER_UPDATE_CONTINUATION_KEY]: null } : {}), + ...(continuationMarkerPresent || interruptedByRestart + ? { + [SERVER_UPDATE_CONTINUATION_KEY]: null, + continueAfterServerUpdatePrepared: null, + } + : {}), }, }); } @@ -535,7 +601,9 @@ export const reconcileProviderSessions = Effect.gen(function* () { if ( Option.isSome(binding) && - continuationMarked && + (continuationMarked || interruptedByRestart) && + (session.status === "running" || session.status === "starting" || preparedWhileReady) && + binding.value.resumeCursor != null && thread.archivedAt === null && thread.deletedAt === null ) { @@ -545,6 +613,9 @@ export const reconcileProviderSessions = Effect.gen(function* () { status: "starting", runtimePayload: { ...readRuntimePayload(binding.value.runtimePayload), + // Keep recovery durable if this process also exits before sending. + [SERVER_UPDATE_CONTINUATION_KEY]: session.activeTurnId ?? continuationTurnId, + continueAfterServerUpdatePrepared: true, activeTurnId: null, }, }); @@ -608,12 +679,12 @@ export const reconcileProviderSessions = Effect.gen(function* () { } return; } - yield* Effect.logWarning("failed to continue provider session after server update", { + yield* Effect.logWarning("failed to continue provider session after server restart", { threadId: thread.id, cause: continuationExit.cause, }); yield* settleAsError( - "Could not continue this thread after the server update. Send a new message to continue.", + "Could not continue this thread after the server restart. Send a new message to continue.", ).pipe(Effect.ignoreCause); }), ); diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 82ed8525b9b5..4526e2c988a4 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -272,6 +272,32 @@ it.layer(NodeServices.layer)("server settings", (it) => { ).pipe(Effect.provide(makeServerSettingsLayer())), ); + it.effect("persists custom usage prices and removes them from the settings file", () => + Effect.scoped( + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + const prices = { + inputCostPerMillionTokens: 2, + outputCostPerMillionTokens: 8, + cacheReadCostPerMillionTokens: 0, + }; + const readPersisted = fileSystem + .readFileString(serverConfig.settingsPath) + .pipe(Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(ServerSettings)))); + + yield* serverSettings.updateSettings({ usagePriceOverrides: { "example-model": prices } }); + const persisted = yield* readPersisted; + assert.deepStrictEqual(persisted.usagePriceOverrides, { "example-model": prices }); + + yield* serverSettings.updateSettings({ usagePriceOverrides: { "example-model": null } }); + const restored = yield* readPersisted; + assert.deepStrictEqual(restored.usagePriceOverrides, {}); + }), + ).pipe(Effect.provide(makeServerSettingsLayer())), + ); + it.effect("persists and broadcasts thread settlement settings", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/serviceLauncher.ts b/apps/server/src/serviceLauncher.ts index b99f90e0fdf5..e912130c8939 100644 --- a/apps/server/src/serviceLauncher.ts +++ b/apps/server/src/serviceLauncher.ts @@ -70,8 +70,9 @@ async function pathExists(target: string): Promise { } } +// Opened read-write: Windows refuses to flush a handle without write access. async function syncFile(filePath: string): Promise { - const handle = await NodeFSP.open(filePath, "r"); + const handle = await NodeFSP.open(filePath, "r+"); try { await handle.sync(); } finally { @@ -79,10 +80,15 @@ async function syncFile(filePath: string): Promise { } } +// Flushes a directory entry so a rename into it survives power loss. Windows +// has no directory fsync: the handle opens but sync fails with EPERM, and +// NTFS journals the rename on its own. async function syncDirectory(directory: string): Promise { const handle = await NodeFSP.open(directory, "r"); try { await handle.sync(); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EPERM") throw error; } finally { await handle.close(); } @@ -188,12 +194,7 @@ export async function writeServiceState(filePath: string, state: ServiceState): await handle.close(); handle = undefined; await NodeFSP.rename(tempPath, filePath); - const directoryHandle = await NodeFSP.open(directory, "r"); - try { - await directoryHandle.sync(); - } finally { - await directoryHandle.close(); - } + await syncDirectory(directory); } finally { await handle?.close().catch(() => undefined); await NodeFSP.rm(tempPath, { force: true }).catch(() => undefined); diff --git a/apps/server/src/sourceControl/PrTemplateDetection.test.ts b/apps/server/src/sourceControl/PrTemplateDetection.test.ts index 34112c9c528c..5b91e631112f 100644 --- a/apps/server/src/sourceControl/PrTemplateDetection.test.ts +++ b/apps/server/src/sourceControl/PrTemplateDetection.test.ts @@ -10,6 +10,7 @@ import { ServerConfig } from "../config.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import { detectPrTemplate } from "./PrTemplateDetection.ts"; +import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; const SINGLE_TEMPLATE_PATHS = [ ".github/pull_request_template.md", @@ -139,27 +140,29 @@ it.effect.each(TEMPLATE_DIRECTORIES)("recognizes the $0 directory", (relativeDir ), ); -it.effect("skips unusable directory entries and uses the one valid template", () => - runWithTempDirectory((cwd) => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const templateDirectory = path.join(cwd, ".github", "PULL_REQUEST_TEMPLATE"); - yield* fileSystem.makeDirectory(path.join(templateDirectory, "b-directory.md"), { - recursive: true, - }); - yield* fileSystem.writeFileString(path.join(templateDirectory, "a-empty.md"), " \n"); - yield* fileSystem.symlink( - path.join(templateDirectory, "missing.md"), - path.join(templateDirectory, "c-broken.md"), - ); - yield* fileSystem.writeFileString(path.join(templateDirectory, "z-valid.md"), "valid"); - yield* commitTemplates(cwd); - - const template = yield* detectTemplate(cwd); - assert.strictEqual(Option.getOrUndefined(template), "valid"); - }), - ), +it.effect.skipIf(!symlinksSupported)( + "skips unusable directory entries and uses the one valid template", + () => + runWithTempDirectory((cwd) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const templateDirectory = path.join(cwd, ".github", "PULL_REQUEST_TEMPLATE"); + yield* fileSystem.makeDirectory(path.join(templateDirectory, "b-directory.md"), { + recursive: true, + }); + yield* fileSystem.writeFileString(path.join(templateDirectory, "a-empty.md"), " \n"); + yield* fileSystem.symlink( + path.join(templateDirectory, "missing.md"), + path.join(templateDirectory, "c-broken.md"), + ); + yield* fileSystem.writeFileString(path.join(templateDirectory, "z-valid.md"), "valid"); + yield* commitTemplates(cwd); + + const template = yield* detectTemplate(cwd); + assert.strictEqual(Option.getOrUndefined(template), "valid"); + }), + ), ); it.effect("does not guess between multiple directory templates", () => @@ -176,60 +179,64 @@ it.effect("does not guess between multiple directory templates", () => ), ); -it.effect("rejects a committed template symlink escaping the repository", () => - runWithTempDirectory((cwd) => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const outsideDirectory = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-pr-template-outside-", - }); - const outsideTemplate = path.join(outsideDirectory, "secret.md"); - yield* fileSystem.writeFileString(outsideTemplate, "LOCAL_SECRET_SENTINEL"); - const escapedTemplatePath = path.join(cwd, ".github", "pull_request_template.md"); - yield* fileSystem.makeDirectory(path.dirname(escapedTemplatePath), { recursive: true }); - yield* fileSystem.symlink(outsideTemplate, escapedTemplatePath); - yield* writeTemplate(cwd, "pull_request_template.md", "safe template"); - yield* commitTemplates(cwd); - - const template = yield* detectTemplate(cwd); - assert.strictEqual(Option.getOrUndefined(template), "safe template"); - assert.notInclude( - Option.getOrElse(template, () => ""), - "LOCAL_SECRET_SENTINEL", - ); - }), - ), +it.effect.skipIf(!symlinksSupported)( + "rejects a committed template symlink escaping the repository", + () => + runWithTempDirectory((cwd) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const outsideDirectory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-pr-template-outside-", + }); + const outsideTemplate = path.join(outsideDirectory, "secret.md"); + yield* fileSystem.writeFileString(outsideTemplate, "LOCAL_SECRET_SENTINEL"); + const escapedTemplatePath = path.join(cwd, ".github", "pull_request_template.md"); + yield* fileSystem.makeDirectory(path.dirname(escapedTemplatePath), { recursive: true }); + yield* fileSystem.symlink(outsideTemplate, escapedTemplatePath); + yield* writeTemplate(cwd, "pull_request_template.md", "safe template"); + yield* commitTemplates(cwd); + + const template = yield* detectTemplate(cwd); + assert.strictEqual(Option.getOrUndefined(template), "safe template"); + assert.notInclude( + Option.getOrElse(template, () => ""), + "LOCAL_SECRET_SENTINEL", + ); + }), + ), ); -it.effect("reads the committed template when a worktree parent is replaced", () => - runWithTempDirectory((cwd) => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const outsideDirectory = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-pr-template-outside-", - }); - const templatePath = yield* writeTemplate( - cwd, - ".github/pull_request_template.md", - "committed template", - ); - yield* commitTemplates(cwd); - yield* writeTemplate(outsideDirectory, "pull_request_template.md", "LOCAL_SECRET_SENTINEL"); - - const templateDirectory = path.dirname(templatePath); - yield* fileSystem.rename(templateDirectory, path.join(cwd, ".github-original")); - yield* fileSystem.symlink(outsideDirectory, templateDirectory); - - const template = yield* detectTemplate(cwd); - assert.strictEqual(Option.getOrUndefined(template), "committed template"); - assert.notInclude( - Option.getOrElse(template, () => ""), - "LOCAL_SECRET_SENTINEL", - ); - }), - ), +it.effect.skipIf(!symlinksSupported)( + "reads the committed template when a worktree parent is replaced", + () => + runWithTempDirectory((cwd) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const outsideDirectory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-pr-template-outside-", + }); + const templatePath = yield* writeTemplate( + cwd, + ".github/pull_request_template.md", + "committed template", + ); + yield* commitTemplates(cwd); + yield* writeTemplate(outsideDirectory, "pull_request_template.md", "LOCAL_SECRET_SENTINEL"); + + const templateDirectory = path.dirname(templatePath); + yield* fileSystem.rename(templateDirectory, path.join(cwd, ".github-original")); + yield* fileSystem.symlink(outsideDirectory, templateDirectory); + + const template = yield* detectTemplate(cwd); + assert.strictEqual(Option.getOrUndefined(template), "committed template"); + assert.notInclude( + Option.getOrElse(template, () => ""), + "LOCAL_SECRET_SENTINEL", + ); + }), + ), ); it.effect("bounds template reads and marks truncated content", () => diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts index 861da9a10e05..461bff08668a 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts @@ -3,6 +3,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; import * as Layer from "effect/Layer"; import * as PlatformError from "effect/PlatformError"; import { ChildProcessSpawner } from "effect/unstable/process"; @@ -153,10 +154,11 @@ it.effect("preserves provider failures without deriving the repository message f it.effect("clones a looked-up repository into the requested destination", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const parent = yield* fs.makeTempDirectoryScoped({ prefix: "t3-source-control-clone-parent-", }); - const destinationPath = `${parent}/t3code`; + const destinationPath = path.join(parent, "t3code"); const cloneCalls: Array<{ cwd: string; args: ReadonlyArray }> = []; yield* Effect.gen(function* () { diff --git a/apps/server/src/sourceControl/githubGraphQlBudget.test.ts b/apps/server/src/sourceControl/githubGraphQlBudget.test.ts index a166bf0dbbaf..a5b0680fafc5 100644 --- a/apps/server/src/sourceControl/githubGraphQlBudget.test.ts +++ b/apps/server/src/sourceControl/githubGraphQlBudget.test.ts @@ -9,11 +9,11 @@ const NEXT_RESET_AT = "2026-08-13T15:00:00.000Z"; const BEFORE_RESET = Date.parse("2026-08-13T13:30:00.000Z"); const AFTER_RESET = Date.parse("2026-08-13T14:00:01.000Z"); -function rateLimit(remaining: number, limit = 5_000, resetAt = RESET_AT): string { +function rateLimit(remaining: number, limit = 5_000, resetAt = RESET_AT, cost = 14): string { return JSON.stringify({ data: { viewer: { login: "bilal" }, - rateLimit: { cost: 14, limit, remaining, resetAt }, + rateLimit: { cost, limit, remaining, resetAt }, }, }); } @@ -86,6 +86,27 @@ describe("GitHub GraphQL budget", () => { }).pipe(Effect.provide(GitHubGraphQlBudget.layer)), ); + it.effect("learns a cheaper observed cost without restoring reserved quota", () => + Effect.gen(function* () { + yield* TestClock.setTime(BEFORE_RESET); + const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; + const query = "query { viewer { login } }"; + yield* budget.observe("github.com", rateLimit(512, 5_000, RESET_AT, 8)); + yield* budget.query("github.com", query); + // The admission reserved eight points, but the completed read only cost one. + yield* budget.observe("github.com", rateLimit(511, 5_000, RESET_AT, 1)); + + for (let count = 0; count < 4; count += 1) { + expect(yield* budget.query("github.com", query)).toContain("rateLimit"); + } + const error = yield* Effect.flip(budget.query("github.com", query)); + expect(error).toMatchObject({ + _tag: "SourceControlRateLimitPausedError", + retryAt: Date.parse(RESET_AT), + }); + }).pipe(Effect.provide(GitHubGraphQlBudget.layer)), + ); + it.effect("ignores a response from an older reset window", () => Effect.gen(function* () { yield* TestClock.setTime(BEFORE_RESET); diff --git a/apps/server/src/sourceControl/githubGraphQlBudget.ts b/apps/server/src/sourceControl/githubGraphQlBudget.ts index 9c43de8e0586..9ece27547f5e 100644 --- a/apps/server/src/sourceControl/githubGraphQlBudget.ts +++ b/apps/server/src/sourceControl/githubGraphQlBudget.ts @@ -122,15 +122,20 @@ export const make = Effect.gen(function* () { const previous = current.get(key); // Concurrent reads can finish out of order. Quota only falls within one reset window, and // an answer from an older window must not replace the current one. - if ( - previous !== undefined && - (snapshot.resetAtMs < previous.resetAtMs || - (snapshot.resetAtMs === previous.resetAtMs && snapshot.remaining >= previous.remaining)) - ) { + if (previous !== undefined && snapshot.resetAtMs < previous.resetAtMs) { return current; } const next = new Map(current); - next.set(key, snapshot); + // Keep the conservative balance, but learn the observed cost even when our reservation + // was larger. Otherwise one expensive read makes every later cheap read spend its cost. + next.set( + key, + previous !== undefined && + snapshot.resetAtMs === previous.resetAtMs && + snapshot.remaining >= previous.remaining + ? { ...previous, cost: snapshot.cost } + : snapshot, + ); return next; }); }); diff --git a/apps/server/src/testUtils/fakeCli.ts b/apps/server/src/testUtils/fakeCli.ts new file mode 100644 index 000000000000..68ab157aed7a --- /dev/null +++ b/apps/server/src/testUtils/fakeCli.ts @@ -0,0 +1,106 @@ +// @effect-diagnostics nodeBuiltinImport:off preferSchemaOverJson:off - synchronous fixture writer used from plain and Effect tests alike. +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; + +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; + +export interface FakeCliOptions { + /** Directory the launcher and its stub are written into. */ + readonly directory: string; + /** Command name as callers spawn it, e.g. `codex`. */ + readonly name: string; + /** ES module source the launcher runs with `node`. `process.argv.slice(2)` carries the CLI args. */ + readonly source: string; + /** Environment set for the stub before it runs, on top of the inherited environment. */ + readonly env?: Readonly>; + /** + * Platform the launcher is shaped for. Effect callers should pass the value + * they read from `HostProcessPlatform` so an injected override is honoured; + * defaults to the real host for plain tests. + */ + readonly platform?: NodeJS.Platform; +} + +/** + * Writes a fake CLI whose behaviour lives in a Node stub. On posix the + * launcher is a `#!/bin/sh` script; on Windows it is a `.cmd` shim, since a + * shebang file is not executable there and `resolveSpawnCommand` routes + * `.cmd` through a shell. Returns the launcher path to hand to the code under + * test, which on Windows carries the `.cmd` extension. + */ +export function writeFakeCli(options: FakeCliOptions): string { + NodeFS.mkdirSync(options.directory, { recursive: true }); + const stubPath = NodePath.join(options.directory, `${options.name}-stub.mjs`); + // The environment rides in a JSON sidecar the stub applies to itself, since + // neither sh nor cmd.exe can quote every value (newlines, quotes, `%`) safely. + const envPath = NodePath.join(options.directory, `${options.name}-env.json`); + NodeFS.writeFileSync(envPath, JSON.stringify(options.env ?? {}), "utf8"); + NodeFS.writeFileSync( + stubPath, + [ + 'import { readFileSync as readFakeCliEnv } from "node:fs";', + `Object.assign(process.env, JSON.parse(readFakeCliEnv(${JSON.stringify(envPath)}, "utf8")));`, + options.source, + ].join("\n"), + "utf8", + ); + + if ((options.platform ?? HostProcessPlatform.defaultValue()) === "win32") { + const launcherPath = NodePath.join(options.directory, `${options.name}.cmd`); + NodeFS.writeFileSync( + launcherPath, + ["@echo off", `node "%~dp0${options.name}-stub.mjs" %*`, "exit /b %ERRORLEVEL%", ""].join( + "\r\n", + ), + "utf8", + ); + return launcherPath; + } + + const launcherPath = NodePath.join(options.directory, options.name); + NodeFS.writeFileSync( + launcherPath, + ["#!/bin/sh", `exec node "$(dirname "$0")/${options.name}-stub.mjs" "$@"`, ""].join("\n"), + "utf8", + ); + NodeFS.chmodSync(launcherPath, 0o755); + return launcherPath; +} + +/** + * Stub source that becomes `scriptPath`: after optionally requiring a leading + * argv prefix, it imports the script into its own process so the stub is the + * agent. Nothing sits between the code under test and the mock, so a kill or + * a closed stdin reaches it directly and its exit log is faithful on every + * host. The script must not read `process.argv`; the mock agents are driven + * by environment and stdin. + */ +export function execScriptSource(options: { + readonly scriptPath: string; + readonly expectedArgs?: ReadonlyArray; + /** Tab-separated argv appended here per invocation, for launch-flag assertions. */ + readonly argvLogPath?: string; + /** Wait before handing over, for tests that race a slow startup. */ + readonly delayMs?: number; +}): string { + return [ + 'import { appendFileSync } from "node:fs";', + 'import { pathToFileURL } from "node:url";', + "const args = process.argv.slice(2);", + ...(options.argvLogPath === undefined + ? [] + : [ + `appendFileSync(${JSON.stringify(options.argvLogPath)}, args.join(${JSON.stringify("\t")}) + ${JSON.stringify("\n")});`, + ]), + ...(options.delayMs === undefined + ? [] + : [`Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ${options.delayMs});`]), + `const expected = ${JSON.stringify(options.expectedArgs ?? [])};`, + "if (expected.some((value, index) => args[index] !== value)) {", + ' process.stderr.write(`unexpected args: ${args.join(" ")}\\n`);', + " process.exit(11);", + "}", + `await import(pathToFileURL(${JSON.stringify(options.scriptPath)}).href);`, + "", + ].join("\n"); +} diff --git a/apps/server/src/testUtils/gitConfig.setup.ts b/apps/server/src/testUtils/gitConfig.setup.ts new file mode 100644 index 000000000000..03bd860bf0d0 --- /dev/null +++ b/apps/server/src/testUtils/gitConfig.setup.ts @@ -0,0 +1,21 @@ +// Pins git behaviour for every repository the suite creates, ahead of the +// host's ~/.gitconfig. Git for Windows installs with core.autocrlf=true, +// which checks committed LF files out as CRLF and breaks every byte-exact +// content assertion; a signing key or a non-default init branch on the +// developer's machine breaks fixtures the same way. Set as environment so +// each git child the driver spawns sees it without touching the fixtures. +const entries: ReadonlyArray = [ + ["core.autocrlf", "false"], + ["core.filemode", "false"], + ["core.longpaths", "true"], + ["commit.gpgsign", "false"], + ["tag.gpgsign", "false"], + ["init.defaultBranch", "main"], +]; + +const existing = Number(process.env.GIT_CONFIG_COUNT ?? "0"); +process.env.GIT_CONFIG_COUNT = String(existing + entries.length); +entries.forEach(([key, value], index) => { + process.env[`GIT_CONFIG_KEY_${existing + index}`] = key; + process.env[`GIT_CONFIG_VALUE_${existing + index}`] = value; +}); diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts index 8dcaa3720295..fe95c8649ea3 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts @@ -1,7 +1,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; import { ClaudeSettings, ProviderInstanceId } from "@t3tools/contracts"; -import { isHostWindows } from "@t3tools/shared/hostProcess"; +import { HostProcessPlatform, isHostWindows } from "@t3tools/shared/hostProcess"; import { createModelSelection } from "@t3tools/shared/model"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -21,27 +21,26 @@ import { import * as TextGeneration from "./TextGeneration.ts"; import { sanitizeThreadTitle } from "./TextGenerationUtils.ts"; import { makeClaudeTextGeneration } from "./ClaudeTextGeneration.ts"; +import { writeFakeCli } from "../testUtils/fakeCli.ts"; const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); const ClaudeTextGenerationTestLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3code-claude-text-generation-test-", }).pipe(Layer.provideMerge(NodeServices.layer)); +// The stub behaviour lives in Node so the same implementation runs on Windows, +// where a shebang file is not executable and would fall through to the real +// Claude CLI on PATH; `writeFakeCli` picks the launcher shape per host. function makeFakeClaudeBinary(dir: string) { return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const isWindows = yield* isHostWindows; + const platform = yield* HostProcessPlatform; const binDir = path.join(dir, "bin"); - const stubPath = path.join(binDir, "claude-stub.mjs"); - yield* fs.makeDirectory(binDir, { recursive: true }); - - // The stub behaviour lives in Node rather than a `#!/bin/sh` script so the - // same implementation is usable on Windows, where a shebang file is not - // executable and would fall through to the real Claude CLI on PATH. - yield* fs.writeFileString( - stubPath, - [ + writeFakeCli({ + directory: binDir, + name: "claude", + platform, + source: [ 'const args = process.argv.slice(2).join(" ");', "", "function fail(message, code) {", @@ -87,24 +86,7 @@ function makeFakeClaudeBinary(dir: string) { "process.exitCode = Number(process.env.T3_FAKE_CLAUDE_EXIT_CODE ?? 0);", "", ].join("\n"), - ); - - if (isWindows) { - // Windows resolves executables through PATHEXT, so the entry point has to - // carry a real extension. `resolveSpawnCommand` spawns `.cmd` via a shell. - yield* fs.writeFileString( - path.join(binDir, "claude.cmd"), - ["@echo off", 'node "%~dp0claude-stub.mjs" %*', "exit /b %ERRORLEVEL%", ""].join("\r\n"), - ); - } else { - const claudePath = path.join(binDir, "claude"); - yield* fs.writeFileString( - claudePath, - ["#!/bin/sh", 'exec node "$(dirname "$0")/claude-stub.mjs" "$@"', ""].join("\n"), - ); - yield* fs.chmod(claudePath, 0o755); - } - + }); return binDir; }); } diff --git a/apps/server/src/textGeneration/CodexTextGeneration.test.ts b/apps/server/src/textGeneration/CodexTextGeneration.test.ts index 657118fff51c..0129136d5e8d 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.test.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.test.ts @@ -14,6 +14,7 @@ import { CodexSettings, ProviderInstanceId, TextGenerationError } from "@t3tools import * as ServerConfig from "../config.ts"; import * as TextGeneration from "./TextGeneration.ts"; import { makeCodexTextGeneration } from "./CodexTextGeneration.ts"; +import { writeFakeCli } from "../testUtils/fakeCli.ts"; const decodeCodexSettings = Schema.decodeSync(CodexSettings); const DEFAULT_TEST_MODEL_SELECTION = createModelSelection( @@ -25,170 +26,112 @@ const CodexTextGenerationTestLayer = ServerConfig.ServerConfig.layerTest(process prefix: "t3code-codex-text-generation-test-", }).pipe(Layer.provideMerge(NodeServices.layer)); -function makeFakeCodexBinary( - dir: string, - input: { - output: string; - exitCode?: number; - stderr?: string; - requireImage?: boolean; - requireServiceTier?: string; - requireReasoningEffort?: string; - forbidReasoningEffort?: boolean; - requireArg?: string; - forbidArg?: string; - stdinMustContain?: string; - stdinMustNotContain?: string; - }, -) { +interface FakeCodexInput { + output: string; + exitCode?: number; + stderr?: string; + requireImage?: boolean; + requireServiceTier?: string; + requireReasoningEffort?: string; + forbidReasoningEffort?: boolean; + requireArg?: string; + forbidArg?: string; + stdinMustContain?: string; + stdinMustNotContain?: string; +} + +// The stub walks argv the way the shell script it replaced did: `--image`, +// `--config key=value`, and `--output-last-message ` are consumed, the +// prompt arrives on stdin, and each check exits with its own code so a +// failing test names the assertion that tripped. +function makeFakeCodexBinary(dir: string, input: FakeCodexInput) { + const check = JSON.stringify({ + requireImage: input.requireImage ?? false, + requireServiceTier: input.requireServiceTier ?? null, + requireReasoningEffort: input.requireReasoningEffort ?? null, + forbidReasoningEffort: input.forbidReasoningEffort ?? false, + requireArg: input.requireArg ?? null, + forbidArg: input.forbidArg ?? null, + stdinMustContain: input.stdinMustContain ?? null, + stdinMustNotContain: input.stdinMustNotContain ?? null, + stderr: input.stderr ?? null, + output: input.output, + exitCode: input.exitCode ?? 0, + }); return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const binDir = path.join(dir, "bin"); - const codexPath = path.join(binDir, "codex"); - yield* fs.makeDirectory(binDir, { recursive: true }); - - yield* fs.writeFileString( - codexPath, - [ - "#!/bin/sh", - 'original_args="$*"', - 'output_path=""', - 'seen_image="0"', - 'seen_service_tier=""', - 'seen_reasoning_effort=""', - "while [ $# -gt 0 ]; do", - ' if [ "$1" = "--image" ]; then', - " shift", - ' if [ -n "$1" ]; then', - ' seen_image="1"', - " fi", - " shift", - " continue", - " fi", - ' if [ "$1" = "--config" ]; then', - " shift", - ' case "$1" in', - " service_tier=*)", - ' seen_service_tier="$1"', - " ;;", - " esac", - ' case "$1" in', - " model_reasoning_effort=*)", - ' seen_reasoning_effort="$1"', - " ;;", - " esac", - " shift", - " continue", - " fi", - ' if [ "$1" = "--output-last-message" ]; then', - " shift", - ' output_path="$1"', - " shift", - " continue", - " fi", - " shift", - "done", - 'stdin_content="$(cat)"', - ...(input.requireArg !== undefined - ? [ - `case " $original_args " in *" ${input.requireArg} "*) ;; *)`, - ` printf "%s\\n" "missing arg: ${input.requireArg}" >&2`, - ` exit 8`, - "esac", - ] - : []), - ...(input.forbidArg !== undefined - ? [ - `case " $original_args " in *" ${input.forbidArg} "*)`, - ` printf "%s\\n" "forbidden arg: ${input.forbidArg}" >&2`, - ` exit 9`, - "esac", - ] - : []), - ...(input.requireImage - ? [ - 'if [ "$seen_image" != "1" ]; then', - ' printf "%s\\n" "missing --image input" >&2', - ` exit 2`, - "fi", - ] - : []), - ...(input.requireServiceTier - ? [ - `if [ "$seen_service_tier" != "service_tier=\\"${input.requireServiceTier}\\"" ]; then`, - ' printf "%s\\n" "unexpected service tier config: $seen_service_tier" >&2', - ` exit 5`, - "fi", - ] - : []), - ...(input.requireReasoningEffort !== undefined - ? [ - `if [ "$seen_reasoning_effort" != "model_reasoning_effort=\\"${input.requireReasoningEffort}\\"" ]; then`, - ' printf "%s\\n" "unexpected reasoning effort config: $seen_reasoning_effort" >&2', - ` exit 6`, - "fi", - ] - : []), - ...(input.forbidReasoningEffort - ? [ - 'if [ -n "$seen_reasoning_effort" ]; then', - ' printf "%s\\n" "reasoning effort config should be omitted: $seen_reasoning_effort" >&2', - ` exit 7`, - "fi", - ] - : []), - ...(input.stdinMustContain !== undefined - ? [ - // @effect-diagnostics-next-line preferSchemaOverJson:off - `if ! printf "%s" "$stdin_content" | grep -F -- ${JSON.stringify(input.stdinMustContain)} >/dev/null; then`, - ' printf "%s\\n" "stdin missing expected content" >&2', - ` exit 3`, - "fi", - ] - : []), - ...(input.stdinMustNotContain !== undefined - ? [ - // @effect-diagnostics-next-line preferSchemaOverJson:off - `if printf "%s" "$stdin_content" | grep -F -- ${JSON.stringify(input.stdinMustNotContain)} >/dev/null; then`, - ' printf "%s\\n" "stdin contained forbidden content" >&2', - ` exit 4`, - "fi", - ] - : []), - ...(input.stderr !== undefined - ? [ - // @effect-diagnostics-next-line preferSchemaOverJson:off - `printf "%s\\n" ${JSON.stringify(input.stderr)} >&2`, - ] - : []), - 'if [ -n "$output_path" ]; then', - " cat > \"$output_path\" <<'__T3CODE_FAKE_CODEX_OUTPUT__'", - input.output, - "__T3CODE_FAKE_CODEX_OUTPUT__", - "fi", - `exit ${input.exitCode ?? 0}`, + return writeFakeCli({ + directory: path.join(dir, "bin"), + name: "codex", + source: [ + 'import * as NodeFS from "node:fs";', + `const check = ${check};`, + "const args = process.argv.slice(2);", + 'const originalArgs = ` ${args.join(" ")} `;', + "let outputPath = null;", + "let seenImage = false;", + 'let seenServiceTier = "";', + 'let seenReasoningEffort = "";', + "for (let index = 0; index < args.length; index += 1) {", + ' if (args[index] === "--image") {', + " index += 1;", + " if (args[index]) seenImage = true;", + ' } else if (args[index] === "--config") {', + " index += 1;", + ' const value = args[index] ?? "";', + ' if (value.startsWith("service_tier=")) seenServiceTier = value;', + ' if (value.startsWith("model_reasoning_effort=")) seenReasoningEffort = value;', + ' } else if (args[index] === "--output-last-message") {', + " index += 1;", + " outputPath = args[index] ?? null;", + " }", + "}", + "const chunks = [];", + "for await (const chunk of process.stdin) chunks.push(chunk);", + 'const stdinContent = Buffer.concat(chunks).toString("utf8");', + "function fail(message, code) {", + ' process.stderr.write(message + "\\n");', + " process.exit(code);", + "}", + "if (check.requireArg !== null && !originalArgs.includes(` ${check.requireArg} `)) {", + ' fail("missing arg: " + check.requireArg, 8);', + "}", + "if (check.forbidArg !== null && originalArgs.includes(` ${check.forbidArg} `)) {", + ' fail("forbidden arg: " + check.forbidArg, 9);', + "}", + 'if (check.requireImage && !seenImage) fail("missing --image input", 2);', + "if (", + " check.requireServiceTier !== null &&", + ' seenServiceTier !== `service_tier="${check.requireServiceTier}"`', + ") {", + ' fail("unexpected service tier config: " + seenServiceTier, 5);', + "}", + "if (", + " check.requireReasoningEffort !== null &&", + ' seenReasoningEffort !== `model_reasoning_effort="${check.requireReasoningEffort}"`', + ") {", + ' fail("unexpected reasoning effort config: " + seenReasoningEffort, 6);', + "}", + "if (check.forbidReasoningEffort && seenReasoningEffort.length > 0) {", + ' fail("reasoning effort config should be omitted: " + seenReasoningEffort, 7);', + "}", + "if (check.stdinMustContain !== null && !stdinContent.includes(check.stdinMustContain)) {", + ' fail("stdin missing expected content", 3);', + "}", + "if (check.stdinMustNotContain !== null && stdinContent.includes(check.stdinMustNotContain)) {", + ' fail("stdin contained forbidden content", 4);', + "}", + 'if (check.stderr !== null) process.stderr.write(check.stderr + "\\n");', + 'if (outputPath !== null) NodeFS.writeFileSync(outputPath, check.output + "\\n");', + "process.exitCode = check.exitCode;", "", ].join("\n"), - ); - yield* fs.chmod(codexPath, 0o755); - return codexPath; + }); }); } function withFakeCodexEnv( - input: { - output: string; - exitCode?: number; - stderr?: string; - requireImage?: boolean; - requireServiceTier?: string; - requireReasoningEffort?: string; - forbidReasoningEffort?: boolean; - requireArg?: string; - forbidArg?: string; - stdinMustContain?: string; - stdinMustNotContain?: string; + input: FakeCodexInput & { launchArgs?: string; environment?: NodeJS.ProcessEnv; }, diff --git a/apps/server/src/textGeneration/CursorTextGeneration.test.ts b/apps/server/src/textGeneration/CursorTextGeneration.test.ts index 2dc4720dcadb..22baa75fb8ed 100644 --- a/apps/server/src/textGeneration/CursorTextGeneration.test.ts +++ b/apps/server/src/textGeneration/CursorTextGeneration.test.ts @@ -11,6 +11,7 @@ import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { createModelSelection } from "@t3tools/shared/model"; import { expect } from "vite-plus/test"; @@ -19,39 +20,26 @@ import { CursorSettings, ProviderInstanceId } from "@t3tools/contracts"; import * as ServerConfig from "../config.ts"; import * as TextGeneration from "./TextGeneration.ts"; import { makeCursorTextGeneration } from "./CursorTextGeneration.ts"; +import { execScriptSource, writeFakeCli } from "../testUtils/fakeCli.ts"; const decodeCursorSettings = Schema.decodeSync(CursorSettings); const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); const mockAgentPath = NodePath.join(__dirname, "../../scripts/acp-mock-agent.ts"); -function shellSingleQuote(value: string): string { - return `'${value.replaceAll("'", `'"'"'`)}'`; -} - const CursorTextGenerationTestLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3code-cursor-text-generation-test-", }).pipe(Layer.provideMerge(NodeServices.layer)); function makeAcpAgentWrapper(dir: string, env: Record): string { - const binDir = NodePath.join(dir, "bin"); - const agentPath = NodePath.join(binDir, "agent"); - NodeFS.mkdirSync(binDir, { recursive: true }); - NodeFS.writeFileSync( - agentPath, - [ - "#!/bin/sh", - ...Object.entries(env).map(([key, value]) => `export ${key}=${shellSingleQuote(value)}`), - 'if [ "$1" != "acp" ]; then', - ' printf "%s\\n" "unexpected args: $*" >&2', - " exit 11", - "fi", - `exec node ${JSON.stringify(mockAgentPath)}`, - "", - ].join("\n"), - "utf8", - ); - NodeFS.chmodSync(agentPath, 0o755); - return agentPath; + return writeFakeCli({ + directory: NodePath.join(dir, "bin"), + name: "agent", + env, + source: execScriptSource({ + scriptPath: mockAgentPath, + expectedArgs: ["acp"], + }), + }); } function withFakeAcpAgent( @@ -236,41 +224,46 @@ it.layer(CursorTextGenerationTestLayer)("CursorTextGeneration", (it) => { ), ); - it.effect("closes the ACP child process after text generation completes", () => { - const exitLogDir = NodeFS.mkdtempSync( - NodePath.join(NodeOS.tmpdir(), "t3code-cursor-text-exit-log-"), - ); - const exitLogPath = NodePath.join(exitLogDir, "exit.log"); + // Closing the runtime on Windows is taskkill /F, which never lets the mock + // agent reach its exit handler, so there is no exit log to assert on. + it.effect.skipIf(HostProcessPlatform.defaultValue() === "win32")( + "closes the ACP child process after text generation completes", + () => { + const exitLogDir = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3code-cursor-text-exit-log-"), + ); + const exitLogPath = NodePath.join(exitLogDir, "exit.log"); - return withFakeAcpAgent( - { - T3_ACP_EXIT_LOG_PATH: exitLogPath, - T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({ - subject: "Close runtime after generation", - body: "", - }), - }, - (textGeneration) => - Effect.gen(function* () { - const generated = yield* textGeneration.generateCommitMessage({ - cwd: process.cwd(), - branch: "feature/cursor-runtime-close", - stagedSummary: "M apps/server/src/textGeneration/CursorTextGeneration.ts", - stagedPatch: - "diff --git a/apps/server/src/textGeneration/CursorTextGeneration.ts b/apps/server/src/textGeneration/CursorTextGeneration.ts", - modelSelection: { - instanceId: ProviderInstanceId.make("cursor"), - model: "composer-2", - }, - }); + return withFakeAcpAgent( + { + T3_ACP_EXIT_LOG_PATH: exitLogPath, + T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({ + subject: "Close runtime after generation", + body: "", + }), + }, + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/cursor-runtime-close", + stagedSummary: "M apps/server/src/textGeneration/CursorTextGeneration.ts", + stagedPatch: + "diff --git a/apps/server/src/textGeneration/CursorTextGeneration.ts b/apps/server/src/textGeneration/CursorTextGeneration.ts", + modelSelection: { + instanceId: ProviderInstanceId.make("cursor"), + model: "composer-2", + }, + }); - expect(generated.subject).toBe("Close runtime after generation"); + expect(generated.subject).toBe("Close runtime after generation"); - const exitLog = yield* waitForFileContent(exitLogPath); - expect(exitLog).toContain("exit:0"); + const exitLog = yield* waitForFileContent(exitLogPath); + expect(exitLog).toContain("exit:0"); - NodeFS.rmSync(exitLogDir, { recursive: true, force: true }); - }), - ); - }); + NodeFS.rmSync(exitLogDir, { recursive: true, force: true }); + }), + ); + }, + ); }); diff --git a/apps/server/src/textGeneration/GrokTextGeneration.test.ts b/apps/server/src/textGeneration/GrokTextGeneration.test.ts index 85127b519b98..e1622c8aaeca 100644 --- a/apps/server/src/textGeneration/GrokTextGeneration.test.ts +++ b/apps/server/src/textGeneration/GrokTextGeneration.test.ts @@ -16,39 +16,26 @@ import { GrokSettings, ProviderInstanceId } from "@t3tools/contracts"; import * as ServerConfig from "../config.ts"; import * as TextGeneration from "./TextGeneration.ts"; import { makeGrokTextGeneration } from "./GrokTextGeneration.ts"; +import { execScriptSource, writeFakeCli } from "../testUtils/fakeCli.ts"; const decodeGrokSettings = Schema.decodeSync(GrokSettings); const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); const mockAgentPath = NodePath.join(__dirname, "../../scripts/acp-mock-agent.ts"); -function shellSingleQuote(value: string): string { - return `'${value.replaceAll("'", `'"'"'`)}'`; -} - const GrokTextGenerationTestLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3code-grok-text-generation-test-", }).pipe(Layer.provideMerge(NodeServices.layer)); function makeAcpGrokWrapper(dir: string, env: Record): string { - const binDir = NodePath.join(dir, "bin"); - const grokPath = NodePath.join(binDir, "grok"); - NodeFS.mkdirSync(binDir, { recursive: true }); - NodeFS.writeFileSync( - grokPath, - [ - "#!/bin/sh", - ...Object.entries(env).map(([key, value]) => `export ${key}=${shellSingleQuote(value)}`), - 'if [ "$1" != "agent" ] || [ "$2" != "stdio" ]; then', - ' printf "%s\\n" "unexpected args: $*" >&2', - " exit 11", - "fi", - `exec ${JSON.stringify(process.execPath)} ${JSON.stringify(mockAgentPath)}`, - "", - ].join("\n"), - "utf8", - ); - NodeFS.chmodSync(grokPath, 0o755); - return grokPath; + return writeFakeCli({ + directory: NodePath.join(dir, "bin"), + name: "grok", + env, + source: execScriptSource({ + scriptPath: mockAgentPath, + expectedArgs: ["agent", "stdio"], + }), + }); } function withFakeAcpGrok( diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index 63b74510e29e..9d728c88cf42 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -9,9 +9,11 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; import { UsageDay, type UsageSummaryInput } from "@t3tools/contracts"; import * as Duration from "effect/Duration"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Scheduler from "effect/Scheduler"; import * as TestClock from "effect/testing/TestClock"; @@ -21,7 +23,7 @@ import * as ServerConfig from "../config.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as UsageService from "./UsageService.ts"; -function claudeLine(id: number, outputTokens: number): string { +function claudeLine(id: number, outputTokens: number, model = "claude-fable-5"): string { return `${JSON.stringify({ type: "assistant", timestamp: "2026-08-01T10:00:00Z", @@ -29,7 +31,7 @@ function claudeLine(id: number, outputTokens: number): string { sessionId: "session-1", message: { id: `msg_${id}`, - model: "claude-fable-5", + model, usage: { input_tokens: 10, output_tokens: outputTokens }, }, })}\n`; @@ -96,6 +98,49 @@ function totalOutputTokens(summary: { buckets: readonly { totals: { outputTokens } describe("UsageService", () => { + it.live("reprices unchanged transcripts when custom prices are added, edited, or removed", () => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + yield* Effect.promise(() => NodeFSP.writeFile(transcript, claudeLine(1, 5, "example-model"))); + + yield* Effect.gen(function* () { + const settingsService = yield* ServerSettings.ServerSettingsService; + const service = yield* UsageService.make; + + const original = yield* service.readSummary(WINDOW); + assert.strictEqual(original.buckets[0]?.costUsd, 0); + assert.strictEqual(original.buckets[0]?.unpricedRecords, 1); + + yield* settingsService.updateSettings({ + usagePriceOverrides: { + "example-model": { inputCostPerMillionTokens: 2, outputCostPerMillionTokens: 8 }, + }, + }); + const overridden = yield* service.readSummary(WINDOW); + assert.closeTo(overridden.buckets[0]?.costUsd ?? -1, 0.00006, 1e-12); + assert.strictEqual(overridden.buckets[0]?.costSource, "modelPriced"); + assert.strictEqual(overridden.buckets[0]?.unpricedRecords, 0); + assert.deepStrictEqual(overridden.buckets[0]?.totals, original.buckets[0]?.totals); + + yield* settingsService.updateSettings({ + usagePriceOverrides: { + "example-model": { inputCostPerMillionTokens: 4, outputCostPerMillionTokens: 16 }, + }, + }); + const edited = yield* service.readSummary(WINDOW); + assert.closeTo(edited.buckets[0]?.costUsd ?? -1, 0.00012, 1e-12); + + yield* settingsService.updateSettings({ usagePriceOverrides: { "example-model": null } }); + const restored = yield* service.readSummary(WINDOW); + assert.deepStrictEqual(restored.buckets, original.buckets); + }).pipe( + Effect.provide( + serviceLayers({ prefix: "usage-service-price-overrides-test", home, settings }), + ), + ); + }).pipe(Effect.scoped), + ); + it.live("counts appended usage on a rescan of a grown transcript", () => Effect.gen(function* () { const { transcript, settings, home } = yield* setup; @@ -114,6 +159,65 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); + it.live("does not share an in-flight scan after custom prices change", () => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + yield* Effect.promise(() => NodeFSP.writeFile(transcript, claudeLine(1, 5, "example-model"))); + + yield* Effect.gen(function* () { + const settingsService = yield* ServerSettings.ServerSettingsService; + const fileSystem = yield* FileSystem.FileSystem; + const firstScanStarted = yield* Deferred.make(); + const secondScanStarted = yield* Deferred.make(); + const releaseRates = yield* Deferred.make(); + let homeProbes = 0; + const service = yield* UsageService.make.pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fileSystem, + exists: (path) => + fileSystem.exists(path).pipe( + Effect.tap(() => { + if (path !== NodePath.join(home, "claude", ".claude", "projects")) + return Effect.void; + homeProbes += 1; + return Deferred.succeed( + homeProbes === 1 ? firstScanStarted : secondScanStarted, + undefined, + ); + }), + ), + }), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => + Deferred.await(releaseRates).pipe( + Effect.as(HttpClientResponse.fromWeb(request, Response.json({}))), + ), + ), + ), + ); + + const first = yield* service.readSummary(WINDOW).pipe(Effect.forkChild); + yield* Deferred.await(firstScanStarted); + yield* settingsService.updateSettings({ + usagePriceOverrides: { + "example-model": { inputCostPerMillionTokens: 2, outputCostPerMillionTokens: 8 }, + }, + }); + const second = yield* service.readSummary(WINDOW).pipe(Effect.forkChild); + yield* Deferred.await(secondScanStarted); + yield* Deferred.succeed(releaseRates, undefined); + + const original = yield* Fiber.join(first); + const updated = yield* Fiber.join(second); + assert.strictEqual(original.buckets[0]?.costUsd, 0); + assert.closeTo(updated.buckets[0]?.costUsd ?? -1, 0.00006, 1e-12); + }).pipe( + Effect.provide(serviceLayers({ prefix: "usage-service-price-race-test", home, settings })), + ); + }).pipe(Effect.scoped), + ); + it.live("shares one scan between concurrent identical requests", () => Effect.gen(function* () { const { transcript, settings, home } = yield* setup; diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 7c446ae14f96..0e6b0c1eecd6 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -16,6 +16,7 @@ import * as NodeOS from "node:os"; import { USAGE_CONTRACT_VERSION, + type ServerSettings as ServerSettingsValue, type UsageProviderKind, type UsageSource, type UsagePricing, @@ -44,7 +45,7 @@ import * as ServerSettings from "../serverSettings.ts"; import { resolveClaudeHomePath } from "../provider/Drivers/ClaudeHome.ts"; import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; import { UsageAggregator } from "./usageAggregation.ts"; -import { parseRateTable, type RateTable } from "./usagePricing.ts"; +import { createOverrideRateTable, parseRateTable, type RateTable } from "./usagePricing.ts"; import { listTranscriptFiles, readDirectoryVolumeId, @@ -232,24 +233,22 @@ export const make = Effect.gen(function* () { return nestedExists ? nested : path.join(homePath, "projects"); }); - /** Resolves the transcript directory for each provider. */ - const resolveTranscriptDirs = Effect.fn("UsageService.resolveTranscriptDirs")(function* () { - // A settings failure must surface as an error: swallowing it here would - // present "zero usage from every provider" as a valid answer. - const settings = yield* settingsService.getSettings.pipe( - Effect.catchCause( - (cause) => - new UsageReadError({ - reason: "scanFailed", - // Bounded description; the squashed failure travels as the cause. - // Squashed, not the Cause tree: a full tree in a Defect field is - // the unbounded wire payload the bounded detail exists to avoid. - detail: "Server settings could not be read.", - cause: Cause.squash(cause), - }), - ), - ); + // A settings failure must not silently discard custom rates or transcript homes. + const readSettings = settingsService.getSettings.pipe( + Effect.catchCause( + (cause) => + new UsageReadError({ + reason: "scanFailed", + detail: "Server settings could not be read.", + cause: Cause.squash(cause), + }), + ), + ); + /** Resolves the transcript directory for each provider. */ + const resolveTranscriptDirs = Effect.fn("UsageService.resolveTranscriptDirs")(function* ( + settings: ServerSettingsValue, + ) { const claudeHome = yield* resolveClaudeHomePath(settings.providers.claudeAgent); const claudeDir = yield* resolveClaudeTranscriptDir(claudeHome); const codexLayout = yield* resolveCodexHomeLayout(settings.providers.codex); @@ -379,10 +378,15 @@ export const make = Effect.gen(function* () { | null; } - const collectDirs = Effect.fn("UsageService.collectDirs")(function* (windowStartMs: number) { + const collectDirs = Effect.fn("UsageService.collectDirs")(function* ( + windowStartMs: number, + settings: ServerSettingsValue, + ) { // The home resolvers ask for `Path` themselves; satisfy them from the // instance we already hold so the scan stays context-free. - const dirs = yield* resolveTranscriptDirs().pipe(Effect.provideService(Path.Path, path)); + const dirs = yield* resolveTranscriptDirs(settings).pipe( + Effect.provideService(Path.Path, path), + ); const scanned: ScannedDir[] = []; for (const { provider, dir, fileName } of dirs) { const volumeId = yield* Effect.promise(() => readDirectoryVolumeId(dir)); @@ -406,7 +410,10 @@ export const make = Effect.gen(function* () { return scanned; }); - const scanSummary = Effect.fn("UsageService.scanSummary")(function* (input: UsageSummaryInput) { + const scanSummary = Effect.fn("UsageService.scanSummary")(function* ( + input: UsageSummaryInput, + settings: ServerSettingsValue, + ) { if (input.sinceDay > input.untilDay) { return yield* new UsageReadError({ reason: "invalidWindow", @@ -455,9 +462,10 @@ export const make = Effect.gen(function* () { // Pricing only matters once records are aggregated, so the rate table // loads while transcripts stream instead of gating them: a cold rates // fetch on a slow network no longer delays the scan by its own timeout. - const [, scannedDirs] = yield* Effect.all([ensureRates(false), collectDirs(windowStartMs)], { - concurrency: 2, - }); + const [, scannedDirs] = yield* Effect.all( + [ensureRates(false), collectDirs(windowStartMs, settings)], + { concurrency: 2 }, + ); const aggregator = new UsageAggregator({ timeZone: input.timeZone, @@ -466,6 +474,7 @@ export const make = Effect.gen(function* () { resolution: input.resolution ?? "day", ...hourlyWindow, rates, + priceOverrides: createOverrideRateTable(settings.usagePriceOverrides), }); const sources: UsageSource[] = []; @@ -547,13 +556,16 @@ export const make = Effect.gen(function* () { }); /** - * In-flight scans by window, so concurrent identical requests (the usage + * In-flight scans by window and custom prices, so concurrent identical requests (the usage * page open on two clients at once) share one scan instead of racing over * the same corpus twice. */ const inflightScans = new Map>(); - const scanKey = (input: UsageSummaryInput): string => + const scanKey = ( + input: UsageSummaryInput, + priceOverrides: ServerSettingsValue["usagePriceOverrides"], + ): string => JSON.stringify([ input.timeZone, input.sinceDay, @@ -561,10 +573,12 @@ export const make = Effect.gen(function* () { input.resolution ?? "day", input.sinceTime ?? null, input.untilTime ?? null, + priceOverrides, ]); const readSummary = Effect.fn("UsageService.readSummary")(function* (input: UsageSummaryInput) { - const key = scanKey(input); + const settings = yield* readSettings; + const key = scanKey(input, settings.usagePriceOverrides); const deferred = yield* Effect.uninterruptible( Effect.gen(function* () { const existing = inflightScans.get(key); @@ -576,7 +590,7 @@ export const make = Effect.gen(function* () { inflightScans.set(key, created); // Detached so one departing client cannot tear the scan out from under // the fibers awaiting it; a finished scan warms the cache either way. - yield* scanSummary(input).pipe( + yield* scanSummary(input, settings).pipe( Effect.onExit((exit) => Effect.sync(() => inflightScans.delete(key)).pipe( Effect.andThen(Deferred.done(created, exit)), diff --git a/apps/server/src/usage/usageAggregation.ts b/apps/server/src/usage/usageAggregation.ts index e100be76e979..01a1195efb60 100644 --- a/apps/server/src/usage/usageAggregation.ts +++ b/apps/server/src/usage/usageAggregation.ts @@ -61,6 +61,7 @@ export interface AggregateOptions { readonly sinceDay: string; readonly untilDay: string; readonly rates: RateTable; + readonly priceOverrides?: RateTable; readonly resolution?: UsageResolution; readonly sinceTimeMs?: number; readonly untilTimeMs?: number; @@ -165,11 +166,17 @@ export class UsageAggregator { record.model, record.totals, record.reportedCostUsd, + this.#options.priceOverrides, ); bucket.totals = addTotals(bucket.totals, record.totals); bucket.costUsd += priced.costUsd; - bucket.cacheSavingsUsd += cacheSavingsUsd(this.#options.rates, record.model, record.totals); + bucket.cacheSavingsUsd += cacheSavingsUsd( + this.#options.rates, + record.model, + record.totals, + this.#options.priceOverrides, + ); bucket.records += 1; if (priced.costSource === "unpriced") bucket.unpricedRecords += 1; if (priced.costSource === "providerReported") bucket.providerReportedRecords += 1; diff --git a/apps/server/src/usage/usagePricing.test.ts b/apps/server/src/usage/usagePricing.test.ts index 45113414f69c..d45dfe2dd09b 100644 --- a/apps/server/src/usage/usagePricing.test.ts +++ b/apps/server/src/usage/usagePricing.test.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from "@effect/vitest"; -import { lookupRate, normalizeModelName, parseRateTable } from "./usagePricing.ts"; +import { + cacheSavingsUsd, + createOverrideRateTable, + lookupRate, + normalizeModelName, + parseRateTable, + priceUsage, +} from "./usagePricing.ts"; const rate = (input: number, cacheRead?: number) => ({ input_cost_per_token: input, @@ -9,6 +16,73 @@ const rate = (input: number, cacheRead?: number) => ({ }); describe("usage pricing", () => { + const totals = { + uncachedInputTokens: 1_000_000, + cachedInputTokens: 1_000_000, + cacheCreationTokens: 1_000_000, + outputTokens: 1_000_000, + reasoningTokens: 500_000, + }; + + it("uses custom token rates ahead of public and provider-reported costs", () => { + const table = parseRateTable({ "example-model": rate(1) }); + const overrides = createOverrideRateTable({ + "example-model": { + inputCostPerMillionTokens: 2, + outputCostPerMillionTokens: 8, + cacheReadCostPerMillionTokens: 0.5, + cacheWriteCostPerMillionTokens: 3, + }, + }); + + for (const reportedCostUsd of [null, 99]) { + expect(priceUsage(table, "example-model", totals, reportedCostUsd, overrides)).toEqual({ + costUsd: 13.5, + costSource: "modelPriced", + }); + } + expect(cacheSavingsUsd(table, "example-model", totals, overrides)).toBe(1.5); + }); + + it("prices unknown models offline and uses input prices for omitted cache rates", () => { + const table = parseRateTable({}); + const overrides = createOverrideRateTable({ + "example-model": { inputCostPerMillionTokens: 2, outputCostPerMillionTokens: 8 }, + }); + + expect(priceUsage(table, "example-model", totals, null, overrides)).toEqual({ + costUsd: 14, + costSource: "modelPriced", + }); + expect(cacheSavingsUsd(table, "example-model", totals, overrides)).toBe(0); + }); + + it("preserves explicit zero rates and matches only the exact trimmed model ID", () => { + const table = parseRateTable({}); + const overrides = createOverrideRateTable({ + " vendor/example-model[1m] ": { + inputCostPerMillionTokens: 0, + outputCostPerMillionTokens: 0, + }, + }); + expect(priceUsage(table, " vendor/example-model[1m] ", totals, 99, overrides)).toEqual({ + costUsd: 0, + costSource: "modelPriced", + }); + for (const model of [ + "example-model[1m]", + "vendor/example-model", + "vendor/Example-model[1m]", + "other/example-model[1m]", + ]) { + expect(priceUsage(table, model, totals, null, overrides).costSource).toBe("unpriced"); + expect(priceUsage(table, model, totals, 99, overrides)).toEqual({ + costUsd: 99, + costSource: "providerReported", + }); + } + }); + it("keeps the existing model-name normalization contract", () => { expect(normalizeModelName(" Anthropic/Claude-Opus-5 ")).toBe("claude-opus-5"); }); diff --git a/apps/server/src/usage/usagePricing.ts b/apps/server/src/usage/usagePricing.ts index a7beecb1f552..5ca75a68cb32 100644 --- a/apps/server/src/usage/usagePricing.ts +++ b/apps/server/src/usage/usagePricing.ts @@ -7,7 +7,11 @@ * * @module usagePricing */ -import type { UsageCostSource, UsageTokenTotals } from "@t3tools/contracts"; +import type { + UsageCostSource, + UsageModelPriceOverride, + UsageTokenTotals, +} from "@t3tools/contracts"; /** * The subset of a LiteLLM entry we price against. All values are USD per token. @@ -26,6 +30,25 @@ export interface ModelRate { export type RateTable = ReadonlyMap; +/** Custom IDs keep their case, provider prefix, and variant suffix. */ +export function createOverrideRateTable( + overrides: Readonly>, +): RateTable { + return new Map( + Object.entries(overrides).map(([model, prices]) => [ + model.trim(), + { + inputCostPerToken: prices.inputCostPerMillionTokens / 1_000_000, + outputCostPerToken: prices.outputCostPerMillionTokens / 1_000_000, + cacheReadCostPerToken: + (prices.cacheReadCostPerMillionTokens ?? prices.inputCostPerMillionTokens) / 1_000_000, + cacheCreationCostPerToken: + (prices.cacheWriteCostPerMillionTokens ?? prices.inputCostPerMillionTokens) / 1_000_000, + }, + ]), + ); +} + /** Raw shape of one LiteLLM entry, narrowed to the fields we read. */ interface LiteLlmEntry { readonly input_cost_per_token?: unknown; @@ -168,12 +191,14 @@ export function priceUsage( model: string, totals: UsageTokenTotals, reportedCostUsd: number | null, + overrides?: RateTable, ): PricedUsage { - if (reportedCostUsd !== null && Number.isFinite(reportedCostUsd)) { + const override = overrides?.get(model.trim()); + if (override === undefined && reportedCostUsd !== null && Number.isFinite(reportedCostUsd)) { return { costUsd: reportedCostUsd, costSource: "providerReported" }; } - const rate = lookupRate(table, model); + const rate = override ?? lookupRate(table, model); if (rate === null) return { costUsd: 0, costSource: "unpriced" }; const costUsd = @@ -189,8 +214,13 @@ export function priceUsage( * What the cached input would have cost at full input rates, minus what it * actually cost. Drives the "cache savings" figure. */ -export function cacheSavingsUsd(table: RateTable, model: string, totals: UsageTokenTotals): number { - const rate = lookupRate(table, model); +export function cacheSavingsUsd( + table: RateTable, + model: string, + totals: UsageTokenTotals, + overrides?: RateTable, +): number { + const rate = overrides?.get(model.trim()) ?? lookupRate(table, model); if (rate === null) return 0; return totals.cachedInputTokens * (rate.inputCostPerToken - rate.cacheReadCostPerToken); } diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 75f69de952c1..b2400f68779c 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -1,4 +1,7 @@ +// @effect-diagnostics nodeBuiltinImport:off - realpathSync.native resolves Windows 8.3 short names, which the Effect realPath does not. +import * as NodeFS from "node:fs"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { assert, it, describe } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -570,7 +573,6 @@ it.effect("backs off failed upstream refreshes across linked worktrees", () => const driver = yield* makeGitVcsDriverCore().pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, failingFetchSpawner), ); - const fileSystem = yield* FileSystem.FileSystem; const cwd = yield* makeTmpDir(); const remote = yield* makeTmpDir("git-vcs-driver-remote-"); const worktreesRoot = yield* makeTmpDir("git-vcs-driver-worktrees-"); @@ -606,9 +608,11 @@ it.effect("backs off failed upstream refreshes across linked worktrees", () => "rev-parse", "--git-common-dir", ])).stdout.trim(); + // Native realpath, since git reports the long form of a directory the + // temp dir may name by its 8.3 short form on Windows. assert.equal( - yield* fileSystem.realPath(pathService.resolve(cwd, rootCommonDir)), - yield* fileSystem.realPath(pathService.resolve(worktreePath, linkedCommonDir)), + NodeFS.realpathSync.native(pathService.resolve(cwd, rootCommonDir)), + NodeFS.realpathSync.native(pathService.resolve(worktreePath, linkedCommonDir)), ); yield* Ref.set(fetchAttempts, 0); @@ -1376,31 +1380,33 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }); describe("worktree operations", () => { - it.effect("preserves newline characters in worktree paths when listing refs", () => - Effect.gen(function* () { - const cwd = yield* makeTmpDir(); - yield* initRepoWithCommit(cwd); - const worktreesRoot = yield* makeTmpDir("git-vcs-driver-worktrees-"); - const fileSystem = yield* FileSystem.FileSystem; - const pathService = yield* Path.Path; - const worktreePath = pathService.join(worktreesRoot, "linked\nworktree"); - const driver = yield* GitVcsDriver.GitVcsDriver; + // NTFS rejects a newline in a file name, so there is nothing to preserve there. + it.effect.skipIf(HostProcessPlatform.defaultValue() === "win32")( + "preserves newline characters in worktree paths when listing refs", + () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const worktreesRoot = yield* makeTmpDir("git-vcs-driver-worktrees-"); + const pathService = yield* Path.Path; + const worktreePath = pathService.join(worktreesRoot, "linked\nworktree"); + const driver = yield* GitVcsDriver.GitVcsDriver; - yield* git(cwd, ["worktree", "add", "-b", "feature/newline-path", worktreePath]); + yield* git(cwd, ["worktree", "add", "-b", "feature/newline-path", worktreePath]); - const refs = yield* driver.listRefs({ cwd, refresh: true }); - const listedPath = refs.refs.find( - (ref) => ref.name === "feature/newline-path", - )?.worktreePath; + const refs = yield* driver.listRefs({ cwd, refresh: true }); + const listedPath = refs.refs.find( + (ref) => ref.name === "feature/newline-path", + )?.worktreePath; - if (typeof listedPath !== "string") { - return assert.fail("expected the linked branch to include its worktree path"); - } - assert.equal( - yield* fileSystem.realPath(listedPath), - yield* fileSystem.realPath(worktreePath), - ); - }), + if (typeof listedPath !== "string") { + return assert.fail("expected the linked branch to include its worktree path"); + } + assert.equal( + NodeFS.realpathSync.native(listedPath), + NodeFS.realpathSync.native(worktreePath), + ); + }), ); it.effect("checks out submodules in a new worktree", () => diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts index c7d7b79a12e0..ac21a61ddf78 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts @@ -27,6 +27,7 @@ import { GitManagerError } from "@t3tools/contracts"; import * as VcsStatusBroadcaster from "./VcsStatusBroadcaster.ts"; import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; import * as GitWorkflowService from "../git/GitWorkflowService.ts"; +import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); @@ -144,7 +145,7 @@ function makeBackgroundPolicyLayer(shouldRunScopeWork: (scope: BackgroundScope) } describe("VcsStatusBroadcaster", () => { - it.effect( + it.effect.skipIf(!symlinksSupported)( "automatically pulls an enabled clean default branch when status detects it is behind", () => { let remoteStatus: VcsStatusRemoteResult = { ...baseRemoteStatus, behindCount: 2 }; @@ -522,67 +523,70 @@ describe("VcsStatusBroadcaster", () => { }).pipe(Effect.provide(makeTestLayer(state))); }); - it.effect("normalizes symlinked CWDs before cache lookup and workflow calls", () => { - const seenCwds: string[] = []; - const state = { - currentLocalStatus: baseLocalStatus, - currentRemoteStatus: baseRemoteStatus, - localStatusCalls: 0, - remoteStatusCalls: 0, - localInvalidationCalls: 0, - remoteInvalidationCalls: 0, - }; - const testLayer = VcsStatusBroadcaster.layer.pipe( - Layer.provideMerge(NodeServices.layer), - Layer.provide(makeBackgroundPolicyLayer(() => true)), - Layer.provide( - Layer.mock(GitWorkflowService.GitWorkflowService)({ - localStatus: (input) => - Effect.sync(() => { - seenCwds.push(input.cwd); - state.localStatusCalls += 1; - return state.currentLocalStatus; - }), - remoteStatus: (input) => - Effect.sync(() => { - seenCwds.push(input.cwd); - state.remoteStatusCalls += 1; - return state.currentRemoteStatus; - }), - invalidateLocalStatus: () => - Effect.sync(() => { - state.localInvalidationCalls += 1; - }), - invalidateRemoteStatus: () => - Effect.sync(() => { - state.remoteInvalidationCalls += 1; - }), - } satisfies Partial), - ), - ); + it.effect.skipIf(!symlinksSupported)( + "normalizes symlinked CWDs before cache lookup and workflow calls", + () => { + const seenCwds: string[] = []; + const state = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: baseRemoteStatus, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + }; + const testLayer = VcsStatusBroadcaster.layer.pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provide(makeBackgroundPolicyLayer(() => true)), + Layer.provide( + Layer.mock(GitWorkflowService.GitWorkflowService)({ + localStatus: (input) => + Effect.sync(() => { + seenCwds.push(input.cwd); + state.localStatusCalls += 1; + return state.currentLocalStatus; + }), + remoteStatus: (input) => + Effect.sync(() => { + seenCwds.push(input.cwd); + state.remoteStatusCalls += 1; + return state.currentRemoteStatus; + }), + invalidateLocalStatus: () => + Effect.sync(() => { + state.localInvalidationCalls += 1; + }), + invalidateRemoteStatus: () => + Effect.sync(() => { + state.remoteInvalidationCalls += 1; + }), + } satisfies Partial), + ), + ); - return Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const realDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-vcs-status-real-", - }); - const linkParent = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-vcs-status-link-", - }); - const linkDir = path.join(linkParent, "repo-link"); - yield* fileSystem.symlink(realDir, linkDir); - const realPath = yield* fileSystem.realPath(realDir); + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const realDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-vcs-status-real-", + }); + const linkParent = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-vcs-status-link-", + }); + const linkDir = path.join(linkParent, "repo-link"); + yield* fileSystem.symlink(realDir, linkDir); + const realPath = yield* fileSystem.realPath(realDir); - const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; - yield* broadcaster.getStatus({ cwd: linkDir }); - yield* broadcaster.getStatus({ cwd: realDir }); + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + yield* broadcaster.getStatus({ cwd: linkDir }); + yield* broadcaster.getStatus({ cwd: realDir }); - assert.deepStrictEqual(seenCwds, [realPath, realPath]); - assert.equal(state.localStatusCalls, 1); - assert.equal(state.remoteStatusCalls, 1); - }).pipe(Effect.provide(testLayer)); - }); + assert.deepStrictEqual(seenCwds, [realPath, realPath]); + assert.equal(state.localStatusCalls, 1); + assert.equal(state.remoteStatusCalls, 1); + }).pipe(Effect.provide(testLayer)); + }, + ); it.effect("streams a local snapshot first and remote updates later", () => { const state = { diff --git a/apps/server/src/workspace/WorkspaceFileSystem.test.ts b/apps/server/src/workspace/WorkspaceFileSystem.test.ts index 17c7f0bb050c..5afc212ce172 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.test.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.test.ts @@ -14,6 +14,8 @@ import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as WorkspaceEntries from "./WorkspaceEntries.ts"; import * as WorkspaceFileSystem from "./WorkspaceFileSystem.ts"; import * as WorkspacePaths from "./WorkspacePaths.ts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; const ProjectLayer = WorkspaceFileSystem.layer.pipe( Layer.provide(WorkspacePaths.layer), @@ -99,28 +101,31 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i }), ); - it.effect("rejects a FIFO without blocking on open", () => - Effect.gen(function* () { - const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; - const path = yield* Path.Path; - const cwd = yield* makeTempDir; - const outsideDir = yield* makeTempDir; - const fifoPath = path.join(outsideDir, "pipe"); - yield* Effect.promise( - () => - new Promise((resolve, reject) => - NodeChildProcess.execFile("mkfifo", [fifoPath], (error) => - error ? reject(error) : resolve(), + // Needs mkfifo; Windows has no FIFOs to reject. + it.effect.skipIf(HostProcessPlatform.defaultValue() === "win32")( + "rejects a FIFO without blocking on open", + () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + const outsideDir = yield* makeTempDir; + const fifoPath = path.join(outsideDir, "pipe"); + yield* Effect.promise( + () => + new Promise((resolve, reject) => + NodeChildProcess.execFile("mkfifo", [fifoPath], (error) => + error ? reject(error) : resolve(), + ), ), - ), - ); + ); - const error = yield* workspaceFileSystem - .readFile({ cwd, relativePath: fifoPath }) - .pipe(Effect.flip); + const error = yield* workspaceFileSystem + .readFile({ cwd, relativePath: fifoPath }) + .pipe(Effect.flip); - expect(error).toBeInstanceOf(WorkspaceFileSystem.WorkspacePathNotFileError); - }), + expect(error).toBeInstanceOf(WorkspaceFileSystem.WorkspacePathNotFileError); + }), ); it.effect("rejects reads outside the workspace root", () => @@ -138,34 +143,36 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i }), ); - it.effect("rejects symlinks that resolve outside the workspace root", () => - Effect.gen(function* () { - const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const cwd = yield* makeTempDir; - const outsideDir = yield* makeTempDir; - yield* writeTextFile(outsideDir, "secret.txt", "outside\n"); - yield* fileSystem.symlink( - path.join(outsideDir, "secret.txt"), - path.join(cwd, "linked-secret.txt"), - ); - - const error = yield* workspaceFileSystem - .readFile({ cwd, relativePath: "linked-secret.txt" }) - .pipe(Effect.flip); - const resolvedWorkspaceRoot = yield* fileSystem.realPath(cwd); - const resolvedPath = yield* fileSystem.realPath(path.join(outsideDir, "secret.txt")); - - expect(error).toBeInstanceOf(WorkspaceFileSystem.WorkspaceFilePathEscapeError); - expect(error).toMatchObject({ - workspaceRoot: cwd, - relativePath: "linked-secret.txt", - resolvedWorkspaceRoot, - resolvedPath, - }); - expect("cause" in error).toBe(false); - }), + it.effect.skipIf(!symlinksSupported)( + "rejects symlinks that resolve outside the workspace root", + () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + const outsideDir = yield* makeTempDir; + yield* writeTextFile(outsideDir, "secret.txt", "outside\n"); + yield* fileSystem.symlink( + path.join(outsideDir, "secret.txt"), + path.join(cwd, "linked-secret.txt"), + ); + + const error = yield* workspaceFileSystem + .readFile({ cwd, relativePath: "linked-secret.txt" }) + .pipe(Effect.flip); + const resolvedWorkspaceRoot = yield* fileSystem.realPath(cwd); + const resolvedPath = yield* fileSystem.realPath(path.join(outsideDir, "secret.txt")); + + expect(error).toBeInstanceOf(WorkspaceFileSystem.WorkspaceFilePathEscapeError); + expect(error).toMatchObject({ + workspaceRoot: cwd, + relativePath: "linked-secret.txt", + resolvedWorkspaceRoot, + resolvedPath, + }); + expect("cause" in error).toBe(false); + }), ); it.effect("rejects directories without manufacturing an I/O cause", () => diff --git a/apps/server/vite.config.ts b/apps/server/vite.config.ts index 647af2a889d5..88c8c2f4d37f 100644 --- a/apps/server/vite.config.ts +++ b/apps/server/vite.config.ts @@ -78,6 +78,8 @@ export default mergeConfig( // The server suite exercises sqlite, git, temp worktrees, and orchestration // runtimes heavily. Running files in parallel introduces load-sensitive flakes. fileParallelism: false, + // Appended to the root setup, which mergeConfig concatenates. + setupFiles: ["./src/testUtils/gitConfig.setup.ts"], // Server integration tests exercise sqlite, git, and orchestration together. // Under package-wide runs they can exceed the default budget on loaded CI hosts. hookTimeout: 120_000, diff --git a/apps/web/src/browser/browserTargetResolver.ts b/apps/web/src/browser/browserTargetResolver.ts index c06c60b5f740..4acb1e2b487f 100644 --- a/apps/web/src/browser/browserTargetResolver.ts +++ b/apps/web/src/browser/browserTargetResolver.ts @@ -4,158 +4,16 @@ import type { PreviewUrlResolution, } from "@t3tools/contracts"; import { isLoopbackHost, normalizePreviewUrl } from "@t3tools/shared/preview"; +import { isLocalLoopbackHost, isPrivateNetworkHost } from "@t3tools/shared/hostClassification"; import { readPreparedConnection } from "~/state/session"; -export const normalizeHostname = (host: string): string => - host - .toLowerCase() - .replace(/^\[|\]$/g, "") - .replace(/\.+$/u, ""); - -const parseIpv4Address = (host: string): readonly number[] | null => { - const parts = normalizeHostname(host).split(".").map(Number); - return parts.length === 4 && - parts.every((part) => Number.isInteger(part) && part >= 0 && part <= 255) - ? parts - : null; -}; - -const parseIpv4MappedIpv6Address = (host: string): readonly number[] | null => { - const normalized = normalizeHostname(host); - if (!normalized.startsWith("::ffff:")) return null; - const suffix = normalized.slice("::ffff:".length); - const dotted = parseIpv4Address(suffix); - if (dotted) return dotted; - const hextets = suffix.split(":"); - if (hextets.length !== 2 || hextets.some((part) => !/^[\da-f]{1,4}$/u.test(part))) return null; - const high = Number.parseInt(hextets[0]!, 16); - const low = Number.parseInt(hextets[1]!, 16); - return [high >>> 8, high & 0xff, low >>> 8, low & 0xff]; -}; - -const parseIpv6Address = (host: string): readonly number[] | null => { - const normalized = normalizeHostname(host); - if (!normalized.includes(":")) return null; - const halves = normalized.split("::"); - if (halves.length > 2) return null; - const head = halves[0] ? halves[0].split(":") : []; - const tail = halves[1] ? halves[1].split(":") : []; - if ([...head, ...tail].some((part) => !/^[\da-f]{1,4}$/u.test(part))) return null; - const missing = 8 - head.length - tail.length; - if ((halves.length === 1 && missing !== 0) || (halves.length === 2 && missing < 1)) return null; - return [...head, ...Array.from({ length: missing }, () => "0"), ...tail].map((part) => - Number.parseInt(part, 16), - ); -}; - -const ipv6PrefixMatches = ( - address: readonly number[], - prefix: readonly number[], - prefixLength: number, -): boolean => { - const fullHextets = Math.floor(prefixLength / 16); - if (address.slice(0, fullHextets).some((part, index) => part !== prefix[index])) return false; - const remainingBits = prefixLength % 16; - if (remainingBits === 0) return true; - const mask = (0xffff << (16 - remainingBits)) & 0xffff; - return (address[fullHextets]! & mask) === (prefix[fullHextets]! & mask); -}; - -const isPrivateIpv4Address = (parts: readonly number[]): boolean => - parts[0] === 0 || - parts[0] === 10 || - parts[0] === 127 || - (parts[0] === 100 && parts[1]! >= 64 && parts[1]! <= 127) || - (parts[0] === 172 && parts[1]! >= 16 && parts[1]! <= 31) || - (parts[0] === 192 && parts[1] === 168) || - (parts[0] === 169 && parts[1] === 254) || - (parts[0] === 198 && parts[1]! >= 18 && parts[1]! <= 19); - -const isSpecialPurposeIpv4Address = (parts: readonly number[]): boolean => - isPrivateIpv4Address(parts) || - parts[0]! >= 224 || - // Deliberately suppress the whole protocol-assignment block. IANA marks - // .9 and .10 globally reachable, but privacy-safe false negatives are - // preferable to disclosing another special-purpose address by mistake. - (parts[0] === 192 && parts[1] === 0 && parts[2] === 0) || - (parts[0] === 192 && parts[1] === 0 && parts[2] === 2) || - (parts[0] === 192 && parts[1] === 88 && parts[2] === 99) || - (parts[0] === 198 && parts[1] === 51 && parts[2] === 100) || - (parts[0] === 203 && parts[1] === 0 && parts[2] === 113); - -export const isLocalLoopbackHost = (host: string): boolean => { - const normalized = normalizeHostname(host); - if (normalized === "localhost" || normalized === "::1") return true; - return parseIpv4Address(normalized)?.[0] === 127; -}; - -export const isPrivateNetworkHost = (host: string): boolean => { - const normalized = normalizeHostname(host); - if ( - normalized === "::" || - isLocalLoopbackHost(normalized) || - normalized.endsWith(".localhost") || - normalized.endsWith(".local") || - normalized === "home.arpa" || - normalized.endsWith(".home.arpa") || - (!normalized.includes(".") && !normalized.includes(":")) - ) { - return true; - } - if (normalized.endsWith(".ts.net")) return true; - const parts = parseIpv4Address(normalized) ?? parseIpv4MappedIpv6Address(normalized); - if (parts) return isPrivateIpv4Address(parts); - const firstIpv6Token = normalized.split(":", 1)[0] ?? ""; - if (!normalized.includes(":") || !/^[\da-f]{1,4}$/u.test(firstIpv6Token)) return false; - const firstIpv6Hextet = Number.parseInt(firstIpv6Token, 16); - return ( - Number.isInteger(firstIpv6Hextet) && - ((firstIpv6Hextet & 0xfe00) === 0xfc00 || (firstIpv6Hextet & 0xffc0) === 0xfe80) - ); -}; - -/** Whether a hostname is eligible to be disclosed to a public favicon provider. */ -export const isPublicFaviconHost = (host: string): boolean => { - // A single trailing dot is a valid absolute DNS name. Repeated trailing - // dots are malformed and can conceal legacy numeric forms such as 127.1. - if (host.endsWith("..")) return false; - const normalized = normalizeHostname(host); - if (isPrivateNetworkHost(normalized)) return false; - if ( - [".alt", ".example", ".internal", ".invalid", ".onion", ".test"].some( - (suffix) => normalized === suffix.slice(1) || normalized.endsWith(suffix), - ) - ) { - return false; - } - const ipv4 = parseIpv4Address(normalized) ?? parseIpv4MappedIpv6Address(normalized); - if (ipv4) return !isSpecialPurposeIpv4Address(ipv4); - if (!normalized.includes(":")) return true; - const ipv6 = parseIpv6Address(normalized); - if (!ipv6) return false; - if (ipv6PrefixMatches(ipv6, [0x0064, 0xff9b, 0, 0, 0, 0, 0, 0], 96)) { - const embeddedIpv4 = [ipv6[6]! >>> 8, ipv6[6]! & 0xff, ipv6[7]! >>> 8, ipv6[7]! & 0xff]; - return !isSpecialPurposeIpv4Address(embeddedIpv4); - } - const first = ipv6[0]!; - if ((first & 0xe000) !== 0x2000) return false; - if (ipv6PrefixMatches(ipv6, [0x2001, 0, 0, 0, 0, 0, 0, 0], 23)) { - const publicProtocolAssignment = - (ipv6[1] === 1 && - ipv6.slice(2, 7).every((part) => part === 0) && - [1, 2, 3].includes(ipv6[7]!)) || - ipv6PrefixMatches(ipv6, [0x2001, 3, 0, 0, 0, 0, 0, 0], 32) || - ipv6PrefixMatches(ipv6, [0x2001, 4, 0x0112, 0, 0, 0, 0, 0], 48) || - ipv6PrefixMatches(ipv6, [0x2001, 0x20, 0, 0, 0, 0, 0, 0], 28) || - ipv6PrefixMatches(ipv6, [0x2001, 0x30, 0, 0, 0, 0, 0, 0], 28); - return publicProtocolAssignment; - } - if (ipv6PrefixMatches(ipv6, [0x2001, 0x0db8, 0, 0, 0, 0, 0, 0], 32)) return false; - if (ipv6PrefixMatches(ipv6, [0x2002, 0, 0, 0, 0, 0, 0, 0], 16)) return false; - if (first === 0x3fff && (ipv6[1]! & 0xf000) === 0) return false; - return true; -}; +export { + normalizeHostname, + isLocalLoopbackHost, + isPrivateNetworkHost, + isPublicFaviconHost, +} from "@t3tools/shared/hostClassification"; const readEnvironmentUrl = (environmentId: EnvironmentId): URL => { const connection = readPreparedConnection(environmentId); diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 3bef170bcf51..6769586f7fa8 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -15,7 +15,11 @@ import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings" import { cn, isMacPlatform } from "../lib/utils"; import { primaryServerKeybindingsAtom } from "../state/server"; import { useEnvironmentIdentificationMode, useLegacySidebarEnabled } from "../hooks/useSettings"; -import { usePanelAnimationSettings } from "../panelAnimations"; +import { + PanelAnimationSuppressionProvider, + usePanelAnimationSettings, + usePanelNavigationSuppression, +} from "../panelAnimations"; import LegacyThreadSidebar from "./LegacySidebar"; import ThreadSidebar from "./Sidebar"; import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; @@ -145,6 +149,8 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { // Settings routes show the settings nav in place of whichever thread // sidebar is active. const pathname = useLocation({ select: (location) => location.pathname }); + const panelAnimationsSuppressed = usePanelNavigationSuppression(pathname); + const routePanelAnimationsActive = panelAnimationsActive && !panelAnimationsSuppressed; const isOnSettings = pathname === "/settings" || pathname.startsWith("/settings/"); const isMacosDesktop = isElectron && isMacPlatform(navigator.platform); const [sidebarWidth, setSidebarWidth] = useState(readInitialThreadSidebarWidth); @@ -213,42 +219,44 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { }, [navigate, pathname]); return ( - - - - nextWidth <= currentWidth || - wrapper.clientWidth - nextWidth >= THREAD_MAIN_CONTENT_MIN_WIDTH, - storageKey: THREAD_SIDEBAR_WIDTH_STORAGE_KEY, - onResize: setSidebarWidth, - }} + + - {isOnSettings ? ( - <> - - - - ) : legacySidebarEnabled ? ( - - ) : ( - - )} - - - {children} - - + + + nextWidth <= currentWidth || + wrapper.clientWidth - nextWidth >= THREAD_MAIN_CONTENT_MIN_WIDTH, + storageKey: THREAD_SIDEBAR_WIDTH_STORAGE_KEY, + onResize: setSidebarWidth, + }} + > + {isOnSettings ? ( + <> + + + + ) : legacySidebarEnabled ? ( + + ) : ( + + )} + + + {children} + + + ); } diff --git a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx index 5a868483b34f..c0fa463fe72a 100644 --- a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx @@ -114,7 +114,7 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe - + Workspace diff --git a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx index fabda55688bc..431805f2174b 100644 --- a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx @@ -102,7 +102,7 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir - + Run on {availableEnvironments.map((env) => ( diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index 18a6c5115eeb..9762506531a8 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -72,6 +72,37 @@ function codeButton(renderer: ReactTestRenderer, label: string) { return button.props as ComponentProps; } +describe("ChatMarkdown favicon privacy", () => { + it("suppresses private link images while preserving public links across updates", async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + let renderer: ReactTestRenderer | undefined; + const markdown = (url: string) => ; + try { + await act(async () => { + renderer = create(markdown("https://github.com")); + }); + expect(renderer!.root.findAllByType("img").map((image) => image.props.src)).toEqual([ + "https://www.google.com/s2/favicons?domain=github.com&sz=32", + ]); + for (const url of ["http://192.168.1.10:8080", "http://localhost:3000", "http://home.arpa"]) { + await act(async () => { + renderer!.update(markdown(url)); + }); + expect(renderer!.root.findAllByType("img")).toHaveLength(0); + } + await act(async () => { + renderer!.update(markdown("https://github.com")); + }); + expect(renderer!.root.findAllByType("img")).toHaveLength(1); + } finally { + await act(async () => { + renderer?.unmount(); + }); + vi.unstubAllGlobals(); + } + }); +}); + describe("ChatMarkdown streaming", () => { it("preserves code controls and details without highlighting an unchanged fence again", async () => { const highlighter = await getSyntaxHighlighterPromise("text"); @@ -262,6 +293,46 @@ describe("hasMarkdownFilePrimaryAction", () => { }); }); +describe("ChatMarkdown skill chips", () => { + it("updates digit-leading skill labels when discovered skills change", async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + let renderer: ReactTestRenderer | undefined; + const text = "Use $2spec with a $20k budget."; + try { + await act(async () => { + renderer = create(); + }); + const mounted = renderer!; + const labels = (label: string) => + mounted.root.findAllByType("span").filter((node) => node.children.includes(label)); + expect(labels("2Spec")).toHaveLength(0); + + await act(async () => { + mounted.update( + , + ); + }); + expect(labels("2Spec")).toHaveLength(1); + expect(labels("MoneySkill")).toHaveLength(0); + + await act(async () => { + mounted.update(); + }); + expect(labels("2Spec")).toHaveLength(0); + } finally { + await act(async () => renderer?.unmount()); + vi.unstubAllGlobals(); + } + }); +}); + describe("ChatMarkdown file option chips", () => { it("keeps the fallback button text selectable", () => { const html = renderToStaticMarkup( diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index deaa68ebdaee..48fafbd0d76d 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -28,6 +28,7 @@ import type { ServerProviderSkill, ThreadLinkedPullRequest, } from "@t3tools/contracts"; +import { faviconUrlForOrigin } from "@t3tools/shared/favicon"; import { isAtomCommandInterrupted, squashAtomCommandFailure, @@ -1133,16 +1134,17 @@ const failedFaviconHosts = new Set(); const MarkdownLinkFavicon = memo(function MarkdownLinkFavicon({ host }: { host: string }) { const [failedHost, setFailedHost] = useState(null); + const faviconUrl = faviconUrlForOrigin(`https://${host}`); return ( - {failedHost === host || failedFaviconHosts.has(host) ? ( + {faviconUrl === null || failedHost === host || failedFaviconHosts.has(host) ? ( ) : ( positionAnchor(12)); }, []); + const onToolOutputCollapsedAtEnd = useCallback(() => { + composerRef.current?.restoreAfterTimelineReachedEnd(); + }, []); + const onIsAtEndChange = useCallback((isAtEnd: boolean) => { if ( !isAtEnd && @@ -7828,6 +7832,7 @@ export default function ChatView(props: ChatViewProps) { contentInsetEndAdjustment={composerTimelineInset} liveFollowEnabled={timelineLiveFollowEnabled} onIsAtEndChange={onIsAtEndChange} + onToolOutputCollapsedAtEnd={onToolOutputCollapsedAtEnd} onManualNavigation={cancelTimelineLiveFollowForUserNavigation} hideEmptyPlaceholder={isDraftHeroState || threadDetailLoading} topFadeEnabled={!hasTimelineTopBanner} diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 06813228b8e4..27dddf00ee53 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -508,7 +508,10 @@ export function CommandPalette({ children }: { children: ReactNode }) { setOpen(open); }} > - {children} + {/* Block background focus calls for the entire time the palette is open. */} +
+ {children} +
group.items) .find((item) => item.shortcutCommand === command); if (matchingItem) { - event.preventDefault(); - event.stopPropagation(); executeItem(matchingItem); - return; } + return; } if (command === "thread.copyReference" && activeThreadReferenceCopyTarget !== null) { event.preventDefault(); diff --git a/apps/web/src/components/CommandPaletteContent.tsx b/apps/web/src/components/CommandPaletteContent.tsx index af3c1b671704..8c1a5b0e3c83 100644 --- a/apps/web/src/components/CommandPaletteContent.tsx +++ b/apps/web/src/components/CommandPaletteContent.tsx @@ -1,5 +1,5 @@ import { ArrowDownIcon, ArrowUpIcon } from "lucide-react"; -import type { ComponentProps, ReactNode } from "react"; +import { type ComponentProps, type ReactNode, useLayoutEffect, useRef } from "react"; import { Command, CommandFooter, CommandInput, CommandPanel } from "./ui/command"; import { Kbd, KbdGroup } from "./ui/kbd"; @@ -33,11 +33,20 @@ export function CommandPaletteContent({ testId, ...commandProps }: CommandPaletteContentProps) { + const inputRef = useRef(null); + + // Direct-open flows replace the initial palette view after the dialog has + // already moved focus. Reclaim it when the replacement input mounts so + // typing cannot continue in the composer behind the modal. + useLayoutEffect(() => { + inputRef.current?.focus(); + }, []); + return (
- + {inputAccessory}
{children} diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index bad0e1b4ffe0..87f5f1ba56f8 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -800,8 +800,9 @@ export default function DiffPanel({ )} { const next = value[0]; @@ -810,10 +811,10 @@ export default function DiffPanel({ } }} > - + - + diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 341444324e73..b7fc811d5127 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -42,6 +42,7 @@ import { Radio as RadioPrimitive } from "@base-ui/react/radio"; import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "~/components/Icons"; import { RadioGroup } from "~/components/ui/radio-group"; import { Spinner } from "~/components/ui/spinner"; +import { toggleVariants } from "~/components/ui/toggle"; import { cn } from "~/lib/utils"; import { buildGitActionProgressStages, @@ -831,32 +832,29 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { Protocol - setPublishProtocol(value as SourceControlCloneProtocol) - } + onValueChange={(protocol) => { + if (protocol === "ssh" || protocol === "https") { + setPublishProtocol(protocol); + } + }} aria-labelledby="publish-protocol-label" disabled={publishRepositoryAction.isPending} - className="grid grid-cols-2 gap-2" > - {(["ssh", "https"] as const).map((value) => { - const isSelected = publishProtocol === value; - return ( - - {value === "ssh" ? "SSH" : "HTTPS"} - - ); - })} + {(["ssh", "https"] as const).map((protocol) => ( + + {protocol.toUpperCase()} + + ))}
diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index ea910905efe0..650ad12bebcb 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -172,7 +172,7 @@ import { useSidebar, } from "./ui/sidebar"; import { useThreadSelectionStore } from "../threadSelectionStore"; -import { openCommandPalette } from "../commandPaletteBus"; +import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus"; import { archiveSelectedThreadEntries, buildMultiSelectThreadContextMenuItems, @@ -3510,7 +3510,7 @@ export default function LegacySidebar() { const onWindowKeyDown = (event: globalThis.KeyboardEvent) => { const shortcutContext = getCurrentSidebarShortcutContext(); - if (event.defaultPrevented || event.repeat) { + if (event.defaultPrevented || event.repeat || isCommandPaletteOpen() || isModelPickerOpen()) { return; } diff --git a/apps/web/src/components/ServerUpdateAction.test.tsx b/apps/web/src/components/ServerUpdateAction.test.tsx index 584078c12bf4..67236361f677 100644 --- a/apps/web/src/components/ServerUpdateAction.test.tsx +++ b/apps/web/src/components/ServerUpdateAction.test.tsx @@ -15,7 +15,8 @@ vi.mock("~/hooks/useCopyToClipboard", () => ({ useCopyToClipboard: () => ({ copyToClipboard: vi.fn() }), })); vi.mock("~/hooks/useSettings", () => ({ - useClientSettings: ( + useEnvironmentSettings: ( + _environmentId: EnvironmentId, selector: (settings: { continueThreadsAfterServerUpdate: boolean }) => unknown, ) => selector({ continueThreadsAfterServerUpdate: testState.continueThreadsAfterServerUpdate }), })); diff --git a/apps/web/src/components/ServerUpdateAction.tsx b/apps/web/src/components/ServerUpdateAction.tsx index 71b974416dd3..1845ada716b6 100644 --- a/apps/web/src/components/ServerUpdateAction.tsx +++ b/apps/web/src/components/ServerUpdateAction.tsx @@ -8,7 +8,7 @@ import type { ComponentProps } from "react"; import { requestConfirmDialog } from "~/confirmDialog"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; -import { useClientSettings } from "~/hooks/useSettings"; +import { useEnvironmentSettings } from "~/hooks/useSettings"; import { serverEnvironment } from "~/state/server"; import { useAtomCommand } from "~/state/use-atom-command"; import { manualServerUpdateCommand } from "~/versionSkew"; @@ -99,7 +99,8 @@ export function ServerUpdateAction({ readonly size?: ComponentProps["size"]; }) { const isDesktopAppUpdate = selfUpdate === "desktop-managed"; - const continueThreadsAfterServerUpdate = useClientSettings( + const continueThreadsAfterServerUpdate = useEnvironmentSettings( + environmentId, (settings) => settings.continueThreadsAfterServerUpdate, ); const updateServer = useAtomCommand(serverEnvironment.updateServer, { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 4509fde6ceb9..2e962810d621 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -104,14 +104,18 @@ import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore" import { useThreadSelectionStore } from "../threadSelectionStore"; import { useThreadActions } from "../hooks/useThreadActions"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; -import { openCommandPalette } from "../commandPaletteBus"; +import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus"; import { startNewThreadFromContext } from "../lib/chatThreadActions"; import { useClientSettings } from "../hooks/useSettings"; import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; import { useLocalStorage } from "../hooks/useLocalStorage"; import { useNowMinute } from "../hooks/useNowMinute"; import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; -import { useProjects, useThreadShells } from "../state/entities"; +import { + useAllEnvironmentProjectSnapshotsReady, + useProjects, + useThreadShells, +} from "../state/entities"; import { environmentServerConfigsAtom, primaryServerKeybindingsAtom } from "../state/server"; import { vcsEnvironment } from "../state/vcs"; import { threadEnvironment } from "../state/threads"; @@ -2098,7 +2102,11 @@ export default function Sidebar() { // Project scope: one menu above the list. Scoping filters the list without // making the header width depend on the number or length of project names. - const [projectScopeKey, setProjectScopeKey] = useState(null); + // The selection lives in the persisted UI store next to the other sidebar + // project preferences, so routes that unmount the sidebar (Settings) and + // app restarts keep it. + const projectScopeKey = useUiStateStore((store) => store.sidebarProjectScopeKey); + const setProjectScopeKey = useUiStateStore((store) => store.setSidebarProjectScopeKey); // {value, label} items let Base UI drive the combobox selection contract // while the popup search filters the same collection. const projectScopeItems = useMemo( @@ -2162,11 +2170,15 @@ export default function Sidebar() { ), [scopedProjectGroup], ); + // A persisted scope whose project is gone falls back to all projects, but + // only after every catalog environment has a live project snapshot. Cached + // or disconnected environments cannot establish that the project is gone. + const allProjectSnapshotsReady = useAllEnvironmentProjectSnapshotsReady(); useEffect(() => { - if (projectScopeKey !== null && scopedProjectGroup === null) { + if (projectScopeKey !== null && allProjectSnapshotsReady && scopedProjectGroup === null) { setProjectScopeKey(null); } - }, [projectScopeKey, scopedProjectGroup]); + }, [allProjectSnapshotsReady, projectScopeKey, scopedProjectGroup, setProjectScopeKey]); // Count-only subscription: the parent needs "are there draft rows" for the // empty state, while SidebarDraftBlock owns the per-keystroke content // subscription. Selecting a number keeps typing in a draft composer from @@ -3493,7 +3505,9 @@ export default function Sidebar() { ); useEffect(() => { const onWindowKeyDown = (event: KeyboardEvent) => { - if (event.defaultPrevented || event.repeat) return; + if (event.defaultPrevented || event.repeat || isCommandPaletteOpen() || isModelPickerOpen()) { + return; + } const command = resolveShortcutCommand(event, keybindings, { platform: navigator.platform, context: { diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 89cdc3649acd..9f4956aae682 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -516,7 +516,10 @@ export function TerminalViewport({ // never started, so only "exited" triggers the message — as with xterm.) synchronizedStatusRef.current = "closed"; synchronizeTerminalStatus(terminal, latestSession.status); - if (autoFocus && visibleRef.current) window.requestAnimationFrame(() => terminal.focus()); + // Startup may finish after the user has returned to the composer. + if (visibleRef.current && mount.contains(document.activeElement)) { + terminal.focus(); + } const dismissSelectionAction = (supersede = false) => { const ownsMenu = @@ -870,10 +873,10 @@ export function TerminalViewport({ return () => { cancelled = true; + const hadFocus = mount.contains(document.activeElement); teardown?.(); + if (hadFocus && mount.isConnected) mount.focus({ preventScroll: true }); }; - // autoFocus is intentionally omitted; - // it is only read at mount time and must not trigger terminal teardown/recreation. }, [cwd, environmentId, runtimeEnvKey, terminalId, threadId, worktreePath]); useEffect(() => { @@ -904,24 +907,14 @@ export function TerminalViewport({ writeSystemMessage(terminal, current.error); } - if (previous.version === 0 && autoFocus && visibleRef.current) { - window.requestAnimationFrame(() => { - terminal.focus(); - }); - } previousSessionRef.current = current; - }, [autoFocus, terminalOutput, terminalError, terminalStatus, terminalVersion]); + }, [terminalOutput, terminalError, terminalStatus, terminalVersion]); useEffect(() => { if (!autoFocus || !visible) return; - const terminal = terminalRef.current; - if (!terminal) return; - const frame = window.requestAnimationFrame(() => { - terminal.focus(); - }); - return () => { - window.cancelAnimationFrame(frame); - }; + // Claim focus when requested, then hand it to the terminal once ready only + // if the user has not focused something else in the meantime. + (terminalRef.current ?? containerRef.current)?.focus(); }, [autoFocus, focusRequestId, visible]); useEffect(() => { @@ -944,6 +937,7 @@ export function TerminalViewport({ return (
); diff --git a/apps/web/src/components/chat/ChangedFilesTree.test.tsx b/apps/web/src/components/chat/ChangedFilesTree.test.tsx index 4998c40b0c55..899714ed55a1 100644 --- a/apps/web/src/components/chat/ChangedFilesTree.test.tsx +++ b/apps/web/src/components/chat/ChangedFilesTree.test.tsx @@ -10,26 +10,21 @@ describe("ChangedFilesCard", () => { {}} onToggleAllDirectories={() => {}} onOpenTurnDiff={() => {}} />, ); - expect(markup).toContain('data-changed-files-state="expanded"'); - expect(markup).toContain('aria-expanded="true"'); - expect(markup).toContain('aria-label="Collapse all folders"'); + expect(markup).toContain('data-changed-files-state="tree"'); expect(markup).toContain('aria-label="Open diff"'); expect(markup).toContain('role="group" aria-label="2 additions, 1 deletions"'); expect(markup).toContain("1 changed file"); expect(markup).not.toContain("1 changed files"); }); - it("renders a scope and representative-file preview for a large latest change", () => { + it("shows collapsed folders and root files together", () => { const markup = renderToStaticMarkup( { }, { path: "README.md", kind: "modified", additions: 3, deletions: 0 }, ]} - expanded={false} - showCompactPreview allDirectoriesExpanded={false} resolvedTheme="light" - onExpandedChange={() => {}} onToggleAllDirectories={() => {}} onOpenTurnDiff={() => {}} />, ); - expect(markup).toContain('data-changed-files-state="preview"'); + expect(markup).toContain('data-changed-files-state="tree"'); expect(markup).toContain('aria-expanded="false"'); - expect(markup).toContain("apps"); - expect(markup).toContain("2 files"); - expect(markup).toContain("packages"); - expect(markup).toContain("root"); - expect(markup).toContain("App.tsx"); - expect(markup).toContain("git.ts"); + expect(markup).toContain("apps/web/src"); + expect(markup).not.toContain("App.tsx"); + expect(markup).toContain("packages/shared/src"); + expect(markup).not.toContain("git.ts"); expect(markup).toContain("README.md"); - expect(markup).toContain("Show all 4 files"); + expect(markup).not.toContain("Show all"); expect(markup).not.toContain("App.test.tsx"); }); - it("keeps older collapsed changes to a one-line receipt", () => { + it("keeps the folder tree visible when folders are collapsed", () => { const markup = renderToStaticMarkup( {}} onToggleAllDirectories={() => {}} onOpenTurnDiff={() => {}} />, ); - expect(markup).toContain('data-changed-files-state="collapsed"'); + expect(markup).toContain('data-changed-files-state="tree"'); expect(markup).toContain("1 changed file"); + expect(markup).toContain("apps/web/src"); expect(markup).not.toContain("Show all"); expect(markup).not.toContain("App.tsx"); }); diff --git a/apps/web/src/components/chat/ChangedFilesTree.tsx b/apps/web/src/components/chat/ChangedFilesTree.tsx index 906bf4c34cb4..5ac9f09613f3 100644 --- a/apps/web/src/components/chat/ChangedFilesTree.tsx +++ b/apps/web/src/components/chat/ChangedFilesTree.tsx @@ -19,99 +19,59 @@ import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; import { PierreEntryIcon } from "./PierreEntryIcon"; import { Button } from "../ui/button"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; -import { - changedFileName, - selectChangedFilePreview, - summarizeChangedFileScopes, -} from "./changedFilesPresentation"; const EMPTY_DIRECTORY_OVERRIDES: Record = {}; export const ChangedFilesCard = memo(function ChangedFilesCard(props: { turnId: TurnId; files: ReadonlyArray; - expanded: boolean; - showCompactPreview: boolean; allDirectoriesExpanded: boolean; resolvedTheme: "light" | "dark"; - onExpandedChange: (expanded: boolean) => void; onToggleAllDirectories: () => void; onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; }) { const { turnId, files, - expanded, - showCompactPreview, allDirectoriesExpanded, resolvedTheme, - onExpandedChange, onToggleAllDirectories, onOpenTurnDiff, } = props; const summaryStat = useMemo(() => summarizeTurnDiffStats(files), [files]); - const scopeSummary = useMemo(() => summarizeChangedFileScopes(files), [files]); - const previewFiles = useMemo(() => selectChangedFilePreview(files), [files]); - const compactPreviewVisible = showCompactPreview && !expanded; + const hasDirectories = files.some((file) => /[/\\]/.test(file.path)); return (
- -
- {expanded ? ( + {hasNonZeroStat(summaryStat) && ( + + )} +
+
+ {hasDirectories && ( - ) : null} + )} onOpenTurnDiff(turnId, files[0]?.path)} /> @@ -150,61 +110,14 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: {
- {expanded ? ( - - ) : compactPreviewVisible ? ( -
-

- {scopeSummary.map((scope, index) => ( - - {index > 0 ? : null} - {scope.label} - - {scope.fileCount} file{scope.fileCount === 1 ? "" : "s"} - - - ))} -

-
- {previewFiles.map((file) => ( - - onOpenTurnDiff(turnId, file.path)} - /> - } - > - - {changedFileName(file.path)} - - {file.path} - - ))} - -
-
- ) : null} +
); }); @@ -261,7 +174,8 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: { {isExpanded && ( -
- {node.children.map((childNode) => renderTreeNode(childNode, depth + 1))} -
+
{node.children.map((childNode) => renderTreeNode(childNode, depth + 1))}
)}
); @@ -299,7 +211,7 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: { ); @@ -2111,30 +2134,21 @@ function AssistantChangedFilesSectionInner({ resolvedTheme: "light" | "dark"; onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; }) { - const activity = use(TimelineRowActivityCtx); - const isLatestTurn = activity.latestTurnId === turnSummary.turnId; const persistedExpanded = useUiStateStore( (store) => store.threadChangedFilesExpandedById[routeThreadKey]?.[turnSummary.turnId], ); const setExpanded = useUiStateStore((store) => store.setThreadChangedFilesExpanded); - const [autoExpanded] = useState(() => - shouldAutoExpandChangedFiles(checkpointFiles, isLatestTurn), - ); - const [allDirectoriesExpanded, setAllDirectoriesExpanded] = useState(autoExpanded); - const expanded = persistedExpanded ?? (isLatestTurn && autoExpanded); + const allDirectoriesExpanded = persistedExpanded ?? false; return ( - setExpanded(routeThreadKey, turnSummary.turnId, nextExpanded) + onToggleAllDirectories={() => + setExpanded(routeThreadKey, turnSummary.turnId, !allDirectoriesExpanded) } - onToggleAllDirectories={() => setAllDirectoriesExpanded((current) => !current)} onOpenTurnDiff={onOpenTurnDiff} /> ); @@ -3049,6 +3063,7 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { workspaceRoot: string | undefined; isExpandedToolGroupEntry: boolean; displayLabel?: string | undefined; + onToggleEntry?: ((collapsed: boolean) => void) | undefined; }) { const { workEntry, workspaceRoot, isExpandedToolGroupEntry, displayLabel } = props; // Before any hooks: spawn CTA rows render their own component. @@ -3061,6 +3076,7 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { workspaceRoot={workspaceRoot} isExpandedToolGroupEntry={isExpandedToolGroupEntry} displayLabel={displayLabel} + onToggleEntry={props.onToggleEntry} /> ); }); @@ -3070,6 +3086,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { workspaceRoot: string | undefined; isExpandedToolGroupEntry: boolean; displayLabel?: string | undefined; + onToggleEntry?: ((collapsed: boolean) => void) | undefined; }) { const { workEntry, workspaceRoot, isExpandedToolGroupEntry, displayLabel } = props; const { threadRef, onImageExpand } = use(TimelineRowCtx); @@ -3080,9 +3097,11 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { const toggleExpanded = () => { const next = !expanded; if (groupView) { - groupView.onToggleEntry(); + groupView.onToggleEntry(!next); if (next) groupView.state.expandedEntries.add(workEntry.id); else groupView.state.expandedEntries.delete(workEntry.id); + } else { + props.onToggleEntry?.(!next); } setExpanded(next); }; diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index 8880369a18c9..63dea7844c46 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -5,6 +5,7 @@ import { type ResolvedKeybindingsConfig, } from "@t3tools/contracts"; import { resolveSelectableModel } from "@t3tools/shared/model"; +import { useAtomValue } from "@effect/atom-react"; import { LegendList, type LegendListRef } from "@legendapp/list/react"; import { memo, useMemo, useState, useCallback, useEffect, useLayoutEffect, useRef } from "react"; import { ChevronRightIcon, SearchIcon } from "lucide-react"; @@ -26,6 +27,8 @@ import { ComboboxListVirtualized, } from "../ui/combobox"; import { ModelEsque } from "./providerIconUtils"; +import { isCommandPaletteOpen } from "../../commandPaletteBus"; +import { primaryServerKeybindingsAtom } from "../../state/server"; import { modelPickerJumpCommandForIndex, modelPickerJumpIndexFromCommand, @@ -217,10 +220,8 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { : [], ), ); - const keybindings = useMemo( - () => providedKeybindings ?? [], - [providedKeybindings], - ); + const serverKeybindings = useAtomValue(primaryServerKeybindingsAtom); + const keybindings = providedKeybindings ?? serverKeybindings; const updateSettings = useUpdateClientSettings(); const focusSearchInput = useCallback(() => { @@ -678,7 +679,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { useEffect(() => { const onWindowKeyDown = (event: globalThis.KeyboardEvent) => { - if (event.defaultPrevented || event.repeat) { + if (event.defaultPrevented || event.repeat || isCommandPaletteOpen()) { return; } @@ -690,6 +691,8 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { if (jumpIndex === null) { return; } + event.preventDefault(); + event.stopPropagation(); const targetModelKey = modelJumpModelKeys[jumpIndex]; if (!targetModelKey) { @@ -699,8 +702,6 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { if (!model) { return; } - event.preventDefault(); - event.stopPropagation(); handleModelSelect(model.slug, model.instanceId); }; diff --git a/apps/web/src/components/chat/SkillInlineText.tsx b/apps/web/src/components/chat/SkillInlineText.tsx index 6d026ea58cce..b6e398539133 100644 --- a/apps/web/src/components/chat/SkillInlineText.tsx +++ b/apps/web/src/components/chat/SkillInlineText.tsx @@ -10,7 +10,8 @@ import { } from "../composerInlineChip"; import { cn } from "~/lib/utils"; -const SKILL_TOKEN_REGEX = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s|$)/g; +const SKILL_TOKEN_REGEX = + /(^|\s)\$(?![0-9][0-9_]*(?:[kKmMbBtT]|[eE][0-9]+)?(?:\s|$))(?=[a-zA-Z0-9:_-]*[a-zA-Z])([a-zA-Z0-9][a-zA-Z0-9:_-]*)(?=\s|$)/g; type InlineSkill = Pick; diff --git a/apps/web/src/components/chat/useComposerFocusState.test.tsx b/apps/web/src/components/chat/useComposerFocusState.test.tsx new file mode 100644 index 000000000000..e4bf38430e85 --- /dev/null +++ b/apps/web/src/components/chat/useComposerFocusState.test.tsx @@ -0,0 +1,87 @@ +import { act, useLayoutEffect } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { shouldUseRestingComposerLayout } from "../composerFooterLayout"; +import { useComposerFocusState } from "./useComposerFocusState"; + +let root: Root; +let composer: ReturnType; +let isResting: boolean; + +function ComposerProbe({ isMobileViewport = false }: { isMobileViewport?: boolean }) { + const state = useComposerFocusState(isMobileViewport); + useLayoutEffect(() => { + composer = state; + isResting = shouldUseRestingComposerLayout({ + isExistingThread: true, + isMobileViewport, + isFocused: state.isComposerFocused, + isScrollCollapsed: state.isComposerScrollCollapsed, + hasExpandedChrome: false, + collapseOnBlur: true, + }); + }); + return null; +} + +beforeEach(async () => { + // The probe has no DOM output, but ReactDOM needs an event target. + const document = { + nodeType: 9, + addEventListener() {}, + removeEventListener() {}, + }; + const container = { + nodeType: 1, + tagName: "DIV", + namespaceURI: "http://www.w3.org/1999/xhtml", + ownerDocument: document, + addEventListener() {}, + removeEventListener() {}, + }; + vi.stubGlobal("document", document); + vi.stubGlobal("window", { document, HTMLIFrameElement: EventTarget }); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + root = createRoot(container as unknown as HTMLElement); + await act(() => root.render()); +}); + +afterEach(async () => { + await act(() => root.unmount()); + vi.unstubAllGlobals(); +}); + +describe("composer focus state", () => { + it("expands at the timeline end after a tool call takes focus", async () => { + await act(() => composer.setIsComposerFocused(true)); + expect(isResting).toBe(false); + + // A tool disclosure takes focus before the user scrolls through its output. + await act(() => composer.setIsComposerFocused(false)); + expect(isResting).toBe(true); + + await act(() => composer.restoreAfterTimelineReachedEnd()); + expect(isResting).toBe(false); + + await act(() => composer.setIsComposerFocused(false)); + expect(isResting).toBe(true); + }); + + it("can collapse again on the next scroll after returning to the end", async () => { + await act(() => composer.setIsComposerScrollCollapsed(true)); + expect(isResting).toBe(true); + + await act(() => composer.restoreAfterTimelineReachedEnd()); + expect(isResting).toBe(false); + + await act(() => composer.setIsComposerScrollCollapsed(true)); + expect(isResting).toBe(true); + }); + + it("does not expand the phone composer when the timeline reaches the end", async () => { + await act(() => root.render()); + await act(() => composer.restoreAfterTimelineReachedEnd()); + expect(composer.isComposerFocused).toBe(false); + }); +}); diff --git a/apps/web/src/components/chat/useComposerFocusState.ts b/apps/web/src/components/chat/useComposerFocusState.ts new file mode 100644 index 000000000000..c8858b33fa6b --- /dev/null +++ b/apps/web/src/components/chat/useComposerFocusState.ts @@ -0,0 +1,23 @@ +import { useCallback, useState } from "react"; + +export function useComposerFocusState(isMobileViewport: boolean) { + const [isComposerFocused, setIsComposerFocused] = useState(false); + const [isComposerScrollCollapsed, setIsComposerScrollCollapsed] = useState(false); + + const restoreAfterTimelineReachedEnd = useCallback(() => { + setIsComposerScrollCollapsed(false); + // Restore the expanded layout after a timeline control takes focus too. + // This state holds the layout open without moving DOM focus to the editor. + if (!isMobileViewport) { + setIsComposerFocused(true); + } + }, [isMobileViewport]); + + return { + isComposerFocused, + setIsComposerFocused, + isComposerScrollCollapsed, + setIsComposerScrollCollapsed, + restoreAfterTimelineReachedEnd, + }; +} diff --git a/apps/web/src/components/diffs/DiffCommentAnnotation.tsx b/apps/web/src/components/diffs/DiffCommentAnnotation.tsx index d210732b6b60..bce9f7e55f58 100644 --- a/apps/web/src/components/diffs/DiffCommentAnnotation.tsx +++ b/apps/web/src/components/diffs/DiffCommentAnnotation.tsx @@ -1,5 +1,5 @@ import { MessageCircle, Trash2 } from "lucide-react"; -import { useState, type ReactNode } from "react"; +import { useLayoutEffect, useRef, useState, type ReactNode } from "react"; import { Button } from "~/components/ui/button"; import { Textarea } from "~/components/ui/textarea"; @@ -25,6 +25,7 @@ interface DiffCommentAnnotationProps { submitLabel?: string; pending?: boolean; secondaryAction?: DiffCommentSecondaryAction; + focusOnMount?: boolean; } /** The shared inline comment treatment for file previews, thread diffs, and pull-request diffs. */ @@ -40,10 +41,20 @@ export function DiffCommentAnnotation({ submitLabel = "Comment", pending = false, secondaryAction, + focusOnMount = true, }: DiffCommentAnnotationProps) { const [localDraftText, setLocalDraftText] = useState(""); const displayedText = kind === "draft" && !onTextChange ? localDraftText : text; const trimmedText = displayedText.trim(); + const textareaRef = useRef(null); + + useLayoutEffect(() => { + if (kind !== "draft" || !focusOnMount) return; + const frame = window.requestAnimationFrame(() => { + textareaRef.current?.focus({ preventScroll: true }); + }); + return () => window.cancelAnimationFrame(frame); + }, [focusOnMount, kind]); if (kind === "comment") { return ( @@ -78,9 +89,10 @@ export function DiffCommentAnnotation({ onPointerDown={(event) => event.stopPropagation()} >