diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 570abd7d0505..7fee5f57a83c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,6 +51,9 @@ jobs: - name: Typecheck run: vpr typecheck + - name: Install browser secret helper build libraries + run: sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config + - name: Build desktop pipeline run: vp run build:desktop @@ -85,6 +88,9 @@ jobs: - name: Ensure Electron runtime is installed run: vp run --filter @t3tools/desktop ensure:electron + - name: Install browser secret helper build libraries + run: sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config + - name: Test run: vp run --parallel --concurrency-limit 4 --filter '!t3' --filter '!@t3tools/monorepo' test diff --git a/.github/workflows/cursor-hygiene-webhook.yml b/.github/workflows/cursor-hygiene-webhook.yml new file mode 100644 index 000000000000..ea0f579b4ac6 --- /dev/null +++ b/.github/workflows/cursor-hygiene-webhook.yml @@ -0,0 +1,36 @@ +name: Forward to Cursor hygiene + +on: + push: + branches: [main] + pull_request: + types: [opened, reopened, ready_for_review] + issues: + types: [opened, closed, reopened] + discussion: + types: [created, closed, reopened] + +permissions: + contents: read + +jobs: + forward: + name: POST to Cursor + runs-on: ubuntu-24.04 + steps: + - name: POST to Cursor + env: + URL: ${{ secrets.CURSOR_T3CODE_WEBHOOK_URL }} + AUTH: ${{ secrets.CURSOR_T3CODE_WEBHOOK_AUTH }} + run: | + set -euo pipefail + if [ -z "${URL:-}" ] || [ -z "${AUTH:-}" ]; then + echo "Missing CURSOR_T3CODE_WEBHOOK_URL or CURSOR_T3CODE_WEBHOOK_AUTH — skipping." + exit 0 + fi + curl -fsS --max-time 60 -X POST "$URL" \ + -H "Authorization: $AUTH" \ + -H "Content-Type: application/json" \ + -H "X-GitHub-Event: ${{ github.event_name }}" \ + -H "X-GitHub-Delivery: ${{ github.run_id }}-${{ github.run_attempt }}" \ + --data-binary @"${{ github.event_path }}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9c179a770a27..404e4e8075cc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -216,6 +216,9 @@ jobs: - name: Typecheck run: vp run typecheck + - name: Install browser secret helper build libraries + run: sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config + - name: Test run: vp run test @@ -524,12 +527,13 @@ jobs: exit $code } - - name: Install ImageMagick + - name: Install Linux desktop build libraries if: matrix.platform == 'linux' shell: bash run: | + sudo apt-get update + sudo apt-get install -y libsecret-1-dev pkg-config if ! command -v magick >/dev/null 2>&1 && ! command -v convert >/dev/null 2>&1; then - sudo apt-get update sudo apt-get install -y imagemagick fi diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 83a07cccb660..cb587e152aaa 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -15,6 +15,7 @@ "@clerk/electron": "catalog:", "@clerk/electron-passkeys": "catalog:", "@effect/platform-node": "catalog:", + "@napi-rs/keyring": "^1.3.0", "@t3tools/client-runtime": "workspace:*", "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", diff --git a/apps/desktop/scripts/browser-secret-native.test.mjs b/apps/desktop/scripts/browser-secret-native.test.mjs new file mode 100644 index 000000000000..754a91f1342c --- /dev/null +++ b/apps/desktop/scripts/browser-secret-native.test.mjs @@ -0,0 +1,103 @@ +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; +import { afterAll, beforeAll, describe, expect, it } from "vite-plus/test"; + +// oxlint-disable-next-line t3code/no-global-process-runtime -- The native compiler targets the actual host; this script has no Effect runtime. +const hostArch = process.arch; +// oxlint-disable-next-line t3code/no-global-process-runtime -- Native compilation only runs on the actual Linux host. +const hostPlatform = process.platform; + +describe.skipIf(hostPlatform !== "linux")("bundled libsecret helper", () => { + let directory; + let executable; + beforeAll(() => { + directory = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-browser-secret-test-")); + executable = NodePath.join(directory, "t3-browser-secret"); + const root = NodeURL.fileURLToPath(new URL("../../../native/browser-secret/", import.meta.url)); + const flags = NodeChildProcess.execFileSync( + "pkg-config", + ["--cflags", "--libs", "libsecret-1"], + { + encoding: "utf8", + }, + ) + .trim() + .split(/\s+/); + NodeChildProcess.execFileSync( + process.env.CC || "cc", + [ + "-std=c11", + "-Wall", + "-Wextra", + "-Werror", + NodePath.join(root, "main.c"), + NodePath.join(root, "test.c"), + "-Wl,--wrap=secret_service_search_sync", + "-Wl,--wrap=secret_item_get_locked", + "-Wl,--wrap=secret_item_get_secret", + "-o", + executable, + ...flags, + ], + { stdio: "pipe" }, + ); + }); + afterAll(() => { + if (directory) NodeFS.rmSync(directory, { recursive: true, force: true }); + }); + + const run = (args) => + NodeChildProcess.spawnSync(executable, args, { + env: { ...process.env, DBUS_SESSION_BUS_ADDRESS: "unix:path=/unused-test-bus" }, + }); + + it("builds an executable for the requested architecture into a staged resource directory", () => { + const output = NodePath.join(directory, "resources", "browser-secret", "t3-browser-secret"); + NodeChildProcess.execFileSync(process.execPath, [ + NodeURL.fileURLToPath(new URL("./build-browser-secret.mjs", import.meta.url)), + "--arch", + hostArch, + "--output", + output, + ]); + const header = NodeFS.readFileSync(output).subarray(0, 20); + expect(header.toString("hex", 0, 6)).toBe("7f454c460201"); + expect(header.readUInt16LE(18)).toBe({ x64: 62, arm64: 183 }[hostArch]); + expect(NodeFS.statSync(output).mode & 0o111).not.toBe(0); + // Invalid arguments exit before the real executable could contact a keyring. + expect(NodeChildProcess.spawnSync(output, []).status).toBe(64); + }); + + it("preserves the exact secret bytes with no added or removed delimiter", () => { + const result = run(["success"]); + expect(result.status).toBe(0); + expect(result.stdout).toEqual(Buffer.from("secret\0with whitespace \t\r\n")); + expect(result.stderr.length).toBe(0); + }); + + for (const [scenario, code] of [ + ["missing", 2], + ["empty", 2], + ["locked", 3], + ["cancelled", 3], + ["denied", 3], + ["unavailable", 4], + ["unloaded", 4], + ]) { + it(`reports ${scenario} without emitting a secret`, () => { + const result = run([scenario]); + expect(result.status).toBe(code); + expect(result.stdout.length).toBe(0); + }); + } + it("rejects invalid arguments before accessing the keyring", () => { + for (const args of [[], [""], ["chrome", "extra"]]) { + const result = run(args); + expect(result.status).toBe(64); + expect(result.stdout.length).toBe(0); + } + }); +}); diff --git a/apps/desktop/scripts/build-browser-secret.mjs b/apps/desktop/scripts/build-browser-secret.mjs new file mode 100644 index 000000000000..c65d16a87e8b --- /dev/null +++ b/apps/desktop/scripts/build-browser-secret.mjs @@ -0,0 +1,66 @@ +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; +import * as NodeUtil from "node:util"; + +// oxlint-disable-next-line t3code/no-global-process-runtime -- The native compiler targets the actual host; this script has no Effect runtime. +const hostArch = process.arch; +// oxlint-disable-next-line t3code/no-global-process-runtime -- Native compilation only runs on the actual Linux host. +const hostPlatform = process.platform; + +const { values } = NodeUtil.parseArgs({ + options: { output: { type: "string" }, arch: { type: "string", default: hostArch } }, +}); + +if (hostPlatform === "linux") { + const machine = { x64: 62, arm64: 183 }[values.arch]; + if (machine === undefined) throw new Error(`Unsupported Linux architecture: ${values.arch}`); + const root = NodeURL.fileURLToPath(new URL("../../../native/browser-secret/", import.meta.url)); + const source = NodePath.resolve(root, "main.c"); + const output = values.output ?? NodePath.resolve(root, "build", values.arch, "t3-browser-secret"); + const matchesArchitecture = (file) => { + const header = NodeFS.readFileSync(file).subarray(0, 20); + return header.toString("hex", 0, 6) === "7f454c460201" && header.readUInt16LE(18) === machine; + }; + let current = false; + try { + current = + NodeFS.statSync(output).mtimeMs >= + Math.max( + NodeFS.statSync(source).mtimeMs, + NodeFS.statSync(NodeURL.fileURLToPath(import.meta.url)).mtimeMs, + ) && matchesArchitecture(output); + } catch { + /* The first build has no output yet. */ + } + if (!current) { + let flags; + try { + flags = NodeChildProcess.execFileSync("pkg-config", ["--cflags", "--libs", "libsecret-1"], { + encoding: "utf8", + }) + .trim() + .split(/\s+/); + } catch (cause) { + throw new Error( + "Building the Linux browser import helper requires pkg-config and libsecret development headers (Ubuntu/Debian: libsecret-1-dev).", + { cause }, + ); + } + NodeFS.mkdirSync(NodePath.dirname(output), { recursive: true }); + const temporary = `${output}.${process.pid}.tmp`; + try { + NodeChildProcess.execFileSync( + process.env.CC || "cc", + ["-std=c11", "-O2", "-Wall", "-Wextra", "-Werror", source, "-o", temporary, ...flags], + { stdio: "inherit" }, + ); + if (!matchesArchitecture(temporary)) + throw new Error(`C compiler did not produce a Linux ${values.arch} executable.`); + NodeFS.renameSync(temporary, output); + } finally { + NodeFS.rmSync(temporary, { force: true }); + } + } +} diff --git a/apps/desktop/scripts/dev-electron.mjs b/apps/desktop/scripts/dev-electron.mjs index c28d5ec358b6..b5bcc4d06e36 100644 --- a/apps/desktop/scripts/dev-electron.mjs +++ b/apps/desktop/scripts/dev-electron.mjs @@ -37,6 +37,12 @@ const remoteDebuggingPort = process.env.T3CODE_DESKTOP_REMOTE_DEBUGGING_PORT?.tr // oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone dev script has no Effect runtime. const hostPlatform = NodeOS.platform(); +NodeChildProcess.execFileSync( + process.execPath, + [NodePath.join(desktopDir, "scripts/build-browser-secret.mjs")], + { stdio: "inherit" }, +); + await waitForResources({ baseDir: desktopDir, files: requiredFiles, diff --git a/apps/desktop/scripts/start-electron.mjs b/apps/desktop/scripts/start-electron.mjs index ecabd81fb407..5dde034121b8 100644 --- a/apps/desktop/scripts/start-electron.mjs +++ b/apps/desktop/scripts/start-electron.mjs @@ -1,7 +1,14 @@ import * as NodeChildProcess from "node:child_process"; +import * as NodePath from "node:path"; import { desktopDir, resolveElectronLaunchCommand } from "./electron-launcher.mjs"; +NodeChildProcess.execFileSync( + process.execPath, + [NodePath.join(desktopDir, "scripts/build-browser-secret.mjs")], + { stdio: "inherit" }, +); + const childEnv = { ...process.env }; delete childEnv.ELECTRON_RUN_AS_NODE; diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 124ee5095a61..2cdffbefb7ad 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -103,4 +103,6 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" for (const previewMethod of PreviewIpc.methods) { yield* ipc.handle(previewMethod); } + yield* ipc.handle(PreviewIpc.listBrowserImportSources); + yield* ipc.handle(PreviewIpc.importBrowserCookies); }); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 0e966431b06d..81b50d165d24 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -64,6 +64,8 @@ export const PREVIEW_OPEN_DEVTOOLS_CHANNEL = "desktop:preview-open-devtools"; export const PREVIEW_CLEAR_COOKIES_CHANNEL = "desktop:preview-clear-cookies"; export const PREVIEW_CLEAR_CACHE_CHANNEL = "desktop:preview-clear-cache"; export const PREVIEW_GET_CONFIG_CHANNEL = "desktop:preview-get-config"; +export const PREVIEW_IMPORT_SOURCES_CHANNEL = "desktop:preview-import-sources"; +export const PREVIEW_IMPORT_COOKIES_CHANNEL = "desktop:preview-import-cookies"; export const PREVIEW_SET_ANNOTATION_THEME_CHANNEL = "desktop:preview-set-annotation-theme"; export const PREVIEW_PICK_ELEMENT_CHANNEL = "desktop:preview-pick-element"; export const PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL = "desktop:preview-cancel-pick-element"; diff --git a/apps/desktop/src/ipc/methods/preview.test.ts b/apps/desktop/src/ipc/methods/preview.test.ts index 68ff5dbfef9b..18b0b8040e3d 100644 --- a/apps/desktop/src/ipc/methods/preview.test.ts +++ b/apps/desktop/src/ipc/methods/preview.test.ts @@ -12,6 +12,7 @@ import * as Schema from "effect/Schema"; import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; import * as PreviewManager from "../../preview/Manager.ts"; +import * as BrowserImport from "../../preview/BrowserImport/BrowserImport.ts"; import * as PreviewIpc from "./preview.ts"; const { fromPartition } = vi.hoisted(() => ({ @@ -80,6 +81,38 @@ describe("preview IPC methods", () => { }); }); + effectIt.effect("targets imports at the same partition tuple as the renderer", () => { + const received: Array[0]> = + []; + const browserImport = BrowserImport.BrowserImport.of({ + listSources: Effect.succeed([]), + importCookies: (input) => + Effect.sync(() => { + received.push(input); + return { imported: 0, skipped: 0, skippedDomains: [] }; + }), + }); + const request = (environmentId: string, targetProfileId: string) => + PreviewIpc.importBrowserCookies.handler({ + environmentId, + sourceId: "helium", + sourceProfileDirectory: "Default", + targetProfileId, + }); + + return Effect.gen(function* () { + yield* request("a", "b"); + yield* request("a::b", DEFAULT_BROWSER_PROFILE_ID); + + expect(received[0]).toMatchObject(PreviewIpc.resolvePartitionScope("a", "b")); + expect(received[1]).toMatchObject( + PreviewIpc.resolvePartitionScope("a::b", DEFAULT_BROWSER_PROFILE_ID), + ); + expect(received[0]?.namespace).toBe("profile"); + expect(received[1]?.namespace).toBeUndefined(); + }).pipe(Effect.provideService(BrowserImport.BrowserImport, browserImport)); + }); + effectIt.effect("rejects invalid webContents ids before resolving the preview service", () => Effect.map( PreviewIpc.registerWebview diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index 8a77770deb1e..5fb7eff99fc6 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -16,7 +16,10 @@ import { DesktopPreviewScreenshotArtifactSchema, DesktopPreviewSetAudioMutedInputSchema, DesktopPreviewSetColorSchemeInputSchema, + BrowserImportResult, + BrowserImportSource, DesktopPreviewClearDataInputSchema, + DesktopPreviewImportCookiesInputSchema, DesktopPreviewCreateTabInputSchema, DesktopPreviewTabInputSchema, DesktopPreviewWebviewConfigSchema, @@ -30,6 +33,7 @@ import * as Schema from "effect/Schema"; import * as NodeURL from "node:url"; import * as ElectronWindow from "../../electron/ElectronWindow.ts"; +import * as BrowserImport from "../../preview/BrowserImport/BrowserImport.ts"; import * as PreviewManager from "../../preview/Manager.ts"; import { PREVIEW_WEBVIEW_PREFERENCES } from "../../preview/WebviewPreferences.ts"; import * as IpcChannels from "../channels.ts"; @@ -284,6 +288,45 @@ export const getPreviewConfig = DesktopIpc.makeIpcMethod({ }), }); +/** + * Registered separately from `methods`: these carry `BrowserImport` in their + * context and their own failure type, so they do not unify with the + * manager-backed handlers the shared loop iterates. + */ +export const listBrowserImportSources = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PREVIEW_IMPORT_SOURCES_CHANNEL, + payload: Schema.Void, + result: Schema.Array(BrowserImportSource), + handler: Effect.fn("desktop.ipc.preview.listBrowserImportSources")(function* () { + const browserImport = yield* BrowserImport.BrowserImport; + return yield* browserImport.listSources; + }), +}); + +export const importBrowserCookies = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PREVIEW_IMPORT_COOKIES_CHANNEL, + payload: DesktopPreviewImportCookiesInputSchema, + result: BrowserImportResult, + handler: Effect.fn("desktop.ipc.preview.importBrowserCookies")(function* ({ + environmentId, + ...importInput + }) { + const browserImport = yield* BrowserImport.BrowserImport; + // Derived in main from the same helper the webview config uses, so cookies + // land in exactly the partition the profile's tabs attach to. + const { scope, persistent, namespace } = resolvePartitionScope( + environmentId, + importInput.targetProfileId, + ); + return yield* browserImport.importCookies({ + input: importInput, + scope, + persistent, + ...(namespace === undefined ? {} : { namespace }), + }); + }), +}); + export const setAnnotationTheme = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_SET_ANNOTATION_THEME_CHANNEL, payload: DesktopPreviewAnnotationThemeInputSchema, diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index c826c56e1a70..3337228aa962 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -58,6 +58,8 @@ import * as DesktopSshPasswordPrompts from "./ssh/DesktopSshPasswordPrompts.ts"; import * as DesktopState from "./app/DesktopState.ts"; import * as DesktopTelemetryPublisher from "./telemetry/DesktopTelemetryPublisher.ts"; import * as DesktopUpdates from "./updates/DesktopUpdates.ts"; +import * as BrowserImport from "./preview/BrowserImport/BrowserImport.ts"; +import * as LinuxBrowserSecret from "./preview/BrowserImport/LinuxBrowserSecret.ts"; import * as BrowserSession from "./preview/BrowserSession.ts"; import * as PreviewManager from "./preview/Manager.ts"; import * as DesktopWindow from "./window/DesktopWindow.ts"; @@ -149,6 +151,9 @@ const desktopServerExposureLayer = DesktopServerExposure.layer.pipe( ); const desktopPreviewLayer = PreviewManager.layer.pipe( + // Merged rather than provided so the IPC handlers can reach the import + // service alongside the manager; both sit on the same BrowserSession. + Layer.provideMerge(BrowserImport.layer.pipe(Layer.provide(LinuxBrowserSecret.layer))), Layer.provideMerge(BrowserSession.layer), Layer.provideMerge(desktopFoundationLayer), ); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 452f4b851bc3..685a9b1204db 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -223,6 +223,9 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.invoke(IpcChannels.PREVIEW_SET_AUDIO_MUTED_CHANNEL, { tabId, audioMuted }), openDevTools: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, { tabId }), + listBrowserImportSources: () => ipcRenderer.invoke(IpcChannels.PREVIEW_IMPORT_SOURCES_CHANNEL), + importBrowserCookies: (input) => + ipcRenderer.invoke(IpcChannels.PREVIEW_IMPORT_COOKIES_CHANNEL, input), clearCookies: (environmentId, profileId) => ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL, { environmentId, profileId }), clearCache: (environmentId, profileId) => diff --git a/apps/desktop/src/preview/AnnotationStyles.generated.ts b/apps/desktop/src/preview/AnnotationStyles.generated.ts index 5b6b73c8ba78..aba581ab5338 100644 --- a/apps/desktop/src/preview/AnnotationStyles.generated.ts +++ b/apps/desktop/src/preview/AnnotationStyles.generated.ts @@ -1,3 +1,3 @@ // Generated by scripts/build-preview-annotation-css.mjs. Do not edit. export const previewAnnotationStyles = - '/*! tailwindcss v4.3.0 | MIT License | https://tailwindcss.com */\n@layer properties;\n:root, :host {\n --spacing: 0.25rem;\n --text-xs: 0.75rem;\n --text-xs--line-height: calc(1 / 0.75);\n --text-sm: 0.875rem;\n --text-sm--line-height: calc(1.25 / 0.875);\n --text-lg: 1.125rem;\n --text-lg--line-height: calc(1.75 / 1.125);\n --font-weight-medium: 500;\n --font-weight-semibold: 600;\n --font-weight-bold: 700;\n --blur-xl: 24px;\n --default-font-family: var(--t3-font-sans);\n --default-mono-font-family: var(--t3-font-mono);\n}\n*, ::after, ::before, ::backdrop, ::file-selector-button {\n box-sizing: border-box;\n margin: 0;\n padding: 0;\n border: 0 solid;\n}\nhtml, :host {\n line-height: 1.5;\n -webkit-text-size-adjust: 100%;\n tab-size: 4;\n font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, \'Apple Color Emoji\', \'Segoe UI Emoji\', \'Segoe UI Symbol\', \'Noto Color Emoji\');\n font-feature-settings: var(--default-font-feature-settings, normal);\n font-variation-settings: var(--default-font-variation-settings, normal);\n -webkit-tap-highlight-color: transparent;\n}\nhr {\n height: 0;\n color: inherit;\n border-top-width: 1px;\n}\nabbr:where([title]) {\n -webkit-text-decoration: underline dotted;\n text-decoration: underline dotted;\n}\nh1, h2, h3, h4, h5, h6 {\n font-size: inherit;\n font-weight: inherit;\n}\na {\n color: inherit;\n -webkit-text-decoration: inherit;\n text-decoration: inherit;\n}\nb, strong {\n font-weight: bolder;\n}\ncode, kbd, samp, pre {\n font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \'Liberation Mono\', \'Courier New\', monospace);\n font-feature-settings: var(--default-mono-font-feature-settings, normal);\n font-variation-settings: var(--default-mono-font-variation-settings, normal);\n font-size: 1em;\n}\nsmall {\n font-size: 80%;\n}\nsub, sup {\n font-size: 75%;\n line-height: 0;\n position: relative;\n vertical-align: baseline;\n}\nsub {\n bottom: -0.25em;\n}\nsup {\n top: -0.5em;\n}\ntable {\n text-indent: 0;\n border-color: inherit;\n border-collapse: collapse;\n}\n:-moz-focusring {\n outline: auto;\n}\nprogress {\n vertical-align: baseline;\n}\nsummary {\n display: list-item;\n}\nol, ul, menu {\n list-style: none;\n}\nimg, svg, video, canvas, audio, iframe, embed, object {\n display: block;\n vertical-align: middle;\n}\nimg, video {\n max-width: 100%;\n height: auto;\n}\nbutton, input, select, optgroup, textarea, ::file-selector-button {\n font: inherit;\n font-feature-settings: inherit;\n font-variation-settings: inherit;\n letter-spacing: inherit;\n color: inherit;\n border-radius: 0;\n background-color: transparent;\n opacity: 1;\n}\n:where(select:is([multiple], [size])) optgroup {\n font-weight: bolder;\n}\n:where(select:is([multiple], [size])) optgroup option {\n padding-inline-start: 20px;\n}\n::file-selector-button {\n margin-inline-end: 4px;\n}\n::placeholder {\n opacity: 1;\n}\n@supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) {\n ::placeholder {\n color: currentcolor;\n @supports (color: color-mix(in lab, red, red)) {\n color: color-mix(in oklab, currentcolor 50%, transparent);\n }\n }\n}\ntextarea {\n resize: vertical;\n}\n::-webkit-search-decoration {\n -webkit-appearance: none;\n}\n::-webkit-date-and-time-value {\n min-height: 1lh;\n text-align: inherit;\n}\n::-webkit-datetime-edit {\n display: inline-flex;\n}\n::-webkit-datetime-edit-fields-wrapper {\n padding: 0;\n}\n::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field {\n padding-block: 0;\n}\n::-webkit-calendar-picker-indicator {\n line-height: 1;\n}\n:-moz-ui-invalid {\n box-shadow: none;\n}\nbutton, input:where([type=\'button\'], [type=\'reset\'], [type=\'submit\']), ::file-selector-button {\n appearance: button;\n}\n::-webkit-inner-spin-button, ::-webkit-outer-spin-button {\n height: auto;\n}\n[hidden]:where(:not([hidden=\'until-found\'])) {\n display: none !important;\n}\n.pointer-events-auto {\n pointer-events: auto;\n}\n.pointer-events-none {\n pointer-events: none;\n}\n.absolute {\n position: absolute;\n}\n.fixed {\n position: fixed;\n}\n.inset-0 {\n inset: calc(var(--spacing) * 0);\n}\n.top-1\\/2 {\n top: calc(1 / 2 * 100%);\n}\n.top-2\\.5 {\n top: calc(var(--spacing) * 2.5);\n}\n.right-2 {\n right: calc(var(--spacing) * 2);\n}\n.left-1\\/2 {\n left: calc(1 / 2 * 100%);\n}\n.z-1 {\n z-index: 1;\n}\n.block {\n display: block;\n}\n.flex {\n display: flex;\n}\n.grid {\n display: grid;\n}\n.hidden {\n display: none;\n}\n.inline-flex {\n display: inline-flex;\n}\n.h-7 {\n height: calc(var(--spacing) * 7);\n}\n.h-8 {\n height: calc(var(--spacing) * 8);\n}\n.max-h-24 {\n max-height: calc(var(--spacing) * 24);\n}\n.max-h-\\[calc\\(100vh-16px\\)\\] {\n max-height: calc(100vh - 16px);\n}\n.max-h-\\[min\\(176px\\,calc\\(100vh-180px\\)\\)\\] {\n max-height: min(176px, calc(100vh - 180px));\n}\n.min-h-7 {\n min-height: calc(var(--spacing) * 7);\n}\n.min-h-8 {\n min-height: calc(var(--spacing) * 8);\n}\n.w-6 {\n width: calc(var(--spacing) * 6);\n}\n.w-8 {\n width: calc(var(--spacing) * 8);\n}\n.w-\\[min\\(360px\\,calc\\(100vw-16px\\)\\)\\] {\n width: min(360px, calc(100vw - 16px));\n}\n.w-full {\n width: 100%;\n}\n.max-w-70 {\n max-width: calc(var(--spacing) * 70);\n}\n.min-w-0 {\n min-width: calc(var(--spacing) * 0);\n}\n.flex-1 {\n flex: 1;\n}\n.shrink-0 {\n flex-shrink: 0;\n}\n.-translate-x-1\\/2 {\n --tw-translate-x: calc(calc(1 / 2 * 100%) * -1);\n translate: var(--tw-translate-x) var(--tw-translate-y);\n}\n.-translate-y-1\\/2 {\n --tw-translate-y: calc(calc(1 / 2 * 100%) * -1);\n translate: var(--tw-translate-x) var(--tw-translate-y);\n}\n.cursor-grab {\n cursor: grab;\n}\n.cursor-pointer {\n cursor: pointer;\n}\n.resize {\n resize: both;\n}\n.resize-none {\n resize: none;\n}\n.appearance-none {\n appearance: none;\n}\n.grid-cols-\\[22px_minmax\\(0\\,1fr\\)\\] {\n grid-template-columns: 22px minmax(0,1fr);\n}\n.grid-cols-\\[82px_minmax\\(0\\,1fr\\)\\] {\n grid-template-columns: 82px minmax(0,1fr);\n}\n.flex-col {\n flex-direction: column;\n}\n.items-center {\n align-items: center;\n}\n.items-start {\n align-items: flex-start;\n}\n.justify-center {\n justify-content: center;\n}\n.gap-0\\.5 {\n gap: calc(var(--spacing) * 0.5);\n}\n.gap-1 {\n gap: calc(var(--spacing) * 1);\n}\n.gap-2 {\n gap: calc(var(--spacing) * 2);\n}\n.overflow-auto {\n overflow: auto;\n}\n.overflow-hidden {\n overflow: hidden;\n}\n.overflow-y-hidden {\n overflow-y: hidden;\n}\n.rounded-lg {\n border-radius: var(--t3-radius);\n}\n.rounded-md {\n border-radius: calc(var(--t3-radius) - 2px);\n}\n.rounded-xl {\n border-radius: calc(var(--t3-radius) + 4px);\n}\n.border {\n border-style: var(--tw-border-style);\n border-width: 1px;\n}\n.border-0 {\n border-style: var(--tw-border-style);\n border-width: 0px;\n}\n.border-t {\n border-top-style: var(--tw-border-style);\n border-top-width: 1px;\n}\n.border-b {\n border-bottom-style: var(--tw-border-style);\n border-bottom-width: 1px;\n}\n.border-border {\n border-color: var(--t3-border);\n}\n.border-input {\n border-color: var(--t3-input);\n}\n.border-primary {\n border-color: var(--t3-primary);\n}\n.border-transparent {\n border-color: transparent;\n}\n.border-b-transparent {\n border-bottom-color: transparent;\n}\n.bg-background {\n background-color: var(--t3-background);\n}\n.bg-muted {\n background-color: var(--t3-muted);\n}\n.bg-muted\\/40 {\n background-color: var(--t3-muted);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-muted) 40%, transparent);\n }\n}\n.bg-popover\\/95 {\n background-color: var(--t3-popover);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-popover) 95%, transparent);\n }\n}\n.bg-popover\\/96 {\n background-color: var(--t3-popover);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-popover) 96%, transparent);\n }\n}\n.bg-primary {\n background-color: var(--t3-primary);\n}\n.bg-primary\\/10 {\n background-color: var(--t3-primary);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-primary) 10%, transparent);\n }\n}\n.bg-transparent {\n background-color: transparent;\n}\n.p-0 {\n padding: calc(var(--spacing) * 0);\n}\n.p-1 {\n padding: calc(var(--spacing) * 1);\n}\n.p-2 {\n padding: calc(var(--spacing) * 2);\n}\n.px-0 {\n padding-inline: calc(var(--spacing) * 0);\n}\n.px-1 {\n padding-inline: calc(var(--spacing) * 1);\n}\n.px-2 {\n padding-inline: calc(var(--spacing) * 2);\n}\n.px-2\\.5 {\n padding-inline: calc(var(--spacing) * 2.5);\n}\n.px-3 {\n padding-inline: calc(var(--spacing) * 3);\n}\n.py-1 {\n padding-block: calc(var(--spacing) * 1);\n}\n.py-1\\.5 {\n padding-block: calc(var(--spacing) * 1.5);\n}\n.py-2 {\n padding-block: calc(var(--spacing) * 2);\n}\n.font-mono {\n font-family: var(--t3-font-mono);\n}\n.font-sans {\n font-family: var(--t3-font-sans);\n}\n.text-lg {\n font-size: var(--text-lg);\n line-height: var(--tw-leading, var(--text-lg--line-height));\n}\n.text-sm {\n font-size: var(--text-sm);\n line-height: var(--tw-leading, var(--text-sm--line-height));\n}\n.text-xs {\n font-size: var(--text-xs);\n line-height: var(--tw-leading, var(--text-xs--line-height));\n}\n.leading-5 {\n --tw-leading: calc(var(--spacing) * 5);\n line-height: calc(var(--spacing) * 5);\n}\n.font-bold {\n --tw-font-weight: var(--font-weight-bold);\n font-weight: var(--font-weight-bold);\n}\n.font-medium {\n --tw-font-weight: var(--font-weight-medium);\n font-weight: var(--font-weight-medium);\n}\n.font-semibold {\n --tw-font-weight: var(--font-weight-semibold);\n font-weight: var(--font-weight-semibold);\n}\n.text-foreground {\n color: var(--t3-foreground);\n}\n.text-muted-foreground {\n color: var(--t3-muted-foreground);\n}\n.text-popover-foreground {\n color: var(--t3-popover-foreground);\n}\n.text-primary {\n color: var(--t3-primary);\n}\n.text-primary-foreground {\n color: var(--t3-primary-foreground);\n}\n.shadow-2xl {\n --tw-shadow: 0 25px 50px -12px var(--tw-shadow-color, rgb(0 0 0 / 0.25));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-lg {\n --tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 4px 6px -4px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-md {\n --tw-shadow: 0 4px 6px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 2px 4px -2px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-sm {\n --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-xs {\n --tw-shadow: 0 1px 2px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.05));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.ring-0 {\n --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.blur {\n --tw-blur: blur(8px);\n filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);\n}\n.backdrop-blur-xl {\n --tw-backdrop-blur: blur(var(--blur-xl));\n -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n}\n.outline-none {\n --tw-outline-style: none;\n outline-style: none;\n}\n.select-none {\n -webkit-user-select: none;\n user-select: none;\n}\n.placeholder\\:text-muted-foreground {\n &::placeholder {\n color: var(--t3-muted-foreground);\n }\n}\n.hover\\:bg-accent {\n &:hover {\n @media (hover: hover) {\n background-color: var(--t3-accent);\n }\n }\n}\n.hover\\:bg-primary\\/90 {\n &:hover {\n @media (hover: hover) {\n background-color: var(--t3-primary);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-primary) 90%, transparent);\n }\n }\n }\n}\n.hover\\:text-accent-foreground {\n &:hover {\n @media (hover: hover) {\n color: var(--t3-accent-foreground);\n }\n }\n}\n.focus\\:border-b-primary {\n &:focus {\n border-bottom-color: var(--t3-primary);\n }\n}\n.focus\\:ring-0 {\n &:focus {\n --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n }\n}\n.focus\\:outline-none {\n &:focus {\n --tw-outline-style: none;\n outline-style: none;\n }\n}\n.disabled\\:pointer-events-none {\n &:disabled {\n pointer-events: none;\n }\n}\n.disabled\\:opacity-60 {\n &:disabled {\n opacity: 60%;\n }\n}\n:host {\n --t3-font-sans: "DM Sans Variable", "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui,\n sans-serif;\n --t3-font-mono: "SF Mono", "SFMono-Regular", "JetBrains Mono", Consolas, "Liberation Mono", Menlo, monospace;\n --t3-radius: 0.625rem;\n --t3-background: white;\n --t3-foreground: oklch(0.269 0 0);\n --t3-popover: white;\n --t3-popover-foreground: oklch(0.269 0 0);\n --t3-primary: oklch(0.488 0.217 264);\n --t3-primary-foreground: white;\n --t3-muted: rgb(0 0 0 / 4%);\n --t3-muted-foreground: oklch(0.556 0 0);\n --t3-accent: rgb(0 0 0 / 4%);\n --t3-accent-foreground: oklch(0.269 0 0);\n --t3-border: rgb(0 0 0 / 8%);\n --t3-input: rgb(0 0 0 / 10%);\n --t3-ring: oklch(0.488 0.217 264);\n color: var(--t3-foreground);\n font-family: var(--t3-font-sans);\n}\n* {\n box-sizing: border-box;\n border-color: var(--t3-border);\n}\nbutton, input, select, textarea {\n font: inherit;\n}\nbutton:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible {\n outline: 2px solid var(--t3-ring);\n @supports (color: color-mix(in lab, red, red)) {\n outline: 2px solid color-mix(in srgb, var(--t3-ring) 72%, transparent);\n }\n outline-offset: 1px;\n}\n@property --tw-translate-x {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-translate-y {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-translate-z {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-border-style {\n syntax: "*";\n inherits: false;\n initial-value: solid;\n}\n@property --tw-leading {\n syntax: "*";\n inherits: false;\n}\n@property --tw-font-weight {\n syntax: "*";\n inherits: false;\n}\n@property --tw-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-shadow-alpha {\n syntax: "";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-inset-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-inset-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-inset-shadow-alpha {\n syntax: "";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-ring-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-ring-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-inset-ring-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-inset-ring-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-ring-inset {\n syntax: "*";\n inherits: false;\n}\n@property --tw-ring-offset-width {\n syntax: "";\n inherits: false;\n initial-value: 0px;\n}\n@property --tw-ring-offset-color {\n syntax: "*";\n inherits: false;\n initial-value: #fff;\n}\n@property --tw-ring-offset-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-blur {\n syntax: "*";\n inherits: false;\n}\n@property --tw-brightness {\n syntax: "*";\n inherits: false;\n}\n@property --tw-contrast {\n syntax: "*";\n inherits: false;\n}\n@property --tw-grayscale {\n syntax: "*";\n inherits: false;\n}\n@property --tw-hue-rotate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-invert {\n syntax: "*";\n inherits: false;\n}\n@property --tw-opacity {\n syntax: "*";\n inherits: false;\n}\n@property --tw-saturate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-sepia {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow-alpha {\n syntax: "";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-drop-shadow-size {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-blur {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-brightness {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-contrast {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-grayscale {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-hue-rotate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-invert {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-opacity {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-saturate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-sepia {\n syntax: "*";\n inherits: false;\n}\n@layer properties {\n @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {\n *, ::before, ::after, ::backdrop {\n --tw-translate-x: 0;\n --tw-translate-y: 0;\n --tw-translate-z: 0;\n --tw-border-style: solid;\n --tw-leading: initial;\n --tw-font-weight: initial;\n --tw-shadow: 0 0 #0000;\n --tw-shadow-color: initial;\n --tw-shadow-alpha: 100%;\n --tw-inset-shadow: 0 0 #0000;\n --tw-inset-shadow-color: initial;\n --tw-inset-shadow-alpha: 100%;\n --tw-ring-color: initial;\n --tw-ring-shadow: 0 0 #0000;\n --tw-inset-ring-color: initial;\n --tw-inset-ring-shadow: 0 0 #0000;\n --tw-ring-inset: initial;\n --tw-ring-offset-width: 0px;\n --tw-ring-offset-color: #fff;\n --tw-ring-offset-shadow: 0 0 #0000;\n --tw-blur: initial;\n --tw-brightness: initial;\n --tw-contrast: initial;\n --tw-grayscale: initial;\n --tw-hue-rotate: initial;\n --tw-invert: initial;\n --tw-opacity: initial;\n --tw-saturate: initial;\n --tw-sepia: initial;\n --tw-drop-shadow: initial;\n --tw-drop-shadow-color: initial;\n --tw-drop-shadow-alpha: 100%;\n --tw-drop-shadow-size: initial;\n --tw-backdrop-blur: initial;\n --tw-backdrop-brightness: initial;\n --tw-backdrop-contrast: initial;\n --tw-backdrop-grayscale: initial;\n --tw-backdrop-hue-rotate: initial;\n --tw-backdrop-invert: initial;\n --tw-backdrop-opacity: initial;\n --tw-backdrop-saturate: initial;\n --tw-backdrop-sepia: initial;\n }\n }\n}\n'; + '/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */\n@layer properties;\n:root, :host {\n --spacing: 0.25rem;\n --text-xs: 0.75rem;\n --text-xs--line-height: calc(1 / 0.75);\n --text-sm: 0.875rem;\n --text-sm--line-height: calc(1.25 / 0.875);\n --text-lg: 1.125rem;\n --text-lg--line-height: calc(1.75 / 1.125);\n --font-weight-medium: 500;\n --font-weight-semibold: 600;\n --font-weight-bold: 700;\n --blur-xl: 24px;\n --default-font-family: var(--t3-font-sans);\n --default-mono-font-family: var(--t3-font-mono);\n}\n*, ::after, ::before, ::backdrop, ::file-selector-button {\n box-sizing: border-box;\n margin: 0;\n padding: 0;\n border: 0 solid;\n}\nhtml, :host {\n line-height: 1.5;\n -webkit-text-size-adjust: 100%;\n tab-size: 4;\n font-family: var(--default-font-family, -apple-system, BlinkMacSystemFont, \'Segoe UI\', Roboto, \'Helvetica Neue\', \'Noto Sans\', Arial, sans-serif, \'Apple Color Emoji\', \'Segoe UI Emoji\', \'Segoe UI Symbol\', \'Noto Color Emoji\');\n font-feature-settings: var(--default-font-feature-settings, normal);\n font-variation-settings: var(--default-font-variation-settings, normal);\n -webkit-tap-highlight-color: transparent;\n}\nhr {\n height: 0;\n color: inherit;\n border-top-width: 1px;\n}\nabbr:where([title]) {\n -webkit-text-decoration: underline dotted;\n text-decoration: underline dotted;\n}\nh1, h2, h3, h4, h5, h6 {\n font-size: inherit;\n font-weight: inherit;\n}\na {\n color: inherit;\n -webkit-text-decoration: inherit;\n text-decoration: inherit;\n}\nb, strong {\n font-weight: bolder;\n}\ncode, kbd, samp, pre {\n font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \'Liberation Mono\', \'Courier New\', monospace);\n font-feature-settings: var(--default-mono-font-feature-settings, normal);\n font-variation-settings: var(--default-mono-font-variation-settings, normal);\n font-size: 1em;\n}\nsmall {\n font-size: 80%;\n}\nsub, sup {\n font-size: 75%;\n line-height: 0;\n position: relative;\n vertical-align: baseline;\n}\nsub {\n bottom: -0.25em;\n}\nsup {\n top: -0.5em;\n}\ntable {\n text-indent: 0;\n border-color: inherit;\n border-collapse: collapse;\n}\n:-moz-focusring:where(:not(iframe)) {\n outline: auto;\n}\nprogress {\n vertical-align: baseline;\n}\nsummary {\n display: list-item;\n}\nol, ul, menu {\n list-style: none;\n}\nimg, svg, video, canvas, audio, iframe, embed, object {\n display: block;\n vertical-align: middle;\n}\nimg, video {\n max-width: 100%;\n height: auto;\n}\nbutton, input, select, optgroup, textarea, ::file-selector-button {\n font: inherit;\n font-feature-settings: inherit;\n font-variation-settings: inherit;\n letter-spacing: inherit;\n color: inherit;\n border-radius: 0;\n background-color: transparent;\n opacity: 1;\n}\n:where(select:is([multiple], [size])) optgroup {\n font-weight: bolder;\n}\n:where(select:is([multiple], [size])) optgroup option {\n padding-inline-start: 20px;\n}\n::file-selector-button {\n margin-inline-end: 4px;\n}\n::placeholder {\n opacity: 1;\n}\n@supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) {\n ::placeholder {\n color: currentcolor;\n @supports (color: color-mix(in lab, red, red)) {\n color: color-mix(in oklab, currentcolor 50%, transparent);\n }\n }\n}\ntextarea {\n resize: vertical;\n}\n::-webkit-search-decoration {\n -webkit-appearance: none;\n}\n::-webkit-date-and-time-value {\n min-height: 1lh;\n text-align: inherit;\n}\n::-webkit-datetime-edit {\n display: inline-flex;\n}\n::-webkit-datetime-edit-fields-wrapper {\n padding: 0;\n}\n::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field {\n padding-block: 0;\n}\n::-webkit-calendar-picker-indicator {\n line-height: 1;\n}\n:-moz-ui-invalid {\n box-shadow: none;\n}\nbutton, input:where([type=\'button\'], [type=\'reset\'], [type=\'submit\']), ::file-selector-button {\n appearance: button;\n}\n::-webkit-inner-spin-button, ::-webkit-outer-spin-button {\n height: auto;\n}\n[hidden]:where(:not([hidden=\'until-found\'])) {\n display: none !important;\n}\n.pointer-events-auto {\n pointer-events: auto;\n}\n.pointer-events-none {\n pointer-events: none;\n}\n.absolute {\n position: absolute;\n}\n.fixed {\n position: fixed;\n}\n.inset-0 {\n inset: 0px;\n}\n.top-1\\/2 {\n top: calc(1 / 2 * 100%);\n}\n.top-2\\.5 {\n top: calc(var(--spacing) * 2.5);\n}\n.right-2 {\n right: calc(var(--spacing) * 2);\n}\n.left-1\\/2 {\n left: calc(1 / 2 * 100%);\n}\n.z-1 {\n z-index: 1;\n}\n.block {\n display: block;\n}\n.flex {\n display: flex;\n}\n.grid {\n display: grid;\n}\n.hidden {\n display: none;\n}\n.inline-flex {\n display: inline-flex;\n}\n.h-7 {\n height: calc(var(--spacing) * 7);\n}\n.h-8 {\n height: calc(var(--spacing) * 8);\n}\n.max-h-24 {\n max-height: calc(var(--spacing) * 24);\n}\n.max-h-\\[calc\\(100vh-16px\\)\\] {\n max-height: calc(100vh - 16px);\n}\n.max-h-\\[min\\(176px\\,calc\\(100vh-180px\\)\\)\\] {\n max-height: min(176px, calc(100vh - 180px));\n}\n.min-h-7 {\n min-height: calc(var(--spacing) * 7);\n}\n.min-h-8 {\n min-height: calc(var(--spacing) * 8);\n}\n.w-6 {\n width: calc(var(--spacing) * 6);\n}\n.w-8 {\n width: calc(var(--spacing) * 8);\n}\n.w-\\[min\\(360px\\,calc\\(100vw-16px\\)\\)\\] {\n width: min(360px, calc(100vw - 16px));\n}\n.w-full {\n width: 100%;\n}\n.max-w-70 {\n max-width: calc(var(--spacing) * 70);\n}\n.min-w-0 {\n min-width: 0px;\n}\n.flex-1 {\n flex: 1;\n}\n.shrink-0 {\n flex-shrink: 0;\n}\n.-translate-x-1\\/2 {\n --tw-translate-x: calc(calc(1 / 2 * 100%) * -1);\n translate: var(--tw-translate-x) var(--tw-translate-y);\n}\n.-translate-y-1\\/2 {\n --tw-translate-y: calc(calc(1 / 2 * 100%) * -1);\n translate: var(--tw-translate-x) var(--tw-translate-y);\n}\n.cursor-grab {\n cursor: grab;\n}\n.cursor-pointer {\n cursor: pointer;\n}\n.resize {\n resize: both;\n}\n.resize-none {\n resize: none;\n}\n.appearance-none {\n appearance: none;\n}\n.grid-cols-\\[22px_minmax\\(0\\,1fr\\)\\] {\n grid-template-columns: 22px minmax(0,1fr);\n}\n.grid-cols-\\[82px_minmax\\(0\\,1fr\\)\\] {\n grid-template-columns: 82px minmax(0,1fr);\n}\n.flex-col {\n flex-direction: column;\n}\n.items-center {\n align-items: center;\n}\n.items-start {\n align-items: flex-start;\n}\n.justify-center {\n justify-content: center;\n}\n.gap-0\\.5 {\n gap: calc(var(--spacing) * 0.5);\n}\n.gap-1 {\n gap: var(--spacing);\n}\n.gap-2 {\n gap: calc(var(--spacing) * 2);\n}\n.overflow-auto {\n overflow: auto;\n}\n.overflow-hidden {\n overflow: hidden;\n}\n.overflow-y-hidden {\n overflow-y: hidden;\n}\n.rounded-lg {\n border-radius: var(--t3-radius);\n}\n.rounded-md {\n border-radius: calc(var(--t3-radius) - 2px);\n}\n.rounded-xl {\n border-radius: calc(var(--t3-radius) + 4px);\n}\n.border {\n border-style: var(--tw-border-style);\n border-width: 1px;\n}\n.border-0 {\n border-style: var(--tw-border-style);\n border-width: 0px;\n}\n.border-t {\n border-top-style: var(--tw-border-style);\n border-top-width: 1px;\n}\n.border-b {\n border-bottom-style: var(--tw-border-style);\n border-bottom-width: 1px;\n}\n.border-border {\n border-color: var(--t3-border);\n}\n.border-input {\n border-color: var(--t3-input);\n}\n.border-primary {\n border-color: var(--t3-primary);\n}\n.border-transparent {\n border-color: transparent;\n}\n.border-b-transparent {\n border-bottom-color: transparent;\n}\n.bg-background {\n background-color: var(--t3-background);\n}\n.bg-muted {\n background-color: var(--t3-muted);\n}\n.bg-muted\\/40 {\n background-color: var(--t3-muted);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-muted) 40%, transparent);\n }\n}\n.bg-popover\\/95 {\n background-color: var(--t3-popover);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-popover) 95%, transparent);\n }\n}\n.bg-popover\\/96 {\n background-color: var(--t3-popover);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-popover) 96%, transparent);\n }\n}\n.bg-primary {\n background-color: var(--t3-primary);\n}\n.bg-primary\\/10 {\n background-color: var(--t3-primary);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-primary) 10%, transparent);\n }\n}\n.bg-transparent {\n background-color: transparent;\n}\n.p-0 {\n padding: 0px;\n}\n.p-1 {\n padding: var(--spacing);\n}\n.p-2 {\n padding: calc(var(--spacing) * 2);\n}\n.px-0 {\n padding-inline: 0px;\n}\n.px-1 {\n padding-inline: var(--spacing);\n}\n.px-2 {\n padding-inline: calc(var(--spacing) * 2);\n}\n.px-2\\.5 {\n padding-inline: calc(var(--spacing) * 2.5);\n}\n.px-3 {\n padding-inline: calc(var(--spacing) * 3);\n}\n.py-1 {\n padding-block: var(--spacing);\n}\n.py-1\\.5 {\n padding-block: calc(var(--spacing) * 1.5);\n}\n.py-2 {\n padding-block: calc(var(--spacing) * 2);\n}\n.font-mono {\n font-family: var(--t3-font-mono);\n}\n.font-sans {\n font-family: var(--t3-font-sans);\n}\n.text-lg {\n font-size: var(--text-lg);\n line-height: var(--tw-leading, var(--text-lg--line-height));\n}\n.text-sm {\n font-size: var(--text-sm);\n line-height: var(--tw-leading, var(--text-sm--line-height));\n}\n.text-xs {\n font-size: var(--text-xs);\n line-height: var(--tw-leading, var(--text-xs--line-height));\n}\n.leading-5 {\n --tw-leading: calc(var(--spacing) * 5);\n line-height: calc(var(--spacing) * 5);\n}\n.font-bold {\n --tw-font-weight: var(--font-weight-bold);\n font-weight: var(--font-weight-bold);\n}\n.font-medium {\n --tw-font-weight: var(--font-weight-medium);\n font-weight: var(--font-weight-medium);\n}\n.font-semibold {\n --tw-font-weight: var(--font-weight-semibold);\n font-weight: var(--font-weight-semibold);\n}\n.text-foreground {\n color: var(--t3-foreground);\n}\n.text-muted-foreground {\n color: var(--t3-muted-foreground);\n}\n.text-popover-foreground {\n color: var(--t3-popover-foreground);\n}\n.text-primary {\n color: var(--t3-primary);\n}\n.text-primary-foreground {\n color: var(--t3-primary-foreground);\n}\n.shadow-2xl {\n --tw-shadow: 0 25px 50px -12px var(--tw-shadow-color, rgb(0 0 0 / 0.25));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-lg {\n --tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 4px 6px -4px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-md {\n --tw-shadow: 0 4px 6px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 2px 4px -2px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-sm {\n --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-xs {\n --tw-shadow: 0 1px 2px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.05));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.ring-0 {\n --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.blur {\n --tw-blur: blur(8px);\n filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);\n}\n.backdrop-blur-xl {\n --tw-backdrop-blur: blur(var(--blur-xl));\n -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n}\n.outline-none {\n --tw-outline-style: none;\n outline-style: none;\n}\n.select-none {\n -webkit-user-select: none;\n user-select: none;\n}\n.placeholder\\:text-muted-foreground::placeholder {\n color: var(--t3-muted-foreground);\n}\n@media (hover: hover) {\n .hover\\:bg-accent:hover {\n background-color: var(--t3-accent);\n }\n .hover\\:bg-primary\\/90:hover {\n background-color: var(--t3-primary);\n }\n @supports (color: color-mix(in lab, red, red)) {\n .hover\\:bg-primary\\/90:hover {\n background-color: color-mix(in oklab, var(--t3-primary) 90%, transparent);\n }\n }\n .hover\\:text-accent-foreground:hover {\n color: var(--t3-accent-foreground);\n }\n}\n.focus\\:border-b-primary:focus {\n border-bottom-color: var(--t3-primary);\n}\n.focus\\:ring-0:focus {\n --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.focus\\:outline-none:focus {\n --tw-outline-style: none;\n outline-style: none;\n}\n.disabled\\:pointer-events-none:disabled {\n pointer-events: none;\n}\n.disabled\\:opacity-60:disabled {\n opacity: 60%;\n}\n:host {\n --t3-font-sans: "DM Sans Variable", "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui,\n sans-serif;\n --t3-font-mono: "SF Mono", "SFMono-Regular", "JetBrains Mono", Consolas, "Liberation Mono", Menlo, monospace;\n --t3-radius: 0.625rem;\n --t3-background: white;\n --t3-foreground: oklch(0.269 0 0);\n --t3-popover: white;\n --t3-popover-foreground: oklch(0.269 0 0);\n --t3-primary: oklch(0.488 0.217 264);\n --t3-primary-foreground: white;\n --t3-muted: rgb(0 0 0 / 4%);\n --t3-muted-foreground: oklch(0.556 0 0);\n --t3-accent: rgb(0 0 0 / 4%);\n --t3-accent-foreground: oklch(0.269 0 0);\n --t3-border: rgb(0 0 0 / 8%);\n --t3-input: rgb(0 0 0 / 10%);\n --t3-ring: oklch(0.488 0.217 264);\n color: var(--t3-foreground);\n font-family: var(--t3-font-sans);\n}\n* {\n box-sizing: border-box;\n border-color: var(--t3-border);\n}\nbutton, input, select, textarea {\n font: inherit;\n}\nbutton:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible {\n outline: 2px solid var(--t3-ring);\n @supports (color: color-mix(in lab, red, red)) {\n outline: 2px solid color-mix(in srgb, var(--t3-ring) 72%, transparent);\n }\n outline-offset: 1px;\n}\n@property --tw-translate-x {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-translate-y {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-translate-z {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-border-style {\n syntax: "*";\n inherits: false;\n initial-value: solid;\n}\n@property --tw-leading {\n syntax: "*";\n inherits: false;\n}\n@property --tw-font-weight {\n syntax: "*";\n inherits: false;\n}\n@property --tw-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-shadow-alpha {\n syntax: "";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-inset-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-inset-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-inset-shadow-alpha {\n syntax: "";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-ring-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-ring-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-inset-ring-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-inset-ring-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-ring-inset {\n syntax: "*";\n inherits: false;\n}\n@property --tw-ring-offset-width {\n syntax: "";\n inherits: false;\n initial-value: 0px;\n}\n@property --tw-ring-offset-color {\n syntax: "*";\n inherits: false;\n initial-value: #fff;\n}\n@property --tw-ring-offset-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-blur {\n syntax: "*";\n inherits: false;\n}\n@property --tw-brightness {\n syntax: "*";\n inherits: false;\n}\n@property --tw-contrast {\n syntax: "*";\n inherits: false;\n}\n@property --tw-grayscale {\n syntax: "*";\n inherits: false;\n}\n@property --tw-hue-rotate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-invert {\n syntax: "*";\n inherits: false;\n}\n@property --tw-opacity {\n syntax: "*";\n inherits: false;\n}\n@property --tw-saturate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-sepia {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow-alpha {\n syntax: "";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-drop-shadow-size {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-blur {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-brightness {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-contrast {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-grayscale {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-hue-rotate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-invert {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-opacity {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-saturate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-sepia {\n syntax: "*";\n inherits: false;\n}\n@layer properties {\n @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {\n *, ::before, ::after, ::backdrop {\n --tw-translate-x: 0;\n --tw-translate-y: 0;\n --tw-translate-z: 0;\n --tw-border-style: solid;\n --tw-leading: initial;\n --tw-font-weight: initial;\n --tw-shadow: 0 0 #0000;\n --tw-shadow-color: initial;\n --tw-shadow-alpha: 100%;\n --tw-inset-shadow: 0 0 #0000;\n --tw-inset-shadow-color: initial;\n --tw-inset-shadow-alpha: 100%;\n --tw-ring-color: initial;\n --tw-ring-shadow: 0 0 #0000;\n --tw-inset-ring-color: initial;\n --tw-inset-ring-shadow: 0 0 #0000;\n --tw-ring-inset: initial;\n --tw-ring-offset-width: 0px;\n --tw-ring-offset-color: #fff;\n --tw-ring-offset-shadow: 0 0 #0000;\n --tw-blur: initial;\n --tw-brightness: initial;\n --tw-contrast: initial;\n --tw-grayscale: initial;\n --tw-hue-rotate: initial;\n --tw-invert: initial;\n --tw-opacity: initial;\n --tw-saturate: initial;\n --tw-sepia: initial;\n --tw-drop-shadow: initial;\n --tw-drop-shadow-color: initial;\n --tw-drop-shadow-alpha: 100%;\n --tw-drop-shadow-size: initial;\n --tw-backdrop-blur: initial;\n --tw-backdrop-brightness: initial;\n --tw-backdrop-contrast: initial;\n --tw-backdrop-grayscale: initial;\n --tw-backdrop-hue-rotate: initial;\n --tw-backdrop-invert: initial;\n --tw-backdrop-opacity: initial;\n --tw-backdrop-saturate: initial;\n --tw-backdrop-sepia: initial;\n }\n }\n}\n'; diff --git a/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts b/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts new file mode 100644 index 000000000000..9b0a652f09f1 --- /dev/null +++ b/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts @@ -0,0 +1,209 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import { + HostProcessEnvironment, + HostProcessExecutablePath, + HostProcessPlatform, +} from "@t3tools/shared/hostProcess"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +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"; + +const helium = BROWSER_IMPORT_SOURCES.find((source) => source.id === "helium")!; + +const cookie = { + url: "https://rejected.example/path", + name: "session", + value: "value", + domain: undefined, + path: "/", + secure: true, + httpOnly: true, + expirationDate: undefined, + sameSite: "lax" as const, +}; + +/** + * Dies if the import reaches session work: every case here covers a request + * that must be rejected before a cookie is read or written. + */ +const rejectedBeforeSession = Layer.succeed( + BrowserSession.BrowserSession, + BrowserSession.BrowserSession.of({ + getPartition: () => Effect.die("getPartition must not be reached"), + isPartition: () => false, + getSession: () => Effect.die("getSession must not be reached"), + clearCookies: () => Effect.die("clearCookies must not be reached"), + clearCache: () => Effect.die("clearCache must not be reached"), + }), +); + +/** + * Builds the service against a scratch home containing an installed, closed + * copy of the source browser. + */ +const withImporter = Effect.fnUntraced(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-import-" }); + const environment = Layer.succeed(HostProcessEnvironment, { HOME: home }); + const context = yield* sourcePathContext.pipe( + Effect.provideService(HostProcessEnvironment, { HOME: home }), + Effect.provideService(HostProcessPlatform, "darwin"), + ); + const root = helium.userDataDirectory(context); + if (root === undefined) throw new Error("Helium has no macOS user-data directory"); + yield* fileSystem.makeDirectory(`${root}/Default`, { recursive: true }); + // The cookie database is what marks a source as installed, so a fixture + // without one is reported as absent before any other check runs. + yield* fileSystem.writeFileString(`${root}/Default/Cookies`, "db"); + + const importer = yield* BrowserImport.BrowserImport.pipe( + Effect.provide( + BrowserImport.layer.pipe( + Layer.provide(rejectedBeforeSession), + Layer.provide(environment), + Layer.provide(Layer.succeed(HostProcessPlatform, "darwin")), + Layer.provide(Layer.succeed(HostProcessExecutablePath, "/Applications/T3 Code.app")), + Layer.provide(NodeServices.layer), + ), + ), + ); + return { importer, home, root }; +}); + +describe("BrowserImport.importCookies", () => { + it.effect("rejects a source profile the browser never reported", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const { importer, home } = yield* withImporter(); + + // A cookie database reachable on disk but outside the browser's + // user-data directory — the payoff a traversal would be after. + yield* fileSystem.makeDirectory(`${home}/secrets`, { recursive: true }); + yield* fileSystem.writeFileString(`${home}/secrets/Cookies`, "not-a-db"); + + const error = yield* importer + .importCookies({ + input: { + sourceId: "helium", + sourceProfileDirectory: "../../../../secrets", + targetProfileId: "default", + }, + scope: "persist:t3code-preview-test", + persistent: true, + }) + .pipe(Effect.flip); + + assert.instanceOf(error, BrowserImport.BrowserImportFailedError); + assert.equal(error.reason, "unknownSourceProfile"); + }).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); + + assert.equal(error.reason, "browserRunning"); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); +}); + +describe("BrowserImport.writeCookies", () => { + it.effect("counts a rejected cookie and its domain as skipped", () => + Effect.gen(function* () { + let flushes = 0; + const result = yield* BrowserImport.writeCookies( + { + cookies: { + set: () => Promise.reject(new Error("fixture rejection")), + flushStore: () => { + flushes += 1; + return Promise.resolve(); + }, + }, + }, + { cookies: [cookie], undecryptable: 0, undecryptableHosts: [] }, + ); + + assert.deepEqual(result, { + imported: 0, + skipped: 1, + skippedDomains: ["rejected.example"], + }); + // Nothing landed, so there is nothing to persist. + assert.equal(flushes, 0); + }), + ); + + it.effect("flushes the store after writing, and reports success if the flush fails", () => + Effect.gen(function* () { + const events: Array = []; + const result = yield* BrowserImport.writeCookies( + { + cookies: { + set: () => { + events.push("set"); + return Promise.resolve(); + }, + flushStore: () => { + events.push("flush"); + return Promise.reject(new Error("fixture flush failure")); + }, + }, + }, + { cookies: [cookie, cookie], undecryptable: 0, undecryptableHosts: [] }, + ); + + // One flush after every write, not one per cookie; the cookies are in + // the session either way, so a failed flush is not a failed import. + assert.deepEqual(events, ["set", "set", "flush"]); + assert.deepEqual(result, { imported: 2, skipped: 0, skippedDomains: [] }); + }), + ); + + it.effect("propagates interruption while writing a cookie", () => + Effect.gen(function* () { + const write = BrowserImport.writeCookies( + { + cookies: { + set: () => new Promise(() => {}), + flushStore: () => Promise.resolve(), + }, + }, + { cookies: [cookie], undecryptable: 0, undecryptableHosts: [] }, + ); + + const interrupted = yield* Ref.make(false); + const fiber = yield* write.pipe( + Effect.onInterrupt(() => Ref.set(interrupted, true)), + Effect.forkChild, + ); + yield* Effect.yieldNow; + yield* Fiber.interrupt(fiber); + + assert.isTrue(yield* Ref.get(interrupted)); + }), + ); +}); diff --git a/apps/desktop/src/preview/BrowserImport/BrowserImport.ts b/apps/desktop/src/preview/BrowserImport/BrowserImport.ts new file mode 100644 index 000000000000..e92f2f05e05c --- /dev/null +++ b/apps/desktop/src/preview/BrowserImport/BrowserImport.ts @@ -0,0 +1,316 @@ +/** + * Browser import service - lists importable sources and writes their cookies + * into a T3 Code browser profile's Electron partition. + * + * @module BrowserImport + */ +import type { + BrowserImportInput, + BrowserImportResult, + BrowserImportSource, + BrowserImportUnavailableReason, +} from "@t3tools/contracts"; +import { BrowserImportFailureReason } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type { Session } from "electron"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { HostProcessExecutablePath, HostProcessPlatform } from "@t3tools/shared/hostProcess"; + +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 { + BROWSER_IMPORT_SOURCES, + resolveCookieDatabase, + isSourceInstalled, + isSourceRunning, + listSourceProfiles, + sourcePathContext, + type BrowserImportPathContext, + type BrowserImportSourceDefinition, +} from "./Sources.ts"; + +export class BrowserImportFailedError extends Schema.TaggedErrorClass()( + "BrowserImportFailedError", + { + sourceId: Schema.String, + reason: BrowserImportFailureReason, + /** Kept for the log; the user only ever sees the reason's copy. */ + cause: Schema.optional(Schema.Defect()), + }, +) { + // The reason token is part of the message on purpose: IPC flattens the error + // to its message, and the renderer maps that token back to user-facing copy. + override get message(): string { + return `Importing cookies from ${this.sourceId} failed: ${this.reason}.`; + } +} + +export class BrowserCookieWriteError extends Schema.TaggedErrorClass()( + "BrowserCookieWriteError", + { + url: Schema.String, + name: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Could not write imported cookie ${this.name} for ${this.url}.`; + } +} + +export class BrowserImport extends Context.Service< + BrowserImport, + { + readonly listSources: Effect.Effect>; + readonly importCookies: (input: { + readonly input: BrowserImportInput; + /** Partition scope of the target profile, derived by the caller in main. */ + readonly scope: string; + readonly persistent: boolean; + readonly namespace?: BrowserSession.BrowserSessionPartitionNamespace; + }) => Effect.Effect; + } +>()("@t3tools/desktop/preview/BrowserImport/BrowserImport") {} + +const unavailableReason = Effect.fn("BrowserImport.unavailableReason")(function* ( + definition: BrowserImportSourceDefinition, + context: BrowserImportPathContext, +): Effect.fn.Return< + BrowserImportUnavailableReason | undefined, + never, + FileSystem.FileSystem | ChildProcessSpawner.ChildProcessSpawner +> { + if (!definition.platforms.includes(context.platform)) return "unsupportedPlatform"; + if (!(yield* isSourceInstalled(definition, context))) return "notInstalled"; + if (yield* isSourceRunning(definition, context)) return "browserRunning"; + return undefined; +}); + +/** The host a constructed cookie URL points at, for naming what was skipped. */ +const cookieHost = (url: string): string => { + try { + return new URL(url).hostname; + } catch { + return url; + } +}; + +export const writeCookies = Effect.fn("BrowserImport.writeCookies")(function* ( + session: { readonly cookies: Pick }, + read: CookieReadResult, +) { + let imported = 0; + let skipped = read.undecryptable; + const skippedDomains = new Set(read.undecryptableHosts); + for (const cookie of read.cookies) { + const written = yield* Effect.tryPromise({ + try: () => + session.cookies.set({ + url: cookie.url, + name: cookie.name, + value: cookie.value, + // Omitted for host-only cookies: Electron reads any `domain` as a + // domain cookie and re-adds the leading dot, widening its scope. + ...(cookie.domain === undefined ? {} : { domain: cookie.domain }), + path: cookie.path, + secure: cookie.secure, + httpOnly: cookie.httpOnly, + sameSite: cookie.sameSite, + ...(cookie.expirationDate === undefined ? {} : { expirationDate: cookie.expirationDate }), + }), + catch: (cause) => new BrowserCookieWriteError({ url: cookie.url, name: cookie.name, cause }), + }).pipe( + Effect.as(true), + Effect.tapError((error) => Effect.logDebug(error.message, { cause: error.cause })), + Effect.catchTags({ BrowserCookieWriteError: () => Effect.succeed(false) }), + ); + if (written) { + imported += 1; + } else { + skipped += 1; + skippedDomains.add(cookieHost(cookie.url)); + } + } + // `set` resolves once the cookie is in memory; Chromium writes the store to + // disk on its own schedule. Flush before reporting "Done", so a crash right + // after does not lose what the user was just told was imported. A failed + // flush is logged rather than surfaced: the cookies are still in the + // session and land on disk at the next scheduled write. + if (imported > 0) { + yield* Effect.tryPromise(() => session.cookies.flushStore()).pipe( + Effect.tapError((error) => + Effect.logWarning("Imported cookies could not be flushed to disk", { cause: error.cause }), + ), + Effect.ignore, + ); + } + return { imported, skipped, skippedDomains: [...skippedDomains].slice(0, 20) }; +}); + +export const make = Effect.gen(function* BrowserImportMake() { + const browserSession = yield* BrowserSession.BrowserSession; + const platform = yield* HostProcessPlatform; + const executablePath = yield* HostProcessExecutablePath; + // Captured here so the service's methods stay free of a requirements + // channel: the layer is built where NodeServices is already in scope. + const platformServices = yield* Effect.context< + FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner + >(); + const pathContext = yield* sourcePathContext; + + const listSources: Effect.Effect> = Effect.forEach( + BROWSER_IMPORT_SOURCES, + Effect.fnUntraced(function* (definition) { + const unavailable = yield* unavailableReason(definition, pathContext); + return { + id: definition.id, + name: definition.name, + // Listing profiles touches the source's own files, so skip it when the + // source is unusable anyway. + profiles: + unavailable === undefined ? yield* listSourceProfiles(definition, pathContext) : [], + ...(unavailable === undefined ? {} : { unavailable }), + } satisfies BrowserImportSource; + }), + ).pipe(Effect.provide(platformServices)); + + const importCookies = Effect.fn("BrowserImport.importCookies")(function* (input: { + readonly input: BrowserImportInput; + readonly scope: string; + readonly persistent: boolean; + readonly namespace?: BrowserSession.BrowserSessionPartitionNamespace; + }) { + const definition = BROWSER_IMPORT_SOURCES.find( + (candidate) => candidate.id === input.input.sourceId, + ); + if (!definition) { + return yield* new BrowserImportFailedError({ + sourceId: input.input.sourceId, + reason: "unknownSource", + }); + } + + const blocked = yield* unavailableReason(definition, pathContext).pipe( + Effect.provide(platformServices), + ); + if (blocked !== undefined) { + return yield* new BrowserImportFailedError({ sourceId: definition.id, reason: blocked }); + } + + if (platform === "darwin" && definition.engine === "chromium") { + // macOS attributes the Keychain prompt and the resulting ACL grant to the + // executable that asks, so record which one that was — in a packaged build + // it is the signed app, in dev whatever binary hosts the main process. + yield* Effect.logInfo("Reading browser cookie key from the keychain", { + sourceId: definition.id, + executablePath, + }); + } + + // The profile directory arrives over IPC, so it is only honoured when the + // source itself reported it. Forwarding it unchecked would let `..` + // segments walk out of the browser's user-data directory and read any + // cookie database reachable on disk. + const sourceProfiles = yield* listSourceProfiles(definition, pathContext).pipe( + Effect.provide(platformServices), + ); + const requestedProfile = sourceProfiles.find( + (profile) => profile.directory === input.input.sourceProfileDirectory, + ); + if (requestedProfile === undefined) { + return yield* new BrowserImportFailedError({ + sourceId: definition.id, + reason: "unknownSourceProfile", + }); + } + + // The profile was listed against a database moments ago; resolve it again + // rather than assume a path, since a Chromium jar may sit under `Network/`. + const databasePath = yield* resolveCookieDatabase( + definition, + pathContext, + requestedProfile.directory, + ).pipe(Effect.provide(platformServices)); + if (databasePath === undefined) { + // A profile we listed moments ago can lose its database before the + // import runs (browser data cleanup, a profile reset). That is a read + // failure, not a platform problem. + return yield* new BrowserImportFailedError({ sourceId: definition.id, reason: "readFailed" }); + } + + // Both branches fail with a tagged error, so the union stays structurally + // identifiable and each tag is handled on its own below. The success side + // is normalized to one shape too, so the skipped tally survives either + // engine — Firefox stores plaintext, so nothing there is ever unreadable. + const userDataDirectory = definition.userDataDirectory(pathContext); + const read: Effect.Effect< + CookieReadResult, + ChromiumCookieReadError | FirefoxCookieReadError, + FileSystem.FileSystem | Path.Path | Scope.Scope | ChildProcessSpawner.ChildProcessSpawner + > = + 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, + Effect.provide(platformServices), + Effect.catchTags({ + ChromiumCookieReadError: (cause) => + Effect.fail( + new BrowserImportFailedError({ sourceId: definition.id, reason: cause.reason, cause }), + ), + // Firefox has one failure mode — its plaintext database would not open + // — so its error carries no reason of its own and the user-facing one + // is supplied here. + FirefoxCookieReadError: (cause) => + Effect.fail( + new BrowserImportFailedError({ sourceId: definition.id, reason: "readFailed", cause }), + ), + }), + ); + + const session = yield* browserSession + .getSession(input.scope, input.persistent, input.namespace) + .pipe( + Effect.mapError( + (cause) => + new BrowserImportFailedError({ + sourceId: definition.id, + reason: "sessionUnavailable", + cause, + }), + ), + ); + + // Written one at a time rather than in parallel: Chromium's cookie store + // serialises writes anyway, and a rejected cookie should only cost itself. + return yield* writeCookies(session, result); + }); + + return BrowserImport.of({ listSources, importCookies }); +}); + +export const layer = Layer.effect(BrowserImport, make); diff --git a/apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts new file mode 100644 index 000000000000..fc60c658b1d1 --- /dev/null +++ b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts @@ -0,0 +1,493 @@ +// @effect-diagnostics nodeBuiltinImport:off - Encrypts fixtures with the same +// OSCrypt primitives the module under test decrypts. +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; +import * as NodeCrypto from "node:crypto"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { + decryptChromiumValue, + readChromiumCookieDatabase, + readChromiumCookies, +} from "./ChromiumCookies.ts"; +import { ChromiumKeyError } from "./ChromiumKeys.ts"; +import { LinuxBrowserSecretPath } from "./LinuxBrowserSecret.ts"; +import { cookieScope } from "./CookieDatabase.ts"; + +const encryptChromium = ( + prefix: "v10" | "v11", + value: string | Buffer, + key: Buffer, +): Uint8Array => { + const cipher = NodeCrypto.createCipheriv("aes-128-cbc", key, Buffer.alloc(16, 0x20)); + return Buffer.concat([Buffer.from(prefix), cipher.update(value), cipher.final()]); +}; + +const encryptV10 = (value: string | Buffer, key: Buffer): Uint8Array => + encryptChromium("v10", value, key); + +const encryptWindowsV10 = (value: string | Buffer, key: Buffer): Uint8Array => { + const nonce = Buffer.from("0123456789ab"); + const cipher = NodeCrypto.createCipheriv("aes-256-gcm", key, nonce); + const encrypted = Buffer.concat([cipher.update(value), cipher.final()]); + return Buffer.concat([Buffer.from("v10"), nonce, encrypted, cipher.getAuthTag()]); +}; + +describe("cookieScope", () => { + it("keeps a host-only cookie host-only", () => { + // Chromium stores a host-only cookie without a leading dot. Passing any + // `domain` to Electron makes it a domain cookie and re-adds the dot, which + // would expose the cookie to every subdomain it was never scoped to. + expect(cookieScope("example.test", "/", true)).toEqual({ + url: "https://example.test/", + domain: undefined, + }); + }); + + it("preserves a domain cookie's leading dot", () => { + expect(cookieScope(".example.test", "/app", true)).toEqual({ + url: "https://example.test/app", + domain: ".example.test", + }); + }); + + it("matches the scheme to the secure flag", () => { + expect(cookieScope("example.test", "/", false).url).toBe("http://example.test/"); + }); + + it("brackets bare IPv6 hosts without duplicating existing brackets", () => { + expect(cookieScope("::1", "/", false)).toEqual({ + url: "http://[::1]/", + domain: undefined, + }); + expect(cookieScope("[::1]", "/app", true)).toEqual({ + url: "https://[::1]/app", + domain: undefined, + }); + }); +}); + +describe("readChromiumCookieDatabase", () => { + it("decrypts Windows v10 AES-GCM records and rejects app-bound v20 records", () => { + const key = Buffer.from("0123456789abcdef0123456789abcdef"); + const host = ".example.test"; + const bound = Buffer.concat([ + NodeCrypto.createHash("sha256").update(host).digest(), + Buffer.from("windows value"), + ]); + + expect( + decryptChromiumValue(encryptWindowsV10(bound, key), { gcmV10: key }, host, 24, "win32"), + ).toBe("windows value"); + expect( + decryptChromiumValue(Buffer.from("v20app-bound"), { gcmV10: key }, host, 24, "win32"), + ).toBeNull(); + expect( + decryptChromiumValue( + encryptWindowsV10(bound, Buffer.alloc(32, 1)), + { gcmV10: key }, + host, + 24, + "win32", + ), + ).toBeNull(); + }); + + it.effect( + "reports the missing key when no cookies can be read, while preserving partial imports", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const directory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-missing-key-", + }); + const filename = `${directory}/Cookies`; + const key = Buffer.from("0123456789abcdef"); + yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`create table meta (key text primary key, value text not null)`; + yield* sql`insert into meta values ('version', 23)`; + yield* sql`create table cookies ( + host_key text not null, name text not null, value text not null, + encrypted_value blob not null, path text not null, expires_utc integer not null, + is_secure integer not null, is_httponly integer not null, samesite integer not null, + top_frame_site_key text not null default '' + )`; + yield* sql`insert into cookies values ('v11.example', 'session', '', ${encryptChromium("v11", "secret", key)}, '/', 0, 1, 1, 1, '')`; + }).pipe(Effect.provide(NodeSqliteClient.layer({ filename }))); + + const error = yield* readChromiumCookies({ + cookieDatabasePath: filename, + platform: "linux", + linuxSecretApplication: "chromium", + keychainService: undefined, + keychainAccount: undefined, + }).pipe(Effect.provideService(LinuxBrowserSecretPath, undefined), Effect.flip); + expect(error.reason).toBe("keychainUnavailable"); + + yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`insert into cookies values ('v10.example', 'readable', '', ${encryptV10("kept", key)}, '/', 0, 1, 1, 1, '')`; + }).pipe(Effect.provide(NodeSqliteClient.layer({ filename }))); + const keys = { + cbcV10: key, + cbcV11Error: new ChromiumKeyError({ reason: "keychainUnavailable" }), + }; + const partial = yield* readChromiumCookieDatabase(filename, keys, "linux"); + expect(partial.cookies.map((cookie) => cookie.value)).toEqual(["kept"]); + expect(partial.undecryptable).toBe(1); + + // A partitioned-only jar does not need its key: it is skipped separately. + yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`delete from cookies where name = 'readable'`; + yield* sql`update cookies set top_frame_site_key = 'https://top.example'`; + }).pipe(Effect.provide(NodeSqliteClient.layer({ filename }))); + const partitioned = yield* readChromiumCookieDatabase(filename, keys, "linux"); + expect(partitioned.cookies).toEqual([]); + expect(partitioned.undecryptable).toBe(1); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); + + it.effect("reads plaintext, encrypted, and genuinely empty cookie values", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const directory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-chromium-cookies-", + }); + const filename = `${directory}/Cookies`; + const key = Buffer.from("0123456789abcdef"); + + yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`create table meta (key text primary key, value text not null)`; + yield* sql`insert into meta values ('version', 23)`; + yield* sql` + create table cookies ( + host_key text not null, + name text not null, + value text not null, + encrypted_value blob not null, + path text not null, + expires_utc integer not null, + is_secure integer not null, + is_httponly integer not null, + samesite integer not null, + top_frame_site_key text not null default '' + ) + `; + yield* sql` + insert into cookies (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite) values + ('plain.example', 'plain', 'stored plaintext', ${new Uint8Array()}, '/', 0, 0, 0, -1) + `; + yield* sql` + insert into cookies (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite) values + ('secure.example', 'encrypted', '', ${encryptV10("stored encrypted", key)}, '/', 0, 1, 1, 2) + `; + yield* sql` + insert into cookies (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite) values + ('empty.example', 'empty', '', ${new Uint8Array()}, '/', 0, 0, 0, 0) + `; + }).pipe(Effect.provide(NodeSqliteClient.layer({ filename }))); + + const result = yield* readChromiumCookieDatabase(filename, { cbcV10: key }, "darwin"); + + expect(result.undecryptable).toBe(0); + expect(result.cookies.map(({ name, value }) => ({ name, value }))).toEqual([ + { name: "plain", value: "stored plaintext" }, + { name: "encrypted", value: "stored encrypted" }, + { name: "empty", value: "" }, + ]); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); + + it.effect("enforces domain binding only for schema 24 and newer", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const directory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-chromium-cookies-", + }); + const filename = `${directory}/Cookies`; + const key = Buffer.from("0123456789abcdef"); + const boundValue = (host: string, value: string) => + Buffer.concat([NodeCrypto.createHash("sha256").update(host).digest(), Buffer.from(value)]); + + yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`create table meta (key text primary key, value text not null)`; + yield* sql`insert into meta values ('version', 24)`; + yield* sql` + create table cookies ( + host_key text not null, name text not null, value text not null, + encrypted_value blob not null, path text not null, expires_utc integer not null, + is_secure integer not null, is_httponly integer not null, samesite integer not null, + top_frame_site_key text not null default '' + ) + `; + yield* sql`insert into cookies (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite) values + ('bound.example', 'valid', '', ${encryptV10(boundValue("bound.example", "kept"), key)}, '/', 0, 1, 0, 0)`; + yield* sql`insert into cookies (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite) values + ('wrong.example', 'mismatch', '', ${encryptV10(boundValue("another.example", "drop"), key)}, '/', 0, 1, 0, 0)`; + yield* sql`insert into cookies (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite) values + ('short.example', 'short', '', ${encryptV10("short value", key)}, '/', 0, 1, 0, 0)`; + }).pipe(Effect.provide(NodeSqliteClient.layer({ filename }))); + + const result = yield* readChromiumCookieDatabase(filename, { cbcV10: key }, "darwin"); + + expect(result.cookies.map(({ name, value }) => ({ name, value }))).toEqual([ + { name: "valid", value: "kept" }, + ]); + expect(result.undecryptable).toBe(2); + expect(result.undecryptableHosts).toEqual(["wrong.example", "short.example"]); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); + + it.effect("decrypts mixed v10 and v11 cookies with their respective keys", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const directory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-chromium-cookies-", + }); + const filename = `${directory}/Cookies`; + const cbcV10 = Buffer.from("0123456789abcdef"); + const cbcV11 = Buffer.from("fedcba9876543210"); + const boundValue = (host: string, value: string) => + Buffer.concat([NodeCrypto.createHash("sha256").update(host).digest(), Buffer.from(value)]); + + yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`create table meta (key text primary key, value text not null)`; + yield* sql`insert into meta values ('version', '24')`; + yield* sql` + create table cookies ( + host_key text not null, name text not null, value text not null, + encrypted_value blob not null, path text not null, expires_utc integer not null, + is_secure integer not null, is_httponly integer not null, samesite integer not null, + top_frame_site_key text not null default '' + ) + `; + yield* sql`insert into cookies (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite) values + ('v10.example', 'v10-cookie', '', ${encryptChromium("v10", boundValue("v10.example", "v10 value"), cbcV10)}, '/', 0, 1, 0, 0)`; + yield* sql`insert into cookies (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite) values + ('v11.example', 'v11-cookie', '', ${encryptChromium("v11", boundValue("v11.example", "v11 value"), cbcV11)}, '/', 0, 1, 0, 0)`; + }).pipe(Effect.provide(NodeSqliteClient.layer({ filename }))); + + const complete = yield* readChromiumCookieDatabase(filename, { cbcV10, cbcV11 }, "linux"); + expect(complete.cookies.map(({ name, value }) => ({ name, value }))).toEqual([ + { name: "v10-cookie", value: "v10 value" }, + { name: "v11-cookie", value: "v11 value" }, + ]); + expect(complete.undecryptable).toBe(0); + + const v10Only = yield* readChromiumCookieDatabase(filename, { cbcV10 }, "linux"); + expect(v10Only.cookies.map(({ name, value }) => ({ name, value }))).toEqual([ + { name: "v10-cookie", value: "v10 value" }, + ]); + expect(v10Only.undecryptable).toBe(1); + expect(v10Only.undecryptableHosts).toEqual(["v11.example"]); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); + + it.effect("recovers records written with the empty-passphrase key", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const directory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-chromium-cookies-", + }); + const filename = `${directory}/Cookies`; + const cbcV10 = Buffer.from("0123456789abcdef"); + const cbcV11 = Buffer.from("fedcba9876543210"); + // The key some Linux clients actually encrypted with (crbug.com/1195256): + // OSCrypt's derivation over an empty passphrase. + const cbcEmpty = NodeCrypto.pbkdf2Sync("", "saltysalt", 1, 16, "sha1"); + + yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`create table meta (key text primary key, value text not null)`; + yield* sql`insert into meta values ('version', '23')`; + yield* sql` + create table cookies ( + host_key text not null, name text not null, value text not null, + encrypted_value blob not null, path text not null, expires_utc integer not null, + is_secure integer not null, is_httponly integer not null, samesite integer not null, + top_frame_site_key text not null default '' + ) + `; + yield* sql`insert into cookies (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite) values + ('ev10.example', 'empty-v10', '', ${encryptChromium("v10", "empty v10 value", cbcEmpty)}, '/', 0, 1, 0, 0)`; + yield* sql`insert into cookies (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite) values + ('ev11.example', 'empty-v11', '', ${encryptChromium("v11", "empty v11 value", cbcEmpty)}, '/', 0, 1, 0, 0)`; + }).pipe(Effect.provide(NodeSqliteClient.layer({ filename }))); + + // The records' own keys fail, and the empty key recovers both — the + // retry Chromium itself performs. + const recovered = yield* readChromiumCookieDatabase( + filename, + { cbcV10, cbcV11, cbcEmpty }, + "linux", + ); + expect(recovered.cookies.map(({ name, value }) => ({ name, value }))).toEqual([ + { name: "empty-v10", value: "empty v10 value" }, + { name: "empty-v11", value: "empty v11 value" }, + ]); + expect(recovered.undecryptable).toBe(0); + + // Matching Chromium: a record whose own key is missing entirely is not + // retried with the empty key. + const noV11 = yield* readChromiumCookieDatabase(filename, { cbcV10, cbcEmpty }, "linux"); + expect(noV11.cookies.map(({ name }) => name)).toEqual(["empty-v10"]); + expect(noV11.undecryptableHosts).toEqual(["ev11.example"]); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); + + it.effect("preserves arbitrary long encrypted values from pre-24 schemas", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const directory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-chromium-cookies-", + }); + const filename = `${directory}/Cookies`; + const key = Buffer.from("0123456789abcdef"); + const value = "x".repeat(32) + " legacy value"; + + yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`create table meta (key text primary key, value text not null)`; + yield* sql`insert into meta values ('version', 23)`; + yield* sql` + create table cookies ( + host_key text not null, name text not null, value text not null, + encrypted_value blob not null, path text not null, expires_utc integer not null, + is_secure integer not null, is_httponly integer not null, samesite integer not null, + top_frame_site_key text not null default '' + ) + `; + yield* sql`insert into cookies (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite) values + ('legacy.example', 'legacy', '', ${encryptV10(value, key)}, '/', 0, 0, 0, 0)`; + }).pipe(Effect.provide(NodeSqliteClient.layer({ filename }))); + + const result = yield* readChromiumCookieDatabase(filename, { cbcV10: key }, "darwin"); + expect(result.cookies[0]?.value).toBe(value); + expect(result.undecryptable).toBe(0); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); + + it.effect("rejects a malformed text schema version", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const directory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-chromium-cookies-", + }); + const filename = `${directory}/Cookies`; + + yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`create table meta (key text primary key, value text not null)`; + yield* sql`insert into meta values ('version', 'not-a-version')`; + }).pipe(Effect.provide(NodeSqliteClient.layer({ filename }))); + + const error = yield* readChromiumCookieDatabase( + filename, + { cbcV10: Buffer.from("0123456789abcdef") }, + "darwin", + ).pipe(Effect.flip); + + expect(error._tag).toBe("SchemaError"); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); + + it.effect("treats unversioned encrypted values as legacy plaintext on macOS and Linux", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const directory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-chromium-cookies-", + }); + const filename = `${directory}/Cookies`; + const key = Buffer.from("0123456789abcdef"); + + yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`create table meta (key text primary key, value integer not null)`; + yield* sql`insert into meta values ('version', 23)`; + yield* sql` + create table cookies ( + host_key text not null, name text not null, value text not null, + encrypted_value blob not null, path text not null, expires_utc integer not null, + is_secure integer not null, is_httponly integer not null, samesite integer not null, + top_frame_site_key text not null default '' + ) + `; + yield* sql`insert into cookies (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite) values + ('legacy.example', 'legacy', '', ${Buffer.from("legacy cleartext")}, '/', 0, 0, 0, 0)`; + }).pipe(Effect.provide(NodeSqliteClient.layer({ filename }))); + + // Chromium's OSCrypt returns unprefixed data as-is on both platforms + // (os_crypt_mac.mm and os_crypt_linux.cc: "old data saved as clear + // text"), so neither counts it as undecryptable. + const mac = yield* readChromiumCookieDatabase(filename, { cbcV10: key }, "darwin"); + const linux = yield* readChromiumCookieDatabase(filename, { cbcV10: key }, "linux"); + + expect(mac.cookies[0]?.value).toBe("legacy cleartext"); + expect(mac.undecryptable).toBe(0); + expect(linux.cookies[0]?.value).toBe("legacy cleartext"); + expect(linux.undecryptable).toBe(0); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); + + it.effect("skips partitioned cookies without breaking pre-CHIPS schemas", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const directory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-chromium-cookies-", + }); + const legacyFilename = `${directory}/LegacyCookies`; + const chipsFilename = `${directory}/ChipsCookies`; + const key = Buffer.from("0123456789abcdef"); + + yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`create table meta (key text primary key, value integer not null)`; + yield* sql`insert into meta values ('version', 14)`; + yield* sql` + create table cookies ( + host_key text not null, name text not null, value text not null, + encrypted_value blob not null, path text not null, expires_utc integer not null, + is_secure integer not null, is_httponly integer not null, samesite integer not null + ) + `; + yield* sql`insert into cookies values + ('legacy.example', 'legacy', 'kept', ${new Uint8Array()}, '/', 0, 0, 0, 0)`; + }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: legacyFilename }))); + + yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`create table meta (key text primary key, value integer not null)`; + yield* sql`insert into meta values ('version', 23)`; + yield* sql` + create table cookies ( + host_key text not null, name text not null, value text not null, + encrypted_value blob not null, path text not null, expires_utc integer not null, + is_secure integer not null, is_httponly integer not null, samesite integer not null, + top_frame_site_key text not null + ) + `; + yield* sql`insert into cookies values + ('plain.example', 'plain', 'kept', ${new Uint8Array()}, '/', 0, 0, 0, 0, '')`; + yield* sql`insert into cookies values + ('partitioned.example', 'partitioned', 'must skip', ${new Uint8Array()}, '/', 0, 1, 0, 0, 'https://top.example')`; + }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: chipsFilename }))); + + const legacy = yield* readChromiumCookieDatabase(legacyFilename, { cbcV10: key }, "darwin"); + const chips = yield* readChromiumCookieDatabase(chipsFilename, { cbcV10: key }, "darwin"); + + expect(legacy.cookies.map(({ name }) => name)).toEqual(["legacy"]); + expect(legacy.undecryptable).toBe(0); + expect(chips.cookies.map(({ name }) => name)).toEqual(["plain"]); + expect(chips.undecryptable).toBe(1); + expect(chips.undecryptableHosts).toEqual(["partitioned.example"]); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); +}); diff --git a/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts new file mode 100644 index 000000000000..4b8d9d43a47b --- /dev/null +++ b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts @@ -0,0 +1,370 @@ +// @effect-diagnostics nodeBuiltinImport:off - `node:crypto` implements the +// OSCrypt primitives Chromium uses; Effect has no equivalent. +/** + * Chromium cookie extraction. + * + * Reads a Chromium-family browser's cookie database and decrypts each record + * with the key its prefix calls for. Key acquisition — and the consent it + * needs — lives in `ChromiumKeys`. + * + * Records whose scheme we hold no key for are skipped rather than failing the + * whole import: a Linux database can mix `v10` and `v11`. A partial result + * reported honestly is more useful than an all-or-nothing error. + * + * @module ChromiumCookies + */ +import * as NodeCrypto from "node:crypto"; + +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { + ChromiumKeyError, + ChromiumKeyFailure, + readWindowsKey, + resolveChromiumKeys, + type ChromiumKeyMaterial, +} from "./ChromiumKeys.ts"; +import { + bareHost, + cookieScope, + snapshotCookieDatabase, + type CookieReadResult, + type ImportedCookie, +} from "./CookieDatabase.ts"; + +/** OSCrypt's CBC mode uses a fixed IV of 16 spaces rather than a per-record one. */ +const AES_CBC_IV = Buffer.alloc(16, 0x20); +const AES_GCM_NONCE_LENGTH = 12; +const AES_GCM_TAG_LENGTH = 16; +const isChromiumKeyError = Schema.is(ChromiumKeyError); + +/** + * Every way the read can fail: the key failures, plus the ones this module + * raises itself. + */ +export const ChromiumCookieReadReason = Schema.Literals([ + // `readFailed` already comes from the key failures, so it is not repeated. + ...ChromiumKeyFailure.literals, + "browserRunning", +]); +export type ChromiumCookieReadReason = typeof ChromiumCookieReadReason.Type; + +export class ChromiumCookieReadError extends Schema.TaggedErrorClass()( + "ChromiumCookieReadError", + { + reason: ChromiumCookieReadReason, + /** + * Which database the read was for. Without it every `readFailed` and + * keychain failure logs identically, and a user with several browsers + * installed has no way to tell which one refused. + */ + cookieDatabasePath: Schema.String, + /** Kept for the log; never surfaced to the user. */ + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `Could not read Chromium cookies at ${this.cookieDatabasePath}: ${this.reason}.`; + } +} + +/** Row shape of the cookie table, decoded rather than cast. */ +const CookieRow = Schema.Struct({ + host_key: Schema.String, + name: Schema.String, + value: Schema.String, + encrypted_value: Schema.Uint8Array, + path: Schema.String, + expires_seconds: Schema.Number, + is_secure: Schema.Number, + is_httponly: Schema.Number, + samesite: Schema.Number, + top_frame_site_key: Schema.String, +}); +const decodeCookieRows = Schema.decodeUnknownEffect(Schema.Array(CookieRow)); +const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)); +const SchemaVersion = Schema.Union([ + NonNegativeInt, + Schema.FiniteFromString.pipe(Schema.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0))), +]); +const decodeSchemaVersion = Schema.decodeUnknownEffect( + Schema.Tuple([Schema.Struct({ value: SchemaVersion })]), +); + +/** + * Chromium stores `SameSite` as an int: -1 = unspecified, 0 = none, 1 = lax, + * 2 = strict. Unspecified is imported as Electron's own `unspecified` rather + * than pinned to Lax, so the target browser applies its default just as the + * source did; anything unrecognised lands there too, since guessing "none" + * would widen a cookie's scope on import. + */ +const sameSiteFromColumn = (value: number): ImportedCookie["sameSite"] => { + if (value === 0) return "no_restriction"; + if (value === 1) return "lax"; + if (value === 2) return "strict"; + return "unspecified"; +}; + +/** + * Chromium timestamps count microseconds from 1601-01-01; Electron wants + * seconds from the UNIX epoch. The microsecond value overflows JavaScript's + * safe integer range and `node:sqlite` refuses to narrow it, so the division + * happens in SQL and this only ever sees seconds. + */ +const WEBKIT_EPOCH_OFFSET_SECONDS = 11_644_473_600; +const toUnixSeconds = (webkitSeconds: number): number | undefined => { + if (webkitSeconds <= 0) return undefined; + return webkitSeconds - WEBKIT_EPOCH_OFFSET_SECONDS; +}; + +/** + * Chromium >= 127 prefixes the plaintext with SHA-256 of the host key, binding + * a cookie to its domain. Strip it when present. + */ +const stripDomainBinding = ( + plaintext: Buffer, + domain: string, + schemaVersion: number, +): Buffer | null => { + if (schemaVersion < 24) return plaintext; + const domainHash = NodeCrypto.createHash("sha256").update(domain).digest(); + return plaintext.length >= 32 && plaintext.subarray(0, 32).equals(domainHash) + ? plaintext.subarray(32) + : null; +}; + +const decryptCbc = ( + payload: Buffer, + key: Buffer, + domain: string, + schemaVersion: number, +): string | null => { + try { + const decipher = NodeCrypto.createDecipheriv("aes-128-cbc", key, AES_CBC_IV); + decipher.setAutoPadding(true); + const plaintext = Buffer.concat([decipher.update(payload), decipher.final()]); + return stripDomainBinding(plaintext, domain, schemaVersion)?.toString("utf8") ?? null; + } catch { + return null; + } +}; + +const decryptGcm = ( + payload: Buffer, + key: Buffer, + domain: string, + schemaVersion: number, +): string | null => { + if (payload.length < AES_GCM_NONCE_LENGTH + AES_GCM_TAG_LENGTH) return null; + try { + const nonce = payload.subarray(0, AES_GCM_NONCE_LENGTH); + const ciphertext = payload.subarray(AES_GCM_NONCE_LENGTH, -AES_GCM_TAG_LENGTH); + const tag = payload.subarray(-AES_GCM_TAG_LENGTH); + const decipher = NodeCrypto.createDecipheriv("aes-256-gcm", key, nonce); + decipher.setAuthTag(tag); + const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]); + return stripDomainBinding(plaintext, domain, schemaVersion)?.toString("utf8") ?? null; + } catch { + return null; + } +}; + +/** + * Decrypts one stored value, choosing the scheme from its prefix. Returns null + * when no key covers that scheme — including Windows' app-bound `v20`, which + * this build has no key for at all. + */ +export function decryptChromiumValue( + encrypted: Uint8Array, + keys: ChromiumKeyMaterial, + domain: string, + schemaVersion = 23, + platform: NodeJS.Platform = "linux", +): string | null { + const buffer = Buffer.from(encrypted); + if (buffer.length === 0) return ""; + const prefix = buffer.subarray(0, 3).toString("latin1"); + const payload = buffer.subarray(3); + + // Windows' legacy v10 format is AES-256-GCM. App-bound records use v20 and + // intentionally have no key here, so they fall through as undecryptable. + if (platform === "win32") { + return prefix === "v10" && keys.gcmV10 + ? decryptGcm(payload, keys.gcmV10, domain, schemaVersion) + : null; + } + + // Chromium retries a failed record with a key derived from an empty + // passphrase, because some Linux clients wrote data that way + // (crbug.com/1195256). A record whose own key is missing entirely stays + // skipped, matching Chromium. + if (prefix === "v10") { + if (!keys.cbcV10) return null; + return ( + decryptCbc(payload, keys.cbcV10, domain, schemaVersion) ?? + (keys.cbcEmpty ? decryptCbc(payload, keys.cbcEmpty, domain, schemaVersion) : null) + ); + } + if (prefix === "v11") { + if (!keys.cbcV11) return null; + return ( + decryptCbc(payload, keys.cbcV11, domain, schemaVersion) ?? + (keys.cbcEmpty ? decryptCbc(payload, keys.cbcEmpty, domain, schemaVersion) : null) + ); + } + // No recognised prefix: Chromium on macOS and Linux both treat this as + // legacy data stored in the clear and return it as-is, so it is a readable + // cookie rather than an undecryptable one. Windows is the exception — its + // app-bound `v20` blobs also lack these prefixes and must not be read as + // plaintext — but Windows Chromium is not importable here at all. + if (platform === "darwin" || platform === "linux") { + return stripDomainBinding(buffer, domain, schemaVersion)?.toString("utf8") ?? null; + } + return null; +} + +/** Reads and decodes one snapshotted Chromium cookie database. */ +export const readChromiumCookieDatabase = Effect.fn("ChromiumCookies.readChromiumCookieDatabase")( + function* (snapshotPath: string, keys: ChromiumKeyMaterial, platform: NodeJS.Platform) { + const result = yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const schemaVersion = yield* sql`select value from meta where key = 'version' limit 1`.pipe( + Effect.flatMap(decodeSchemaVersion), + Effect.map(([row]) => row.value), + ); + const raw = + schemaVersion >= 15 + ? yield* sql`select host_key, name, value, encrypted_value, path, + expires_utc / 1000000 as expires_seconds, is_secure, is_httponly, + samesite, top_frame_site_key from cookies` + : yield* sql`select host_key, name, value, encrypted_value, path, + expires_utc / 1000000 as expires_seconds, is_secure, is_httponly, + samesite, '' as top_frame_site_key from cookies`; + return { rows: yield* decodeCookieRows(raw), schemaVersion }; + }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: snapshotPath, readonly: true }))); + + const cookies: ImportedCookie[] = []; + let undecryptable = 0; + const undecryptableHosts = new Set(); + for (const row of result.rows) { + if (row.top_frame_site_key !== "") { + undecryptable += 1; + undecryptableHosts.add(bareHost(row.host_key)); + continue; + } + const value = + row.encrypted_value.length === 0 + ? row.value + : decryptChromiumValue( + row.encrypted_value, + keys, + row.host_key, + result.schemaVersion, + platform, + ); + if (value === null) { + undecryptable += 1; + undecryptableHosts.add(bareHost(row.host_key)); + continue; + } + const secure = row.is_secure === 1; + const scope = cookieScope(row.host_key, row.path, secure); + cookies.push({ + url: scope.url, + name: row.name, + value, + domain: scope.domain, + path: row.path, + secure, + httpOnly: row.is_httponly === 1, + expirationDate: toUnixSeconds(row.expires_seconds), + sameSite: sameSiteFromColumn(row.samesite), + }); + } + // Keep partial imports, but do not call a missing key a successful import + // when it prevented every otherwise importable cookie from being read. + if ( + cookies.length === 0 && + keys.cbcV11Error !== undefined && + result.rows.some( + (row) => + row.top_frame_site_key === "" && + Buffer.from(row.encrypted_value.subarray(0, 3)).toString("latin1") === "v11", + ) + ) { + return yield* keys.cbcV11Error; + } + return { + cookies, + undecryptable, + undecryptableHosts: [...undecryptableHosts], + } satisfies CookieReadResult; + }, +); + +export interface ChromiumCookieSource { + readonly cookieDatabasePath: string; + readonly keychainService: string | undefined; + readonly keychainAccount: string | undefined; + readonly linuxSecretApplication: string | undefined; + readonly windowsLocalStatePath?: string; + /** Supplied by the caller from `HostProcessPlatform` rather than read here. */ + readonly platform: NodeJS.Platform; +} + +export const readChromiumCookies = Effect.fn("ChromiumCookies.readChromiumCookies")(function* ( + source: ChromiumCookieSource, +): Effect.fn.Return< + CookieReadResult, + ChromiumCookieReadError, + FileSystem.FileSystem | Path.Path | Scope.Scope | ChildProcessSpawner.ChildProcessSpawner +> { + const keys = yield* ( + source.platform === "win32" && source.windowsLocalStatePath + ? readWindowsKey(source.windowsLocalStatePath).pipe(Effect.map((gcmV10) => ({ gcmV10 }))) + : resolveChromiumKeys({ + platform: source.platform, + keychainService: source.keychainService, + keychainAccount: source.keychainAccount, + linuxSecretApplication: source.linuxSecretApplication, + }) + ).pipe( + Effect.mapError( + (cause: ChromiumKeyError) => + new ChromiumCookieReadError({ + reason: cause.reason, + cookieDatabasePath: source.cookieDatabasePath, + cause, + }), + ), + ); + + const snapshotPath = yield* snapshotCookieDatabase(source.cookieDatabasePath).pipe( + Effect.mapError( + (cause) => + new ChromiumCookieReadError({ + reason: "readFailed", + cookieDatabasePath: source.cookieDatabasePath, + cause, + }), + ), + ); + + return yield* readChromiumCookieDatabase(snapshotPath, keys, source.platform).pipe( + Effect.mapError( + (cause) => + new ChromiumCookieReadError({ + reason: isChromiumKeyError(cause) ? cause.reason : "readFailed", + cookieDatabasePath: source.cookieDatabasePath, + cause, + }), + ), + ); +}); diff --git a/apps/desktop/src/preview/BrowserImport/ChromiumKeys.test.ts b/apps/desktop/src/preview/BrowserImport/ChromiumKeys.test.ts new file mode 100644 index 000000000000..c6d26e7a435b --- /dev/null +++ b/apps/desktop/src/preview/BrowserImport/ChromiumKeys.test.ts @@ -0,0 +1,274 @@ +import { describe, expect, it } from "@effect/vitest"; +import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import * as PlatformError from "effect/PlatformError"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { + ChromiumKeyError, + decodeWindowsWrappedKey, + readLinuxSecret, + resolveChromiumKeys, + unwrapWindowsDpapiKey, +} from "./ChromiumKeys.ts"; +import { LinuxBrowserSecretPath } from "./LinuxBrowserSecret.ts"; + +type CapturedCommand = { + readonly command: string; + readonly args: ReadonlyArray; + readonly options: { + readonly stdin?: string; + readonly env?: Readonly>; + }; +}; + +const helperLayer = (input: { + readonly stdout?: string; + readonly stderr?: string; + readonly stdoutStream?: Stream.Stream; + readonly stderrStream?: Stream.Stream; + readonly exitCode?: number; + readonly spawnError?: PlatformError.PlatformError; + readonly capture?: (command: CapturedCommand) => void; +}) => + Layer.merge( + Layer.succeed(LinuxBrowserSecretPath, "/bundled/browser-secret/t3-browser-secret"), + Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => + input.spawnError + ? Effect.fail(input.spawnError) + : Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(input.exitCode ?? 0)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: input.stdoutStream ?? Stream.encodeText(Stream.make(input.stdout ?? "")), + stderr: input.stderrStream ?? Stream.encodeText(Stream.make(input.stderr ?? "")), + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }), + ).pipe( + Effect.tap(() => Effect.sync(() => input.capture?.(command as CapturedCommand))), + ), + ), + ), + ); + +describe("Linux Chromium secrets", () => { + it.effect("retains a missing helper failure alongside the keyring-free fallback", () => + Effect.gen(function* () { + const keys = yield* resolveChromiumKeys({ + platform: "linux", + keychainService: undefined, + keychainAccount: undefined, + linuxSecretApplication: "chromium", + }); + expect(keys.cbcV10).toHaveLength(16); + expect(keys.cbcV11).toBeUndefined(); + expect(keys.cbcV11Error?.reason).toBe("keychainUnavailable"); + }).pipe( + Effect.provide( + helperLayer({ + spawnError: PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + }), + }), + ), + ), + ); + + it.effect("reports an unconfigured helper without searching PATH", () => + readLinuxSecret("chromium").pipe( + Effect.flip, + Effect.tap((error) => Effect.sync(() => expect(error.reason).toBe("keychainUnavailable"))), + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.die("must not spawn")), + ), + Effect.provideService(LinuxBrowserSecretPath, undefined), + ), + ); + + it.effect("looks up the browser's libsecret application attribute", () => { + let captured: CapturedCommand | undefined; + return Effect.gen(function* () { + const keys = yield* resolveChromiumKeys({ + platform: "linux", + keychainService: "ignored macOS service", + keychainAccount: "ignored macOS account", + linuxSecretApplication: "msedge", + }); + + expect(captured?.command).toBe("/bundled/browser-secret/t3-browser-secret"); + expect(captured?.args).toEqual(["msedge"]); + expect(captured?.options.stdin).toBe("ignore"); + expect(keys.cbcV10).toHaveLength(16); + expect(keys.cbcV11).toHaveLength(16); + }).pipe( + Effect.provide( + helperLayer({ stdout: "linux-secret", capture: (value) => (captured = value) }), + ), + ); + }); + + it.effect("reports an unavailable Secret Service backend as a read failure", () => + Effect.gen(function* () { + const error = yield* readLinuxSecret("chrome").pipe(Effect.flip); + expect(error).toBeInstanceOf(ChromiumKeyError); + expect(error.reason).toBe("keychainUnavailable"); + }).pipe( + Effect.provide( + helperLayer({ stderr: "Cannot autolaunch D-Bus without X11 $DISPLAY", exitCode: 1 }), + ), + ), + ); + + it.effect("preserves trailing whitespace in the stored secret", () => + Effect.gen(function* () { + const secret = yield* readLinuxSecret("chrome"); + expect(secret).toBe("linux-secret \t\n"); + }).pipe(Effect.provide(helperLayer({ stdout: "linux-secret \t\n" }))), + ); + + it.effect("drains stdout and stderr concurrently", () => + Effect.gen(function* () { + const stderrDrainStarted = yield* Deferred.make(); + const stdout = Stream.fromEffect(Deferred.await(stderrDrainStarted)).pipe( + Stream.flatMap(() => Stream.encodeText(Stream.make("linux-secret"))), + ); + const stderr = Stream.fromEffect(Deferred.succeed(stderrDrainStarted, undefined)).pipe( + Stream.drain, + ); + + const secret = yield* readLinuxSecret("chrome").pipe( + Effect.provide(helperLayer({ stdoutStream: stdout, stderrStream: stderr })), + ); + + expect(secret).toBe("linux-secret"); + }), + ); + + it.effect( + "preserves the desktop environment and identifies denial without parsing stderr", + () => { + let captured: CapturedCommand | undefined; + return Effect.gen(function* () { + const error = yield* readLinuxSecret("brave").pipe(Effect.flip); + expect(error).toBeInstanceOf(ChromiumKeyError); + expect(error.reason).toBe("needsKeychainApproval"); + expect(captured?.options.env?.LC_ALL).toBe("localized"); + expect(captured?.options.env?.PATH).toBe("/synthetic/bin"); + expect(captured?.options.env?.SESSION_MARKER).toBe("kept"); + }).pipe( + Effect.provide( + helperLayer({ + stderr: "Zugriff verweigert", + exitCode: 3, + capture: (value) => (captured = value), + }), + ), + Effect.provideService(HostProcessEnvironment, { + PATH: "/synthetic/bin", + SESSION_MARKER: "kept", + LC_ALL: "localized", + }), + ); + }, + ); + + it.effect("does not discard a denied unlock prompt while resolving keys", () => + Effect.gen(function* () { + const error = yield* resolveChromiumKeys({ + platform: "linux", + keychainService: undefined, + keychainAccount: undefined, + linuxSecretApplication: "brave", + }).pipe(Effect.flip); + expect(error.reason).toBe("needsKeychainApproval"); + }).pipe(Effect.provide(helperLayer({ stderr: "Keyring is locked", exitCode: 3 }))), + ); + + it.effect("keeps the v10 fallback when the Secret Service backend is unavailable", () => + Effect.gen(function* () { + const keys = yield* resolveChromiumKeys({ + platform: "linux", + keychainService: undefined, + keychainAccount: undefined, + linuxSecretApplication: "chrome", + }); + expect(keys.cbcV10).toHaveLength(16); + expect(keys.cbcV11).toBeUndefined(); + }).pipe( + Effect.provide( + helperLayer({ + stderr: "Cannot autolaunch D-Bus without X11 $DISPLAY", + exitCode: 1, + }), + ), + ), + ); + + it.effect("keeps the v10 fallback when no matching v11 secret exists", () => + Effect.gen(function* () { + const keys = yield* resolveChromiumKeys({ + platform: "linux", + keychainService: undefined, + keychainAccount: undefined, + linuxSecretApplication: "vivaldi", + }); + expect(keys.cbcV10).toHaveLength(16); + expect(keys.cbcV11).toBeUndefined(); + }).pipe(Effect.provide(helperLayer({ exitCode: 2 }))), + ); +}); + +describe("Windows Chromium secrets", () => { + it.effect("accepts only DPAPI-wrapped non-app-bound keys", () => + Effect.gen(function* () { + const wrapped = Buffer.from("wrapped-key"); + const encoded = Buffer.concat([Buffer.from("DPAPI"), wrapped]).toString("base64"); + const localState = `{"os_crypt":{"encrypted_key":"${encoded}"}}`; + expect(yield* decodeWindowsWrappedKey(localState)).toEqual(wrapped); + + const appBound = yield* decodeWindowsWrappedKey( + `{"os_crypt":{"encrypted_key":"${encoded}","app_bound_encrypted_key":"present"}}`, + ).pipe(Effect.flip); + expect(appBound.reason).toBe("unsupportedPlatform"); + + const malformed = yield* decodeWindowsWrappedKey( + `{"os_crypt":{"encrypted_key":"${wrapped.toString("base64")}"}}`, + ).pipe(Effect.flip); + expect(malformed.reason).toBe("readFailed"); + }), + ); + + it.effect("unwraps the binary key through PowerShell without placing it in argv", () => { + let captured: CapturedCommand | undefined; + const wrapped = Buffer.from("wrapped-key"); + const key = Buffer.from("0123456789abcdef0123456789abcdef"); + return Effect.gen(function* () { + expect(yield* unwrapWindowsDpapiKey(wrapped)).toEqual(key); + expect(captured?.command).toBe( + "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", + ); + expect(captured?.args).toContain("-NonInteractive"); + expect(captured?.args.join(" ")).not.toContain(wrapped.toString("base64")); + }).pipe( + Effect.provide( + helperLayer({ stdout: key.toString("base64"), capture: (value) => (captured = value) }), + ), + Effect.provideService(HostProcessEnvironment, { SystemRoot: "C:\\Windows" }), + ); + }); +}); diff --git a/apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts b/apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts new file mode 100644 index 000000000000..d32462662a36 --- /dev/null +++ b/apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts @@ -0,0 +1,333 @@ +// @effect-diagnostics nodeBuiltinImport:off - `node:crypto` implements the +// OSCrypt key derivation Chromium uses; Effect has no equivalent. +/** + * Chromium cookie-encryption keys, per platform. + * + * Chromium calls this OSCrypt, and it works differently on each OS: + * + * - **macOS** keeps one key in the login keychain. Reading it prompts the + * user, which is the consent this feature is built around. + * - **Linux** may keep a key in libsecret/kwallet (`v11` records), or use a + * hardcoded `peanuts` passphrase when no keyring is available (`v10`). Both + * can appear in the same database, so both are derived up front and chosen + * per record. + * + * - **Windows** legacy Chromium stores protect a random AES key with DPAPI. + * App-Bound Encryption remains deliberately unsupported. + * + * @module ChromiumKeys + */ +import * as Keyring from "@napi-rs/keyring"; +import * as NodeCrypto from "node:crypto"; + +import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; + +import * as Effect from "effect/Effect"; +import * as Encoding from "effect/Encoding"; +import * as FileSystem from "effect/FileSystem"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { LinuxBrowserSecretPath } from "./LinuxBrowserSecret.ts"; + +const KEY_SALT = "saltysalt"; +const KEY_LENGTH = 16; +/** macOS stretches the keychain secret; Linux uses a single iteration. */ +const MAC_KEY_ITERATIONS = 1003; +const LINUX_KEY_ITERATIONS = 1; +/** Chromium's documented fallback passphrase when no Linux keyring is present. */ +const LINUX_FALLBACK_PASSPHRASE = "peanuts"; + +export const ChromiumKeyFailure = Schema.Literals([ + "needsKeychainApproval", + "keychainItemMissing", + "keychainUnavailable", + "unsupportedPlatform", + /** The key store itself could not be read, as opposed to holding no key. */ + "readFailed", +]); +export type ChromiumKeyFailure = typeof ChromiumKeyFailure.Type; + +export class ChromiumKeyError extends Schema.TaggedErrorClass()( + "ChromiumKeyError", + { + reason: ChromiumKeyFailure, + /** Kept for the log; never surfaced to the user. */ + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `Could not obtain the Chromium cookie key: ${this.reason}.`; + } +} + +/** + * Keys to try, indexed by the record prefix they decrypt. A database can hold + * records written under more than one scheme, so a missing entry means those + * records are skipped rather than the whole import failing. + */ +export interface ChromiumKeyMaterial { + /** AES-128-CBC on macOS, and the keyring-free Linux fallback. */ + readonly cbcV10?: Buffer; + /** AES-128-CBC, Linux keyring-derived. */ + readonly cbcV11?: Buffer; + /** Retained so an import that needs this key can report why it is missing. */ + readonly cbcV11Error?: ChromiumKeyError; + /** + * AES-128-CBC from an empty passphrase. Some Linux clients wrote records + * with it (crbug.com/1195256), so Chromium — and this import — retry with it + * after a record's own key fails. + */ + readonly cbcEmpty?: Buffer; + /** AES-256-GCM key used by pre-App-Bound Chromium on Windows. */ + readonly gcmV10?: Buffer; +} + +const derive = (passphrase: string, iterations: number) => + NodeCrypto.pbkdf2Sync(passphrase, KEY_SALT, iterations, KEY_LENGTH, "sha1"); + +/** + * Reads the macOS OSCrypt secret from the login keychain. + * + * Uses the in-process Keychain API rather than shelling out to + * `/usr/bin/security`, because macOS attributes both the consent prompt and the + * resulting ACL entry to the binary that asks. Via the CLI the prompt says + * "security" and "Always Allow" grants trust to a tool every process on the + * machine can invoke; in-process it names this app and the grant belongs to it. + * (In an unsigned dev build the name is the dev binary, not the shipped app + * identity.) + * + * Deliberately untimed: macOS answers this with a modal, and a timeout racing + * the user means the prompt can be approved while nothing is left listening, + * which reads as "approving did nothing". + */ +const readKeychainSecret = Effect.fn("ChromiumKeys.readKeychainSecret")(function* ( + service: string, + account: string, +) { + const secret = yield* Effect.try({ + try: () => new Keyring.Entry(service, account).getPassword(), + catch: (cause) => { + const message = String((cause as { message?: unknown } | undefined)?.message ?? ""); + // Distinguish the causes rather than reporting "approve the prompt" for + // a failure approving cannot fix. + const missing = /no (matching )?entry|not found/i.test(message); + return new ChromiumKeyError({ + reason: missing ? "keychainItemMissing" : "needsKeychainApproval", + cause, + }); + }, + }); + if (secret === null || secret === "") { + return yield* new ChromiumKeyError({ reason: "keychainItemMissing" }); + } + return secret; +}); + +/** + * The bundled helper searches Chromium's libsecret schema and application + * attribute, retaining the desktop's normal unlock prompt. Its exit codes + * distinguish a missing key, denied access, and an unavailable keyring without + * parsing localized error messages. Stdout is the unmodified secret. + */ +export const readLinuxSecret = Effect.fn("ChromiumKeys.readLinuxSecret")(function* ( + application: string, +) { + return yield* Effect.scoped( + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const environment = yield* HostProcessEnvironment; + const helper = yield* LinuxBrowserSecretPath; + if (helper === undefined) { + return yield* new ChromiumKeyError({ reason: "keychainUnavailable" }); + } + const handle = yield* spawner + .spawn(ChildProcess.make(helper, [application], { stdin: "ignore", env: environment })) + .pipe( + Effect.mapError( + (cause) => new ChromiumKeyError({ reason: "keychainUnavailable", cause }), + ), + ); + const [secret, , exitCode] = yield* Effect.all( + [ + handle.stdout.pipe(Stream.decodeText(), Stream.mkString), + handle.stderr.pipe(Stream.runDrain), + handle.exitCode, + ], + { concurrency: "unbounded" }, + ).pipe( + Effect.mapError((cause) => new ChromiumKeyError({ reason: "keychainUnavailable", cause })), + ); + if (Number(exitCode) !== 0) { + return yield* new ChromiumKeyError({ + reason: + Number(exitCode) === 2 + ? "keychainItemMissing" + : Number(exitCode) === 3 + ? "needsKeychainApproval" + : "keychainUnavailable", + }); + } + if (secret === "") { + return yield* new ChromiumKeyError({ reason: "keychainItemMissing" }); + } + return secret; + }), + ); +}); + +const WindowsLocalState = Schema.Struct({ + os_crypt: Schema.Struct({ + encrypted_key: Schema.String, + app_bound_encrypted_key: Schema.optional(Schema.String), + }), +}); +const decodeWindowsLocalState = Schema.decodeUnknownEffect( + Schema.fromJsonString(WindowsLocalState), +); +const DPAPI_PREFIX = Buffer.from("DPAPI"); +const WINDOWS_KEY_LENGTH = 32; +const WINDOWS_DPAPI_SCRIPT = + "Add-Type -AssemblyName System.Security;" + + "$value=[Console]::In.ReadToEnd();" + + "$encrypted=[Convert]::FromBase64String($value);" + + "$plain=[Security.Cryptography.ProtectedData]::Unprotect($encrypted,$null,[Security.Cryptography.DataProtectionScope]::CurrentUser);" + + "[Console]::Out.Write([Convert]::ToBase64String($plain))"; + +export const decodeWindowsWrappedKey = Effect.fn("ChromiumKeys.decodeWindowsWrappedKey")(function* ( + contents: string, +) { + const state = yield* decodeWindowsLocalState(contents).pipe( + Effect.mapError((cause) => new ChromiumKeyError({ reason: "readFailed", cause })), + ); + if (state.os_crypt.app_bound_encrypted_key !== undefined) { + return yield* new ChromiumKeyError({ reason: "unsupportedPlatform" }); + } + const wrapped = yield* Effect.fromResult( + Encoding.decodeBase64(state.os_crypt.encrypted_key), + ).pipe(Effect.mapError((cause) => new ChromiumKeyError({ reason: "readFailed", cause }))); + const wrappedBuffer = Buffer.from(wrapped); + if (!wrappedBuffer.subarray(0, DPAPI_PREFIX.length).equals(DPAPI_PREFIX)) { + return yield* new ChromiumKeyError({ reason: "readFailed" }); + } + return wrappedBuffer.subarray(DPAPI_PREFIX.length); +}); + +/** Unwraps a key with the current Windows user's DPAPI identity. */ +export const unwrapWindowsDpapiKey = Effect.fn("ChromiumKeys.unwrapWindowsDpapiKey")(function* ( + wrapped: Buffer, +) { + const environment = yield* HostProcessEnvironment; + return yield* Effect.scoped( + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const windowsRoot = environment.SystemRoot ?? environment.WINDIR; + const powershell = windowsRoot + ? `${windowsRoot}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe` + : "powershell.exe"; + const handle = yield* spawner + .spawn( + ChildProcess.make( + powershell, + [ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-WindowStyle", + "Hidden", + "-Command", + WINDOWS_DPAPI_SCRIPT, + ], + { + env: environment, + stdin: Stream.encodeText(Stream.make(wrapped.toString("base64"))), + }, + ), + ) + .pipe( + Effect.mapError( + (cause) => new ChromiumKeyError({ reason: "keychainUnavailable", cause }), + ), + ); + const [plainEncoded, , exitCode] = yield* Effect.all( + [ + handle.stdout.pipe(Stream.decodeText(), Stream.mkString), + handle.stderr.pipe(Stream.runDrain), + handle.exitCode, + ], + { concurrency: "unbounded" }, + ).pipe(Effect.mapError((cause) => new ChromiumKeyError({ reason: "readFailed", cause }))); + if (Number(exitCode) !== 0) { + return yield* new ChromiumKeyError({ reason: "readFailed" }); + } + const plain = yield* Effect.fromResult(Encoding.decodeBase64(plainEncoded)).pipe( + Effect.mapError((cause) => new ChromiumKeyError({ reason: "readFailed", cause })), + ); + if (plain.length !== WINDOWS_KEY_LENGTH) { + return yield* new ChromiumKeyError({ reason: "readFailed" }); + } + return Buffer.from(plain); + }), + ); +}); + +/** Reads and unwraps a legacy Windows Chromium key without exposing it in argv. */ +export const readWindowsKey = Effect.fn("ChromiumKeys.readWindowsKey")(function* ( + localStatePath: string, +) { + const fileSystem = yield* FileSystem.FileSystem; + const contents = yield* fileSystem + .readFileString(localStatePath) + .pipe(Effect.mapError((cause) => new ChromiumKeyError({ reason: "readFailed", cause }))); + return yield* unwrapWindowsDpapiKey(yield* decodeWindowsWrappedKey(contents)); +}); + +export interface ChromiumKeyRequest { + readonly platform: NodeJS.Platform; + readonly keychainService: string | undefined; + readonly keychainAccount: string | undefined; + readonly linuxSecretApplication: string | undefined; +} + +export const resolveChromiumKeys = Effect.fn("ChromiumKeys.resolveChromiumKeys")(function* ( + request: ChromiumKeyRequest, +): Effect.fn.Return< + ChromiumKeyMaterial, + ChromiumKeyError, + ChildProcessSpawner.ChildProcessSpawner +> { + if (request.platform === "darwin") { + if (!request.keychainService || !request.keychainAccount) { + return yield* new ChromiumKeyError({ reason: "unsupportedPlatform" }); + } + const secret = yield* readKeychainSecret(request.keychainService, request.keychainAccount); + return { cbcV10: derive(secret, MAC_KEY_ITERATIONS) }; + } + + if (request.platform === "linux") { + // The fallback passphrase always applies to `v10` records; a keyring + // secret, when one is reachable, additionally unlocks `v11`. Preserve its + // failure until the reader knows whether any cookies needed that key. + const keyringSecret = request.linuxSecretApplication + ? yield* readLinuxSecret(request.linuxSecretApplication).pipe( + // v10 remains importable when Secret Service is absent or does not + // contain a key. An explicit denial/lock/cancel remains a consent + // failure rather than being silently downgraded. + Effect.catch((error) => + error.reason === "needsKeychainApproval" ? Effect.fail(error) : Effect.succeed(error), + ), + ) + : undefined; + return { + cbcV10: derive(LINUX_FALLBACK_PASSPHRASE, LINUX_KEY_ITERATIONS), + ...(typeof keyringSecret === "string" + ? { cbcV11: derive(keyringSecret, LINUX_KEY_ITERATIONS) } + : keyringSecret + ? { cbcV11Error: keyringSecret } + : {}), + cbcEmpty: derive("", LINUX_KEY_ITERATIONS), + }; + } + + return yield* new ChromiumKeyError({ reason: "unsupportedPlatform" }); +}); diff --git a/apps/desktop/src/preview/BrowserImport/CookieDatabase.test.ts b/apps/desktop/src/preview/BrowserImport/CookieDatabase.test.ts new file mode 100644 index 000000000000..8ae178e17eff --- /dev/null +++ b/apps/desktop/src/preview/BrowserImport/CookieDatabase.test.ts @@ -0,0 +1,84 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Scope from "effect/Scope"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { snapshotCookieDatabase } from "./CookieDatabase.ts"; + +const runNode = ( + effect: Effect.Effect, +) => effect.pipe(Effect.provide(NodeServices.layer), Effect.scoped); + +describe("snapshotCookieDatabase", () => { + it.effect("includes committed WAL data in one consistent database", () => + runNode( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sourceDirectory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-cookie-source-", + }); + const source = path.join(sourceDirectory, "Cookies"); + const snapshot = yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`PRAGMA journal_mode = WAL`; + yield* sql`PRAGMA wal_autocheckpoint = 0`; + yield* sql`CREATE TABLE cookies(name TEXT NOT NULL)`; + yield* sql`INSERT INTO cookies(name) VALUES (${"committed-in-wal"})`; + expect(yield* fileSystem.exists(`${source}-wal`)).toBe(true); + return yield* snapshotCookieDatabase(source); + }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: source }))); + const rows = yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + return yield* sql<{ readonly name: string }>`SELECT name FROM cookies`; + }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: snapshot, readonly: true }))); + expect(rows).toEqual([{ name: "committed-in-wal" }]); + }), + ), + ); + + it.effect("propagates snapshot failures and removes its temporary directory", () => + runNode( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sourceDirectory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-cookie-invalid-source-", + }); + const source = path.join(sourceDirectory, "Cookies"); + yield* fileSystem.writeFileString(source, "not a sqlite database"); + const prefix = `t3code-cookie-failed-${process.pid}-`; + const error = yield* snapshotCookieDatabase(source, prefix).pipe( + Effect.scoped, + Effect.flip, + ); + expect(error._tag).toBe("SqlError"); + const temporaryEntries = yield* fileSystem.readDirectory(path.dirname(sourceDirectory)); + expect(temporaryEntries.some((entry) => entry.startsWith(prefix))).toBe(false); + }), + ), + ); + + it.effect("removes a successful snapshot when its scope closes", () => + runNode( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sourceDirectory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-cookie-cleanup-source-", + }); + const source = path.join(sourceDirectory, "Cookies"); + yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`CREATE TABLE cookies(name TEXT NOT NULL)`; + }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: source }))); + const snapshot = yield* snapshotCookieDatabase(source).pipe(Effect.scoped); + expect(yield* fileSystem.exists(snapshot)).toBe(false); + }), + ), + ); +}); diff --git a/apps/desktop/src/preview/BrowserImport/CookieDatabase.ts b/apps/desktop/src/preview/BrowserImport/CookieDatabase.ts new file mode 100644 index 000000000000..a9e6be495c05 --- /dev/null +++ b/apps/desktop/src/preview/BrowserImport/CookieDatabase.ts @@ -0,0 +1,100 @@ +/** + * Shared pieces of cookie extraction: the shape both engines produce, and the + * snapshot every reader takes before touching a live database. + * + * @module CookieDatabase + */ +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; + +/** A cookie in the shape Electron's `session.cookies.set` accepts. */ +export interface ImportedCookie { + readonly url: string; + readonly name: string; + readonly value: string; + /** + * Set only for domain cookies, which the sources mark with a leading dot. + * A host-only cookie leaves this undefined: Electron treats any `domain` it + * is given as marking a domain cookie and re-adds the dot, which would widen + * the cookie to every subdomain of the host it was scoped to, and rejects + * `__Host-` cookies, which require it to be absent. + */ + readonly domain: string | undefined; + readonly path: string; + readonly secure: boolean; + readonly httpOnly: boolean; + /** Seconds since the UNIX epoch, or undefined for a session cookie. */ + readonly expirationDate: number | undefined; + readonly sameSite: "unspecified" | "no_restriction" | "lax" | "strict"; +} + +/** + * Cookies recovered from one database and rows that could not be decrypted. + * The skipped count reaches the user instead of disappearing from a partial + * import result. + */ +export interface CookieReadResult { + readonly cookies: ReadonlyArray; + readonly undecryptable: number; + /** Distinct hosts of the rows that could not be decrypted. */ + readonly undecryptableHosts: ReadonlyArray; +} + +/** + * The URL and domain Electron should register a stored row under. + * + * Both engines mark a domain cookie with a leading dot on the host. Electron + * matches on a URL, so the dot comes off for that; `domain` is passed through + * only for domain cookies, because supplying it at all makes Electron treat + * the cookie as one and re-add the dot — widening a host-only cookie to every + * subdomain of the host it was scoped to, and rejecting `__Host-` cookies, + * which require it to be absent. + */ +export const cookieScope = ( + host: string, + path: string, + secure: boolean, +): { readonly url: string; readonly domain: string | undefined } => { + const isDomainCookie = host.startsWith("."); + const unwrappedHost = bareHost(host); + const authority = + unwrappedHost.includes(":") && !(unwrappedHost.startsWith("[") && unwrappedHost.endsWith("]")) + ? `[${unwrappedHost}]` + : unwrappedHost; + return { + url: `${secure ? "https" : "http"}://${authority}${path}`, + domain: isDomainCookie ? host : undefined, + }; +}; + +/** A host without the leading dot both engines put on a domain cookie, for display. */ +export const bareHost = (host: string): string => (host.startsWith(".") ? host.slice(1) : host); + +/** + * Creates a transactionally consistent snapshot of a cookie database in a + * temporary directory and returns the snapshot's path. + * + * Both engines keep the file open with WAL while the browser runs, so reading + * in place can observe a torn write. Copying also guarantees we never open the + * browser's own file for writing. + * + * Scoped: the temporary directory goes away when the caller's scope closes. + */ +export const snapshotCookieDatabase = Effect.fn("CookieDatabase.snapshotCookieDatabase")(function* ( + cookiePath: string, + tempPrefix = "t3code-cookie-import-", +) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: tempPrefix }); + const target = path.join(directory, path.basename(cookiePath)); + yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`VACUUM INTO ${target}`; + }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: cookiePath, readonly: true }))); + return target; +}); diff --git a/apps/desktop/src/preview/BrowserImport/FirefoxCookies.test.ts b/apps/desktop/src/preview/BrowserImport/FirefoxCookies.test.ts new file mode 100644 index 000000000000..84e7678cce4a --- /dev/null +++ b/apps/desktop/src/preview/BrowserImport/FirefoxCookies.test.ts @@ -0,0 +1,459 @@ +// @effect-diagnostics nodeBuiltinImport:off - Builds a Firefox-shaped +// `cookies.sqlite` fixture with the same native bindings Firefox itself uses. +import * as NodePath from "@effect/platform-node/NodePath"; +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 Scope from "effect/Scope"; +import * as NodeSqlite from "node:sqlite"; + +import { readFirefoxCookies } from "./FirefoxCookies.ts"; +import { parseFirefoxProfiles } from "./Sources.ts"; + +const parsePosixFirefoxProfiles = (ini: string, root = "/home/user/.mozilla/firefox") => + Effect.gen(function* () { + const path = yield* Path.Path; + return parseFirefoxProfiles(ini, path, root); + }).pipe(Effect.provide(NodePath.layerPosix)); + +const parseWindowsFirefoxProfiles = (ini: string, root = "C:\\Users\\user\\Firefox") => + Effect.gen(function* () { + const path = yield* Path.Path; + return parseFirefoxProfiles(ini, path, root); + }).pipe(Effect.provide(NodePath.layerWin32)); + +/** Builds a `cookies.sqlite` with Firefox's real `moz_cookies` shape. */ +const writeFirefoxCookieDatabase = Effect.fnUntraced(function* ( + rows: ReadonlyArray<{ + host: string; + name: string; + value: string; + path: string; + expiry: number; + isSecure: number; + isHttpOnly: number; + sameSite: number | null; + rawSameSite?: number; + originAttributes?: string; + }>, + // Firefox stamps `PRAGMA user_version`; schema 16+ stores `expiry` in + // milliseconds, earlier ones in seconds. + schemaVersion = 15, +) { + const fileSystem = yield* FileSystem.FileSystem; + const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-firefox-test-" }); + const file = `${directory}/cookies.sqlite`; + const database = new NodeSqlite.DatabaseSync(file); + database.exec(`pragma user_version = ${schemaVersion}`); + // Only schemas 10–14 have `rawSameSite`; the schema-15 migration dropped it. + const hasRawSameSite = schemaVersion >= 10 && schemaVersion <= 14; + database.exec( + `create table moz_cookies ( + id integer primary key, host text, name text, value text, path text, + expiry integer, isSecure integer, isHttpOnly integer, sameSite integer, + ${hasRawSameSite ? "rawSameSite integer," : ""} + originAttributes text not null default '' + )`, + ); + const insert = database.prepare( + `insert into moz_cookies + (host, name, value, path, expiry, isSecure, isHttpOnly, sameSite, + ${hasRawSameSite ? "rawSameSite," : ""} originAttributes) + values (?, ?, ?, ?, ?, ?, ?, ?, ${hasRawSameSite ? "?," : ""} ?)`, + ); + for (const row of rows) { + insert.run( + row.host, + row.name, + row.value, + row.path, + row.expiry, + row.isSecure, + row.isHttpOnly, + row.sameSite, + ...(hasRawSameSite ? [row.rawSameSite ?? row.sameSite] : []), + row.originAttributes ?? "", + ); + } + database.close(); + return file; +}); + +const run = (effect: Effect.Effect) => + effect.pipe(Effect.provide(NodeServices.layer), Effect.scoped); + +describe("readFirefoxCookies", () => { + it.effect("converts millisecond expiries from schema 16 and newer", () => + run( + Effect.gen(function* () { + // Firefox 129 (schema 16) migrated `expiry` to milliseconds; older + // profiles still hold seconds. Both must land as seconds for Electron. + const row = { + host: "example.test", + name: "c", + value: "v", + path: "/", + expiry: 1_800_000_000_000, + isSecure: 0, + isHttpOnly: 0, + sameSite: 0, + }; + const modern = yield* readFirefoxCookies(yield* writeFirefoxCookieDatabase([row], 16)); + expect(modern[0]?.expirationDate).toBe(1_800_000_000); + + const legacy = yield* readFirefoxCookies( + yield* writeFirefoxCookieDatabase([{ ...row, expiry: 1_800_000_000 }], 15), + ); + expect(legacy[0]?.expirationDate).toBe(1_800_000_000); + }), + ), + ); + + it.effect("maps moz_cookies onto the shape Electron accepts", () => + run( + Effect.gen(function* () { + const file = yield* writeFirefoxCookieDatabase([ + { + host: ".github.com", + name: "session", + value: "abc", + path: "/", + expiry: 1_800_000_000, + isSecure: 1, + isHttpOnly: 1, + sameSite: 1, + }, + { + host: "example.test", + name: "plain", + value: "v", + path: "/app", + // Firefox writes 0 for a session cookie. + expiry: 0, + isSecure: 0, + isHttpOnly: 0, + sameSite: 0, + }, + ]); + + const cookies = yield* readFirefoxCookies(file); + + expect(cookies).toEqual([ + { + // The leading dot stays on the domain but not in the URL, which is + // what Electron matches against. + url: "https://github.com/", + name: "session", + value: "abc", + domain: ".github.com", + path: "/", + secure: true, + httpOnly: true, + expirationDate: 1_800_000_000, + sameSite: "lax", + }, + { + url: "http://example.test/app", + name: "plain", + value: "v", + // Host-only in Firefox, so no `domain`: supplying one would make + // Electron widen it to every subdomain of example.test. + domain: undefined, + path: "/app", + secure: false, + httpOnly: false, + // Session cookies carry no expiry rather than one at the epoch. + expirationDate: undefined, + sameSite: "no_restriction", + }, + ]); + }), + ), + ); + + it.effect("keeps an unset SameSite unspecified instead of widening it to none", () => + run( + Effect.gen(function* () { + // nsICookie::SAMESITE_UNSET is 256, a cookie that carried no SameSite + // attribute. It is not SAMESITE_NONE (0), which is an explicit opt-in + // to cross-site use; importing it as "none" would widen its scope. + const row = { + host: "example.test", + name: "c", + value: "v", + path: "/", + expiry: 0, + isSecure: 0, + isHttpOnly: 0, + }; + const cookies = yield* readFirefoxCookies( + yield* writeFirefoxCookieDatabase([ + { ...row, name: "unset", sameSite: 256 }, + { ...row, name: "none", sameSite: 0 }, + ]), + ); + expect(cookies.map(({ name, sameSite }) => ({ name, sameSite }))).toEqual([ + { name: "unset", sameSite: "unspecified" }, + { name: "none", sameSite: "no_restriction" }, + ]); + }), + ), + ); + + it.effect("imports rows whose SameSite was never written", () => + run( + Effect.gen(function* () { + // Schema 9 added `sameSite` without a default, so rows from before the + // upgrade hold NULL. One such row must not fail the whole import. + const row = { + host: "example.test", + name: "c", + value: "v", + path: "/", + expiry: 0, + isSecure: 0, + isHttpOnly: 0, + }; + const cookies = yield* readFirefoxCookies( + yield* writeFirefoxCookieDatabase( + [ + { ...row, name: "legacy", sameSite: null }, + { ...row, name: "strict", sameSite: 2 }, + ], + 9, + ), + ); + expect(cookies.map(({ name, sameSite }) => ({ name, sameSite }))).toEqual([ + { name: "legacy", sameSite: "unspecified" }, + { name: "strict", sameSite: "strict" }, + ]); + }), + ), + ); + + it.effect("applies the schema-15 rawSameSite rule to older databases", () => + run( + Effect.gen(function* () { + // Schemas 10–14 defaulted `sameSite` to Lax and kept the declared value + // in `rawSameSite`. Firefox's own migration to 15 turns "Lax by + // default, None declared" into Unset; an unmigrated database has to be + // read the same way or an undeclared cookie becomes an explicit Lax. + const row = { + host: "example.test", + name: "c", + value: "v", + path: "/", + expiry: 0, + isSecure: 0, + isHttpOnly: 0, + }; + const cookies = yield* readFirefoxCookies( + yield* writeFirefoxCookieDatabase( + [ + { ...row, name: "defaulted", sameSite: 1, rawSameSite: 0 }, + { ...row, name: "declared", sameSite: 1, rawSameSite: 1 }, + { ...row, name: "none", sameSite: 0, rawSameSite: 0 }, + ], + 14, + ), + ); + expect(cookies.map(({ name, sameSite }) => ({ name, sameSite }))).toEqual([ + { name: "defaulted", sameSite: "unspecified" }, + { name: "declared", sameSite: "lax" }, + { name: "none", sameSite: "no_restriction" }, + ]); + }), + ), + ); + + it.effect("imports only the default container", () => + run( + Effect.gen(function* () { + const file = yield* writeFirefoxCookieDatabase([ + { + host: "mail.test", + name: "session", + value: "default-container", + path: "/", + expiry: 1_800_000_000, + isSecure: 1, + isHttpOnly: 0, + sameSite: 1, + }, + { + // Same host, name and path as above: Firefox keeps these apart by + // container, Electron cannot, so importing both would hand the + // profile whichever one happened to be written last. + host: "mail.test", + name: "session", + value: "work-container", + path: "/", + expiry: 1_800_000_000, + isSecure: 1, + isHttpOnly: 0, + sameSite: 1, + originAttributes: "^userContextId=2", + }, + { + host: "mail.test", + name: "private", + value: "private-window", + path: "/", + expiry: 1_800_000_000, + isSecure: 1, + isHttpOnly: 0, + sameSite: 1, + originAttributes: "^privateBrowsingId=1", + }, + ]); + + const cookies = yield* readFirefoxCookies(file); + + expect(cookies.map((cookie) => cookie.value)).toEqual(["default-container"]); + }), + ), + ); + + it.effect("reads without mutating the source database", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const file = yield* writeFirefoxCookieDatabase([ + { + host: "a.test", + name: "n", + value: "v", + path: "/", + expiry: 1_800_000_000, + isSecure: 1, + isHttpOnly: 0, + sameSite: 2, + }, + ]); + const before = yield* fileSystem.stat(file); + + yield* readFirefoxCookies(file); + + // The browser's own file is snapshotted, never opened for writing. + const after = yield* fileSystem.stat(file); + expect(after.mtime).toEqual(before.mtime); + expect(after.size).toBe(before.size); + }), + ), + ); +}); + +describe("parseFirefoxProfiles", () => { + it.effect("reads named profiles and ignores Install sections", () => + Effect.gen(function* () { + // `Install*` sections name a default profile but do not describe one, so + // counting them would invent a profile whose directory does not exist. + const parsed = yield* parsePosixFirefoxProfiles( + [ + "[Install4F96D1932A9F858E]", + "Default=Profiles/abcd1234.default-release", + "Locked=1", + "", + "[Profile0]", + "Name=default-release", + "IsRelative=1", + "Path=Profiles/abcd1234.default-release", + "", + "[Profile1]", + "Name=Work", + "IsRelative=0", + "Path=/Volumes/External/firefox-work", + "", + "[General]", + "StartWithLastProfile=1", + ].join("\n"), + ); + + expect(parsed).toEqual([ + { directory: "Profiles/abcd1234.default-release", name: "default-release" }, + { directory: "/Volumes/External/firefox-work", name: "Work" }, + ]); + }), + ); + + it.effect("falls back to the path when a profile has no name", () => + Effect.gen(function* () { + expect( + yield* parsePosixFirefoxProfiles(["[Profile0]", "Path=Profiles/x.default"].join("\n")), + ).toEqual([{ directory: "Profiles/x.default", name: "Profiles/x.default" }]); + }), + ); + + for (const [platform, root] of [ + ["Linux", "/home/user/.mozilla/firefox"], + ["macOS", "/Users/user/Library/Application Support/Firefox"], + ] as const) { + it.effect(`validates relative and absolute ${platform} profile paths`, () => + Effect.gen(function* () { + const parsed = yield* parsePosixFirefoxProfiles( + [ + "[Profile0]", + "Name=Relative", + "IsRelative=1", + "Path=Profiles/relative.default", + "[Profile1]", + "Name=Custom", + "IsRelative=0", + "Path=/mnt/custom/firefox-profile", + "[Profile2]", + "IsRelative=1", + "Path=../../escape", + "[Profile3]", + "IsRelative=1", + "Path=/absolute-marked-relative", + "[Profile4]", + "IsRelative=0", + "Path=relative-marked-absolute", + "[Profile5]", + "IsRelative=1", + "Path=Profiles/nul\u0000escape", + ].join("\n"), + root, + ); + + expect(parsed).toEqual([ + { directory: "Profiles/relative.default", name: "Relative" }, + { directory: "/mnt/custom/firefox-profile", name: "Custom" }, + ]); + }), + ); + } + + it.effect("uses Windows path rules for relative and absolute profiles", () => + Effect.gen(function* () { + const parsed = yield* parseWindowsFirefoxProfiles( + [ + "[Profile0]", + "Name=Relative", + "IsRelative=1", + "Path=Profiles\\relative.default", + "[Profile1]", + "Name=Custom", + "IsRelative=0", + "Path=D:\\Firefox Profiles\\Work", + "[Profile2]", + "IsRelative=1", + "Path=..\\..\\escape", + "[Profile3]", + "IsRelative=1", + "Path=D:\\absolute-marked-relative", + "[Profile4]", + "IsRelative=0", + "Path=relative-marked-absolute", + ].join("\n"), + ); + + expect(parsed).toEqual([ + { directory: "Profiles\\relative.default", name: "Relative" }, + { directory: "D:\\Firefox Profiles\\Work", name: "Custom" }, + ]); + }), + ); +}); diff --git a/apps/desktop/src/preview/BrowserImport/FirefoxCookies.ts b/apps/desktop/src/preview/BrowserImport/FirefoxCookies.ts new file mode 100644 index 000000000000..f757f1ce01f5 --- /dev/null +++ b/apps/desktop/src/preview/BrowserImport/FirefoxCookies.ts @@ -0,0 +1,169 @@ +/** + * Firefox cookie extraction. + * + * Firefox stores cookies unencrypted in `cookies.sqlite`, so there is no key + * to fetch and no consent prompt — the file is readable by anything running as + * the user. That is Mozilla's design choice, not a control being circumvented, + * which is why this path works identically on macOS, Windows, and Linux while + * the Chromium one needs a per-platform credential store. + * + * @module FirefoxCookies + */ +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { cookieScope, snapshotCookieDatabase, type ImportedCookie } from "./CookieDatabase.ts"; + +/** + * Mirrors `ChromiumCookieReadError` so both engines fail with a tagged error + * the service can tell apart, rather than one of them widening the channel to + * an anonymous shape. + * + * No `reason` field: unlike Chromium there is only one way this fails — the + * plaintext database would not open — and the tag already says which engine it + * was. `BrowserImport` supplies the user-facing reason when it maps the union. + */ +export class FirefoxCookieReadError extends Schema.TaggedErrorClass()( + "FirefoxCookieReadError", + { + /** + * Which database the read was for. Firefox keeps one per profile, so + * without it a failure cannot be traced back to the profile that caused + * it. + */ + cookieDatabasePath: Schema.String, + /** Always present: every construction site wraps a real failure. */ + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Could not read Firefox cookies at ${this.cookieDatabasePath}.`; + } +} + +/** + * `moz_cookies.sameSite` holds nsICookie's constants: 0 = None, 1 = Lax, + * 2 = Strict, and 256 = Unset for a cookie that carried no SameSite attribute + * at all. Unset is not the same thing as None — None is an explicit opt-in to + * cross-site use — so it is imported as Electron's `unspecified`, which lets + * the target browser apply its own default exactly as Firefox did. Anything + * unrecognised also lands there rather than on `no_restriction`, since + * guessing "none" would widen a cookie's scope on import. + */ +const SAMESITE_NONE = 0; +const SAMESITE_LAX = 1; +const SAMESITE_STRICT = 2; + +/** + * Schemas 10–14 carried a second column, `rawSameSite`: the value the cookie + * actually declared, beside a `sameSite` that Firefox had already defaulted to + * Lax. The schema-15 migration folded them back together with + * `sameSite = UNSET where sameSite = LAX and rawSameSite = NONE`, i.e. a row + * that "is Lax" only because nothing was declared. Reading such a database + * before Firefox has migrated it must apply the same rule, or an undeclared + * cookie is imported as an explicit Lax. + */ +const FIREFOX_RAW_SAMESITE_FIRST_SCHEMA = 10; +const FIREFOX_RAW_SAMESITE_LAST_SCHEMA = 14; + +const sameSiteFromColumn = ( + value: number | null, + rawValue: number | null, +): ImportedCookie["sameSite"] => { + // Schema 9 added the column with no default, so older rows carry NULL. + if (value === null) return "unspecified"; + if (value === SAMESITE_LAX && rawValue === SAMESITE_NONE) return "unspecified"; + if (value === SAMESITE_NONE) return "no_restriction"; + if (value === SAMESITE_LAX) return "lax"; + if (value === SAMESITE_STRICT) return "strict"; + return "unspecified"; +}; + +const CookieRow = Schema.Struct({ + host: Schema.String, + name: Schema.String, + value: Schema.String, + path: Schema.String, + // UNIX-epoch based, unlike Chromium's 1601-based microseconds — but the + // unit depends on the schema version; see `expiryToSeconds`. + expiry: Schema.Number, + isSecure: Schema.Number, + isHttpOnly: Schema.Number, + sameSite: Schema.NullOr(Schema.Number), + // Present only for schemas 10–14; selected as NULL elsewhere. + rawSameSite: Schema.NullOr(Schema.Number), +}); +const decodeCookieRows = Schema.decodeUnknownEffect(Schema.Array(CookieRow)); + +/** + * Firefox schema 16 (Firefox 129) moved `expiry` from seconds to milliseconds + * — the migration is `UPDATE moz_cookies SET expiry = expiry * 1000`. Electron + * wants seconds, so the unit is decided by `PRAGMA user_version` rather than + * assumed: importing a pre-16 profile as milliseconds would expire every cookie + * at once, and a post-16 one as seconds would keep them for ~1000× too long. + */ +const FIREFOX_EXPIRY_MILLISECONDS_SCHEMA = 16; + +const UserVersionRow = Schema.Struct({ user_version: Schema.Number }); +const decodeUserVersion = Schema.decodeUnknownEffect(Schema.Array(UserVersionRow)); + +const expiryToSeconds = (expiry: number, schemaVersion: number): number | undefined => { + if (expiry <= 0) return undefined; + return schemaVersion >= FIREFOX_EXPIRY_MILLISECONDS_SCHEMA ? Math.floor(expiry / 1000) : expiry; +}; + +export const readFirefoxCookies = Effect.fn("FirefoxCookies.readFirefoxCookies")(function* ( + cookieDatabasePath: string, +) { + const snapshotPath = yield* snapshotCookieDatabase(cookieDatabasePath).pipe( + Effect.mapError((cause) => new FirefoxCookieReadError({ cookieDatabasePath, cause })), + ); + + const { rows, schemaVersion } = yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const [versionRow] = yield* decodeUserVersion(yield* sql`pragma user_version`); + const schemaVersion = versionRow?.user_version ?? 0; + const hasRawSameSite = + schemaVersion >= FIREFOX_RAW_SAMESITE_FIRST_SCHEMA && + schemaVersion <= FIREFOX_RAW_SAMESITE_LAST_SCHEMA; + // Only the default container. Firefox isolates cookies per container and + // per private window via `originAttributes` (`^userContextId=2`, + // `^privateBrowsingId=1`); Electron has no equivalent, so importing them + // all would collapse several identities onto one host/name/path and hand + // the profile an arbitrary container's session. + const raw = hasRawSameSite + ? yield* sql` + select host, name, value, path, expiry, isSecure, isHttpOnly, sameSite, rawSameSite + from moz_cookies + where originAttributes = '' + ` + : yield* sql` + select host, name, value, path, expiry, isSecure, isHttpOnly, sameSite, + null as rawSameSite + from moz_cookies + where originAttributes = '' + `; + return { rows: yield* decodeCookieRows(raw), schemaVersion }; + }).pipe( + Effect.provide(NodeSqliteClient.layer({ filename: snapshotPath, readonly: true })), + Effect.mapError((cause) => new FirefoxCookieReadError({ cookieDatabasePath, cause })), + ); + + return rows.map((row) => { + const secure = row.isSecure === 1; + const scope = cookieScope(row.host, row.path, secure); + return { + url: scope.url, + name: row.name, + value: row.value, + domain: scope.domain, + path: row.path, + secure, + httpOnly: row.isHttpOnly === 1, + expirationDate: expiryToSeconds(row.expiry, schemaVersion), + sameSite: sameSiteFromColumn(row.sameSite, row.rawSameSite), + } satisfies ImportedCookie; + }); +}); diff --git a/apps/desktop/src/preview/BrowserImport/LinuxBrowserSecret.test.ts b/apps/desktop/src/preview/BrowserImport/LinuxBrowserSecret.test.ts new file mode 100644 index 000000000000..efeab3262de2 --- /dev/null +++ b/apps/desktop/src/preview/BrowserImport/LinuxBrowserSecret.test.ts @@ -0,0 +1,69 @@ +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 Layer from "effect/Layer"; +import * as Path from "effect/Path"; + +import * as DesktopConfig from "../../app/DesktopConfig.ts"; +import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts"; +import * as LinuxBrowserSecret from "./LinuxBrowserSecret.ts"; + +it.layer(NodeServices.layer)("Linux browser secret path", (it) => { + it.effect("finds development and packaged helpers without falling back outside the install", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-browser-secret-path-" }); + const resourcesPath = path.join(root, "install", "resources"); + const native = path.join( + root, + "native", + "browser-secret", + "build", + "x64", + "t3-browser-secret", + ); + const staged = path.join( + root, + "apps", + "desktop", + "prod-resources", + "browser-secret", + "t3-browser-secret", + ); + const packaged = path.join(resourcesPath, "browser-secret", "t3-browser-secret"); + for (const filename of [native, staged, packaged]) { + yield* fileSystem.makeDirectory(path.dirname(filename), { recursive: true }); + yield* fileSystem.writeFileString(filename, "helper"); + } + const resolve = (isPackaged: boolean, platform: NodeJS.Platform = "linux") => { + const environment = DesktopEnvironment.layer({ + dirname: path.join(root, "apps", "desktop", "dist-electron"), + homeDirectory: root, + platform, + processArch: "x64", + appVersion: "0.0.1", + appPath: path.join(resourcesPath, "app.asar"), + isPackaged, + resourcesPath, + runningUnderArm64Translation: false, + }).pipe(Layer.provide(DesktopConfig.layerTest({}))); + return LinuxBrowserSecret.LinuxBrowserSecretPath.pipe( + Effect.provide(LinuxBrowserSecret.layer.pipe(Layer.provide(environment))), + ); + }; + + assert.equal(yield* resolve(false), native); + assert.equal(yield* resolve(true), packaged); + yield* fileSystem.remove(native); + assert.equal(yield* resolve(false), staged); + yield* fileSystem.remove(packaged); + assert.isUndefined(yield* resolve(true)); + assert.isUndefined(yield* resolve(false, "darwin")); + assert.isUndefined(yield* resolve(false, "win32")); + yield* fileSystem.remove(staged); + assert.isUndefined(yield* resolve(false)); + }).pipe(Effect.scoped), + ); +}); diff --git a/apps/desktop/src/preview/BrowserImport/LinuxBrowserSecret.ts b/apps/desktop/src/preview/BrowserImport/LinuxBrowserSecret.ts new file mode 100644 index 000000000000..1f5fe02bc454 --- /dev/null +++ b/apps/desktop/src/preview/BrowserImport/LinuxBrowserSecret.ts @@ -0,0 +1,40 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; + +import { DesktopEnvironment } from "../../app/DesktopEnvironment.ts"; + +/** Absolute path to the helper shipped with this desktop instance. */ +export const LinuxBrowserSecretPath = Context.Reference( + "@t3tools/desktop/preview/BrowserImport/LinuxBrowserSecretPath", + { defaultValue: () => undefined }, +); + +export const layer = Layer.effect( + LinuxBrowserSecretPath, + Effect.gen(function* () { + const environment = yield* DesktopEnvironment; + if (environment.platform !== "linux") return undefined; + const fileSystem = yield* FileSystem.FileSystem; + const relative = environment.path.join("browser-secret", "t3-browser-secret"); + const candidates = environment.isPackaged + ? [environment.path.join(environment.resourcesPath, relative)] + : [ + environment.path.join( + environment.rootDir, + "native", + "browser-secret", + "build", + environment.processArch, + "t3-browser-secret", + ), + ...environment.resolveResourcePathCandidates(relative), + ]; + for (const candidate of candidates) { + if (yield* fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false))) + return candidate; + } + return undefined; + }), +); diff --git a/apps/desktop/src/preview/BrowserImport/Sources.test.ts b/apps/desktop/src/preview/BrowserImport/Sources.test.ts new file mode 100644 index 000000000000..feaac842cbef --- /dev/null +++ b/apps/desktop/src/preview/BrowserImport/Sources.test.ts @@ -0,0 +1,1058 @@ +// @effect-diagnostics nodeBuiltinImport:off - Builds a Chromium-shaped cookie +// table with the same native bindings the source reads. +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import { + HostProcessEnvironment, + HostProcessHostname, + HostProcessPlatform, +} from "@t3tools/shared/hostProcess"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import * as NodeSqlite from "node:sqlite"; + +import type { BrowserImportPathContext } from "./Sources.ts"; +import { + BROWSER_IMPORT_SOURCES, + chromiumProcessIsAlive, + chromiumSingletonLockIsHeld, + cookieDatabaseCandidatePaths, + firefoxSymlinkLockIsHeld, + resolveCookieDatabase, + isSourceInstalled, + isSourceRunning, + isWindowsLockHeldError, + posixLockIsHeld, + listSourceProfiles, + sourcePathContext, + windowsChromiumCookiesAreHeld, +} from "./Sources.ts"; + +const helium = BROWSER_IMPORT_SOURCES.find((source) => source.id === "helium")!; + +describe("Linux Chromium secret applications", () => { + it("pins the libsecret application attribute for each supported fork", () => { + assert.deepEqual( + Object.fromEntries( + BROWSER_IMPORT_SOURCES.filter((source) => source.platforms.includes("linux")).map( + (source) => [source.id, source.linuxSecretApplication], + ), + ), + { + chrome: "chrome", + edge: "msedge", + brave: "brave", + vivaldi: "vivaldi", + opera: "opera", + helium: "chromium", + firefox: undefined, + }, + ); + }); +}); + +const platformError = (reasonTag: string): PlatformError.PlatformError => + ({ _tag: "PlatformError", reason: { _tag: reasonTag } }) as never; + +describe("Windows browser lock errors", () => { + it("treats sharing and lock violations reported as Busy as held", () => { + assert.isTrue(isWindowsLockHeldError(platformError("Busy"))); + }); + + it("does not treat access denied as proof of an active lock", () => { + assert.isFalse(isWindowsLockHeldError(platformError("PermissionDenied"))); + }); + + it("does not treat a missing lock file as held", () => { + assert.isFalse(isWindowsLockHeldError(platformError("NotFound"))); + }); +}); + +/** A scratch home with the source's user-data directory already created. */ +const withSourceHome = Effect.fnUntraced(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-sources-" }); + const context = yield* sourcePathContext.pipe( + Effect.provideService(HostProcessEnvironment, { HOME: home }), + Effect.provideService(HostProcessPlatform, "darwin"), + ); + yield* fileSystem.makeDirectory(userDataDirectory(context), { recursive: true }); + return context; +}); + +/** Every case here runs on darwin, where Helium always resolves a directory. */ +const userDataDirectory = (context: BrowserImportPathContext) => { + const root = helium.userDataDirectory(context); + if (root === undefined) throw new Error("Helium has no macOS user-data directory"); + return root; +}; + +const run = ( + effect: Effect.Effect< + A, + E, + FileSystem.FileSystem | Path.Path | Scope.Scope | ChildProcessSpawner.ChildProcessSpawner + >, +) => effect.pipe(Effect.provide(NodeServices.layer), Effect.scoped); + +/** Writes a Chromium-shaped cookie table with `count` rows. */ +const writeCookieDatabase = (file: string, count: number) => + Effect.sync(() => { + const database = new NodeSqlite.DatabaseSync(file); + database.exec("create table cookies (host_key text, name text)"); + const insert = database.prepare("insert into cookies (host_key, name) values (?, ?)"); + for (let index = 0; index < count; index += 1) insert.run("example.test", `c${index}`); + database.close(); + }); + +const writeFirefoxCookieDatabase = ( + file: string, + defaultContainerCount: number, + containerCount: number, +) => + Effect.sync(() => { + const database = new NodeSqlite.DatabaseSync(file); + database.exec("create table moz_cookies (originAttributes text not null)"); + const insert = database.prepare("insert into moz_cookies (originAttributes) values (?)"); + for (let index = 0; index < defaultContainerCount; index += 1) insert.run(""); + for (let index = 0; index < containerCount; index += 1) insert.run("^userContextId=2"); + database.close(); + }); + +describe("Helium on Linux", () => { + it.effect("discovers its profiles and checks the user-data lock", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-helium-linux-" }); + const context = yield* sourcePathContext.pipe( + Effect.provideService(HostProcessEnvironment, { HOME: home }), + Effect.provideService(HostProcessPlatform, "linux"), + ); + const root = `${home}/.config/net.imput.helium`; + yield* fileSystem.makeDirectory(`${root}/Default`, { recursive: true }); + yield* writeCookieDatabase(`${root}/Default/Cookies`, 3); + yield* fileSystem.writeFileString( + `${root}/Local State`, + '{"profile":{"info_cache":{"Default":{"name":"Personal"}}}}', + ); + + assert.include(helium.platforms, "linux"); + assert.isTrue(yield* isSourceInstalled(helium, context)); + assert.deepEqual(yield* listSourceProfiles(helium, context), [ + { directory: "Default", name: "Personal", cookieCount: 3 }, + ]); + assert.isFalse(yield* isSourceRunning(helium, context)); + yield* fileSystem.symlink("foreign-host-4242", `${root}/SingletonLock`); + assert.isTrue(yield* isSourceRunning(helium, context)); + }), + ), + ); +}); + +describe("Helium on Windows", () => { + it.effect("uses Helium's local app-data profile while other Chromium forks stay disabled", () => + run( + Effect.gen(function* () { + const context = yield* sourcePathContext.pipe( + Effect.provideService(HostProcessEnvironment, { + USERPROFILE: "C:\\Users\\browser-user", + LOCALAPPDATA: "C:\\Users\\browser-user\\AppData\\Local", + }), + Effect.provideService(HostProcessPlatform, "win32"), + ); + + assert.include(helium.platforms, "win32"); + assert.equal( + helium.userDataDirectory(context), + context.path.join( + "C:\\Users\\browser-user\\AppData\\Local", + "imput", + "Helium", + "User Data", + ), + ); + for (const source of BROWSER_IMPORT_SOURCES) { + if (source.engine === "chromium" && source.id !== "helium") { + assert.notInclude(source.platforms, "win32"); + } + } + }), + ), + ); +}); + +describe("isSourceRunning", () => { + it.effect("uses the held cookie database as Chromium's Windows running signal", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const home = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-helium-windows-lock-", + }); + const context = yield* sourcePathContext.pipe( + Effect.provideService(HostProcessEnvironment, { + HOME: home, + LOCALAPPDATA: home, + }), + Effect.provideService(HostProcessPlatform, "win32"), + ); + const profile = context.path.join(helium.userDataDirectory(context)!, "Default"); + const database = context.path.join(profile, "Network", "Cookies"); + yield* fileSystem.makeDirectory(context.path.join(profile, "Network"), { recursive: true }); + yield* writeCookieDatabase(database, 1); + + const probed: string[] = []; + assert.isTrue( + yield* windowsChromiumCookiesAreHeld(helium, context, (path) => + Effect.sync(() => { + probed.push(path); + return true; + }), + ), + ); + assert.deepEqual(probed, [database]); + }), + ), + ); + + 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`, + ); + + 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`, + ); + + assert.isTrue( + yield* isSourceRunning(helium, paths).pipe( + Effect.provideService(HostProcessHostname, "another-host"), + ), + ); + assert.isFalse( + yield* isSourceRunning(helium, paths).pipe( + Effect.provideService(HostProcessHostname, "lock-owner"), + ), + ); + }), + ), + ); +}); + +describe("chromiumSingletonLockIsHeld", () => { + it.effect("ignores a positively dead PID on the current host", () => + Effect.gen(function* () { + const checked: number[] = []; + const held = yield* chromiumSingletonLockIsHeld("current-host-4321", "current-host", (pid) => + Effect.sync(() => { + checked.push(pid); + return false; + }), + ); + assert.isFalse(held); + assert.deepEqual(checked, [4321]); + }), + ); + + it.effect("keeps a live PID on the current host", () => + chromiumSingletonLockIsHeld("current-host-4321", "current-host", () => + Effect.succeed(true), + ).pipe(Effect.tap((held) => Effect.sync(() => assert.isTrue(held)))), + ); + + it.effect("keeps foreign-host and malformed targets without probing a PID", () => + Effect.gen(function* () { + let probes = 0; + const probe = (_pid: number) => + Effect.sync(() => { + probes += 1; + return false; + }); + assert.isTrue(yield* chromiumSingletonLockIsHeld("another-host-4321", "current-host", probe)); + assert.isTrue( + yield* chromiumSingletonLockIsHeld("current-host-no-pid", "current-host", probe), + ); + assert.isTrue(yield* chromiumSingletonLockIsHeld("current-host-0", "current-host", probe)); + assert.strictEqual(probes, 0); + }), + ); +}); + +describe("chromiumProcessIsAlive", () => { + it.effect("returns false only when signal 0 reports a missing process", () => + Effect.gen(function* () { + const missing = Object.assign(new Error("missing"), { code: "ESRCH" }); + const denied = Object.assign(new Error("denied"), { code: "EPERM" }); + assert.isFalse( + yield* chromiumProcessIsAlive(4321, () => { + throw missing; + }), + ); + assert.isTrue( + yield* chromiumProcessIsAlive(4321, () => { + throw denied; + }), + ); + assert.isTrue( + yield* chromiumProcessIsAlive(4321, () => { + throw undefined; + }), + ); + assert.isTrue( + yield* chromiumProcessIsAlive(4321, () => { + throw "unknown failure"; + }), + ); + assert.isTrue( + yield* chromiumProcessIsAlive(4321, () => { + throw null; + }), + ); + assert.isTrue(yield* chromiumProcessIsAlive(4321, () => true)); + }), + ); +}); + +describe("isSourceInstalled", () => { + it.effect("ignores a user-data directory that holds no cookie database", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const context = yield* withSourceHome(); + const root = userDataDirectory(context); + + // Installers for native messaging hosts create an empty user-data + // directory for every Chromium fork they know about, so treating the + // directory as evidence lists browsers the user does not have. + yield* fileSystem.makeDirectory(`${root}/NativeMessagingHosts`, { recursive: true }); + assert.isFalse(yield* isSourceInstalled(helium, context)); + + yield* fileSystem.makeDirectory(`${root}/Default`, { recursive: true }); + yield* fileSystem.writeFileString(`${root}/Default/Cookies`, "db"); + assert.isTrue(yield* isSourceInstalled(helium, context)); + + // A real install whose cookies live outside `Default` still counts: + // reporting it as absent hides the source from the menu entirely. + yield* fileSystem.remove(`${root}/Default`, { recursive: true }); + yield* fileSystem.makeDirectory(`${root}/Profile 1`, { recursive: true }); + yield* fileSystem.writeFileString(`${root}/Profile 1/Cookies`, "db"); + assert.isTrue(yield* isSourceInstalled(helium, context)); + + yield* fileSystem.remove(root, { recursive: true }); + assert.isFalse(yield* isSourceInstalled(helium, context)); + }), + ), + ); + + it.effect("detects a Chromium 127+ install with cookies under Network/", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const context = yield* withSourceHome(); + const root = userDataDirectory(context); + + yield* fileSystem.makeDirectory(`${root}/Default/Network`, { recursive: true }); + yield* fileSystem.writeFileString(`${root}/Default/Network/Cookies`, "db"); + assert.isTrue(yield* isSourceInstalled(helium, context)); + }), + ), + ); + + 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`); + + 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)); + }), + ), + ); +}); + +describe("listSourceProfiles", () => { + it.effect("ignores a profile whose Cookies entry is not a file", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const paths = yield* withSourceHome(); + const root = helium.userDataDirectory(paths); + // A directory named `Cookies` would list as importable and then fail + // the SQLite open, so only a regular file counts as a database. + yield* fileSystem.makeDirectory(`${root}/Broken/Cookies`, { recursive: true }); + yield* fileSystem.makeDirectory(`${root}/Real`, { recursive: true }); + yield* fileSystem.writeFileString(`${root}/Real/Cookies`, "db"); + + assert.deepEqual(yield* listSourceProfiles(helium, paths), [ + { directory: "Real", name: "Real" }, + ]); + assert.isTrue(yield* isSourceInstalled(helium, paths)); + }), + ), + ); + + it.effect("discovers profiles by their cookie database when Local State is absent", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const context = yield* withSourceHome(); + const root = userDataDirectory(context); + // Assuming `Default` would report a browser whose cookies live in + // `Profile 1` as having nothing to import, and it is then hidden. + yield* fileSystem.makeDirectory(`${root}/Profile 1`, { recursive: true }); + yield* fileSystem.writeFileString(`${root}/Profile 1/Cookies`, "db"); + yield* fileSystem.makeDirectory(`${root}/NativeMessagingHosts`, { recursive: true }); + + assert.deepEqual(yield* listSourceProfiles(helium, context), [ + { directory: "Profile 1", name: "Profile 1" }, + ]); + }), + ), + ); + + it.effect("reads the profile names the browser shows", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const context = yield* withSourceHome(); + yield* fileSystem.writeFileString( + `${userDataDirectory(context)}/Local State`, + `{"profile":{"info_cache":{"Default":{"name":"You"},"Profile 2":{"name":" "}}}}`, + ); + + assert.deepEqual(yield* listSourceProfiles(helium, context), [ + { directory: "Default", name: "You" }, + // Blank display name falls back to the directory rather than + // rendering an empty row. + { directory: "Profile 2", name: "Profile 2" }, + ]); + }), + ), + ); + + it.effect("scans for profiles when Local State is malformed", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const context = yield* withSourceHome(); + const root = userDataDirectory(context); + yield* fileSystem.writeFileString(`${root}/Local State`, "{not-json"); + yield* fileSystem.makeDirectory(`${root}/Default`, { recursive: true }); + yield* fileSystem.writeFileString(`${root}/Default/Cookies`, "db"); + + assert.deepEqual(yield* listSourceProfiles(helium, context), [ + { directory: "Default", name: "Default" }, + ]); + }), + ), + ); + + it.effect("reports nothing when no directory holds a cookie database", () => + run( + Effect.gen(function* () { + const context = yield* withSourceHome(); + assert.deepEqual(yield* listSourceProfiles(helium, context), []); + }), + ), + ); + + it.effect("drops Firefox profiles that hold no cookie database", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const context = yield* withSourceHome(); + const root = firefox.userDataDirectory(context)!; + yield* fileSystem.makeDirectory(root, { recursive: true }); + yield* fileSystem.writeFileString( + `${root}/profiles.ini`, + `[Profile0] +Name=original +IsRelative=1 +Path=Profiles/abcd.default-release +Default=1 + +[Profile1] +Name=empty +IsRelative=1 +Path=Profiles/wxyz.empty +`, + ); + yield* fileSystem.makeDirectory(`${root}/Profiles/abcd.default-release`, { + recursive: true, + }); + yield* fileSystem.writeFileString( + `${root}/Profiles/abcd.default-release/cookies.sqlite`, + "db", + ); + yield* fileSystem.makeDirectory(`${root}/Profiles/wxyz.empty`, { recursive: true }); + + assert.deepEqual(yield* listSourceProfiles(firefox, context), [ + { directory: "Profiles/abcd.default-release", name: "original" }, + ]); + }), + ), + ); + + it.effect("drops empty profiles when falling back to the Profiles/ scan", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const context = yield* withSourceHome(); + const root = firefox.userDataDirectory(context)!; + yield* fileSystem.makeDirectory(`${root}/Profiles/filled.default`, { recursive: true }); + yield* fileSystem.writeFileString(`${root}/Profiles/filled.default/cookies.sqlite`, "db"); + yield* fileSystem.makeDirectory(`${root}/Profiles/empty.default`, { recursive: true }); + + assert.deepEqual(yield* listSourceProfiles(firefox, context), [ + { + directory: context.path.join("Profiles", "filled.default"), + name: "filled.default", + }, + ]); + }), + ), + ); + + it.effect("discovers profiles with cookies under Network/ (Chromium 127+)", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const context = yield* withSourceHome(); + const root = userDataDirectory(context); + yield* fileSystem.makeDirectory(`${root}/Default/Network`, { recursive: true }); + yield* fileSystem.writeFileString(`${root}/Default/Network/Cookies`, "db"); + + assert.deepEqual(yield* listSourceProfiles(helium, context), [ + { directory: "Default", name: "Default" }, + ]); + }), + ), + ); + + it.effect("counts a profile's cookies without decrypting them", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const paths = yield* withSourceHome(); + const root = helium.userDataDirectory(paths); + yield* fileSystem.makeDirectory(`${root}/Default`, { recursive: true }); + yield* writeCookieDatabase(`${root}/Default/Cookies`, 3); + + const [profile] = yield* listSourceProfiles(helium, paths); + assert.equal(profile?.cookieCount, 3); + }), + ), + ); + + it.effect("falls through to the legacy database when Network/Cookies is a directory", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const paths = yield* withSourceHome(); + const root = helium.userDataDirectory(paths); + // A folder squatting on the preferred candidate path must not shadow + // the real legacy database behind it. + yield* fileSystem.makeDirectory(`${root}/Default/Network/Cookies`, { recursive: true }); + yield* writeCookieDatabase(`${root}/Default/Cookies`, 2); + + const [profile] = yield* listSourceProfiles(helium, paths); + assert.equal(profile?.directory, "Default"); + assert.equal(profile?.cookieCount, 2); + }), + ), + ); +}); + +describe("cookieDatabaseCandidatePaths", () => { + it.effect("prefers Network/Cookies and falls back to the legacy Cookies", () => + run( + Effect.gen(function* () { + const context = yield* withSourceHome(); + const profile = `${context.home}/Library/Application Support/net.imput.helium/Profile 1`; + assert.deepEqual(cookieDatabaseCandidatePaths(helium, context, "Profile 1"), [ + `${profile}/Network/Cookies`, + `${profile}/Cookies`, + ]); + }), + ), + ); + + it.effect("resolves the live Network/ jar over a leftover root Cookies", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const context = yield* withSourceHome(); + const root = helium.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 }); + yield* fileSystem.writeFileString(`${root}/Default/Network/Cookies`, "live"); + yield* fileSystem.writeFileString(`${root}/Default/Cookies`, "stale"); + + assert.equal( + yield* resolveCookieDatabase(helium, context, "Default"), + `${root}/Default/Network/Cookies`, + ); + // A fresh install with only the Network/ jar is installed, not hidden. + yield* fileSystem.remove(`${root}/Default/Cookies`); + assert.isTrue(yield* isSourceInstalled(helium, context)); + }), + ), + ); + + it.effect("returns only cookies.sqlite for Firefox", () => + run( + Effect.gen(function* () { + const path = yield* Path.Path; + const context = yield* sourcePathContext.pipe( + Effect.provideService(HostProcessEnvironment, { HOME: "/tmp/test" }), + Effect.provideService(HostProcessPlatform, "darwin"), + ); + const candidates = cookieDatabaseCandidatePaths(firefox, context, "Profiles/abc.default"); + assert.deepEqual(candidates, [ + path.join( + "/tmp/test", + "Library/Application Support/Firefox/Profiles/abc.default/cookies.sqlite", + ), + ]); + }), + ), + ); +}); + +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", + ); + + 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)); + + 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", () => + 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 native = `${home}/.mozilla/firefox`; + const snap = `${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); + yield* fileSystem.writeFileString( + `${root}/profiles.ini`, + "[Profile0]\nName=Personal\nIsRelative=1\nPath=abcd.default\n" + + `[Profile1]\nName=Shared\nIsRelative=0\nPath=${snap}/abcd.default\n`, + ); + } + + const profiles = yield* listSourceProfiles(firefox, context); + assert.deepEqual( + profiles.map((profile) => profile.directory), + ["abcd.default", `${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`, + ]); + }), + ), + ); +}); + +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" }, + ]; + + for (const { platform, profileDirectory } of cases) { + it.effect(`scans the ${platform} profile location and excludes stale entries`, () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fileSystem.makeTempDirectoryScoped({ + prefix: `t3code-firefox-${platform}-`, + }); + const appData = path.join(home, "AppData", "Roaming"); + const context = yield* sourcePathContext.pipe( + Effect.provideService(HostProcessEnvironment, { + HOME: home, + APPDATA: appData, + }), + Effect.provideService(HostProcessPlatform, platform), + ); + const root = firefox.userDataDirectory(context)!; + const scanRoot = platform === "linux" ? root : path.join(root, "Profiles"); + yield* fileSystem.makeDirectory(path.join(root, profileDirectory), { recursive: true }); + yield* fileSystem.writeFileString( + path.join(root, profileDirectory, "cookies.sqlite"), + "db", + ); + yield* fileSystem.makeDirectory(path.join(scanRoot, "stale.default"), { + recursive: true, + }); + yield* fileSystem.writeFileString(path.join(scanRoot, "stale-file.default"), "not-dir"); + + assert.deepEqual(yield* listSourceProfiles(firefox, context), [ + { + directory: profileDirectory, + name: path.basename(profileDirectory), + }, + ]); + }), + ), + ); + } + + it.effect("scans for profiles when profiles.ini declares only ones without cookies", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-firefox-stale-ini-", + }); + const context = yield* sourcePathContext.pipe( + Effect.provideService(HostProcessEnvironment, { HOME: home }), + Effect.provideService(HostProcessPlatform, "darwin"), + ); + const root = firefox.userDataDirectory(context)!; + // `profiles.ini` names a profile that was never launched (no cookie + // database), while the real cookies sit in an undeclared one. + yield* fileSystem.makeDirectory(path.join(root, "Profiles", "stale.default"), { + recursive: true, + }); + const realDirectory = path.join(root, "Profiles", "real.default"); + yield* fileSystem.makeDirectory(realDirectory, { recursive: true }); + yield* writeFirefoxCookieDatabase(path.join(realDirectory, "cookies.sqlite"), 3, 0); + yield* fileSystem.writeFileString( + path.join(root, "profiles.ini"), + ["[Profile0]", "Name=Stale", "IsRelative=1", "Path=Profiles/stale.default"].join("\n"), + ); + + // 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 }, + ]); + assert.isTrue(yield* isSourceInstalled(firefox, context)); + }), + ), + ); + + it.effect("counts only importable cookies for declared and fallback profiles", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-firefox-counts-", + }); + const context = yield* sourcePathContext.pipe( + Effect.provideService(HostProcessEnvironment, { HOME: home }), + Effect.provideService(HostProcessPlatform, "darwin"), + ); + const root = firefox.userDataDirectory(context)!; + const declaredDirectory = path.join(root, "Profiles", "declared.default"); + yield* fileSystem.makeDirectory(declaredDirectory, { recursive: true }); + yield* writeFirefoxCookieDatabase(path.join(declaredDirectory, "cookies.sqlite"), 2, 3); + yield* fileSystem.writeFileString( + path.join(root, "profiles.ini"), + ["[Profile0]", "Name=Declared", "IsRelative=1", "Path=Profiles/declared.default"].join( + "\n", + ), + ); + + assert.deepEqual(yield* listSourceProfiles(firefox, context), [ + { directory: "Profiles/declared.default", name: "Declared", cookieCount: 2 }, + ]); + + yield* fileSystem.remove(path.join(root, "profiles.ini")); + const fallbackDirectory = path.join(root, "Profiles", "fallback.default"); + yield* fileSystem.makeDirectory(fallbackDirectory, { recursive: true }); + 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 }, + ]); + }), + ), + ); +}); + +describe("isSourceRunning for Firefox", () => { + it.effect("finds the lock inside the profile, not at the root", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + 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"); + + assert.isFalse(yield* isSourceRunning(firefox, context)); + + // Firefox keeps its locks per profile. A root-level lock is not one, + // and looking there was why a running Firefox read as importable. + yield* fileSystem.writeFileString(`${root}/lock`, ""); + assert.isFalse(yield* isSourceRunning(firefox, context)); + + // `.parentlock` is deliberately left on disk after a clean exit as a + // last-used marker, so an unlocked one is not evidence of a running + // browser — treating it as one blocked every import after first use. + yield* fileSystem.writeFileString(`${profile}/.parentlock`, ""); + assert.isFalse(yield* isSourceRunning(firefox, context)); + + // The `lock` symlink is what Firefox removes on exit; a live pid in + // its target means the profile is held. + yield* fileSystem.symlink(`127.0.0.1:+${process.pid}`, `${profile}/lock`); + assert.isTrue(yield* isSourceRunning(firefox, context)); + }), + ), + ); + + it.effect("reports not-held when no interpreter can run the fcntl probe", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-lock-" }); + const lock = `${directory}/.parentlock`; + yield* fileSystem.writeFileString(lock, ""); + // A Mac without the developer tools has only Apple's shim, which + // refuses to run the script; a machine with no python at all has + // nothing. Either way the probe is unavailable, not the lock held — + // treating it as held would block Firefox import on that machine for + // good. + assert.isFalse(yield* posixLockIsHeld(lock, ["/nonexistent/python3"])); + // And a fake "interpreter" that exits non-zero without a verdict, as + // the shim does, is the same case. + assert.isFalse(yield* posixLockIsHeld(lock, ["/usr/bin/false"])); + }), + ), + ); + + 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, + ); + + assert.isTrue(yield* isSourceRunning(firefox, context)); + yield* holder.kill(); + }), + ), + ); + + it.effect("reads a Firefox lock symlink's pid to tell live from crashed", () => + Effect.gen(function* () { + const alive = (pid: number) => Effect.succeed(pid === 4242); + // The resolver may hand Firefox any of the machine's addresses, not + // just 127.0.0.1 — 127.0.1.1 on Debian-style hosts, a LAN address + // elsewhere — so every local address counts as ours. + const local = new Set(["127.0.0.1", "127.0.1.1", "192.168.1.20"]); + // Both the plain and the fcntl-marked (`+`) forms carry the pid. + assert.isTrue(yield* firefoxSymlinkLockIsHeld("127.0.0.1:4242", local, alive)); + assert.isTrue(yield* firefoxSymlinkLockIsHeld("127.0.1.1:+4242", local, alive)); + assert.isTrue(yield* firefoxSymlinkLockIsHeld("192.168.1.20:+4242", local, alive)); + // A crash leaves the symlink behind with a dead pid, on any local address. + assert.isFalse(yield* firefoxSymlinkLockIsHeld("127.0.0.1:+9999", local, alive)); + assert.isFalse(yield* firefoxSymlinkLockIsHeld("192.168.1.20:+9999", local, alive)); + // Anything unparseable stays conservative. + assert.isTrue(yield* firefoxSymlinkLockIsHeld("garbage", local, alive)); + // A foreign owner (a shared profile locked from another machine) names + // a pid we cannot probe, so it is held regardless of local liveness. + assert.isTrue(yield* firefoxSymlinkLockIsHeld("10.0.0.7:+9999", local, alive)); + }), + ); + + it.effect("does not treat a stale parent.lock file as a running browser", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-firefox-" }); + const context = yield* sourcePathContext.pipe( + // Firefox's win32 root hangs off %APPDATA%; without it the root is + // undefined and the fixture would escape the sandbox into the repo. + Effect.provideService(HostProcessEnvironment, { + HOME: home, + APPDATA: `${home}/AppData/Roaming`, + }), + Effect.provideService(HostProcessPlatform, "win32"), + ); + const root = firefox.userDataDirectory(context)!; + const profile = `${root}/Profiles/gx7x7fqx.default-release`; + yield* fileSystem.makeDirectory(profile, { recursive: true }); + yield* fileSystem.writeFileString(`${profile}/cookies.sqlite`, "db"); + + // On Windows, Firefox creates parent.lock as a regular file that + // persists after the process exits. The file is only locked while + // Firefox is running; the old stat-based check always found it. + yield* fileSystem.writeFileString(`${profile}/parent.lock`, ""); + assert.isFalse(yield* isSourceRunning(firefox, context)); + }), + ), + ); +}); + +describe("Windows user-data directories", () => { + it.effect("keeps app-bound Chromium forks unsupported on win32", () => + Effect.sync(() => { + // Helium retains the older DPAPI-backed store. Other Chromium forks use + // App-Bound Encryption, so omitting win32 makes `unavailableReason` + // report `unsupportedPlatform` and keeps them out of the menu. + for (const source of BROWSER_IMPORT_SOURCES) { + if (source.engine === "chromium" && source.id !== "helium") { + assert.notInclude(source.platforms, "win32"); + } + } + }), + ); +}); + +describe("listSourceProfiles hardening", () => { + it.effect("drops profile directories that are not a single plain segment", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const context = yield* withSourceHome(); + // `Local State` is writable by anything running as the user, so a + // crafted key must not reach `cookieDatabasePath` and read a database + // outside the browser's user-data directory. + yield* fileSystem.writeFileString( + `${userDataDirectory(context)}/Local State`, + `{"profile":{"info_cache":{"Default":{"name":"You"},"../../../../secrets":{"name":"Escape"},"a/b":{"name":"Nested"},"..":{"name":"Parent"}}}}`, + ); + + const profiles = yield* listSourceProfiles(helium, context); + + assert.deepEqual( + profiles.map((profile) => profile.directory), + ["Default"], + ); + }), + ), + ); +}); diff --git a/apps/desktop/src/preview/BrowserImport/Sources.ts b/apps/desktop/src/preview/BrowserImport/Sources.ts new file mode 100644 index 000000000000..702933a432b3 --- /dev/null +++ b/apps/desktop/src/preview/BrowserImport/Sources.ts @@ -0,0 +1,832 @@ +/** + * Importable browser sources. + * + * Two engines are modelled. 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. + * + * Each entry pins its own paths and credential-store coordinates rather than + * deriving them, because the forks do not agree. macOS uses service/account + * pairs, while Linux Chromium uses a custom libsecret schema keyed by an + * `application` attribute. The user-data directory also differs per fork and + * per platform. + * + * @module BrowserImportSources + */ +import type { BrowserImportSourceId, BrowserImportSourceProfile } from "@t3tools/contracts"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; +import { + HostProcessEnvironment, + HostProcessAddresses, + HostProcessHostname, + HostProcessPlatform, +} from "@t3tools/shared/hostProcess"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +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 * as SqlClient from "effect/unstable/sql/SqlClient"; + +export type BrowserImportEngine = "chromium" | "firefox"; + +/** + * Directory roots a definition builds its paths from. Passed in rather than + * read from `process`, so source resolution stays testable for platforms the + * host is not currently running. + */ +export interface BrowserImportPathContext { + readonly path: Path.Path; + readonly platform: NodeJS.Platform; + readonly home: string; + /** `%APPDATA%` on Windows; unused elsewhere. */ + readonly appData: string | undefined; + /** `%LOCALAPPDATA%` on Windows; unused elsewhere. */ + readonly localAppData: string | undefined; +} + +export interface BrowserImportSourceDefinition { + readonly id: BrowserImportSourceId; + readonly name: string; + readonly engine: BrowserImportEngine; + /** Platforms the definition has paths for. */ + readonly platforms: ReadonlyArray; + readonly userDataDirectory: (context: BrowserImportPathContext) => string | undefined; + /** Chromium on macOS only: where the OSCrypt key lives in the keychain. */ + readonly keychainService?: string; + readonly keychainAccount?: string; + /** Chromium's `application` attribute in the Linux libsecret schema. */ + readonly linuxSecretApplication?: string; +} + +const macApplicationSupport = ( + context: BrowserImportPathContext, + ...segments: ReadonlyArray +) => context.path.join(context.home, "Library", "Application Support", ...segments); + +/** + * One Chromium fork. The leaves differ per fork; omitting a platform's + * segments marks the fork as unavailable there. Most Windows Chromium builds + * use App-Bound Encryption, but forks can retain the older DPAPI-backed store. + */ +const chromiumSource = (input: { + readonly id: BrowserImportSourceId; + readonly name: string; + readonly keychainService: string; + readonly keychainAccount: string; + readonly macSegments: ReadonlyArray; + readonly linuxSegments?: ReadonlyArray; + readonly linuxSecretApplication?: string; + readonly windowsSegments?: ReadonlyArray; +}): BrowserImportSourceDefinition => ({ + id: input.id, + name: input.name, + engine: "chromium", + platforms: [ + "darwin" as NodeJS.Platform, + ...(input.linuxSegments ? ["linux" as NodeJS.Platform] : []), + ...(input.windowsSegments ? ["win32" as NodeJS.Platform] : []), + ], + keychainService: input.keychainService, + keychainAccount: input.keychainAccount, + ...(input.linuxSecretApplication === undefined + ? {} + : { linuxSecretApplication: input.linuxSecretApplication }), + userDataDirectory: (context) => { + if (context.platform === "darwin") return macApplicationSupport(context, ...input.macSegments); + if (context.platform === "win32") { + return input.windowsSegments && context.localAppData + ? context.path.join(context.localAppData, ...input.windowsSegments) + : undefined; + } + return input.linuxSegments + ? context.path.join(context.home, ".config", ...input.linuxSegments) + : undefined; + }, +}); + +export const BROWSER_IMPORT_SOURCES: ReadonlyArray = [ + // No Chromium fork is importable on Windows: since Chrome 127 their cookies + // are encrypted to the browser's own identity (App-Bound Encryption), so no + // other process can read them. macOS and Linux keep working, so only the + // Windows segments are omitted. + chromiumSource({ + id: "chrome", + name: "Chrome", + keychainService: "Chrome Safe Storage", + keychainAccount: "Chrome", + macSegments: ["Google", "Chrome"], + linuxSegments: ["google-chrome"], + linuxSecretApplication: "chrome", + }), + chromiumSource({ + id: "edge", + name: "Microsoft Edge", + keychainService: "Microsoft Edge Safe Storage", + keychainAccount: "Microsoft Edge", + macSegments: ["Microsoft Edge"], + linuxSegments: ["microsoft-edge"], + linuxSecretApplication: "msedge", + }), + chromiumSource({ + id: "brave", + name: "Brave", + keychainService: "Brave Safe Storage", + keychainAccount: "Brave", + macSegments: ["BraveSoftware", "Brave-Browser"], + linuxSegments: ["BraveSoftware", "Brave-Browser"], + linuxSecretApplication: "brave", + }), + chromiumSource({ + id: "vivaldi", + name: "Vivaldi", + keychainService: "Vivaldi Safe Storage", + keychainAccount: "Vivaldi", + macSegments: ["Vivaldi"], + linuxSegments: ["vivaldi"], + linuxSecretApplication: "vivaldi", + }), + chromiumSource({ + id: "opera", + name: "Opera", + keychainService: "Opera Safe Storage", + keychainAccount: "Opera", + macSegments: ["com.operasoftware.Opera"], + linuxSegments: ["opera"], + linuxSecretApplication: "opera", + }), + // Arc has no Linux build. + chromiumSource({ + id: "arc", + name: "Arc", + keychainService: "Arc Safe Storage", + keychainAccount: "Arc", + macSegments: ["Arc", "User Data"], + }), + chromiumSource({ + id: "helium", + name: "Helium", + keychainService: "Helium Storage Key", + keychainAccount: "Helium", + macSegments: ["net.imput.helium"], + linuxSegments: ["net.imput.helium"], + windowsSegments: ["imput", "Helium", "User Data"], + // Helium retains Chromium's libsecret application name on Linux. + linuxSecretApplication: "chromium", + }), + { + id: "firefox", + name: "Firefox", + engine: "firefox", + platforms: ["darwin", "win32", "linux"], + userDataDirectory: (context) => { + if (context.platform === "darwin") return macApplicationSupport(context, "Firefox"); + if (context.platform === "win32") { + return context.appData + ? context.path.join(context.appData, "Mozilla", "Firefox") + : undefined; + } + return context.path.join(context.home, ".mozilla", "firefox"); + }, + }, +]; + +/** + * Where a profile's cookie database may live, most current first. Chromium 96 + * moved the live jar to `Network/Cookies`; a root-level `Cookies` is either a + * pre-96 install or a leftover from before the move. Importing the leftover + * while sessions live in `Network/` would snapshot a stale or empty database, + * and a fresh install with only `Network/Cookies` would read as not installed. + * Firefox uses `cookies.sqlite`, and its profile paths from `profiles.ini` + * may already be absolute. + * + * Chrome 96+ moved network-related files (including Cookies) into a `Network` + * subdirectory for sandboxing. The candidate list includes both locations so + * callers tolerate fresh and legacy installs alike. + */ +export const cookieDatabaseCandidatePaths = ( + definition: BrowserImportSourceDefinition, + context: BrowserImportPathContext, + profileDirectory: string, +): ReadonlyArray => { + const root = definition.userDataDirectory(context); + if (root === undefined) return []; + const profilePath = context.path.isAbsolute(profileDirectory) + ? profileDirectory + : context.path.join(root, profileDirectory); + if (definition.engine === "firefox") { + return [context.path.join(profilePath, "cookies.sqlite")]; + } + // 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 [ + context.path.join(profilePath, "Network", "Cookies"), + context.path.join(profilePath, "Cookies"), + ]; +}; + +/** The first candidate that is a regular file, or undefined when none is. */ +export const resolveCookieDatabase = Effect.fnUntraced(function* ( + definition: BrowserImportSourceDefinition, + context: BrowserImportPathContext, + profileDirectory: string, +) { + for (const candidate of cookieDatabaseCandidatePaths(definition, context, profileDirectory)) { + if (yield* databaseFileExists(candidate)) return candidate; + } + return undefined; +}); + +/** + * Firefox records its profiles in `profiles.ini`. `Install*` sections point at + * a default profile but do not describe one, so only `[ProfileN]` blocks + * count. + */ +export function parseFirefoxProfiles( + ini: string, + path: Path.Path, + root: string, +): ReadonlyArray { + const profiles: BrowserImportSourceProfile[] = []; + let current: { name?: string; path?: string; isRelative?: string } | null = null; + + const flush = () => { + if (current?.path) { + const candidate = current.path; + const isRelative = current.isRelative === undefined || current.isRelative === "1"; + const validIsRelative = current.isRelative === undefined || /^[01]$/.test(current.isRelative); + if (!validIsRelative || candidate.includes("\u0000")) { + current = null; + return; + } + + let directory: string | undefined; + if (isRelative) { + if (!path.isAbsolute(candidate)) { + const resolved = path.resolve(root, candidate); + const relative = path.relative(root, resolved); + const escapesRoot = + relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative); + if (!escapesRoot) directory = path.normalize(candidate); + } + } else if (path.isAbsolute(candidate)) { + // Firefox supports profiles on arbitrary custom roots when + // IsRelative=0. Do not constrain them to the standard Firefox root. + directory = path.normalize(candidate); + } + + if (directory !== undefined) { + profiles.push({ directory, name: current.name?.trim() || directory }); + } + } + current = null; + }; + + for (const rawLine of ini.split(/\r?\n/)) { + const line = rawLine.trim(); + if (line.startsWith("[")) { + flush(); + current = /^\[Profile\d+\]$/i.test(line) ? {} : null; + continue; + } + if (!current) continue; + const separator = line.indexOf("="); + if (separator === -1) continue; + const key = line.slice(0, separator).trim().toLowerCase(); + const value = line.slice(separator + 1).trim(); + if (key === "name") current.name = value; + if (key === "path") current.path = value; + if (key === "isrelative") current.isRelative = value; + } + flush(); + return profiles; +} + +/** + * Resolves the roots the registry builds its paths from, from the ambient + * process. Tests build a context directly instead. + */ +export const sourcePathContext = Effect.gen(function* () { + const path = yield* Path.Path; + const platform = yield* HostProcessPlatform; + const environment = yield* HostProcessEnvironment; + return { + path, + platform, + home: environment.HOME ?? environment.USERPROFILE ?? "", + appData: environment.APPDATA, + localAppData: environment.LOCALAPPDATA, + } satisfies BrowserImportPathContext; +}); + +/** Shape of the slice of Chromium's `Local State` that names its profiles. */ +const LocalState = Schema.Struct({ + profile: Schema.optional( + Schema.Struct({ + info_cache: Schema.optional( + Schema.Record(Schema.String, Schema.Struct({ name: Schema.optional(Schema.String) })), + ), + }), + ), +}); +const decodeLocalState = Schema.decodeUnknownEffect(Schema.fromJsonString(LocalState)); + +/** A single plain path segment: no separators, no `.`/`..`, not empty. */ +const isSafeProfileDirectory = (directory: string): boolean => + directory.length > 0 && + directory !== "." && + directory !== ".." && + !/[\\/]/.test(directory) && + !directory.includes("\u0000"); + +const CookieCountRow = Schema.Struct({ count: Schema.Number }); +const decodeCookieCount = Schema.decodeUnknownEffect(Schema.Array(CookieCountRow)); + +/** + * How many importable cookies a profile holds, counted without decrypting + * anything. Firefox containers use identities Electron cannot represent, so + * its count uses the same default-container predicate as the reader. Best + * effort: a locked, missing or unexpected database yields `undefined` rather + * than failing the listing. + */ +const countProfileCookies = Effect.fnUntraced(function* ( + definition: BrowserImportSourceDefinition, + context: BrowserImportPathContext, + directory: string, +): Effect.fn.Return { + const database = yield* resolveCookieDatabase(definition, context, directory); + if (database === undefined) return undefined; + return yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const rows = + definition.engine === "firefox" + ? yield* sql`select count(*) as count from moz_cookies where originAttributes = ''` + : yield* sql`select count(*) as count from cookies`; + const [row] = yield* decodeCookieCount(rows); + return row?.count; + }).pipe( + Effect.provide(NodeSqliteClient.layer({ filename: database, readonly: true })), + Effect.orElseSucceed(() => undefined), + ); +}); + +const withCookieCounts = ( + definition: BrowserImportSourceDefinition, + context: BrowserImportPathContext, + profiles: ReadonlyArray, +) => + Effect.forEach(profiles, (profile) => + countProfileCookies(definition, context, profile.directory).pipe( + Effect.map((cookieCount) => + cookieCount === undefined ? profile : { ...profile, cookieCount }, + ), + ), + ); + +/** + * Profiles the source browser knows about. + * + * Firefox declares them in `profiles.ini`; Chromium in `Local State`. When + * that metadata is missing, unreadable or malformed, the directories that + * actually hold a cookie database are scanned instead. Assuming a single + * `Default` would report a browser whose cookies live in `Profile 1` as having + * nothing to import — and it is then left out of the menu entirely. + */ +const listSourceProfilesInDirectory = Effect.fnUntraced(function* ( + definition: BrowserImportSourceDefinition, + context: BrowserImportPathContext, +): Effect.fn.Return, never, FileSystem.FileSystem> { + const fileSystem = yield* FileSystem.FileSystem; + const root = definition.userDataDirectory(context); + if (root === undefined) return []; + + if (definition.engine === "firefox") { + const declared = yield* fileSystem.readFileString(context.path.join(root, "profiles.ini")).pipe( + Effect.map((ini) => parseFirefoxProfiles(ini, context.path, root)), + Effect.orElseSucceed(() => [] as ReadonlyArray), + ); + // `profiles.ini` also lists profiles the installer created but the user + // never launched, which hold no cookie database and nothing to import. + // Only keep the ones a database proves exist, like the directory scans + // below do. When none of the declared profiles has one, fall through to + // the scan rather than returning empty: `profiles.ini` can list stale or + // never-launched profiles while the cookies live in one it does not + // mention, and an empty answer here hides the browser entirely. + if (declared.length > 0) { + const found = yield* Effect.forEach(declared, (profile) => + Effect.forEach( + cookieDatabaseCandidatePaths(definition, context, profile.directory), + (candidate) => databaseFileExists(candidate), + ).pipe(Effect.map((results) => (results.some(Boolean) ? profile : undefined))), + ); + const withDatabase = found.filter((profile) => profile !== undefined); + if (withDatabase.length > 0) { + return yield* withCookieCounts(definition, context, withDatabase); + } + } + + // No usable `profiles.ini`, so fall back to scanning the directory the + // profiles actually live in, keeping only the ones a cookie database + // proves were launched. + const fallbackDirectory = + context.platform === "linux" ? root : context.path.join(root, "Profiles"); + const scanned = yield* fileSystem + .readDirectory(fallbackDirectory) + .pipe(Effect.orElseSucceed(() => [] as ReadonlyArray)); + const found = yield* Effect.forEach(scanned, (entry) => { + const directory = context.platform === "linux" ? entry : context.path.join("Profiles", entry); + return resolveCookieDatabase(definition, context, directory).pipe( + Effect.map((database) => (database === undefined ? undefined : { directory, name: entry })), + ); + }); + return yield* withCookieCounts( + definition, + context, + found.filter((profile) => profile !== undefined), + ); + } + + const declared = yield* fileSystem.readFileString(context.path.join(root, "Local State")).pipe( + Effect.flatMap(decodeLocalState), + Effect.map((state) => Object.entries(state.profile?.info_cache ?? {})), + // The keys are directory names from the browser's own metadata file, which + // anything running as the user can write. Anything but a single plain + // segment is dropped: `..` or a path separator would otherwise be handed + // to `cookieDatabasePath` and read a database outside the user-data + // directory. + Effect.map((entries) => entries.filter(([directory]) => isSafeProfileDirectory(directory))), + Effect.map((entries) => + entries.map(([directory, info]) => ({ directory, name: info.name?.trim() || directory })), + ), + Effect.orElseSucceed(() => [] as ReadonlyArray), + ); + if (declared.length > 0) return yield* withCookieCounts(definition, context, declared); + + // `Local State` is missing, unreadable or malformed. Scanning for directories + // that hold a cookie database finds the profiles anyway. + const entries = yield* fileSystem + .readDirectory(root) + .pipe(Effect.orElseSucceed(() => [] as ReadonlyArray)); + const found = yield* Effect.forEach(entries.filter(isSafeProfileDirectory), (directory) => + resolveCookieDatabase(definition, context, directory).pipe( + Effect.map((database) => + database === undefined ? undefined : { directory, name: directory }, + ), + ), + ); + return yield* withCookieCounts( + definition, + context, + found.filter((profile) => profile !== undefined), + ); +}); + +/** + * Include Firefox's Snap home alongside its native home. Snap profiles use + * absolute directories so cookie reads and lock checks keep pointing at the + * installation they came from, even when both installs use the same name. + */ +export const listSourceProfiles = Effect.fn("BrowserImportSources.listSourceProfiles")(function* ( + definition: BrowserImportSourceDefinition, + context: BrowserImportPathContext, +): Effect.fn.Return, never, FileSystem.FileSystem> { + if (definition.engine !== "firefox" || context.platform !== "linux") { + return yield* listSourceProfilesInDirectory(definition, context); + } + + const root = definition.userDataDirectory(context); + if (root === undefined) return []; + const roots = [ + root, + context.path.join(context.home, "snap", "firefox", "common", ".mozilla", "firefox"), + ]; + const profiles = new Map(); + for (const directory of roots) { + const found = yield* listSourceProfilesInDirectory( + { ...definition, userDataDirectory: () => directory }, + context, + ); + for (const profile of found) { + const absolute = context.path.resolve(directory, profile.directory); + if (!profiles.has(absolute)) { + profiles.set(absolute, directory === root ? profile : { ...profile, directory: absolute }); + } + } + } + return [...profiles.values()]; +}); + +/** + * Whether a cookie database candidate is a regular file. Presence alone is + * not enough: a directory at the path would list as an importable profile and + * then fail the SQLite open, so anything but a file is treated as absent. + */ +const databaseFileExists = Effect.fnUntraced(function* (path: string) { + const fileSystem = yield* FileSystem.FileSystem; + return yield* fileSystem.stat(path).pipe( + Effect.map((info) => info.type === "File"), + Effect.orElseSucceed(() => false), + ); +}); + +type ProcessLivenessProbe = (pid: number) => Effect.Effect; + +export const chromiumProcessIsAlive = ( + pid: number, + signalProcess: (pid: number, signal: 0) => unknown = process.kill.bind(process), +) => + Effect.sync(() => { + try { + // Signal 0 performs a read-only existence/permission check. + signalProcess(pid, 0); + return true; + } catch (cause) { + // Only ESRCH positively proves the process is gone. Permission errors + // and unknown failures stay conservative so an active browser is never + // mistaken for a stale lock. + return !( + typeof cause === "object" && + cause !== null && + "code" in cause && + cause.code === "ESRCH" + ); + } + }); + +const processIsAlive: ProcessLivenessProbe = (pid) => chromiumProcessIsAlive(pid); + +/** Whether a Chromium `-` lock target may still name its owner. */ +export const chromiumSingletonLockIsHeld = Effect.fnUntraced(function* ( + target: string, + currentHost: string, + isProcessAlive: ProcessLivenessProbe, +) { + const separator = target.lastIndexOf("-"); + if (separator <= 0) return true; + const host = target.slice(0, separator); + const pidText = target.slice(separator + 1); + if (!/^\d+$/.test(pidText)) return true; + const pid = Number(pidText); + if (!Number.isSafeInteger(pid) || pid <= 0) return true; + // A PID is meaningful only on this host. A foreign hostname can come from a + // shared home directory, and cannot safely be declared stale from here. + if (host !== currentHost) return true; + return yield* isProcessAlive(pid); +}); + +/** Windows sharing and lock violations are translated by libuv to `Busy`. */ +export const isWindowsLockHeldError = (error: PlatformError.PlatformError): boolean => + error.reason._tag === "Busy"; + +/** + * Whether a Windows `parent.lock` is actually held by a running process. It + * is opened with no sharing, so it persists on disk after the process exits + * and `stat` always succeeds; only trying to open it for write reveals an + * active holder, which surfaces as `Busy`. + */ +const windowsLockIsHeld = Effect.fnUntraced(function* (lockPath: string) { + // Permission failures are distinct: they do not prove a browser owns the + // lock, so they must not hide the source as running. + const fileSystem = yield* FileSystem.FileSystem; + return yield* fileSystem.open(lockPath, { flag: "r+" }).pipe( + Effect.as(false), + Effect.catchIf(isWindowsLockHeldError, () => Effect.succeed(true)), + Effect.orElseSucceed(() => false), + Effect.scoped, + ); +}); + +type WindowsLockProbe = (path: string) => Effect.Effect; + +/** + * Chromium does not create its POSIX `SingletonLock` symlink on Windows. The + * live cookie database is opened without sharing instead, so probing each + * profile's current jar is the reliable running signal there. + */ +export const windowsChromiumCookiesAreHeld = Effect.fnUntraced(function* ( + definition: BrowserImportSourceDefinition, + context: BrowserImportPathContext, + lockIsHeld: WindowsLockProbe = windowsLockIsHeld, +) { + const profiles = yield* listSourceProfiles(definition, context); + const held = yield* Effect.forEach(profiles, (profile) => + resolveCookieDatabase(definition, context, profile.directory).pipe( + Effect.flatMap((database) => + database === undefined ? Effect.succeed(false) : lockIsHeld(database), + ), + ), + ); + return held.some(Boolean); +}); + +/** + * Whether a Firefox `lock` symlink's `:[+]` target still names a + * live owner. Firefox writes this symlink beside the profile while it runs and + * unlinks it on a clean exit, so a dangling one is either live or a crash. + */ +export const firefoxSymlinkLockIsHeld = Effect.fnUntraced(function* ( + target: string, + localAddresses: ReadonlySet, + isProcessAlive: ProcessLivenessProbe, +) { + const separator = target.lastIndexOf(":"); + if (separator < 0) return true; + // The owner half is whatever Firefox's resolver returned for the machine's + // hostname — 127.0.0.1 when the lookup fails, but often 127.0.1.1 or a LAN + // address — so a pid is only meaningful when that address is one of ours. + // A shared (NFS) profile locked from another machine names a foreign + // address whose pid cannot be probed here, nor could a reused local pid + // vouch for it, so it stays conservatively held. + const owner = target.slice(0, separator); + if (!localAddresses.has(owner)) return true; + // A `+` marks an fcntl-holding owner; the pid follows either way. + const pidText = target.slice(separator + 1).replace(/^\+/, ""); + if (!/^\d+$/.test(pidText)) return true; + const pid = Number(pidText); + if (!Number.isSafeInteger(pid) || pid <= 0) return true; + return yield* isProcessAlive(pid); +}); + +/** + * Interpreters that can run the fcntl probe, tried in order. `/usr/bin/python3` + * is named absolutely first so a Dock-launched app with launchd's bare `PATH` + * still finds it without depending on the login-shell PATH merge; Linux + * distributions carry python3 on the default path. + */ +const FCNTL_PROBE_INTERPRETERS = ["/usr/bin/python3", "python3"] as const; + +/** + * The probe prints exactly one of these. Anything else means the script never + * ran — most importantly Apple's `/usr/bin/python3` shim, which on a Mac + * without the Command Line Tools exits non-zero after printing an install + * prompt, without ever reaching our code. + */ +const FCNTL_PROBE_SCRIPT = + "import fcntl,os,sys\n" + + "fd=os.open(sys.argv[1],os.O_WRONLY)\n" + + "try:\n" + + " fcntl.lockf(fd,fcntl.LOCK_EX|fcntl.LOCK_NB)\n" + + "except BlockingIOError:\n" + + " print('held')\n" + + "else:\n" + + " print('free')"; + +/** + * Whether another process holds an fcntl write lock on `path`. + * + * Firefox's `.parentlock` is an empty file whose only signal is the kernel + * lock, and Node exposes no fcntl, so a throwaway interpreter tries a + * non-blocking `F_SETLK` and reports `EWOULDBLOCK`. The lock is never + * acquired for real: on success the child exits and the kernel drops it. + * + * The answer is trusted only when the script itself spoke. A verdict of + * `held` or `free` on stdout is the probe's own, and stands. Anything else — + * no interpreter on any candidate path, or one that refused to run the script + * (Apple's shim without the developer tools) — is the probe being unavailable, + * not evidence about the lock. That case falls back to "not held" rather than + * "held": reporting every profile as locked forever would block Firefox import + * outright on such machines, and the SQLite snapshot already copes with a + * live database's WAL, as it does for every other engine. + */ +export const posixLockIsHeld = Effect.fnUntraced(function* ( + path: string, + interpreters: ReadonlyArray = FCNTL_PROBE_INTERPRETERS, +) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const environment = yield* HostProcessEnvironment; + for (const interpreter of interpreters) { + const verdict = yield* Effect.scoped( + Effect.gen(function* () { + const handle = yield* spawner.spawn( + ChildProcess.make(interpreter, ["-c", FCNTL_PROBE_SCRIPT, path], { + stdin: "ignore", + env: environment, + }), + ); + const [stdout] = yield* Effect.all( + [handle.stdout.pipe(Stream.decodeText(), Stream.mkString), handle.exitCode], + { concurrency: "unbounded" }, + ); + return stdout.trim(); + }), + ).pipe(Effect.orElseSucceed(() => "")); + if (verdict === "held") return true; + if (verdict === "free") return false; + } + return false; +}); + +/** + * Whether Firefox holds a profile. + * + * Firefox leaves two kinds of lock behind, and they mean different things. + * On Linux the `lock` symlink (target `:+`) is removed on a clean + * exit, so its presence is evidence — provided the pid it names is alive. But + * `.parentlock` (macOS/Linux) and `parent.lock` (Windows) are regular files + * held with fcntl or a Windows handle and are *deliberately left on disk* + * after exit, as a last-used marker; treating them as proof of a running + * browser blocks every import after Firefox has been used once. On POSIX the + * fcntl lock itself is the truth, and macOS in particular writes nothing else + * (no symlink, no pid), so `.parentlock` is probed for the kernel lock. On + * Windows the held handle denies our open, which `windowsLockIsHeld` reads as `Busy`. + */ +const firefoxProfileIsHeld = Effect.fnUntraced(function* ( + directory: string, + context: BrowserImportPathContext, + // Resolved once by the caller: it involves a DNS lookup of the hostname and + // is the same for every profile. + localAddresses: ReadonlySet, +) { + const fileSystem = yield* FileSystem.FileSystem; + if (context.platform === "win32") { + return yield* windowsLockIsHeld(context.path.join(directory, "parent.lock")); + } + // Linux additionally writes the `lock` symlink; a live pid there settles it + // without spawning anything. + const symlinkHeld = yield* fileSystem.readLink(context.path.join(directory, "lock")).pipe( + Effect.flatMap((target) => firefoxSymlinkLockIsHeld(target, localAddresses, processIsAlive)), + Effect.orElseSucceed(() => false), + ); + if (symlinkHeld) return true; + const parentLock = context.path.join(directory, ".parentlock"); + const present = yield* fileSystem.stat(parentLock).pipe( + Effect.map((info) => info.type === "File"), + Effect.orElseSucceed(() => false), + ); + if (!present) return false; + return yield* posixLockIsHeld(parentLock); +}); + +/** Whether the browser is running, which leaves its cookie DB mid-write. */ +export const isSourceRunning = Effect.fn("BrowserImportSources.isSourceRunning")(function* ( + definition: BrowserImportSourceDefinition, + context: BrowserImportPathContext, +): Effect.fn.Return< + boolean, + never, + FileSystem.FileSystem | ChildProcessSpawner.ChildProcessSpawner +> { + const fileSystem = yield* FileSystem.FileSystem; + const root = definition.userDataDirectory(context); + if (root === undefined) return false; + // Probe the source's own lock state rather than scanning the process table. + // 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 !== "firefox") { + if (context.platform === "win32") { + return yield* windowsChromiumCookiesAreHeld(definition, context); + } + const currentHost = yield* HostProcessHostname; + const lock = context.path.join(root, "SingletonLock"); + return yield* fileSystem.readLink(lock).pipe( + Effect.flatMap((target) => chromiumSingletonLockIsHeld(target, currentHost, processIsAlive)), + Effect.catch((error) => Effect.succeed(error.reason._tag !== "NotFound")), + ); + } + + const profiles = yield* listSourceProfiles(definition, context); + // Only the Linux `lock` symlink names an address, so Windows skips the lookup. + const localAddresses: ReadonlySet = + context.platform === "win32" ? new Set() : yield* yield* HostProcessAddresses; + const found = yield* Effect.forEach(profiles, (profile) => { + const directory = context.path.isAbsolute(profile.directory) + ? profile.directory + : context.path.join(root, profile.directory); + return firefoxProfileIsHeld(directory, context, localAddresses); + }); + return found.some(Boolean); +}); + +/** + * Whether the source has cookies to import. + * + * Keyed off the cookie database rather than the user-data directory, because + * that directory is not evidence the browser exists: installers for native + * messaging hosts create an empty one for every Chromium fork they know about, + * so a machine with only Chrome reports Edge, Brave, Vivaldi, Opera and Arc as + * present. The database is the thing an import actually needs, so its absence + * is the honest answer either way. + * + * Existence is checked without opening the file, which matters for Safari: TCC + * permits `stat` on the jar inside its container but refuses a read, so this + * still sees it and the user gets the Full Disk Access prompt rather than + * having Safari disappear. + */ +export const isSourceInstalled = Effect.fn("BrowserImportSources.isSourceInstalled")(function* ( + definition: BrowserImportSourceDefinition, + context: BrowserImportPathContext, +): Effect.fn.Return { + const profiles = yield* listSourceProfiles(definition, context); + const found = yield* Effect.forEach(profiles, (profile) => + resolveCookieDatabase(definition, context, profile.directory).pipe( + Effect.map((database) => database !== undefined), + ), + ); + return found.some(Boolean); +}); diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index d334b3635080..a7b3afabd3c3 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -677,6 +677,67 @@ describe("PreviewManager", () => { ), ); + effectIt.effect("detaches through the pinned debugger after the webview is destroyed", () => + withManager((manager) => + Effect.gen(function* () { + // Real Electron throws on any `wc.debugger` access once the + // WebContents is destroyed, so cleanup must go through the debugger + // reference captured at attach time (electron/electron#53376). + let destroyed = false; + let attached = false; + const debuggerOff = vi.fn(); + const debuggerDetach = vi.fn(() => { + attached = false; + }); + const wcDebugger = { + isAttached: () => attached, + attach: vi.fn(() => { + attached = true; + }), + detach: debuggerDetach, + sendCommand: vi.fn(async () => undefined), + on: vi.fn(), + off: debuggerOff, + }; + fromId.mockReturnValue({ + id: 42, + isDestroyed: () => destroyed, + getType: () => "webview", + getURL: () => "http://localhost:3200/", + getTitle: () => "Preview", + isLoading: () => false, + isDevToolsOpened: () => false, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + setAudioMuted: vi.fn(), + isCurrentlyAudible: () => false, + reload: vi.fn(), + loadURL: vi.fn(async () => undefined), + on: vi.fn(), + off: vi.fn(), + ipc: { on: vi.fn(), off: vi.fn() }, + send: webviewSend, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setWindowOpenHandler: vi.fn(), + get debugger() { + if (destroyed) throw new Error("Object has been destroyed"); + return wcDebugger; + }, + } as never); + yield* manager.createTab("tab_pinned_debugger"); + yield* manager.registerWebview("tab_pinned_debugger", 42); + yield* manager.setColorScheme("tab_pinned_debugger", "dark"); + expect(attached).toBe(true); + destroyed = true; + + yield* manager.navigate("tab_pinned_debugger", "https://example.com/"); + + expect(debuggerOff).toHaveBeenCalledWith("message", expect.any(Function)); + expect(debuggerDetach).toHaveBeenCalledOnce(); + }), + ), + ); + effectIt.effect("does not let destroyed-webview cleanup detach a same-id replacement", () => withManager((manager) => Effect.gen(function* () { diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 8af2a460fb3e..324b92034f36 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -437,6 +437,12 @@ interface PickSession { interface BrowserControlSession { readonly webContentsId: number; + // Pins the WebContents' Debugger wrapper for the session's lifetime. + // Electron's Debugger is GC-managed but registered with Chromium as a raw + // DevToolsAgentHostClient pointer; collecting it while attached crashes the + // browser process (electron/electron#53376). Detach must also go through + // this reference: `wc.debugger` throws once the WebContents is destroyed. + readonly debugger: Electron.Debugger; readonly semaphore: Semaphore.Semaphore; readonly scope: Scope.Closeable; readonly onMessage: ( @@ -1184,6 +1190,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const createControlSession = Effect.fn("PreviewManager.createControlSession")(function* () { const semaphore = yield* Semaphore.make(1); const scope = yield* Scope.fork(parentScope, "sequential"); + const wcDebugger = wc.debugger; const handleDebuggerMessage = Effect.fnUntraced(function* ( method: string, params: Record, @@ -1196,7 +1203,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function operation: "ackScreencastFrame", webContentsId: wc.id, }, - () => wc.debugger.sendCommand("Page.screencastFrameAck", { sessionId }), + () => wcDebugger.sendCommand("Page.screencastFrameAck", { sessionId }), ).pipe(Effect.ignore); } const tabId = yield* tabIdForWebContents(wc.id); @@ -1244,8 +1251,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }), ), attempt({ operation: "detachControlSession", webContentsId: wc.id }, () => { - wc.debugger.off("message", onMessage); - if (wc.debugger.isAttached()) wc.debugger.detach(); + wcDebugger.off("message", onMessage); + if (wcDebugger.isAttached()) wcDebugger.detach(); }).pipe(Effect.ignore), ], { discard: true }, @@ -1253,6 +1260,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); const control: BrowserControlSession = { webContentsId: wc.id, + debugger: wcDebugger, semaphore, scope, onMessage, @@ -1268,15 +1276,15 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }), ); yield* attempt({ operation: "attachDebuggerListeners", webContentsId: wc.id }, () => { - wc.debugger.on("message", onMessage); - wc.debugger.attach("1.3"); + wcDebugger.on("message", onMessage); + wcDebugger.attach("1.3"); }); yield* Effect.all( ["Runtime.enable", "Accessibility.enable", "Network.enable", "Log.enable"].map( (method) => attemptPromise( { operation: `initializeDebugger.${method}`, webContentsId: wc.id }, - () => wc.debugger.sendCommand(method), + () => wcDebugger.sendCommand(method), ), ), { concurrency: "unbounded", discard: true }, @@ -1365,7 +1373,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function } const result = yield* attemptPromise( { operation: `${action}.${method}`, tabId, webContentsId: wc.id }, - () => wc.debugger.sendCommand(method, commandParams), + () => control.debugger.sendCommand(method, commandParams), ); const after = (yield* Ref.get(controlEpochRef)).get(tabId) ?? 0; if (after !== epoch) { @@ -1389,7 +1397,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function tabId, webContentsId: wc.id, }, - () => wc.debugger.sendCommand(method, commandParams), + () => control.debugger.sendCommand(method, commandParams), ); }, ); @@ -2578,9 +2586,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wc: Electron.WebContents, colorScheme: DesktopPreviewColorScheme, ) { - yield* ensureControlSession(wc); + const control = yield* ensureControlSession(wc); yield* attemptPromise({ operation: "applyColorScheme", tabId, webContentsId: wc.id }, () => - wc.debugger.sendCommand("Emulation.setEmulatedMedia", { + control.debugger.sendCommand("Emulation.setEmulatedMedia", { features: [ { name: "prefers-color-scheme", @@ -2600,7 +2608,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function Effect.gen(function* () { const beforeAttach = (yield* SynchronizedRef.get(tabsRef)).get(tabId); if (beforeAttach?.webContentsId !== wc.id) return; - yield* ensureControlSession(wc); + const control = yield* ensureControlSession(wc); const afterAttach = (yield* SynchronizedRef.get(tabsRef)).get(tabId); if (afterAttach?.webContentsId !== wc.id) { yield* detachControlSession(wc.id); @@ -2608,7 +2616,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function } if (afterAttach.colorScheme !== "system") { yield* attemptPromise({ operation: "applyColorScheme", tabId, webContentsId: wc.id }, () => - wc.debugger.sendCommand("Emulation.setEmulatedMedia", { + control.debugger.sendCommand("Emulation.setEmulatedMedia", { features: [ { name: "prefers-color-scheme", diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 97f4ca85c506..28cce3cfb507 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -28,6 +28,8 @@ const clientSettings: ClientSettings = { confirmThreadUnpin: false, continueThreadsAfterServerUpdate: true, contextWindowMeterEnabled: false, + composerCollapseOnBlur: false, + composerCollapseOnScroll: true, dismissedProviderUpdateNotificationKeys: [], diffIgnoreWhitespace: true, diffLayout: "stacked", diff --git a/apps/desktop/src/window/QuitHold.test.ts b/apps/desktop/src/window/QuitHold.test.ts index fb12be2162c1..c4bf2f34b0a1 100644 --- a/apps/desktop/src/window/QuitHold.test.ts +++ b/apps/desktop/src/window/QuitHold.test.ts @@ -368,14 +368,14 @@ describe("makeQuitShortcutHandler", () => { expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN, UP, DOUBLE_CLICK_DOWN]); }); - it("does not treat two quick presses as a quit in hold mode", async () => { + it("quits on a quick second press in hold mode", async () => { const harness = makeHarness(); await harness.send(makeInput({})); await harness.send(makeInput({ type: "keyUp" })); vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS - 100); await harness.send(makeInput({})); - expect(harness.quit).not.toHaveBeenCalled(); - expect(harness.notifications).toEqual([HOLD_DOWN, UP, HOLD_DOWN]); + expect(harness.quit).toHaveBeenCalledTimes(1); + expect(harness.notifications).toEqual([HOLD_DOWN, UP]); }); it("cancels the hold when another key interrupts it", async () => { diff --git a/apps/desktop/src/window/QuitHold.ts b/apps/desktop/src/window/QuitHold.ts index 7088f4f28ce8..4095e3d4354b 100644 --- a/apps/desktop/src/window/QuitHold.ts +++ b/apps/desktop/src/window/QuitHold.ts @@ -181,11 +181,9 @@ export function makeQuitShortcutHandler( quitNow(); return; } - if ( - resolvedMode === "double-click" && - previousPressAt !== 0 && - now - previousPressAt <= QUIT_DOUBLE_PRESS_MS - ) { + // Keep a second press as an escape hatch when macOS misses the events + // that would complete a hold. + if (previousPressAt !== 0 && now - previousPressAt <= QUIT_DOUBLE_PRESS_MS) { quitNow(); return; } diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index 9f25204f1630..ce74cf58e0a3 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -14,18 +14,20 @@ export default defineConfig({ run: { tasks: { build: { - command: "node scripts/build-preview-annotation-css.mjs && vp pack", + command: + "node scripts/build-browser-secret.mjs && node scripts/build-preview-annotation-css.mjs && vp pack", dependsOn: ["t3#build"], cache: false, }, dev: { command: - "node scripts/build-preview-annotation-css.mjs && cross-env T3CODE_DESKTOP_DEV=1 vp pack --watch", + "node scripts/build-browser-secret.mjs && node scripts/build-preview-annotation-css.mjs && cross-env T3CODE_DESKTOP_DEV=1 vp pack --watch", dependsOn: ["t3#build"], cache: false, }, "dev:bundle": { - command: "node scripts/build-preview-annotation-css.mjs && vp pack --watch", + command: + "node scripts/build-browser-secret.mjs && node scripts/build-preview-annotation-css.mjs && vp pack --watch", cache: false, }, "dev:electron": { diff --git a/apps/mobile/src/components/ProviderIcon.tsx b/apps/mobile/src/components/ProviderIcon.tsx index ef79e6af8e94..8ad497c9a79b 100644 --- a/apps/mobile/src/components/ProviderIcon.tsx +++ b/apps/mobile/src/components/ProviderIcon.tsx @@ -13,7 +13,7 @@ export function ProviderIcon(props: ProviderIconProps) { const size = props.size ?? 16; const mono = isDarkMode ? "#e5e5e5" : "#171717"; - if (props.provider === "antigravity") { + if (props.provider?.trim().toLowerCase() === "antigravity") { return ( void; readonly status: ComposerStatusPillState; }) { - const isReconnecting = props.status.kind !== "unavailable"; + const isReconnecting = props.status.kind === "reconnecting"; return ( { if (!props.serverConfig) return null; diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 18a51eb834e3..bf1a55a8a6ae 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -74,6 +74,7 @@ import { PendingUserInputCard } from "./PendingUserInputCard"; import { FLOATING_WORKING_CONTROL_COVERAGE, FloatingWorkingControl, + type FloatingWorkingStatus, } from "./floating-working-control"; import { derivePendingUserInputMaxHeight, @@ -301,27 +302,38 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread // The raw sync status enters "synchronizing" on every full fetch, cached or // not. Whether messages are already on screen decides the pill label: no // data yet → "Loading messages", cached data reconciling → "Syncing". - const threadSyncPhase = (() => { + const threadSyncLabel = (() => { switch (props.threadSyncStatus) { case "empty": case "cached": case "synchronizing": if (contentPresentationKind === "ready") { - return "syncing" as const; + return "Syncing messages..."; } - return contentPresentationKind === "loading" ? ("loading" as const) : null; + return contentPresentationKind === "loading" ? "Loading messages..." : null; default: return null; } })(); - const showWorkingControl = - props.activeWorkStartedAt !== null && - contentPresentationKind === "ready" && - threadSyncPhase === null && - props.connectionStateLabel === "connected" && - props.activePendingApproval === null && - props.activePendingUserInput === null; - const floatingWorkingStartedAt = showWorkingControl ? props.activeWorkStartedAt : null; + // One floating pill above the composer: it reads the sync state while + // messages load, then the working timer once the feed is settled. + const floatingStatus = ((): FloatingWorkingStatus | null => { + if ( + props.connectionStateLabel !== "connected" || + props.activePendingApproval !== null || + props.activePendingUserInput !== null + ) { + return null; + } + if (threadSyncLabel !== null) { + return { kind: "syncing", label: threadSyncLabel }; + } + if (props.activeWorkStartedAt !== null && contentPresentationKind === "ready") { + return { kind: "working", startedAt: props.activeWorkStartedAt }; + } + return null; + })(); + const showWorkingControl = floatingStatus !== null; const selectedThreadFeed = props.selectedThreadFeed; const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME; const composerOverlapHeight = composerChrome + composerBottomInset; @@ -748,7 +760,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread @@ -807,7 +819,6 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread connectionState={props.connectionStateLabel} connectionError={props.connectionError} environmentLabel={props.environmentLabel} - threadSyncPhase={threadSyncPhase} selectedThread={props.selectedThread} serverConfig={props.serverConfig} queueCount={props.selectedThreadQueueCount} diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 79e898eaa1c9..53eca806cbc7 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -157,8 +157,9 @@ export function ThreadRouteScreen(props: ThreadRouteScreenProps) { // Render the full thread chrome (header, feed, composer) as soon as the // thread SHELL is known — no blocking on message detail. The feed shows a - // loading placeholder while messages fetch, and the composer's connection - // pill reports connecting/reconnecting/syncing status. + // loading placeholder while messages fetch, the floating pill above the + // composer reports loading/syncing, and the composer's connection pill + // reports connecting/reconnecting status. if (selectedThread !== null && selectedThreadKey === routeThreadKey) { return ; } diff --git a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx index ddcdf6b290d9..b802c5ac3014 100644 --- a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx +++ b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx @@ -647,7 +647,7 @@ function useThreadSettingsCatalogItems( if (session.providerFilter !== null && group.providerKey !== session.providerFilter) { return []; } - const driver = group.models[0]?.providerDriver; + const driver = group.models[0]?.providerDriver ?? group.providerKey; const catalogModels = session.showLegacy ? group.models : group.models.filter((model) => !model.isLegacy || session.isDisplayed(model)); diff --git a/apps/mobile/src/features/threads/floating-working-control.tsx b/apps/mobile/src/features/threads/floating-working-control.tsx index bdfa19a9eeaf..a62a3c9d17bc 100644 --- a/apps/mobile/src/features/threads/floating-working-control.tsx +++ b/apps/mobile/src/features/threads/floating-working-control.tsx @@ -1,6 +1,6 @@ import { GlassContainer, GlassView } from "expo-glass-effect"; import { useEffect, useState } from "react"; -import { Text as SystemText, View } from "react-native"; +import { ActivityIndicator, Text as SystemText, View } from "react-native"; import Animated, { Easing, FadeIn, @@ -40,9 +40,17 @@ const AnimatedGlassView = Animated.createAnimatedComponent(UniwindGlassView); export const FLOATING_WORKING_CONTROL_COVERAGE = CONTROL_HEIGHT + CONTROL_COMPOSER_GAP; +/** + * What the floating pill says. Syncing and working share one element so the + * label swaps in place instead of one pill fading out for another. + */ +export type FloatingWorkingStatus = + | { readonly kind: "working"; readonly startedAt: string } + | { readonly kind: "syncing"; readonly label: string }; + export function FloatingWorkingControl(props: { readonly colorScheme: "light" | "dark"; - readonly startedAt: string | null; + readonly status: FloatingWorkingStatus | null; readonly showScrollToEnd: boolean; readonly onScrollToEnd: () => void; }) { @@ -62,7 +70,7 @@ export function FloatingWorkingControl(props: { opacity: separationProgress.value, })); - if (props.startedAt === null && !props.showScrollToEnd) { + if (props.status === null && !props.showScrollToEnd) { return null; } @@ -74,7 +82,7 @@ export function FloatingWorkingControl(props: { entering={NATIVE_LIQUID_GLASS_SUPPORTED ? undefined : CONTROL_ENTERING} exiting={NATIVE_LIQUID_GLASS_SUPPORTED ? undefined : CONTROL_EXITING} > - {props.startedAt !== null && NATIVE_LIQUID_GLASS_SUPPORTED ? ( + {props.status !== null && NATIVE_LIQUID_GLASS_SUPPORTED ? ( - + - ) : props.startedAt !== null ? ( + ) : props.status !== null ? ( - + + + {props.status.label} + + ); + } + return ; +} + function WorkingDuration(props: { readonly startedAt: string }) { const [nowMs, setNowMs] = useState(() => Date.now()); diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 38c7ca04a758..97c13de56aab 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -746,49 +746,54 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { ) : thread.branch || props.environmentLabel ? ( /* "branch · machine" share one truncating line. The machine sits last so a tight fit cuts the repetitive label, not the branch — - and machine-only fills the row for non-git projects. */ - - {thread.branch ? ( - - {thread.branch} - - ) : null} - {thread.branch && props.environmentLabel ? " · " : null} - {props.environmentLabel ? ( - - {props.environmentLabel} - + and machine-only fills the row for non-git projects. The glyph + hugs the label (it cannot live inside the Text without breaking + truncation), and the wrapper takes the slack so the trailers + stay pinned right. */ + + + {thread.branch ? ( + + {thread.branch} + + ) : null} + {thread.branch && props.environmentLabel ? " · " : null} + {props.environmentLabel ? ( + + {props.environmentLabel} + + ) : null} + + {props.environmentLabel && props.environmentMachine ? ( + ) : null} - + ) : ( )} - {status !== "failed" && props.environmentLabel && props.environmentMachine ? ( - - ) : null} {pr ? ( + + {window.label} + {used}% used + + + + = 90 + ? "h-full rounded-full bg-destructive" + : used >= 70 + ? "h-full rounded-full bg-warning" + : "h-full rounded-full bg-foreground" + } + style={{ flex: used }} + /> + + + {elapsed !== null ? ( + + ) : null} + + {detail ? {detail} : null} + + ); +} + +function AccountLimits(props: { + readonly label: string; + readonly detail: string | undefined; + readonly limits: ServerProvider["usageLimits"]; + readonly now: number; + readonly first: boolean; +}) { + const { limits, now } = props; + if (!limits) return null; + const notice = limitsNotice(limits); + return ( + + + {props.label} + {props.detail ? ( + {props.detail} + ) : null} + + {notice ? ( + {notice} + ) : ( + limits.windows.map((window) => ) + )} + + ); +} + +function ProviderLimits(props: { + readonly provider: ServerProvider; + readonly now: number; + readonly first: boolean; +}) { + const { provider } = props; + return ( + undefined)} + detail={provider.auth.label} + limits={provider.usageLimits} + now={props.now} + first={props.first} + /> + ); +} + +const DRIVER_LABEL: Partial> = { codex: "Codex", claudeAgent: "Claude" }; + +/** Emails stay off the phone screen; the plan and driver identify the row. */ +function SourceAccountLimits(props: { + readonly account: UsageLimitSourceAccount; + readonly now: number; + readonly first: boolean; +}) { + const { account } = props; + return ( + + ); +} + +/** + * 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. + */ +export function UsageLimitsSection() { + 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; + + return ( + <> + {sources.map((source) => ( + + {source.error ? ( + {source.error} + ) : source.accounts.length === 0 ? ( + No accounts reported. + ) : ( + source.accounts.map((account, index) => ( + + )) + )} + + ))} + {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 817e6d7f9543..6e913b4999d8 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -21,6 +21,7 @@ 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 type { UsageChartMetric } from "./usageChartData"; import { PROVIDER_LABEL, useProviderColors } from "./usageProviders"; @@ -139,6 +140,7 @@ export function UsageRouteScreen() { timeZone={window.timeZone} /> + diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index c2598e46f679..0ffd0c03071f 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -88,6 +88,29 @@ const nativeQuestion = { } as const; describe("pending user input answers", () => { + it("accepts free-text answers to async questions without options", () => { + const question = { + id: "0", + header: "Question", + question: "What should it be named?", + options: [], + allowCustomAnswer: true, + multiSelect: false, + }; + const requested = makeActivity({ + id: EventId.make("async-question"), + kind: "user-input.requested", + summary: "User input requested", + createdAt: "2026-09-03T00:00:00.000Z", + payload: { requestId: "async-1", responseMode: "message", questions: [question] }, + }); + const questions = derivePendingUserInputs([requested])[0]?.questions; + expect(questions).toEqual([question]); + expect(buildPendingUserInputAnswers(questions!, { "0": { customAnswer: "Example" } })).toEqual({ + "0": "Example", + }); + }); + it("preserves native choice values and custom-answer rules from activities", () => { const requested = makeActivity({ id: EventId.make("native-question"), @@ -2121,6 +2144,104 @@ describe("buildThreadFeed", () => { }); describe("quiet timeline: nested agents", () => { + it.each(["task.updated", "task.progress"] as const)( + "does not mark an ordinary task complete when it resumes through %s", + (resumeKind) => { + const thread = makeThread({ + id: ThreadId.make("resumed-agent"), + projectId: ProjectId.make("project-1"), + title: "Resumed agent", + activities: ( + [ + ["task.progress", "running", "Review"], + ["task.updated", "idle", "Task idle"], + [resumeKind, "running", "Review resumed"], + ] as const + ).map(([kind, status, summary], index) => + makeActivity({ + id: EventId.make(`resumed-${index}`), + kind, + summary, + createdAt: `2026-04-01T00:00:0${index + 1}.000Z`, + payload: { + taskId: "agent-1", + agentKind: "agent", + title: "Reviewer", + status, + detail: summary, + }, + }), + ), + }); + const rows = buildThreadFeed(thread).flatMap((entry) => + entry.type === "activity-group" ? entry.activities : [], + ); + expect(rows).toMatchObject([ + { + lifecycleStatus: "inProgress", + summary: "Reviewer", + workEntry: { label: resumeKind === "task.progress" ? "Review resumed" : "Review" }, + }, + ]); + }, + ); + + it.each(["cancelled", "failed", "interrupted"] as const)( + "replaces Antigravity progress with %s without a timeline bypass flag", + (status) => { + const thread = makeThread({ + id: ThreadId.make("antigravity-agents"), + projectId: ProjectId.make("project-1"), + title: "Antigravity subagents", + activities: [ + ...["trajectory:4", "trajectory:5"].map((taskId, index) => + makeActivity({ + id: EventId.make(`progress-${index}`), + kind: "task.progress", + summary: "Antigravity subagent", + createdAt: `2026-04-01T00:00:0${index + 1}.000Z`, + payload: { + taskId, + taskType: "subagent", + agentKind: "agent", + title: "Antigravity subagent", + detail: "Antigravity subagent", + status: "running", + }, + }), + ), + makeActivity({ + id: EventId.make("agent-stopped"), + kind: "task.updated", + summary: `Task ${status}`, + createdAt: "2026-04-01T00:00:03.000Z", + payload: { + taskId: "trajectory:4", + taskType: "subagent", + agentKind: "agent", + title: "Antigravity subagent", + status, + error: "Antigravity process stopped.", + }, + }), + ], + }); + const rows = buildThreadFeed(thread).flatMap((entry) => + entry.type === "activity-group" ? entry.activities : [], + ); + expect(rows).toHaveLength(2); + expect(rows[0]).toMatchObject({ + lifecycleStatus: status === "failed" ? "failed" : "stopped", + detail: "Antigravity process stopped.", + workEntry: { taskId: "trajectory:4", toolTitle: "Antigravity subagent" }, + }); + expect(rows[1]).toMatchObject({ + lifecycleStatus: "inProgress", + workEntry: { taskId: "trajectory:5" }, + }); + }, + ); + it("keeps a nested agent's terminal row but hides its background work", () => { const thread = makeThread({ id: ThreadId.make("thread-nested"), diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index d70c186400fc..796416d80200 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -245,7 +245,7 @@ function parseUserInputQuestions( }; }) .filter((option): option is UserInputQuestion["options"][number] => option !== null); - if (options.length === 0) { + if (options.length === 0 && question.allowCustomAnswer === false) { return null; } return { @@ -321,17 +321,15 @@ function resolvePendingUserInputAnswer( return selectedOptionValues[0] ?? null; } -/** Codex children settle via task.updated (idle/failed/interrupted), never - * task.completed — these rows are mobile's only terminal signal for them. */ +/** Some providers settle agents through task.updated instead of task.completed. */ const MOBILE_TERMINAL_UPDATE_STATUSES: ReadonlySet = new Set([ - "idle", "completed", "failed", "cancelled", "interrupted", ]); -function isTerminalBypassUpdate(activity: OrchestrationThreadActivity): boolean { +function isTerminalTaskUpdate(activity: OrchestrationThreadActivity): boolean { if (activity.kind !== "task.updated") { return false; } @@ -340,9 +338,9 @@ function isTerminalBypassUpdate(activity: OrchestrationThreadActivity): boolean ? (activity.payload as Record) : null; return ( - payload?.timelineBypass === true && - typeof payload.status === "string" && - MOBILE_TERMINAL_UPDATE_STATUSES.has(payload.status) + typeof payload?.status === "string" && + (MOBILE_TERMINAL_UPDATE_STATUSES.has(payload.status) || + (payload.timelineBypass === true && payload.status === "idle")) ); } @@ -351,8 +349,7 @@ function isTerminalBypassUpdate(activity: OrchestrationThreadActivity): boolean * activity lives in the Agents sheet, not the work log. Terminal rows are * kept — with no Agents surface on mobile they are the terminal signal * (a surface that hides rows must keep its own terminal signal). That means - * task.completed (Claude) AND terminal bypassed task.updated (Codex, whose - * children never emit task.completed — review finding). + * task.completed and terminal task.updated, including Antigravity cancellation. */ function isAgentInternalActivity(activity: OrchestrationThreadActivity): boolean { const payload = @@ -362,7 +359,7 @@ function isAgentInternalActivity(activity: OrchestrationThreadActivity): boolean if (!payload) { return false; } - const isTerminalTaskRow = activity.kind === "task.completed" || isTerminalBypassUpdate(activity); + const isTerminalTaskRow = activity.kind === "task.completed" || isTerminalTaskUpdate(activity); if (payload.timelineBypass === true && !isTerminalTaskRow) { return true; } @@ -387,8 +384,7 @@ function deriveWorkLogEntries( if (activity.tone !== "error" && isWorktreeSetupActivity(activity.kind)) continue; if (activity.kind === "tool.started") continue; if (activity.kind === "task.started") continue; - // Terminal bypassed updates pass: Codex children's only terminal signal. - if (activity.kind === "task.updated" && !isTerminalBypassUpdate(activity)) continue; + if (activity.kind === "task.updated" && !isTerminalTaskUpdate(activity)) continue; if (activity.kind === "tool.progress") continue; if (activity.kind === "context-window.updated") continue; if (activity.summary === "Checkpoint captured") continue; @@ -432,9 +428,7 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo const changedFiles = extractChangedFiles(payload); const title = extractToolTitle(payload); const toolPresentation = extractToolActivityPresentation(payload); - // task.updated included: terminal bypassed updates (Codex children's only - // terminal signal) must carry task identity so they collapse per child - // instead of stacking anonymous "Task idle" rows. + // Terminal task updates carry identity so they replace each child's progress row. const isTaskActivity = activity.kind === "task.progress" || activity.kind === "task.completed" || @@ -498,6 +492,9 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo }); if (detail && !repeatsCommand) entry.detail = detail; } + if (isTaskActivity && typeof payload?.error === "string" && payload.error.trim()) { + entry.detail = payload.error; + } if (viewedImagePath) { entry.viewedImagePath = viewedImagePath; } @@ -1099,6 +1096,8 @@ function extractWorkLogToolLifecycleStatus( payload: Record | null, ): WorkLogToolLifecycleStatus | undefined { const status = payload?.status; + if (status === "pending" || status === "running" || status === "waiting") return "inProgress"; + if (status === "cancelled" || status === "interrupted") return "stopped"; if ( status === "inProgress" || status === "completed" || diff --git a/apps/mobile/src/state/server.ts b/apps/mobile/src/state/server.ts index 1b7060571a5b..2157c72e13ef 100644 --- a/apps/mobile/src/state/server.ts +++ b/apps/mobile/src/state/server.ts @@ -7,6 +7,7 @@ import { environmentSession } from "./session"; export const serverEnvironment = createServerEnvironmentAtoms(connectionAtomRuntime, { initialConfigValueAtom: environmentSession.initialConfigValueAtom, + usageLimitSources: true, }); export const environmentServerConfigsAtom = createEnvironmentServerConfigsAtom({ catalogValueAtom: environmentCatalog.catalogValueAtom, diff --git a/apps/server/src/auth/EnvironmentAuthAdmin.test.ts b/apps/server/src/auth/EnvironmentAuthAdmin.test.ts index 331a722534b4..d8a0e3048c51 100644 --- a/apps/server/src/auth/EnvironmentAuthAdmin.test.ts +++ b/apps/server/src/auth/EnvironmentAuthAdmin.test.ts @@ -59,7 +59,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth administrative operations", (it) = expect(listedBeforeRevoke).toHaveLength(1); expect(listedBeforeRevoke[0]?.id).toBe(created.id); expect(listedBeforeRevoke[0]?.label).toBe("CI phone"); - expect(listedBeforeRevoke[0]?.credential).toBe(created.credential); + expect(listedBeforeRevoke[0]).not.toHaveProperty("credential"); expect(revoked).toBe(true); expect(listedAfterRevoke).toHaveLength(0); }).pipe(Effect.provide(makeEnvironmentAuthLayer())), diff --git a/apps/server/src/auth/PairingGrantStore.test.ts b/apps/server/src/auth/PairingGrantStore.test.ts index 5242dd738b89..9a093be41ca4 100644 --- a/apps/server/src/auth/PairingGrantStore.test.ts +++ b/apps/server/src/auth/PairingGrantStore.test.ts @@ -4,6 +4,8 @@ import * as Duration from "effect/Duration"; 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 * as Queue from "effect/Queue"; import * as TestClock from "effect/testing/TestClock"; import * as ServerConfig from "../config.ts"; @@ -194,6 +196,31 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { ), ); + it.effect("keeps credentials out of pairing lists and change events", () => + Effect.gen(function* () { + const grants = yield* PairingGrantStore.PairingGrantStore; + const changes = yield* Queue.unbounded(); + yield* grants.streamChanges.pipe( + Stream.runForEach((change) => Queue.offer(changes, change)), + Effect.forkScoped({ startImmediately: true }), + ); + for (const input of [{}, { label: "Synthetic phone" }]) { + const issued = yield* grants.issueOneTimeToken(input); + const change = yield* Queue.take(changes); + expect(change?.type).toBe("pairingLinkUpserted"); + if (change?.type !== "pairingLinkUpserted") + throw new Error("Expected a pairing link update"); + expect(change.pairingLink.id).toBe(issued.id); + expect(change.pairingLink).not.toHaveProperty("credential"); + const listed = (yield* grants.listActive()).find((link) => link.id === issued.id); + expect(listed).toEqual(change.pairingLink); + const consumed = yield* grants.consume(issued.credential); + expect(consumed.scopes).toEqual(change.pairingLink.scopes); + expect(yield* Queue.take(changes)).toEqual({ type: "pairingLinkRemoved", id: issued.id }); + } + }).pipe(Effect.scoped, Effect.provide(makePairingGrantStoreLayer())), + ); + it.effect("lists and revokes active pairing links", () => Effect.gen(function* () { const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; @@ -205,6 +232,9 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { const activeBeforeRevoke = yield* bootstrapCredentials.listActive(); expect(activeBeforeRevoke.map((entry) => entry.id)).toContain(first.id); expect(activeBeforeRevoke.map((entry) => entry.id)).toContain(second.id); + for (const entry of activeBeforeRevoke) { + expect(entry).not.toHaveProperty("credential"); + } const revoked = yield* bootstrapCredentials.revoke(first.id); const activeAfterRevoke = yield* bootstrapCredentials.listActive(); diff --git a/apps/server/src/auth/PairingGrantStore.ts b/apps/server/src/auth/PairingGrantStore.ts index 057a257ba664..d455d3ac451c 100644 --- a/apps/server/src/auth/PairingGrantStore.ts +++ b/apps/server/src/auth/PairingGrantStore.ts @@ -340,7 +340,6 @@ export const make = Effect.gen(function* () { row.label ? ({ id: row.id, - credential: row.credential, scopes: row.scopes, subject: row.subject, label: row.label, @@ -349,7 +348,6 @@ export const make = Effect.gen(function* () { } satisfies AuthPairingLink) : ({ id: row.id, - credential: row.credential, scopes: row.scopes, subject: row.subject, createdAt: row.createdAt, @@ -424,7 +422,6 @@ export const make = Effect.gen(function* () { ); yield* emitUpsert({ id, - credential, scopes: input?.scopes ?? AuthStandardClientScopes, subject: input?.subject ?? "one-time-token", ...(input?.label ? { label: input.label } : {}), diff --git a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts index 55b6dd973494..cd4309057935 100644 --- a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts +++ b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts @@ -75,6 +75,7 @@ describe("CheckpointDiffQuery.layer", () => { Layer.provideMerge(Layer.succeed(CheckpointStore.CheckpointStore, checkpointStore)), Layer.provideMerge( Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { + getUserInputActivity: () => Effect.die("unused"), getCommandReadModel: () => Effect.die("CheckpointDiffQuery should not request the command read model"), getSnapshot: () => @@ -185,6 +186,7 @@ describe("CheckpointDiffQuery.layer", () => { Layer.provideMerge(Layer.succeed(CheckpointStore.CheckpointStore, checkpointStore)), Layer.provideMerge( Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { + getUserInputActivity: () => Effect.die("unused"), getCommandReadModel: () => Effect.die("CheckpointDiffQuery should not request the command read model"), getSnapshot: () => @@ -270,6 +272,7 @@ describe("CheckpointDiffQuery.layer", () => { Layer.provideMerge(Layer.succeed(CheckpointStore.CheckpointStore, checkpointStore)), Layer.provideMerge( Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { + getUserInputActivity: () => Effect.die("unused"), getCommandReadModel: () => Effect.die("CheckpointDiffQuery should not request the command read model"), getSnapshot: () => @@ -340,6 +343,7 @@ describe("CheckpointDiffQuery.layer", () => { Layer.provideMerge(Layer.succeed(CheckpointStore.CheckpointStore, checkpointStore)), Layer.provideMerge( Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { + getUserInputActivity: () => Effect.die("unused"), getCommandReadModel: () => Effect.die("CheckpointDiffQuery should not request the command read model"), getSnapshot: () => @@ -395,6 +399,7 @@ describe("CheckpointDiffQuery.layer", () => { Layer.provideMerge(Layer.succeed(CheckpointStore.CheckpointStore, checkpointStore)), Layer.provideMerge( Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { + getUserInputActivity: () => Effect.die("unused"), getCommandReadModel: () => Effect.die("CheckpointDiffQuery should not request the command read model"), getSnapshot: () => diff --git a/apps/server/src/checkpointing/CheckpointStore.test.ts b/apps/server/src/checkpointing/CheckpointStore.test.ts index bf332d20d0da..2f46858986aa 100644 --- a/apps/server/src/checkpointing/CheckpointStore.test.ts +++ b/apps/server/src/checkpointing/CheckpointStore.test.ts @@ -147,6 +147,37 @@ it.layer(TestLayer)("CheckpointStore.layer", (it) => { }), ); + it.effect("keeps a/ and b/ patch prefixes when the repository disables them", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + yield* git(tmp, ["config", "diff.noprefix", "true"]); + const checkpointStore = yield* CheckpointStore.CheckpointStore; + const threadId = ThreadId.make("thread-checkpoint-store-noprefix"); + const fromCheckpointRef = checkpointRefForThreadTurn(threadId, 0); + const toCheckpointRef = checkpointRefForThreadTurn(threadId, 1); + + yield* checkpointStore.captureCheckpoint({ + cwd: tmp, + checkpointRef: fromCheckpointRef, + }); + yield* writeTextFile(NodePath.join(tmp, "README.md"), "# changed\n"); + yield* checkpointStore.captureCheckpoint({ + cwd: tmp, + checkpointRef: toCheckpointRef, + }); + + const diff = yield* checkpointStore.diffCheckpoints({ + cwd: tmp, + fromCheckpointRef, + toCheckpointRef, + ignoreWhitespace: false, + }); + + expect(diff).toContain("diff --git a/README.md b/README.md"); + }), + ); + it.effect("can hide indentation churn when changes wrap existing lines", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); diff --git a/apps/server/src/cliAuthFormat.test.ts b/apps/server/src/cliAuthFormat.test.ts index a428adc7bea6..3822919a9276 100644 --- a/apps/server/src/cliAuthFormat.test.ts +++ b/apps/server/src/cliAuthFormat.test.ts @@ -30,7 +30,6 @@ it("formats pairing listings without exposing the secret token", () => { [ { id: "pairing-1", - credential: "secret-pairing-token", subject: "one-time-token", label: "Phone", scopes: ["orchestration:read"], diff --git a/apps/server/src/cloud/ManagedEndpointRuntime.test.ts b/apps/server/src/cloud/ManagedEndpointRuntime.test.ts index b45b5099252a..ba2cf5c5ac05 100644 --- a/apps/server/src/cloud/ManagedEndpointRuntime.test.ts +++ b/apps/server/src/cloud/ManagedEndpointRuntime.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; import { vi } from "vite-plus/test"; import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; @@ -8,6 +9,7 @@ import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import * as RelayClient from "@t3tools/shared/relayClient"; @@ -283,6 +285,126 @@ describe("CloudManagedEndpointRuntime", () => { }), ); + const makeCrashLoopSpawner = (basePid: number, slots: number) => + Effect.gen(function* () { + const spawned: Array = []; + const exits: Array> = []; + const spawnSignals: Array> = []; + for (let index = 0; index < slots; index += 1) { + exits.push(yield* Deferred.make()); + spawnSignals.push(yield* Deferred.make()); + } + const spawner = ChildProcessSpawner.make(() => + Effect.gen(function* () { + const index = spawned.length; + spawned.push(basePid + index); + yield* Deferred.succeed(spawnSignals[index]!, undefined); + const handle = makeHandle({ + pid: basePid + index, + exitCode: Deferred.await(exits[index]!), + onKill: () => {}, + }); + yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore)); + return handle; + }), + ); + return { spawner, spawned, exits, spawnSignals }; + }); + + it.effect("backs off restarts while the connector crash-loops", () => + Effect.gen(function* () { + const { spawner, spawned, exits, spawnSignals } = yield* makeCrashLoopSpawner(600, 4); + const runtime = yield* buildCloudManagedEndpointRuntime(spawner); + + yield* runtime.applyConfig({ + providerKind: "cloudflare_tunnel", + connectorToken: "token", + tunnelId: "tunnel-1", + }); + expect(spawned).toEqual([600]); + + // The first crash restarts immediately. + yield* Deferred.succeed(exits[0]!, ChildProcessSpawner.ExitCode(1)); + yield* Deferred.await(spawnSignals[1]!); + expect(spawned).toEqual([600, 601]); + + // The second rapid crash waits out the base delay before restarting. + yield* Deferred.succeed(exits[1]!, ChildProcessSpawner.ExitCode(1)); + yield* TestClock.adjust(Duration.millis(999)); + expect(spawned).toEqual([600, 601]); + yield* TestClock.adjust(Duration.millis(1)); + yield* Deferred.await(spawnSignals[2]!); + expect(spawned).toEqual([600, 601, 602]); + + // The third rapid crash doubles the delay. + yield* Deferred.succeed(exits[2]!, ChildProcessSpawner.ExitCode(1)); + yield* TestClock.adjust(Duration.millis(1999)); + expect(spawned).toEqual([600, 601, 602]); + yield* TestClock.adjust(Duration.millis(1)); + yield* Deferred.await(spawnSignals[3]!); + expect(spawned).toEqual([600, 601, 602, 603]); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("resets the backoff after the connector runs stably", () => + Effect.gen(function* () { + const { spawner, spawned, exits, spawnSignals } = yield* makeCrashLoopSpawner(700, 5); + const runtime = yield* buildCloudManagedEndpointRuntime(spawner); + + yield* runtime.applyConfig({ + providerKind: "cloudflare_tunnel", + connectorToken: "token", + }); + + // One rapid crash arms the backoff. + yield* Deferred.succeed(exits[0]!, ChildProcessSpawner.ExitCode(1)); + yield* Deferred.await(spawnSignals[1]!); + + // The replacement stays up past the stable-uptime window, so its exit + // restarts immediately and the backoff starts over. + yield* TestClock.adjust(Duration.millis(30_000)); + yield* Deferred.succeed(exits[1]!, ChildProcessSpawner.ExitCode(1)); + yield* Deferred.await(spawnSignals[2]!); + yield* Deferred.succeed(exits[2]!, ChildProcessSpawner.ExitCode(1)); + yield* Deferred.await(spawnSignals[3]!); + + // The next rapid crash waits the base delay again, not a doubled one. + yield* Deferred.succeed(exits[3]!, ChildProcessSpawner.ExitCode(1)); + yield* TestClock.adjust(Duration.millis(999)); + expect(spawned).toEqual([700, 701, 702, 703]); + yield* TestClock.adjust(Duration.millis(1)); + yield* Deferred.await(spawnSignals[4]!); + expect(spawned).toEqual([700, 701, 702, 703, 704]); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("an explicit config change clears the backoff and preempts a delayed restart", () => + Effect.gen(function* () { + const { spawner, spawned, exits, spawnSignals } = yield* makeCrashLoopSpawner(800, 3); + const runtime = yield* buildCloudManagedEndpointRuntime(spawner); + + yield* runtime.applyConfig({ + providerKind: "cloudflare_tunnel", + connectorToken: "token-1", + }); + yield* Deferred.succeed(exits[0]!, ChildProcessSpawner.ExitCode(1)); + yield* Deferred.await(spawnSignals[1]!); + + // Leave the supervisor sleeping on the base delay, then change config. + yield* Deferred.succeed(exits[1]!, ChildProcessSpawner.ExitCode(1)); + const status = yield* runtime.applyConfig({ + providerKind: "cloudflare_tunnel", + connectorToken: "token-2", + }); + expect(status).toMatchObject({ status: "running", pid: 802 }); + expect(spawned).toEqual([800, 801, 802]); + + // The preempted supervisor wakes later and must not spawn a duplicate. + yield* TestClock.adjust(Duration.millis(60_000)); + expect(spawned).toEqual([800, 801, 802]); + }).pipe(Effect.provide(TestClock.layer())), + ); + it.effect("serializes concurrent connector config changes", () => Effect.gen(function* () { const spawned: Array = []; diff --git a/apps/server/src/cloud/ManagedEndpointRuntime.ts b/apps/server/src/cloud/ManagedEndpointRuntime.ts index 89c0a23783c0..564d7346d036 100644 --- a/apps/server/src/cloud/ManagedEndpointRuntime.ts +++ b/apps/server/src/cloud/ManagedEndpointRuntime.ts @@ -1,6 +1,8 @@ import type { RelayManagedEndpointRuntimeConfig } from "@t3tools/contracts/relay"; import * as RelayClient from "@t3tools/shared/relayClient"; +import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; @@ -66,8 +68,18 @@ interface ActiveConnector { readonly scope: Scope.Closeable; readonly configKey: string; readonly config: RelayManagedEndpointRuntimeConfig; + readonly startedAtMillis: number; } +// A connector that exits before running this long is treated as part of a +// crash loop; one that stays up at least this long earns an immediate restart +// again. Without the backoff below, a relay client that fails instantly (a +// stale version-manager shim, a bad binary) respawns ~100 times per second +// until the accumulated tracing exhausts the V8 heap. +const RELAY_RESTART_STABLE_UPTIME_MS = 30_000; +const RELAY_RESTART_BACKOFF_BASE_MS = 1_000; +const RELAY_RESTART_BACKOFF_MAX_MS = 60_000; + export function classifyRelayClientOutput(line: string): "connected" | "warning" | "debug" { if (/\bRegistered tunnel connection\b/iu.test(line)) { return "connected"; @@ -105,6 +117,7 @@ export const make = Effect.gen(function* () { const activeRef = yield* Ref.make(null); const desiredConfigRef = yield* Ref.make(null); const reconcileSemaphore = yield* Semaphore.make(1); + const restartDelayRef = yield* Ref.make(0); let reconcileConfig: CloudManagedEndpointRuntime["Service"]["applyConfig"]; const stopActive = Effect.gen(function* () { @@ -115,6 +128,39 @@ export const make = Effect.gen(function* () { const superviseConnector = (connector: ActiveConnector) => Effect.gen(function* () { const result = yield* Effect.result(connector.child.exitCode); + const activeAtExit = yield* Ref.get(activeRef); + if ( + activeAtExit?.child.pid !== connector.child.pid || + activeAtExit.configKey !== connector.configKey + ) { + return; + } + const uptimeMillis = (yield* Clock.currentTimeMillis) - connector.startedAtMillis; + // The first crash restarts immediately; every further crash inside the + // stable-uptime window doubles the wait, up to the cap. The delay runs + // before the semaphore so a user config change is never blocked behind + // it, and reconcileConfig re-checks the desired config afterwards. + const restartDelayMillis = yield* Ref.modify(restartDelayRef, (current) => { + if (uptimeMillis >= RELAY_RESTART_STABLE_UPTIME_MS) { + return [0, 0]; + } + return [ + current, + current === 0 + ? RELAY_RESTART_BACKOFF_BASE_MS + : Math.min(current * 2, RELAY_RESTART_BACKOFF_MAX_MS), + ]; + }); + if (restartDelayMillis > 0) { + yield* Effect.logWarning("Relay client is crash-looping; delaying restart", { + pid: Number(connector.child.pid), + uptimeMillis, + restartDelayMillis, + tunnelId: connector.config.tunnelId, + tunnelName: connector.config.tunnelName, + }); + yield* Effect.sleep(Duration.millis(restartDelayMillis)); + } yield* reconcileSemaphore.withPermits(1)( Effect.gen(function* () { const active = yield* Ref.get(activeRef); @@ -274,6 +320,7 @@ export const make = Effect.gen(function* () { scope: connectorScope, configKey: nextConfigKey, config, + startedAtMillis: yield* Clock.currentTimeMillis, } satisfies ActiveConnector; yield* Ref.set(activeRef, connector); yield* Effect.forkIn(observeConnectorOutput(connector), connectorScope); @@ -299,7 +346,11 @@ export const make = Effect.gen(function* () { const applyConfig = Effect.fn("CloudManagedEndpointRuntime.applyConfig")( (config: RelayManagedEndpointRuntimeConfig | null) => reconcileSemaphore.withPermits(1)( - Ref.set(desiredConfigRef, config).pipe(Effect.andThen(reconcileConfig(config))), + // An explicit config change starts over with a fresh backoff. + Ref.set(restartDelayRef, 0).pipe( + Effect.andThen(Ref.set(desiredConfigRef, config)), + Effect.andThen(reconcileConfig(config)), + ), ), ); diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 1010011e90cd..d050bde88565 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -221,6 +221,7 @@ export const make = Effect.gen(function* () { threadAutoSettlement: true, threadSnooze: true, environmentThemes: true, + usageLimitSources: true, threadPinning: true, threadPinReorder: true, threadTitleRegeneration: true, diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 393a8fd05592..d65417e99e13 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -120,7 +120,13 @@ const SHORT_SHA_LENGTH = 7; const TOAST_DESCRIPTION_MAX = 72; const STATUS_RESULT_CACHE_TTL = Duration.seconds(1); const STATUS_RESULT_CACHE_CAPACITY = 2_048; -const PR_LOOKUP_CACHE_TTL = Duration.minutes(2); +// Matches the automatic settlement sweep cadence so every background sweep +// reads fresh branch state: an external merge settles within about a minute +// instead of waiting out a longer cache. Unpublished branches never reach the +// host (a local probe answers first), and failed lookups still back off +// exponentially via prLookupFailureTtl, so throttling pressure still drops +// under 429s instead of amplifying it. +const PR_LOOKUP_CACHE_TTL = Duration.seconds(60); const PR_LOOKUP_FAILURE_BASE_TTL = Duration.seconds(20); const PR_LOOKUP_FAILURE_MAX_TTL = Duration.minutes(15); const PR_LOOKUP_CACHE_CAPACITY = 2_048; diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index a4c7942cfc18..d3d766084a28 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -1,4 +1,11 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + import { + ApprovalRequestId, + EventId, CheckpointRef, CommandId, DEFAULT_PROVIDER_INTERACTION_MODE, @@ -25,7 +32,10 @@ import { PersistenceSqlError } from "../../persistence/Errors.ts"; import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts"; import * as OrchestrationCommandReceipts from "../../persistence/Services/OrchestrationCommandReceipts.ts"; import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; -import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { + makeSqlitePersistenceLive, + SqlitePersistenceMemory, +} from "../../persistence/Layers/Sqlite.ts"; import { OrchestrationEventStore, type OrchestrationEventStoreShape, @@ -49,7 +59,10 @@ const asMessageId = (value: string): MessageId => MessageId.make(value); const asTurnId = (value: string): TurnId => TurnId.make(value); const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.make(value); -function makeOrchestrationLayer() { +function makeOrchestrationLayer(databasePath?: string) { + const persistence = databasePath + ? makeSqlitePersistenceLive(databasePath) + : SqlitePersistenceMemory; const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { prefix: "t3-orchestration-engine-test-", }); @@ -65,19 +78,21 @@ function makeOrchestrationLayer() { Layer.provide(OrchestrationEventStoreLive), Layer.provideMerge(OrchestrationCommandReceiptRepositoryLive), Layer.provide(RepositoryIdentityResolver.layer), - Layer.provide(SqlitePersistenceMemory), + Layer.provide(persistence), Layer.provideMerge(ServerConfigLayer), Layer.provideMerge(NodeServices.layer), ); } -async function createOrchestrationSystem() { - const runtime = ManagedRuntime.make(makeOrchestrationLayer()); +async function createOrchestrationSystem(databasePath?: string) { + const runtime = ManagedRuntime.make(makeOrchestrationLayer(databasePath)); const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService)); const snapshotQuery = await runtime.runPromise(Effect.service(ProjectionSnapshotQuery)); return { engine, readModel: () => runtime.runPromise(snapshotQuery.getSnapshot()), + readThread: (threadId: ThreadId) => + runtime.runPromise(snapshotQuery.getThreadDetailById(threadId)), run: (effect: Effect.Effect) => runtime.runPromise(effect), dispose: () => runtime.dispose(), }; @@ -99,6 +114,196 @@ const hasMetricSnapshot = ( ); describe("OrchestrationEngine", () => { + it.each(["running", "stopped"] as const)( + "sends async answers with a %s session and rejects old duplicate replies", + async (status) => { + const directory = await NodeFSP.mkdtemp( + NodePath.join(NodeOS.tmpdir(), "t3-async-questions-"), + ); + const databasePath = NodePath.join(directory, "state.sqlite"); + let system = await createOrchestrationSystem(databasePath); + const threadId = ThreadId.make("async-thread"); + const projectId = ProjectId.make("async-project"); + const requestId = ApprovalRequestId.make("codex-async:question-1"); + try { + await system.run( + system.engine.dispatch({ + type: "project.create", + commandId: CommandId.make("async-project"), + projectId, + title: "Async questions", + workspaceRoot: "/tmp/async-questions", + createdAt: now(), + }), + ); + await system.run( + system.engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("async-thread"), + threadId, + projectId, + title: "Async questions", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: now(), + }), + ); + await system.run( + system.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("async-session"), + threadId, + createdAt: now(), + session: { + threadId, + status, + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: status === "running" ? TurnId.make("turn-1") : null, + lastError: null, + updatedAt: now(), + }, + }), + ); + await system.run( + system.engine.dispatch({ + type: "thread.activity.append", + commandId: CommandId.make("async-question"), + threadId, + createdAt: now(), + activity: { + id: EventId.make("async-question"), + kind: "user-input.requested", + summary: "User input requested", + tone: "info", + turnId: TurnId.make("turn-1"), + createdAt: now(), + payload: { + requestId, + responseMode: "message", + questions: [ + { + id: "0", + header: "Question", + question: "Which package manager?", + options: [{ label: "pnpm", description: "" }], + }, + { + id: "1", + header: "Question", + question: "What should it be named?", + options: [], + }, + ], + }, + }, + }), + ); + const appendWork = async (prefix: string, createdAt: string) => { + for (let index = 0; index < 501; index += 1) { + await system.run( + system.engine.dispatch({ + type: "thread.activity.append", + commandId: CommandId.make(`${prefix}-${index}`), + threadId, + createdAt, + activity: { + id: EventId.make(`${prefix}-${index}`), + kind: "tool.completed", + summary: "Work continued", + payload: {}, + tone: "info", + turnId: TurnId.make("turn-1"), + createdAt, + }, + }), + ); + } + }; + await appendWork("work", "2026-01-01T00:00:01.000Z"); + const before = await system.readModel(); + expect( + before.threads[0]?.activities.some((activity) => activity.id === "async-question"), + ).toBe(true); + if (status === "stopped") { + await system.dispose(); + system = await createOrchestrationSystem(databasePath); + } + const response = { + type: "thread.user-input.respond" as const, + commandId: CommandId.make("async-response"), + threadId, + requestId, + answers: { "0": "pnpm", "1": "Example" }, + createdAt: "2026-01-01T00:00:02.000Z", + }; + await expect( + system.run( + system.engine.dispatch({ + ...response, + commandId: CommandId.make("incomplete-answer"), + answers: { "0": "pnpm" }, + }), + ), + ).rejects.toThrow("Answer each question before sending."); + await system.run(system.engine.dispatch(response)); + const after = await system.readModel(); + const userMessages = after.threads[0]?.messages.filter( + (message) => message.role === "user", + ); + expect(userMessages).toHaveLength(1); + expect(userMessages?.[0]?.text).toBe( + "Which package manager?\npnpm\n\nWhat should it be named?\nExample", + ); + expect( + after.threads[0]?.activities.find((activity) => activity.kind === "user-input.resolved") + ?.payload, + ).toMatchObject({ requestId, responseMode: "message", answers: response.answers }); + const events = await system.run(Stream.runCollect(system.engine.readEvents(0))); + expect( + Array.from(events) + .filter((event) => event.commandId === response.commandId) + .map((event) => event.type), + ).toEqual([ + "thread.activity-appended", + "thread.message-sent", + "thread.turn-start-requested", + ]); + await expect( + system.run( + system.engine.dispatch({ + ...response, + commandId: CommandId.make("second-client-reply"), + }), + ), + ).rejects.toThrow("This question has already been answered."); + await appendWork("later-work", "2026-01-01T00:00:03.000Z"); + const afterEviction = Option.getOrThrow(await system.readThread(threadId)); + expect( + afterEviction.activities.some((activity) => activity.kind === "user-input.resolved"), + ).toBe(false); + if (status === "stopped") { + await system.dispose(); + system = await createOrchestrationSystem(databasePath); + } + await expect( + system.run( + system.engine.dispatch({ + ...response, + commandId: CommandId.make("reply-after-eviction"), + }), + ), + ).rejects.toThrow("This question has already been answered."); + } finally { + await system.dispose(); + await NodeFSP.rm(directory, { recursive: true, force: true }); + } + }, + ); + it("bootstraps command handling from persisted projections without reading the full snapshot", async () => { let nextSequence = 8; const eventStore: OrchestrationEventStoreShape = { @@ -183,6 +388,7 @@ describe("OrchestrationEngine", () => { const layer = OrchestrationEngineLive.pipe( Layer.provide( Layer.succeed(ProjectionSnapshotQuery, { + getUserInputActivity: () => Effect.die("unused"), getCommandReadModel: () => Effect.succeed(commandReadModel), getSnapshot: () => Effect.sync(() => { diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts index 741e0fac7195..792dd0da3900 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts @@ -195,9 +195,18 @@ const makeOrchestrationEngine = Effect.gen(function* () { }); } + // Command snapshots omit activities at startup and cap them while running. + // Read this request's durable state before deciding how to send the answer. + const userInputActivity = + envelope.command.type === "thread.user-input.respond" + ? yield* projectionSnapshotQuery.getUserInputActivity(envelope.command) + : Option.none(); const eventBase = yield* decideOrchestrationCommand({ command: envelope.command, readModel: commandReadModel, + ...(Option.isSome(userInputActivity) + ? { userInputActivity: userInputActivity.value } + : {}), }).pipe( Effect.provideService(Crypto.Crypto, crypto), Effect.mapError((cause) => diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 24c7003204eb..0d064a1d6e9c 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -1,4 +1,5 @@ import { + ApprovalRequestId, ChatAttachment, CheckpointRef, IsoDateTime, @@ -1119,6 +1120,40 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + const getUserInputActivityRow = SqlSchema.findOneOption({ + Request: Schema.Struct({ threadId: ThreadId, requestId: ApprovalRequestId }), + Result: ProjectionThreadActivityDbRowSchema, + execute: ({ threadId, requestId }) => sql` + SELECT + activity_id AS "activityId", + thread_id AS "threadId", + turn_id AS "turnId", + tone, + kind, + summary, + payload_json AS "payload", + sequence, + created_at AS "createdAt" + FROM projection_thread_activities + WHERE thread_id = ${threadId} + AND kind IN ('user-input.requested', 'user-input.resolved') + AND json_extract(payload_json, '$.requestId') = ${requestId} + ORDER BY sequence DESC, created_at DESC, activity_id DESC + LIMIT 1 + `, + }); + + const getUserInputActivity: ProjectionSnapshotQueryShape["getUserInputActivity"] = (input) => + getUserInputActivityRow(input).pipe( + Effect.map(Option.map(mapThreadActivityRow)), + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getUserInputActivity:query", + "ProjectionSnapshotQuery.getUserInputActivity:decodeRow", + ), + ), + ); + const listThreadActivityIdsByThread = SqlSchema.findAll({ Request: ThreadIdLookupInput, Result: ProjectionThreadActivityIdRowSchema, @@ -3151,6 +3186,7 @@ pending_approval_requests AS ( return { getCommandReadModel, + getUserInputActivity, getSnapshot, getShellSnapshot, getArchivedShellSnapshot, diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 0d8c4f874909..b490d44726c0 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -2108,6 +2108,56 @@ describe("ProviderRuntimeIngestion", () => { expect(message?.streaming).toBe(false); }); + it("keeps streaming while an async question is pending", async () => { + const harness = await createHarness({ serverSettings: { enableLegacyTokenStreaming: true } }); + const base = { + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-async"), + }; + harness.emit({ ...base, type: "turn.started", eventId: asEventId("async-start") }); + harness.emit({ + ...base, + type: "content.delta", + eventId: asEventId("async-before"), + itemId: asItemId("message-1"), + payload: { streamKind: "assistant_text", delta: "Before. " }, + }); + harness.emit({ + ...base, + type: "user-input.requested", + eventId: asEventId("async-request"), + requestId: ApprovalRequestId.make("codex-async:question-1"), + payload: { + responseMode: "message", + questions: [ + { + id: "0", + header: "Question", + question: "Which name?", + options: [], + allowCustomAnswer: true, + }, + ], + }, + }); + harness.emit({ + ...base, + type: "content.delta", + eventId: asEventId("async-after"), + itemId: asItemId("message-1"), + payload: { streamKind: "assistant_text", delta: "After." }, + }); + await harness.drain(); + const thread = (await harness.readModel()).threads[0]; + expect(thread?.session?.status).toBe("running"); + expect(thread?.messages).toMatchObject([{ text: "Before. After.", streaming: true }]); + expect( + thread?.activities.find((activity) => activity.kind === "user-input.requested")?.payload, + ).toMatchObject({ responseMode: "message", requestId: "codex-async:question-1" }); + }); + it("does not create assistant segments for whitespace-only buffered text at approval boundaries", async () => { const harness = await createHarness(); const startedAt = "2026-03-28T06:28:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index d1a64544ccac..a503a5eebbcf 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -520,6 +520,7 @@ export function runtimeEventToActivities( payload: { ...(event.requestId ? { requestId: event.requestId } : {}), questions: event.payload.questions, + ...(event.payload.responseMode ? { responseMode: event.payload.responseMode } : {}), }, turnId: toTurnId(event.turnId) ?? null, ...maybeSequence, @@ -1739,7 +1740,8 @@ const make = Effect.gen(function* () { } const pauseForUserTurnId = - event.type === "request.opened" || event.type === "user-input.requested" + event.type === "request.opened" || + (event.type === "user-input.requested" && event.payload.responseMode !== "message") ? toTurnId(event.turnId) : undefined; if (pauseForUserTurnId) { diff --git a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts index 5ad9ceef1d2a..0854492e255b 100644 --- a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts @@ -7,6 +7,7 @@ * @module ProjectionSnapshotQuery */ import type { + ApprovalRequestId, CheckpointRef, OrchestrationCheckpointSummary, OrchestrationProject, @@ -16,6 +17,7 @@ import type { OrchestrationSearchThreadsResult, OrchestrationShellSnapshot, OrchestrationThread, + OrchestrationThreadActivity, OrchestrationThreadDetailSnapshot, OrchestrationThreadDetailWindow, OrchestrationThreadShell, @@ -72,6 +74,12 @@ export interface ProjectionThreadDetailQuery { * ProjectionSnapshotQueryShape - Service API for read-model snapshots. */ export interface ProjectionSnapshotQueryShape { + /** Read the latest request or resolution without loading the thread history. */ + readonly getUserInputActivity: (input: { + readonly threadId: ThreadId; + readonly requestId: ApprovalRequestId; + }) => Effect.Effect, ProjectionRepositoryError>; + /** * Read the lightweight command snapshot used to bootstrap the in-memory * orchestration engine without hydrating message/activity/checkpoint bodies. diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts index f6264200d2f5..c7d795175262 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts @@ -161,6 +161,7 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: }> >([]); const summaryRecovery = yield* Ref.make>([]); + const invalidatedCwds = yield* Ref.make>([]); const updateSettings = (patch: ServerSettingsPatch) => Effect.gen(function* () { @@ -221,7 +222,10 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: Effect.andThen(Ref.get(snapshots)), ), }), - Layer.mock(GitManager)({ branchPullRequest }), + Layer.mock(GitManager)({ + branchPullRequest, + invalidateStatus: (cwd) => Ref.update(invalidatedCwds, (cwds) => [...cwds, cwd]), + }), Layer.mock(PullRequestService)({ summary: pullRequestSummary, subscribeMerges: PubSub.subscribe(mergedPullRequests).pipe( @@ -251,6 +255,7 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: branchCalls, summaryCalls, summaryRecovery, + invalidatedCwds, updateSettings, publishMerge: PubSub.publish(mergedPullRequests, { projectId: PROJECT_ID, @@ -446,6 +451,113 @@ describe("ThreadSettlementReactor", () => { ), ); + it.effect( + "settles branch threads on a pull request merge without waiting for the next sweep", + () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const state = yield* Ref.make<"open" | "merged">("open"); + const mergedThreadSettled = yield* Deferred.make(); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([makeThread("branch-thread", { branch: "saved-feature" })]), + branchPullRequest: () => + Ref.get(state).pipe( + Effect.map((pullRequestState) => ({ state: pullRequestState, updatedAt: NOW })), + ), + onDispatch: () => Deferred.succeed(mergedThreadSettled, undefined), + }); + + yield* Effect.gen(function* () { + const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; + yield* startHarness(reactor, fixture.activation, fixture.snapshotReads); + assert.deepStrictEqual(yield* Ref.get(fixture.commands), []); + assert.deepStrictEqual(yield* Ref.get(fixture.invalidatedCwds), []); + + yield* Ref.set(state, "merged"); + yield* fixture.publishMerge; + yield* Deferred.await(mergedThreadSettled); + + assert.deepStrictEqual( + (yield* Ref.get(fixture.commands)).map((command) => command.threadId), + [ThreadId.make("branch-thread")], + ); + assert.deepStrictEqual(yield* Ref.get(fixture.invalidatedCwds), ["/workspace/project"]); + yield* reactor.drain; + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("a merge does not settle threads linked to an unrelated pull request", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const mergedThreadSettled = yield* Deferred.make(); + const mergeLookupStarted = yield* Deferred.make(); + const releaseMergeLookup = yield* Deferred.make(); + const lookupCount = yield* Ref.make(0); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([ + makeThread("merged-in-app", { + linkedPullRequest: { + projectId: PROJECT_ID, + repository: "owner/repository", + number: 42, + url: "https://example.test/owner/repository/pull/42", + }, + }), + makeThread("unrelated-linked", { + linkedPullRequest: { + projectId: PROJECT_ID, + repository: "owner/repository", + number: 99, + url: "https://example.test/owner/repository/pull/99", + }, + }), + ]), + pullRequestSummary: (input) => + Ref.updateAndGet(lookupCount, (count) => count + 1).pipe( + // The initial sweep looks up both linked threads; the merge + // sweep only looks up the unrelated one, since the merged + // thread settles from the event itself. + Effect.tap((count) => + count === 3 ? Deferred.succeed(mergeLookupStarted, undefined) : Effect.void, + ), + Effect.tap((count) => + count === 3 ? Deferred.await(releaseMergeLookup) : Effect.void, + ), + Effect.map(() => makePullRequestSummary({ ...input, state: "open" })), + ), + onDispatch: () => Deferred.succeed(mergedThreadSettled, undefined), + }); + + yield* Effect.gen(function* () { + const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; + yield* startHarness(reactor, fixture.activation, fixture.snapshotReads); + assert.deepStrictEqual(yield* Ref.get(fixture.commands), []); + + yield* fixture.publishMerge; + yield* Deferred.await(mergeLookupStarted); + yield* Deferred.await(mergedThreadSettled); + yield* Deferred.succeed(releaseMergeLookup, undefined); + yield* reactor.drain; + + assert.deepStrictEqual( + (yield* Ref.get(fixture.commands)).map((command) => command.threadId), + [ThreadId.make("merged-in-app")], + ); + assert.deepStrictEqual( + (yield* Ref.get(fixture.summaryCalls)) + .map((call) => call.number) + .toSorted((left, right) => left - right), + [42, 99, 99], + ); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + it.effect("uses fresh settlement settings after lookup and ignores unrelated changes", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.ts b/apps/server/src/orchestration/ThreadSettlementReactor.ts index 9867a855a85e..6539135adfe6 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.ts @@ -46,16 +46,11 @@ export const make = Effect.gen(function* () { const snapshot = yield* snapshots.getShellSnapshot(); const now = DateTime.formatIso(yield* DateTime.now); const projects = new Map(snapshot.projects.map((project) => [project.id, project])); - const candidates = snapshot.threads.filter( - (thread) => - isAutoSettlementCandidate(thread, now) && - (mergedPullRequest === null || - (thread.linkedPullRequest != null && - thread.linkedPullRequest.projectId === mergedPullRequest.projectId && - thread.linkedPullRequest.repository.toLowerCase() === - mergedPullRequest.repository.toLowerCase() && - thread.linkedPullRequest.number === mergedPullRequest.number)), - ); + // A merge event re-sweeps every candidate, not just the threads linked to + // the merged pull request: most threads carry no link and settle from + // their branch lookup, which would otherwise wait for the next minute's + // sweep on a possibly stale cached answer. + const candidates = snapshot.threads.filter((thread) => isAutoSettlementCandidate(thread, now)); // Use the same cwd as the sidebar so both paths share GitManager's PR cache. const lookupCwdByThreadId = new Map(); yield* Effect.forEach( @@ -76,6 +71,19 @@ export const make = Effect.gen(function* () { }), { concurrency: 8, discard: true }, ); + if (mergedPullRequest !== null) { + // The merge just confirmed a terminal state the lookup caches can still + // call open (branch answers live two minutes, the sweep runs every + // minute). Drop the swept checkouts' cached answers so the merge settles + // its branch threads now instead of on a later sweep. Threads linked to + // the merged pull request settle from the event itself below and need no + // lookup, so they are absent from this map by construction. + const cwds = [...new Set(lookupCwdByThreadId.values())]; + yield* Effect.forEach(cwds, (cwd) => git.invalidateStatus(cwd), { + concurrency: 8, + discard: true, + }); + } const lookupKey = (thread: (typeof candidates)[number]) => { if (thread.linkedPullRequest != null) { return JSON.stringify([ @@ -97,7 +105,18 @@ export const make = Effect.gen(function* () { thread: (typeof candidates)[number], ) { if (thread.linkedPullRequest != null) { - if (mergedPullRequest !== null) { + // The event carries the merged state, so only the threads linked to + // that exact pull request settle from it. Every other linked thread + // falls through to a fresh summary lookup below: the merge sweep + // covers all candidates, and an unrelated merge must never settle + // them. + if ( + mergedPullRequest !== null && + thread.linkedPullRequest.projectId === mergedPullRequest.projectId && + thread.linkedPullRequest.repository.toLowerCase() === + mergedPullRequest.repository.toLowerCase() && + thread.linkedPullRequest.number === mergedPullRequest.number + ) { return { state: "merged", updatedAt: mergedPullRequest.mergedAt, diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 279413f669c1..b336053ac9e1 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -1,13 +1,19 @@ import { EventId, + MessageId, + UserInputRequestedPayload, type OrchestrationCommand, type OrchestrationEvent, type OrchestrationReadModel, type OrchestrationThread, + type OrchestrationThreadActivity, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as Option from "effect/Option"; +import * as Predicate from "effect/Predicate"; import type * as PlatformError from "effect/PlatformError"; import { @@ -29,6 +35,7 @@ import { projectEvent } from "./projector.ts"; import { threadHasQueuedTurnStart } from "./ThreadSettlementPolicy.ts"; const nowIso = Effect.map(DateTime.now, DateTime.formatIso); +const decodeUserInputRequestedPayload = Schema.decodeUnknownOption(UserInputRequestedPayload); /** * Blocked-on-you work derived from the thread's retained activities: an @@ -55,12 +62,8 @@ function isStaleRequestFailureDetail(payload: Record | null): b } // Scans the read model's activities, which the projector caps at the most -// recent 500. That bound is safe here: an OPEN approval/user-input request -// blocks its turn, so the thread cannot accumulate hundreds of later -// activities while one is outstanding — a request that has scrolled out of -// the window is one whose turn kept running, i.e. it was resolved or went -// stale. (The projection pipeline's pendingApprovalCount reads the same -// capped stream and stays consistent with this view.) +// recent 500 plus pending async questions. Async questions remain actionable +// while the agent works, so they must not expire with the activity window. function hasOpenBlockingRequest(thread: { readonly activities: ReadonlyArray<{ readonly kind: string; readonly payload: unknown }>; }): boolean { @@ -185,9 +188,11 @@ const decideCommandSequence = Effect.fn("decideCommandSequence")(function* ({ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand")(function* ({ command, readModel, + userInputActivity, }: { readonly command: OrchestrationCommand; readonly readModel: OrchestrationReadModel; + readonly userInputActivity?: OrchestrationThreadActivity; }): Effect.fn.Return< DecideOrchestrationCommandResult, OrchestrationCommandRejection | PlatformError.PlatformError, @@ -1061,11 +1066,76 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" } case "thread.user-input.respond": { - yield* requireThread({ + const thread = yield* requireThread({ readModel, command, threadId: command.threadId, }); + const request = userInputActivity; + if ( + request && + Predicate.isObject(request.payload) && + request.payload.responseMode === "message" + ) { + const payload = decodeUserInputRequestedPayload(request.payload); + if (request.kind !== "user-input.requested" || Option.isNone(payload)) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: "This question has already been answered.", + }); + } + const replies: string[] = []; + for (const question of payload.value.questions) { + const answer = command.answers[question.id]; + if (typeof answer !== "string" || answer.trim().length === 0) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: "Answer each question before sending.", + }); + } + replies.push(`${question.question}\n${answer.trim()}`); + } + // Commit the answer and its message together. The normal turn path + // steers a running agent or resumes an idle session. + return yield* decideCommandSequence({ + readModel, + commands: [ + { + type: "thread.activity.append", + commandId: command.commandId, + threadId: command.threadId, + createdAt: command.createdAt, + activity: { + id: EventId.make(`async-answer:${command.requestId}`), + kind: "user-input.resolved", + summary: "User input submitted", + tone: "info", + turnId: request.turnId, + createdAt: command.createdAt, + payload: { + requestId: command.requestId, + responseMode: "message", + answers: command.answers, + }, + }, + }, + { + type: "thread.turn.start", + commandId: command.commandId, + threadId: command.threadId, + createdAt: command.createdAt, + runtimeMode: thread.runtimeMode, + interactionMode: thread.interactionMode, + message: { + messageId: MessageId.make(`async-answer:${command.requestId}`), + role: "user", + text: replies.join("\n\n"), + attachments: [], + }, + }, + ], + }); + } return { ...(yield* withEventBase({ aggregateKind: "thread", diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index fbef8e40b50b..3cea194bbb44 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -7,6 +7,7 @@ import { } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; +import * as Predicate from "effect/Predicate"; import { toProjectorDecodeError, type OrchestrationProjectorDecodeError } from "./Errors.ts"; import { @@ -39,6 +40,28 @@ type ThreadPatch = Partial>; const MAX_THREAD_MESSAGES = 2_000; const MAX_THREAD_CHECKPOINTS = 500; +// Async questions can stay open while the agent produces more activity. +// Match the database snapshot's pending-question retention. +function retainThreadActivities(activities: OrchestrationThread["activities"]) { + const recentStart = activities.length - 500; + if (recentStart <= 0) return activities; + const pending = new Map(); + for (const activity of activities) { + if (!Predicate.isObject(activity.payload)) continue; + const requestId = activity.payload.requestId; + if (typeof requestId !== "string") continue; + if (activity.kind === "user-input.requested" && activity.payload.responseMode === "message") { + pending.set(requestId, activity); + } else if (activity.kind === "user-input.resolved") { + pending.delete(requestId); + } + } + const pendingActivities = new Set(pending.values()); + return activities.filter( + (activity, index) => index >= recentStart || pendingActivities.has(activity), + ); +} + function checkpointStatusToLatestTurnState(status: "ready" | "missing" | "error") { if (status === "error") return "error" as const; if (status === "missing") return "interrupted" as const; @@ -804,12 +827,12 @@ export function projectEvent( return nextBase; } - const activities = [ - ...thread.activities.filter((entry) => entry.id !== payload.activity.id), - payload.activity, - ] - .toSorted(compareThreadActivities) - .slice(-500); + const activities = retainThreadActivities( + [ + ...thread.activities.filter((entry) => entry.id !== payload.activity.id), + payload.activity, + ].toSorted(compareThreadActivities), + ); return { ...nextBase, diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts index b0679a94cc2b..8065d90917f0 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -26,6 +26,7 @@ const makeProject = (scripts: OrchestrationProject["scripts"]): OrchestrationPro const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) => Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { + getUserInputActivity: () => Effect.die("unused"), getCommandReadModel: () => Effect.die("unused"), getSnapshot: () => Effect.die("unused"), getShellSnapshot: () => Effect.die("unused"), diff --git a/apps/server/src/provider/AntigravityAuth.test.ts b/apps/server/src/provider/AntigravityAuth.test.ts index acad458e967f..f009255e90a2 100644 --- a/apps/server/src/provider/AntigravityAuth.test.ts +++ b/apps/server/src/provider/AntigravityAuth.test.ts @@ -54,7 +54,9 @@ const phase = (auth: AntigravityAuth, value: ProviderAuthState["phase"], session const makeHarness = Effect.fn("makeAuthTestHarness")(function* ( options: { readonly interactive?: boolean; + readonly authorizationUrls?: ReadonlyArray; readonly supportsLogout?: boolean; + readonly beforeInitialize?: Effect.Effect; readonly forwardCallback?: Effect.Effect; } = {}, ) { @@ -62,12 +64,16 @@ const makeHarness = Effect.fn("makeAuthTestHarness")(function* ( const discovered = yield* Deferred.make(); const closed = yield* Deferred.make(); const events: string[] = []; + let receiveAuthorizationUrl: + | ((url: string) => Effect.Effect) + | undefined; let forwarded = 0; let catalog = ["previous-account-model"]; const auth = yield* makeAntigravityAuth({ instanceId, makeRuntime: (input) => Effect.gen(function* () { + receiveAuthorizationUrl = input.onAuthorizationUrl; events.push("process-open"); yield* Effect.addFinalizer(() => Effect.gen(function* () { @@ -77,8 +83,9 @@ const makeHarness = Effect.fn("makeAuthTestHarness")(function* ( ); return { initialize: () => - Effect.sync(() => { + Effect.gen(function* () { events.push("initialize"); + yield* options.beforeInitialize ?? Effect.void; return options.supportsLogout === false ? { ...initialized, agentCapabilities: {} } : initialized; @@ -87,7 +94,9 @@ const makeHarness = Effect.fn("makeAuthTestHarness")(function* ( Effect.gen(function* () { events.push("authenticate"); if (options.interactive !== false && input.onAuthorizationUrl) { - yield* input.onAuthorizationUrl(authorizationUrl); + for (const url of options.authorizationUrls ?? [authorizationUrl]) { + yield* input.onAuthorizationUrl(url); + } } yield* Deferred.await(authenticated); events.push("session-new"); @@ -124,10 +133,62 @@ const makeHarness = Effect.fn("makeAuthTestHarness")(function* ( events, catalog: () => catalog, forwarded: () => forwarded, + receiveAuthorizationUrl: (url: string) => + Effect.suspend(() => + receiveAuthorizationUrl + ? receiveAuthorizationUrl(url) + : Effect.die("Authorization URL receiver is not ready."), + ), }; }); it.layer(NodeServices.layer)("AntigravityAuth", (it) => { + it.effect("accepts the same authorization URL from stderr and stdout", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + authorizationUrls: [authorizationUrl, authorizationUrl], + }); + yield* harness.auth.controller.start(owner); + const waiting = yield* phase(harness.auth, "waiting"); + assert.equal(waiting.authorizationUrl, authorizationUrl); + + yield* Deferred.succeed(harness.authenticated, undefined); + yield* Deferred.succeed(harness.discovered, undefined); + yield* phase(harness.auth, "succeeded"); + }), + ); + + it.effect("accepts a delayed duplicate after callback completion starts", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + const state = yield* harness.auth.controller.start(owner); + yield* phase(harness.auth, "waiting"); + yield* harness.auth.controller.complete(owner, { + flowId: state.flowId!, + callbackUrl, + }); + + yield* harness.receiveAuthorizationUrl(authorizationUrl); + + yield* Deferred.succeed(harness.authenticated, undefined); + yield* Deferred.succeed(harness.discovered, undefined); + yield* phase(harness.auth, "succeeded"); + }), + ); + + it.effect("rejects a different second authorization URL", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + authorizationUrls: [authorizationUrl, `${authorizationUrl}&scope=another-request`], + }); + yield* harness.auth.controller.start(owner); + + const failed = yield* phase(harness.auth, "failed"); + assert.isNull(failed.authorizationUrl); + assert.deepEqual(harness.catalog(), ["previous-account-model"]); + }), + ); + it.effect("keeps a remote flow private and waits for native auth and catalog discovery", () => Effect.gen(function* () { const harness = yield* makeHarness(); @@ -335,6 +396,45 @@ it.layer(NodeServices.layer)("AntigravityAuth", (it) => { }), ); + it.effect("signs out after a slow packaged runtime starts", () => + Effect.gen(function* () { + const entered = yield* Deferred.make(); + const initialized = yield* Deferred.make(); + const harness = yield* makeHarness({ + beforeInitialize: Deferred.succeed(entered, undefined).pipe( + Effect.andThen(Deferred.await(initialized)), + ), + }); + const logout = yield* harness.auth.controller.logout(Effect.void).pipe(Effect.forkScoped); + yield* Deferred.await(entered); + yield* TestClock.adjust("47 seconds"); + yield* Deferred.succeed(initialized, undefined); + assert.equal((yield* Fiber.join(logout)).phase, "idle"); + assert.include(harness.events, "logout"); + assert.deepEqual(harness.catalog(), []); + yield* Deferred.await(harness.closed); + }), + ); + + it.effect("closes a stalled sign-out process without clearing its account catalog", () => + Effect.gen(function* () { + const entered = yield* Deferred.make(); + const harness = yield* makeHarness({ + beforeInitialize: Deferred.succeed(entered, undefined).pipe(Effect.andThen(Effect.never)), + }); + const logout = yield* harness.auth.controller + .logout(Effect.void) + .pipe(Effect.exit, Effect.forkScoped); + yield* Deferred.await(entered); + yield* TestClock.adjust("90 seconds"); + assert.isTrue(Exit.isFailure(yield* Fiber.join(logout))); + yield* Deferred.await(harness.closed); + assert.notInclude(harness.events, "logout"); + assert.deepEqual(harness.catalog(), ["previous-account-model"]); + yield* harness.auth.withProcess(Effect.void, Effect.void).pipe(Effect.scoped); + }), + ); + it.effect( "sign-out interrupts startup without interrupting its caller after startup returns", () => diff --git a/apps/server/src/provider/AntigravityAuth.ts b/apps/server/src/provider/AntigravityAuth.ts index 8f5ba71f9ea4..c7118bccad83 100644 --- a/apps/server/src/provider/AntigravityAuth.ts +++ b/apps/server/src/provider/AntigravityAuth.ts @@ -20,11 +20,13 @@ import * as SubscriptionRef from "effect/SubscriptionRef"; import * as AcpErrors from "effect-acp/errors"; import type { AcpSessionRuntime, AcpSessionRuntimeStartResult } from "./acp/AcpSessionRuntime.ts"; -import { parseAntigravityAuthorizationUrl } from "./antigravityAuthSupport.ts"; +import { + parseAntigravityAuthorizationUrl, + type AntigravityAuthorizationUrl, +} from "./antigravityAuthSupport.ts"; import { forwardAntigravityCallback, validateAntigravityCallbackUrl, - type AntigravityPendingCallback, } from "./antigravityCallback.ts"; import type { ProviderAuthController } from "./Services/ProviderAuthService.ts"; @@ -43,7 +45,7 @@ interface AuthFlow { readonly ownerSessionId: string; readonly expiresAtMillis: number; state: ProviderAuthState; - pending: AntigravityPendingCallback | undefined; + pending: AntigravityAuthorizationUrl | undefined; callbackSent: boolean; fiber: Fiber.Fiber | undefined; forwarding: Fiber.Fiber | undefined; @@ -239,6 +241,7 @@ export const makeAntigravityAuth = Effect.fn("makeAntigravityAuth")(function* < Effect.gen(function* () { if (activeFlow !== flow || operation !== "auth") return; if (flow.pending) { + if (flow.pending.authorizationUrl === authorization.authorizationUrl) return; return yield* new AcpErrors.AcpTransportError({ detail: "Antigravity started more than one Google sign-in request.", cause: undefined, @@ -477,7 +480,7 @@ export const makeAntigravityAuth = Effect.fn("makeAntigravityAuth")(function* < }).pipe( Effect.scoped, Effect.timeoutOrElse({ - duration: "30 seconds", + duration: "90 seconds", orElse: () => Effect.fail(setupError("logout", "Antigravity sign-out timed out.")), }), ), diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.test.ts b/apps/server/src/provider/Drivers/AntigravityDriver.test.ts index 4d8b7b8a9fa4..922a27df5768 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.test.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.test.ts @@ -11,10 +11,13 @@ import { HostProcessPlatform, } from "@t3tools/shared/hostProcess"; import * as Effect from "effect/Effect"; +import * as Deferred from "effect/Deferred"; +import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; +import * as TestClock from "effect/testing/TestClock"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; @@ -108,7 +111,7 @@ const makeHarness = Effect.fn("makeAntigravityDriverHarness")(function* ( const first = yield* makeExecutable("runtime 'one"); const second = yield* makeExecutable("runtime two"); const signedOut = yield* makeExecutable("runtime signed-out", true); - const controls = { selected: first, failResolution: false }; + const controls = { selected: first, failResolution: false, beforeAcquire: Effect.void }; const acquisitions: Array<{ binaryPath: string | undefined; path: string | undefined }> = []; const releases: Array = []; const launches: Array<{ @@ -129,6 +132,7 @@ const makeHarness = Effect.fn("makeAntigravityDriverHarness")(function* ( acquire: (binaryPath, environment) => Effect.gen(function* () { acquisitions.push({ binaryPath, path: environment?.PATH }); + yield* controls.beforeAcquire; if (controls.failResolution) { return yield* new AntigravityInstallationError({ operation: "resolve", @@ -249,6 +253,26 @@ it.layer(testLayer)("AntigravityDriver", (it) => { }).pipe(Effect.scoped), ); + it.effect.skipIf(windowsHost)("refreshes models after slow process startup", () => + Effect.gen(function* () { + const h = yield* makeHarness(); + const entered = yield* Deferred.make(); + const ready = yield* Deferred.make(); + h.controls.beforeAcquire = Deferred.succeed(entered, undefined).pipe( + Effect.andThen(Deferred.await(ready)), + ); + const refresh = yield* h.refresh().pipe(Effect.forkScoped); + yield* Deferred.await(entered); + yield* TestClock.adjust("70 seconds"); + yield* Deferred.succeed(ready, undefined); + yield* Fiber.join(refresh); + const snapshot = yield* h.instance.snapshot.getSnapshot; + expect(snapshot.auth.status).toBe("authenticated"); + expect(snapshot.models.length).toBeGreaterThan(0); + yield* h.assertClosed; + }).pipe(Effect.scoped), + ); + it.effect.skipIf(windowsHost)( "refreshes a disabled instance through the selected executable and personal Google ACP", () => diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.ts b/apps/server/src/provider/Drivers/AntigravityDriver.ts index f9f99a5bfa38..80d8586a91b7 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.ts @@ -233,6 +233,9 @@ export const AntigravityDriver: ProviderDriver Effect.fail( new ProviderDriverError({ diff --git a/apps/server/src/provider/Drivers/AntigravitySkills.test.ts b/apps/server/src/provider/Drivers/AntigravitySkills.test.ts index fb5aa06681e1..c5e1faacf237 100644 --- a/apps/server/src/provider/Drivers/AntigravitySkills.test.ts +++ b/apps/server/src/provider/Drivers/AntigravitySkills.test.ts @@ -28,7 +28,7 @@ const makeWorkspace = Effect.fn("makeWorkspace")(function* () { }); it.layer(NodeServices.layer)("discoverAntigravitySkills", (it) => { - it.effect("reads skill names, descriptions and paths from all four native roots", () => + it.effect("reads skill names, descriptions and paths from the current native roots", () => Effect.gen(function* () { const path = yield* Path.Path; const input = yield* makeWorkspace(); @@ -63,6 +63,27 @@ it.layer(NodeServices.layer)("discoverAntigravitySkills", (it) => { }), ); + it.effect("discovers skills from the legacy workspace root", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const input = yield* makeWorkspace(); + const skillPath = yield* writeSkill( + path.join(input.cwd, ".agent", "skills", "review"), + "---\nname: review\ndescription: Review changes.\n---\n", + ); + + assert.deepEqual(yield* discoverAntigravitySkills(input), [ + { + name: "review", + description: "Review changes.", + path: skillPath, + scope: "project", + enabled: true, + }, + ]); + }), + ); + it.effect("uses native root order for duplicate names", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -73,6 +94,7 @@ it.layer(NodeServices.layer)("discoverAntigravitySkills", (it) => { path.join(input.cwd, ".gemini", "skills"), path.join(input.profileDirectory, "antigravity-cli", "skills"), path.join(input.cwd, ".agents", "skills"), + path.join(input.cwd, ".agent", "skills"), ]; for (const [index, root] of roots.entries()) { yield* writeSkill( diff --git a/apps/server/src/provider/Drivers/AntigravitySkills.ts b/apps/server/src/provider/Drivers/AntigravitySkills.ts index 697a1abd5cde..a8b206a89279 100644 --- a/apps/server/src/provider/Drivers/AntigravitySkills.ts +++ b/apps/server/src/provider/Drivers/AntigravitySkills.ts @@ -134,6 +134,7 @@ export const discoverAntigravitySkills = Effect.fn("discoverAntigravitySkills")( scope: "user", }, { directory: path.resolve(input.cwd, ".agents", "skills"), scope: "project" }, + { directory: path.resolve(input.cwd, ".agent", "skills"), scope: "project" }, ]; const budget: ScanBudget = { remainingBytes: MAX_SCAN_BYTES, diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index d47d9c062ae2..efcf19e9d6b1 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -29,6 +29,7 @@ import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { ProviderDriverError } from "../Errors.ts"; import { makeClaudeAdapter } from "../Layers/ClaudeAdapter.ts"; +import { makeClaudeScopedLimitNames } from "../Layers/claudeUsageLimits.ts"; import { checkClaudeProviderStatus, makePendingClaudeProvider, @@ -134,10 +135,14 @@ export const ClaudeDriver: ProviderDriver = { continuationGroupKey, }); + // One per instance: the status probe writes the model-scoped bucket + // names it saw, the adapter reads them to place turn-driven events. + const scopedLimitNames = yield* makeClaudeScopedLimitNames; const adapterOptions = { instanceId, environment: processEnv, modelCatalog, + scopedLimitNames, ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), }; const adapter = yield* makeClaudeAdapter(effectiveConfig, adapterOptions); @@ -171,6 +176,7 @@ export const ClaudeDriver: ProviderDriver = { processEnv, cwd, resolveClaudeModelCatalog(manifest), + scopedLimitNames, ), ), Effect.map(stampIdentity), diff --git a/apps/server/src/provider/Layers/AntigravityAdapter.test.ts b/apps/server/src/provider/Layers/AntigravityAdapter.test.ts index ae727c3996a1..21f16d40a626 100644 --- a/apps/server/src/provider/Layers/AntigravityAdapter.test.ts +++ b/apps/server/src/provider/Layers/AntigravityAdapter.test.ts @@ -26,6 +26,11 @@ import { ServerConfig } from "../../config.ts"; import { ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE } from "../antigravityAuthSupport.ts"; import type { AcpSessionRuntimeEvent } from "../acp/AcpSessionRuntime.ts"; import { makeAntigravityAcpRuntime } from "../acp/AntigravityAcpSupport.ts"; +import { + mergeToolCallState, + parseSessionUpdateEvent, + type AcpToolCallState, +} from "../acp/AcpRuntimeModel.ts"; import { makeAntigravityAdapter, type AntigravityAdapterOptions } from "./AntigravityAdapter.ts"; const instanceId = ProviderInstanceId.make("antigravity-test"); @@ -49,6 +54,20 @@ interface NativePrompt { type Runtime = Effect.Success>; +function nativeToolUpdate( + update: Extract< + AcpSchema.SessionNotification["update"], + { sessionUpdate: "tool_call" | "tool_call_update" } + >, + previous?: AcpToolCallState, +) { + const event = parseSessionUpdateEvent({ sessionId: nativeSessionId, update }).events.find( + (event) => event._tag === "ToolCallUpdated", + ); + if (!event) throw new Error("Expected a native tool update"); + return { ...event, toolCall: mergeToolCallState(previous, event.toolCall) }; +} + const makeHarness = Effect.fn("makeAntigravityAdapterHarness")(function* (options?: { readonly enabled?: boolean; readonly holdCancel?: boolean; @@ -297,6 +316,7 @@ it.layer(layer)("AntigravityAdapter", (it) => { ); const requestLog = path.join(cwd, "requests.ndjson"); const commands: string[] = []; + const modelSelections: string[] = []; const observed: ProviderRuntimeEvent[] = []; const completed = yield* Deferred.make(); const adapter = yield* makeAntigravityAdapter(decodeSettings({ enabled: true }), { @@ -322,6 +342,11 @@ it.layer(layer)("AntigravityAdapter", (it) => { Effect.sync(() => { commands.push(...available.map((command) => command.name)); }), + onConfigOptionsUpdated: (configOptions) => + Effect.sync(() => { + const model = configOptions.find((option) => option.category === "model"); + if (model?.type === "select") modelSelections.push(model.currentValue); + }), }); yield* adapter.streamEvents.pipe( Stream.runForEach((event) => @@ -350,6 +375,8 @@ it.layer(layer)("AntigravityAdapter", (it) => { yield* adapter.sendTurn({ threadId, input: "Reply with one short line." }); yield* Deferred.await(completed); expect(commands).toEqual(["plan", "logout", "plan", "logout"]); + expect(modelSelections.length).toBeGreaterThan(0); + expect(modelSelections.every((model) => model === nativeAlternative)).toBe(true); expect( observed .filter((event) => event.type === "content.delta") @@ -734,6 +761,382 @@ it.layer(layer)("AntigravityAdapter", (it) => { }), ); + it.effect( + "shows concurrent native subagent calls and their results without inventing metadata", + () => + Effect.gen(function* () { + const h = yield* makeHarness(); + yield* h.adapter.startSession({ + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + const sending = yield* h.adapter + .sendTurn({ threadId, input: "Review with subagents" }) + .pipe(Effect.forkChild); + const prompt = yield* h.nextPrompt; + for (const id of ["trajectory:4", "trajectory:5"]) { + yield* h.emitNative( + nativeToolUpdate({ + sessionUpdate: "tool_call", + toolCallId: id, + title: "Running start_subagent", + kind: "other", + status: "in_progress", + rawInput: {}, + }), + ); + const running = yield* h.waitForEvent((event) => event.type === "task.progress"); + expect(running.payload).toEqual({ + taskId: id, + taskType: "subagent", + toolUseId: id, + title: "Antigravity subagent", + description: "Antigravity subagent", + status: "running", + }); + yield* h.emitNative( + nativeToolUpdate({ + sessionUpdate: "tool_call_update", + toolCallId: id, + status: "in_progress", + }), + ); + } + for (const [id, status, result] of [ + ["trajectory:4", "completed", "No defects found."], + ["trajectory:5", "failed", "Subagent exceeded its limit."], + ] as const) { + yield* h.emitNative( + nativeToolUpdate({ + sessionUpdate: "tool_call_update", + toolCallId: id, + status, + rawOutput: result, + }), + ); + const completed = yield* h.waitForEvent((event) => event.type === "task.completed"); + expect(completed.payload).toEqual({ + taskId: id, + taskType: "subagent", + toolUseId: id, + title: "Antigravity subagent", + status, + summary: result, + }); + } + yield* Deferred.succeed(prompt.result, { stopReason: "end_turn" }); + const turn = yield* Fiber.join(sending); + yield* h.waitForEvent((event) => event.type === "turn.completed"); + expect( + h.seen + .filter((event) => event.type.startsWith("task.")) + .every((event) => event.turnId === turn.turnId), + ).toBe(true); + expect(h.seen.filter((event) => event.type.startsWith("item."))).toHaveLength(0); + expect(h.seen.filter((event) => event.type === "task.progress")).toHaveLength(2); + }), + ); + + it.effect("waits for a replayed subagent's final status and result", () => + Effect.gen(function* () { + const h = yield* makeHarness(); + yield* h.adapter.startSession({ + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + // ACP history announces a completed tool first, even when its result failed. + yield* h.emitNative( + nativeToolUpdate({ + sessionUpdate: "tool_call", + toolCallId: "replayed:4", + title: "Running start_subagent", + kind: "other", + status: "completed", + rawInput: "{}", + }), + ); + yield* h.emitNative( + nativeToolUpdate({ + sessionUpdate: "tool_call_update", + toolCallId: "replayed:4", + status: "failed", + rawOutput: "Review failed.", + }), + ); + const completed = yield* h.waitForEvent((event) => event.type === "task.completed"); + expect(completed.payload).toEqual({ + taskId: "replayed:4", + taskType: "subagent", + toolUseId: "replayed:4", + title: "Antigravity subagent", + status: "failed", + summary: "Review failed.", + }); + expect(h.seen.filter((event) => event.type.startsWith("task."))).toHaveLength(1); + }), + ); + + it.effect("completes a live subagent delivered in one tool call", () => + Effect.gen(function* () { + const h = yield* makeHarness(); + yield* h.adapter.startSession({ + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + const sending = yield* h.adapter + .sendTurn({ threadId, input: "Review with subagents" }) + .pipe(Effect.forkChild); + const prompt = yield* h.nextPrompt; + for (const [id, rawOutput] of [ + ["live:4", "Review complete."], + ["live:5", undefined], + ] as const) { + yield* h.emitNative( + nativeToolUpdate({ + sessionUpdate: "tool_call", + toolCallId: id, + title: "Running start_subagent", + kind: "other", + status: "completed", + rawInput: {}, + ...(rawOutput ? { rawOutput } : {}), + }), + ); + const completed = yield* h.waitForEvent((event) => event.type === "task.completed"); + expect(completed.payload).toMatchObject({ taskId: id, status: "completed" }); + expect(completed.payload.summary).toBe(rawOutput); + } + yield* Deferred.succeed(prompt.result, { stopReason: "end_turn" }); + yield* Fiber.join(sending); + yield* h.waitForEvent((event) => event.type === "turn.completed"); + expect(h.seen.filter((event) => event.type === "task.updated")).toHaveLength(0); + }), + ); + + for (const settlement of ["cancelled", "interrupted", "completed"] as const) { + it.effect(`does not reopen a ${settlement} subagent on late merged updates`, () => + Effect.gen(function* () { + const h = yield* makeHarness(); + yield* h.adapter.startSession({ + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + const first = yield* h.adapter + .sendTurn({ threadId, input: "Start review" }) + .pipe(Effect.forkChild); + const firstPrompt = yield* h.nextPrompt; + const started = nativeToolUpdate({ + sessionUpdate: "tool_call", + toolCallId: "old:4", + title: "Running start_subagent", + kind: "other", + status: "in_progress", + rawInput: {}, + }); + yield* h.emitNative(started); + yield* h.waitForEvent((event) => event.type === "task.progress"); + if (settlement === "cancelled") { + yield* h.adapter.interruptTurn(threadId); + } else { + if (settlement === "completed") { + yield* h.emitNative( + nativeToolUpdate( + { + sessionUpdate: "tool_call_update", + toolCallId: "old:4", + status: "completed", + rawOutput: "Original result.", + }, + started.toolCall, + ), + ); + } + yield* Deferred.succeed(firstPrompt.result, { stopReason: "end_turn" }); + } + yield* Fiber.join(first); + yield* h.waitForEvent((event) => event.type === "turn.completed"); + const second = yield* h.adapter + .sendTurn({ threadId, input: "Next review" }) + .pipe(Effect.forkChild); + const secondPrompt = yield* h.nextPrompt; + for (const status of ["in_progress", "completed"] as const) { + yield* h.emitNative( + nativeToolUpdate( + { + sessionUpdate: "tool_call_update", + toolCallId: "old:4", + status, + rawOutput: "Late result.", + }, + started.toolCall, + ), + ); + } + yield* h.emitNative( + nativeToolUpdate({ + sessionUpdate: "tool_call", + toolCallId: "new:4", + title: "Running start_subagent", + kind: "other", + status: "completed", + rawOutput: "New result.", + }), + ); + const completed = yield* h.waitForEvent((event) => event.type === "task.completed"); + expect(completed.payload.taskId).toBe("new:4"); + yield* Deferred.succeed(secondPrompt.result, { stopReason: "end_turn" }); + yield* Fiber.join(second); + yield* h.waitForEvent((event) => event.type === "turn.completed"); + expect(h.seen.filter((event) => event.type === "task.progress")).toHaveLength(1); + expect( + h.seen.filter( + (event) => event.type === "task.completed" && event.payload.taskId === "old:4", + ), + ).toHaveLength(settlement === "completed" ? 1 : 0); + }), + ); + } + + it.effect("keeps MCP identity when later updates omit metadata", () => + Effect.gen(function* () { + const h = yield* makeHarness(); + yield* h.adapter.startSession({ + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + const sending = yield* h.adapter + .sendTurn({ threadId, input: "Run an MCP tool" }) + .pipe(Effect.forkChild); + const prompt = yield* h.nextPrompt; + const started = nativeToolUpdate({ + sessionUpdate: "tool_call", + toolCallId: "mcp-4", + title: "Running start_subagent", + kind: "other", + status: "in_progress", + rawInput: { arguments: {} }, + _meta: { is_mcp_tool_call: true }, + }); + yield* h.emitNative(started); + for (const status of ["in_progress", "completed"] as const) { + yield* h.emitNative( + nativeToolUpdate( + { + sessionUpdate: "tool_call_update", + toolCallId: "mcp-4", + status, + rawOutput: "MCP output.", + }, + started.toolCall, + ), + ); + } + yield* Deferred.succeed(prompt.result, { stopReason: "end_turn" }); + yield* Fiber.join(sending); + yield* h.waitForEvent((event) => event.type === "turn.completed"); + expect(h.seen.filter((event) => event.type.startsWith("task."))).toHaveLength(0); + expect(h.seen.filter((event) => event.type === "item.updated")).toHaveLength(2); + expect(h.seen.filter((event) => event.type === "item.completed")).toHaveLength(1); + }), + ); + + it.effect("shows pending subagents and closes a denied invocation", () => + Effect.gen(function* () { + const h = yield* makeHarness(); + yield* h.adapter.startSession({ + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + yield* h.emitNative( + nativeToolUpdate({ + sessionUpdate: "tool_call", + toolCallId: "permission-1", + title: "Run start_subagent?", + kind: "other", + status: "pending", + rawInput: {}, + }), + ); + const pending = yield* h.waitForEvent((event) => event.type === "task.progress"); + expect(pending.payload.status).toBe("pending"); + yield* h.emitNative( + nativeToolUpdate({ + sessionUpdate: "tool_call_update", + toolCallId: "permission-1", + status: "failed", + }), + ); + const completed = yield* h.waitForEvent((event) => event.type === "task.completed"); + expect(completed.payload.status).toBe("failed"); + }), + ); + + for (const stop of ["cancel", "steer", "disconnect", "end_turn"] as const) { + it.effect(`settles open subagent calls on ${stop}`, () => + Effect.gen(function* () { + const h = yield* makeHarness(); + yield* h.adapter.startSession({ + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + const sending = yield* h.adapter + .sendTurn({ threadId, input: "Start a subagent" }) + .pipe(Effect.forkChild); + const prompt = yield* h.nextPrompt; + yield* h.emitNative( + nativeToolUpdate({ + sessionUpdate: "tool_call", + toolCallId: "trajectory:4", + title: "Running start_subagent", + kind: "other", + status: "in_progress", + rawInput: {}, + }), + ); + yield* h.waitForEvent((event) => event.type === "task.progress"); + if (stop === "disconnect") { + yield* h.emitNative({ + _tag: "ConnectionTerminated", + error: new AcpErrors.AcpTransportError({ detail: "Process exited.", cause: undefined }), + }); + } else if (stop === "cancel") { + yield* h.adapter.interruptTurn(threadId); + } else if (stop === "steer") { + const steering = yield* h.adapter + .sendTurn({ threadId, input: "Change direction" }) + .pipe(Effect.forkChild); + const replacement = yield* h.nextPrompt; + yield* Deferred.succeed(replacement.result, { stopReason: "end_turn" }); + yield* Fiber.join(steering); + } else { + yield* Deferred.succeed(prompt.result, { stopReason: "end_turn" }); + } + const settled = yield* h.waitForEvent((event) => event.type === "task.updated"); + expect(settled.payload).toMatchObject({ + taskId: "trajectory:4", + title: "Antigravity subagent", + taskType: "subagent", + status: + stop === "disconnect" + ? "failed" + : stop === "cancel" || stop === "steer" + ? "cancelled" + : "interrupted", + }); + if (stop === "disconnect") + yield* h.waitForEvent((event) => event.type === "session.exited"); + else yield* Fiber.join(sending); + }), + ); + } + it.effect("retires a prompt cancelled before native dispatch", () => Effect.gen(function* () { const h = yield* makeHarness({ holdDispatch: true }); diff --git a/apps/server/src/provider/Layers/AntigravityAdapter.ts b/apps/server/src/provider/Layers/AntigravityAdapter.ts index e91c04e59025..2541d3ee1670 100644 --- a/apps/server/src/provider/Layers/AntigravityAdapter.ts +++ b/apps/server/src/provider/Layers/AntigravityAdapter.ts @@ -1,5 +1,4 @@ import { - ANTIGRAVITY_DEFAULT_MODEL, ApprovalRequestId, EventId, ProviderDriverKind, @@ -13,6 +12,7 @@ import { type ProviderSession, type ProviderSetupError, type ProviderUserInputAnswers, + type RuntimeTaskStatus, type ThreadId, type TurnCompletedPayload, } from "@t3tools/contracts"; @@ -71,8 +71,11 @@ import { } from "../acp/AntigravityAcpSupport.ts"; import { antigravityApprovalOptions, + antigravitySubagentResult, + classifyAntigravitySubagentToolCall, extractAntigravityUserInputQuestion, isAntigravityOpenCommand, + isAntigravitySubagentReplayStart, isAntigravityUserInputRequest, makeAntigravityUserInputResponse, normalizeAntigravityToolCall, @@ -133,6 +136,9 @@ export interface AntigravityAdapterOptions { commands: ReadonlyArray, cwd: string, ) => Effect.Effect; + readonly onConfigOptionsUpdated?: ( + configOptions: ReadonlyArray, + ) => Effect.Effect; readonly onAuthRequired?: Effect.Effect; /** Model the provider default alias selects, when the account offers it. */ readonly defaultModel?: Effect.Effect; @@ -161,6 +167,20 @@ interface OpenCommand { readonly promoted: boolean; } +interface OpenSubagent { + readonly turnId: TurnId | undefined; + readonly status: "pending" | "running" | undefined; +} + +function subagentLinkage(toolCallId: string) { + return { + taskId: RuntimeTaskId.make(toolCallId), + taskType: "subagent", + toolUseId: toolCallId, + title: "Antigravity subagent", + }; +} + interface TurnIntent { readonly turnId: TurnId; readonly generation: number; @@ -179,6 +199,8 @@ interface SessionContext { readonly approvals: Map; readonly questions: Map; readonly commands: Map; + /** Keep only IDs after settlement or MCP exclusion so merged late updates cannot change identity. */ + readonly subagents: Map; readonly turns: Array<{ id: TurnId; items: Array }>; session: ProviderSession; activeTurnId: TurnId | undefined; @@ -365,6 +387,32 @@ export const makeAntigravityAdapter = Effect.fn("makeAntigravityAdapter")(functi }), ); + const finishSubagents = ( + context: SessionContext, + status: Extract, + error?: string, + ) => + context.commandLock.withPermit( + Effect.gen(function* () { + for (const [id, subagent] of context.subagents) { + if (subagent === "finished" || subagent === "mcp") continue; + yield* emit({ + type: "task.updated", + ...(yield* stamp), + provider: PROVIDER, + threadId: context.threadId, + turnId: subagent.turnId, + payload: { + ...subagentLinkage(id), + status, + ...(error ? { error } : {}), + }, + }); + context.subagents.set(id, "finished"); + } + }), + ); + const stopContext = (context: SessionContext) => context.stopLock .withPermit( @@ -380,6 +428,12 @@ export const makeAntigravityAdapter = Effect.fn("makeAntigravityAdapter")(functi context.closed = true; if (sessions.get(context.threadId) === context) sessions.delete(context.threadId); yield* finishBackgroundCommands(context); + yield* finishSubagents( + context, + context.disconnected ? "failed" : "cancelled", + context.disconnected ? "Antigravity process stopped." : undefined, + ); + context.subagents.clear(); yield* emit({ type: "session.exited", ...(yield* stamp), @@ -503,6 +557,9 @@ export const makeAntigravityAdapter = Effect.fn("makeAntigravityAdapter")(functi case "AvailableCommandsUpdated": yield* options.onAvailableCommands?.(event.availableCommands, context.cwd) ?? Effect.void; return; + case "ConfigOptionsUpdated": + yield* options.onConfigOptionsUpdated?.(event.configOptions) ?? Effect.void; + return; case "ConnectionTerminated": context.stopped = true; context.disconnected = true; @@ -554,6 +611,54 @@ export const makeAntigravityAdapter = Effect.fn("makeAntigravityAdapter")(functi yield* context.commandLock.withPermit( Effect.gen(function* () { const toolCall = normalizeAntigravityToolCall(event.toolCall); + const tracked = context.subagents.get(toolCall.toolCallId); + if (tracked === "finished") return; + const kind = classifyAntigravitySubagentToolCall(toolCall, event.rawPayload); + const isMcp = tracked === "mcp" || kind === "mcp"; + if (isMcp) context.subagents.set(toolCall.toolCallId, "mcp"); + const subagent = tracked === "mcp" ? undefined : tracked; + if (!isMcp && (subagent || kind === "subagent")) { + const turnId = subagent?.turnId ?? context.activeTurnId; + const linkage = subagentLinkage(toolCall.toolCallId); + // Replay starts claim completion before the result says whether the call failed. + if ( + context.activeTurnId === undefined && + isAntigravitySubagentReplayStart(event.rawPayload) + ) { + context.subagents.set(toolCall.toolCallId, { turnId, status: undefined }); + return; + } + if (toolCall.status === "completed" || toolCall.status === "failed") { + const summary = antigravitySubagentResult(toolCall); + yield* emit({ + type: "task.completed", + ...(yield* stamp), + provider: PROVIDER, + threadId: context.threadId, + turnId, + payload: { + ...linkage, + status: toolCall.status, + ...(summary ? { summary } : {}), + }, + }); + context.subagents.set(toolCall.toolCallId, "finished"); + } else { + const status = toolCall.status === "pending" ? "pending" : "running"; + if (subagent?.status !== status) { + yield* emit({ + type: "task.progress", + ...(yield* stamp), + provider: PROVIDER, + threadId: context.threadId, + turnId, + payload: { ...linkage, description: linkage.title, status }, + }); + } + context.subagents.set(toolCall.toolCallId, { turnId, status }); + } + return; + } const existing = context.commands.get(toolCall.toolCallId); yield* emit( makeAcpToolCallEvent({ @@ -739,6 +844,7 @@ export const makeAntigravityAdapter = Effect.fn("makeAntigravityAdapter")(functi approvals: new Map(), questions: new Map(), commands: new Map(), + subagents: new Map(), turns: [], session, activeTurnId: undefined, @@ -860,6 +966,18 @@ export const makeAntigravityAdapter = Effect.fn("makeAntigravityAdapter")(functi if (turn.settled || context.stopped || context.generation !== turn.generation) return; turn.settled = true; yield* promoteBackgroundCommands(context); + yield* finishSubagents( + context, + payload.state === "cancelled" + ? "cancelled" + : payload.state === "failed" + ? "failed" + : "interrupted", + payload.errorMessage ?? + (payload.state === "completed" + ? "Antigravity ended the turn before reporting a subagent result." + : undefined), + ); context.activeTurnId = undefined; context.promptFiber = undefined; context.session = { @@ -917,6 +1035,7 @@ export const makeAntigravityAdapter = Effect.fn("makeAntigravityAdapter")(functi yield* cancelRequests(context); yield* context.runtime.cancel; yield* Fiber.await(context.promptFiber); + yield* finishSubagents(context, "cancelled"); } yield* applyAntigravityAcpModelSelection({ runtime: context.runtime, diff --git a/apps/server/src/provider/Layers/AntigravityProvider.test.ts b/apps/server/src/provider/Layers/AntigravityProvider.test.ts index 639d3d11c796..363afbee1106 100644 --- a/apps/server/src/provider/Layers/AntigravityProvider.test.ts +++ b/apps/server/src/provider/Layers/AntigravityProvider.test.ts @@ -13,6 +13,7 @@ import * as Layer from "effect/Layer"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import * as EffectAcpErrors from "effect-acp/errors"; import type * as EffectAcpSchema from "effect-acp/schema"; @@ -174,7 +175,6 @@ describe("Antigravity model catalog", () => { it("uses legacy session models only when model config is absent", () => { const fromLegacy = buildAntigravityModelsFromSession({ - sessionId: "legacy-session", models: sessionSetupResult.models, }); expect(fromLegacy).toEqual(buildAntigravityModelsFromSession(sessionSetupResult)); @@ -188,7 +188,6 @@ describe("Antigravity model catalog", () => { it("flattens native option groups without combining distinct model IDs", () => { const models = buildAntigravityModelsFromSession({ - sessionId: "grouped-session", configOptions: [ { ...modelConfig, @@ -355,7 +354,9 @@ it.layer(testLayer)("Antigravity provider snapshots", (it) => { supportsTextGeneration: false, }); yield* harness.provider.onAvailableCommands(commands, "/workspace"); + yield* harness.provider.onConfigOptionsUpdated([modelConfig]); expect((yield* harness.provider.snapshotForCwd("/workspace")).slashCommands).toEqual([]); + expect((yield* harness.provider.snapshot.getSnapshot).models).toEqual([]); } const refreshed = yield* harness.provider.snapshot.refresh; expect(refreshed.auth.status).toBe("unauthenticated"); @@ -364,6 +365,39 @@ it.layer(testLayer)("Antigravity provider snapshots", (it) => { ), ); + it.effect("replaces live model choices and accepts an empty catalog", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* harness.initialize; + yield* harness.provider.onSessionStarted(started, "/workspace"); + yield* harness.provider.onAvailableCommands(commands, "/workspace"); + const before = yield* harness.provider.snapshot.getSnapshot; + const configOptions = [ + { + ...modelConfig, + currentValue: "gemini-3.8-flash-high", + options: modelOptions.slice(0, 3), + }, + ]; + const nextSnapshot = yield* Stream.toPull( + harness.provider.snapshot.streamChanges.pipe( + Stream.filter((snapshot) => snapshot.models.length === 3), + ), + ); + yield* harness.provider.onConfigOptionsUpdated(configOptions); + expect((yield* nextSnapshot)[0]).toMatchObject({ + models: buildAntigravityModelsFromSession({ configOptions }), + auth: before.auth, + workspaceSnapshots: before.workspaceSnapshots, + slashCommands: commands, + }); + yield* harness.provider.onConfigOptionsUpdated([]); + expect((yield* harness.provider.snapshot.getSnapshot).models).toEqual([]); + }), + ), + ); + it.effect("replaces one account's catalog instead of combining accounts", () => Effect.scoped( Effect.gen(function* () { @@ -411,6 +445,58 @@ it.layer(testLayer)("Antigravity provider snapshots", (it) => { ), ); + it.effect("allows a slow packaged runtime health check to finish", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* harness.initialize; + const entered = yield* Deferred.make(); + const initialized = yield* Deferred.make(); + yield* Ref.set( + harness.probe, + Deferred.succeed(entered, undefined).pipe(Effect.andThen(Deferred.await(initialized))), + ); + + const refresh = yield* harness.provider.snapshot.refresh.pipe(Effect.forkChild); + yield* Deferred.await(entered); + yield* TestClock.adjust("47 seconds"); + yield* Deferred.succeed(initialized, initializeResult); + const snapshot = yield* Fiber.join(refresh); + + expect(snapshot).toMatchObject({ + installed: true, + status: "warning", + auth: { status: "unknown" }, + }); + }), + ), + ); + + it.effect("closes a stalled health probe at its deadline", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* harness.initialize; + const entered = yield* Deferred.make(); + const closed = yield* Deferred.make(); + yield* Ref.set( + harness.probe, + Deferred.succeed(entered, undefined).pipe( + Effect.andThen(Effect.never), + Effect.ensuring(Deferred.succeed(closed, undefined)), + ), + ); + const refresh = yield* harness.provider.snapshot.refresh.pipe(Effect.forkChild); + yield* Deferred.await(entered); + yield* TestClock.adjust("90 seconds"); + const snapshot = yield* Fiber.join(refresh); + yield* Deferred.await(closed); + expect(snapshot.status).toBe("error"); + expect(snapshot.message).toContain("90 seconds"); + }), + ), + ); + it.effect("distinguishes missing executables from a failed installed executable", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/AntigravityProvider.ts b/apps/server/src/provider/Layers/AntigravityProvider.ts index c8614a118d30..62bd224cd4da 100644 --- a/apps/server/src/provider/Layers/AntigravityProvider.ts +++ b/apps/server/src/provider/Layers/AntigravityProvider.ts @@ -32,11 +32,15 @@ import { const EMPTY_MODEL_CAPABILITIES = createModelCapabilities({ optionDescriptors: [] }); const MAX_WORKSPACE_SNAPSHOTS = 32; +const HEALTH_CHECK_TIMEOUT = "90 seconds"; const SIGN_IN_MESSAGE = "Sign in with Google to use Antigravity."; const AUTH_UNCHECKED_MESSAGE = "Antigravity is installed. Google account access is not checked yet."; -type SessionSetupResult = AcpSessionRuntimeStartResult["sessionSetupResult"]; +type SessionSetupResult = Pick< + AcpSessionRuntimeStartResult["sessionSetupResult"], + "configOptions" | "models" +>; /** Keep the native model IDs, including model-specific thinking levels. */ export function buildAntigravityModelsFromSession( @@ -166,7 +170,10 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func const checkProvider = Effect.fn("checkAntigravityProvider")(function* () { if (!settings.enabled) return yield* getSnapshot; const before = yield* SubscriptionRef.get(metadata); - const result = yield* options.probe.pipe(Effect.timeoutOption("15 seconds"), Effect.result); + const result = yield* options.probe.pipe( + Effect.timeoutOption(HEALTH_CHECK_TIMEOUT), + Effect.result, + ); const initialized = Result.isSuccess(result) && Option.isSome(result.success) ? result.success.value : undefined; const failure = Result.isFailure(result) ? result.failure : undefined; @@ -180,7 +187,7 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func ? "Antigravity is not installed or its executable could not be found." : failure ? "Antigravity could not complete its local health check." - : "Antigravity did not respond to its local health check within 15 seconds."; + : `Antigravity did not respond to its local health check within ${HEALTH_CHECK_TIMEOUT}.`; const supportsTextGeneration = initialized !== undefined ? yield* options.supportsTextGeneration : false; const updatedAt = DateTime.formatIso(yield* DateTime.now); @@ -295,6 +302,16 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func }); }); + const onConfigOptionsUpdated = Effect.fn("AntigravityProvider.onConfigOptionsUpdated")(function* ( + configOptions: ReadonlyArray, + ) { + const models = buildAntigravityModelsFromSession({ configOptions }); + yield* SubscriptionRef.update(metadata, (state) => { + if (state.draft.auth.status !== "authenticated") return state; + return { ...state, draft: { ...state.draft, models } }; + }); + }); + const onAvailableCommands = Effect.fn("AntigravityProvider.onAvailableCommands")(function* ( commands: ReadonlyArray, cwd?: string, @@ -369,6 +386,7 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func return { snapshot: { ...managed, getSnapshot }, onSessionStarted, + onConfigOptionsUpdated, onAvailableCommands, onSignedOut: clearAccountMetadata(), onAuthRequired: clearAccountMetadata(), diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 3b5c0f8586a7..eb8f131009b4 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -28,6 +28,7 @@ import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Random from "effect/Random"; +import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; @@ -44,6 +45,7 @@ import { } from "../ClaudeModelCatalog.testFixtures.ts"; import { ProviderAdapterProcessError, ProviderAdapterValidationError } from "../Errors.ts"; import type { ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts"; +import type { ClaudeScopedLimitNames } from "./claudeUsageLimits.ts"; import { makeClaudeAdapter, type ClaudeAdapterLiveOptions } from "./ClaudeAdapter.ts"; const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); const encodeUnknownJsonString = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); @@ -163,6 +165,7 @@ function makeHarness(config?: { readonly baseDir?: string; readonly claudeConfig?: Partial; readonly instanceId?: ProviderInstanceId; + readonly scopedLimitNames?: ClaudeAdapterLiveOptions["scopedLimitNames"]; }) { const query = new FakeClaudeQuery(); let createInput: @@ -174,6 +177,7 @@ function makeHarness(config?: { const adapterOptions: ClaudeAdapterLiveOptions = { ...(config?.instanceId ? { instanceId: config.instanceId } : {}), + ...(config?.scopedLimitNames ? { scopedLimitNames: config.scopedLimitNames } : {}), modelCatalog: Effect.succeed(SYNTHETIC_CLAUDE_MODEL_CATALOG), createQuery: (input) => { createInput = input; @@ -1239,6 +1243,84 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("places overage-included rate-limit events on the bucket the probe named", () => { + const scopedLimitNames = Ref.makeUnsafe({ overageIncluded: undefined }); + const harness = makeHarness({ scopedLimitNames }); + const rateLimitEvent = (utilization: number): SDKMessage => + ({ + type: "rate_limit_event", + rate_limit_info: { + status: "allowed", + rateLimitType: "seven_day_overage_included", + utilization, + }, + uuid: `rate-limit-${utilization}`, + session_id: "sdk-session-1", + }) as unknown as SDKMessage; + const resultMessage = (uuid: string): SDKMessage => + ({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + num_turns: 1, + session_id: "sdk-session-1", + uuid, + }) as unknown as SDKMessage; + const limitsUpdates = (events: Iterable) => + Array.from(events).flatMap((event) => + event.type === "account.rate-limits.updated" ? [event.payload.limits] : [], + ); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + + // Before any probe names the bucket the event has nowhere to land. + // Collecting through the turn's completion proves the SDK message was + // handled, not merely still queued. + const firstTurnFiber = yield* adapter.streamEvents.pipe( + Stream.takeUntil((event) => event.type === "turn.completed"), + Stream.runCollect, + Effect.forkChild, + ); + yield* adapter.sendTurn({ threadId: session.threadId, input: "hello", attachments: [] }); + harness.query.emit(rateLimitEvent(0.2)); + harness.query.emit(resultMessage("result-1")); + assert.deepStrictEqual(limitsUpdates(yield* Fiber.join(firstTurnFiber)), []); + + // The status probe reads `get_usage` and records the model it saw. + yield* Ref.set(scopedLimitNames, { overageIncluded: "Fable" }); + const secondTurnFiber = yield* adapter.streamEvents.pipe( + Stream.takeUntil((event) => event.type === "turn.completed"), + Stream.runCollect, + Effect.forkChild, + ); + yield* adapter.sendTurn({ threadId: session.threadId, input: "again", attachments: [] }); + harness.query.emit(rateLimitEvent(0.4)); + harness.query.emit(resultMessage("result-2")); + assert.deepStrictEqual(limitsUpdates(yield* Fiber.join(secondTurnFiber)), [ + { + windows: [ + { + id: "seven_day_fable", + kind: "weekly", + label: "Weekly · Fable", + usedPercent: 40, + windowDurationMins: 10_080, + }, + ], + }, + ]); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("does not emit turn.completed for a result with no active turn", () => { const harness = makeHarness(); return Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 62763f947c7d..24a7fb28fd6a 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -21,6 +21,7 @@ import { } from "@anthropic-ai/claude-agent-sdk"; import { parseCliArgs } from "@t3tools/shared/cliArgs"; import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; +import { type ClaudeScopedLimitNames, claudeRateLimitEventToUpdate } from "./claudeUsageLimits.ts"; import { ApprovalRequestId, type CanonicalItemType, @@ -342,6 +343,8 @@ export interface ClaudeAdapterLiveOptions { readonly nativeEventLogPath?: string; readonly nativeEventLogger?: EventNdjsonLogger; readonly modelCatalog?: Effect.Effect; + /** Scoped-bucket names the driver's status probe last saw; see `claudeUsageLimits`. */ + readonly scopedLimitNames?: Ref.Ref; } function isUuid(value: string): boolean { @@ -3595,12 +3598,15 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( } if (message.type === "rate_limit_event") { + const names = options?.scopedLimitNames + ? yield* Ref.get(options.scopedLimitNames) + : { overageIncluded: undefined }; + const limits = claudeRateLimitEventToUpdate(message.rate_limit_info, names); + if (!limits) return; yield* offerRuntimeEvent({ ...base, type: "account.rate-limits.updated", - payload: { - rateLimits: message, - }, + payload: { limits }, }); return; } diff --git a/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts index 2f842bf581f7..253118299820 100644 --- a/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts +++ b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts @@ -77,22 +77,31 @@ it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => { "const lines = createInterface({ input: process.stdin });", 'lines.on("line", (line) => {', " const message = JSON.parse(line);", - ' if (message.type !== "control_request" || message.request?.subtype !== "initialize") return;', - " process.stdout.write(JSON.stringify({", + ' if (message.type !== "control_request") return;', + " const reply = (response) => process.stdout.write(JSON.stringify({", ' type: "control_response",', - " response: {", - ' subtype: "success",', - " request_id: message.request_id,", - " response: {", - ' commands: [{ name: "review", description: "Review changes", argumentHint: "[path]" }],', - " agents: [],", - ' output_style: "default",', - ' available_output_styles: ["default"],', - " models: [],", - ' account: { email: "dev@example.com", subscriptionType: "pro", tokenSource: "oauth" },', - " },", - " },", + ' response: { subtype: "success", request_id: message.request_id, response },', ' }) + "\\n");', + ' if (message.request?.subtype === "initialize") {', + " reply({", + ' commands: [{ name: "review", description: "Review changes", argumentHint: "[path]" }],', + " agents: [],", + ' output_style: "default",', + ' available_output_styles: ["default"],', + " models: [],", + ' account: { email: "dev@example.com", subscriptionType: "pro", tokenSource: "oauth" },', + " });", + " }", + " // The probe follows initialize with get_usage on the same process.", + ' if (message.request?.subtype === "get_usage") {', + " reply({", + " session: {},", + ' subscription_type: "pro",', + " rate_limits_available: true,", + ' rate_limits: { five_hour: { utilization: 12, resets_at: "2026-07-18T14:39:00Z" } },', + " behaviors: null,", + " });", + " }", "});", "setInterval(() => {}, 1_000);", "", @@ -122,6 +131,10 @@ it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => { input: { hint: "[path]" }, }, ], + usage: { + rate_limits_available: true, + rate_limits: { five_hour: { utilization: 12, resets_at: "2026-07-18T14:39:00Z" } }, + }, }); // @effect-diagnostics-next-line preferSchemaOverJson:off diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index bf41046f61e8..bb60327ada0e 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -8,6 +8,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as Ref from "effect/Ref"; import * as Result from "effect/Result"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { createModelCapabilities } from "@t3tools/shared/model"; @@ -16,6 +17,7 @@ import { query as claudeQuery, type Options as ClaudeQueryOptions, type SlashCommand as ClaudeSlashCommand, + type SDKControlGetUsageResponse, type SDKUserMessage, type SettingSource, } from "@anthropic-ai/claude-agent-sdk"; @@ -32,6 +34,12 @@ import { import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts"; import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; import { discoverClaudeSkills } from "../Drivers/ClaudeSkills.ts"; +import { makeUnavailableUsageLimits } from "../providerUsageLimits.ts"; +import { + type ClaudeScopedLimitNames, + claudeUsageResponseToLimits, + recordClaudeUsageResponse, +} from "./claudeUsageLimits.ts"; import { BUNDLED_CLAUDE_MODEL_CATALOG, type ClaudeModelCatalog, @@ -225,6 +233,12 @@ type ClaudeCapabilitiesProbe = { */ readonly apiProvider: string | undefined; readonly slashCommands: ReadonlyArray; + /** + * Subscription windows from the SDK's `get_usage` control request, or + * `undefined` when the request itself failed. Absent windows on an + * otherwise successful response mean the account has none (API key). + */ + readonly usage?: Pick; }; function parseClaudeInitializationCommands( @@ -340,6 +354,15 @@ 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; @@ -354,6 +377,7 @@ const probeClaudeCapabilities = ( tokenSource: account?.tokenSource, apiProvider: account?.apiProvider, slashCommands: parseClaudeInitializationCommands(init.commands), + ...(usage ? { usage } : {}), } satisfies ClaudeCapabilitiesProbe; }); }).pipe( @@ -395,6 +419,8 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( environment?: NodeJS.ProcessEnv, cwd?: string, modelCatalog: ClaudeModelCatalog = BUNDLED_CLAUDE_MODEL_CATALOG, + /** Shared with the adapter so turn events reuse the scoped-bucket names this probe saw. */ + scopedLimitNames?: Ref.Ref, ): Effect.fn.Return< ServerProviderDraft, never, @@ -535,6 +561,14 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( subscriptionType: capabilities.subscriptionType, authMethod: capabilities.tokenSource, }) ?? apiProviderAuthMetadata(capabilities.apiProvider); + const usageLimits = !capabilities.usage + ? makeUnavailableUsageLimits({ checkedAt, reason: "probeFailed" }) + : scopedLimitNames + ? yield* recordClaudeUsageResponse(scopedLimitNames, { + response: capabilities.usage, + checkedAt, + }) + : claudeUsageResponseToLimits({ response: capabilities.usage, checkedAt }).limits; return buildServerProvider({ presentation: CLAUDE_PRESENTATION, enabled: claudeSettings.enabled, @@ -552,6 +586,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( ...(authMetadata ? authMetadata : {}), }, ...(versionUpgradeMessage ? { message: versionUpgradeMessage } : {}), + usageLimits, }, }); }); diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index bcaad3cb5e57..fbfc48c53827 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -1648,6 +1648,81 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { }), ); + it.effect("maps async agent questions without ending the turn", () => + Effect.gen(function* () { + const { adapter, runtime } = yield* startLifecycleRuntime(); + const eventsFiber = yield* Stream.runCollect(Stream.take(adapter.streamEvents, 2)).pipe( + Effect.forkChild, + ); + yield* runtime.emit({ + id: asEventId("evt-async-question"), + kind: "notification", + provider: ProviderDriverKind.make("codex"), + threadId: asThreadId("thread-1"), + createdAt: "2026-01-01T00:00:00.000Z", + method: "item/completed", + payload: { + completedAtMs: 0, + threadId: "thread-1", + turnId: "turn-1", + item: { + type: "agentMessage", + id: "async-question-1", + text: "Which package manager?\n- pnpm\n- npm\n\nWhat should it be named?", + phase: "final_answer", + delivery: "async", + questions: [ + { title: "Which package manager?", options: ["pnpm", "npm"] }, + { title: "What should it be named?" }, + ], + }, + }, + }); + yield* runtime.emit({ + id: asEventId("evt-async-continued"), + kind: "notification", + provider: ProviderDriverKind.make("codex"), + threadId: asThreadId("thread-1"), + createdAt: "2026-01-01T00:00:01.000Z", + method: "item/agentMessage/delta", + payload: { + threadId: "thread-1", + turnId: "turn-1", + itemId: "message-2", + delta: "I will keep working.", + }, + }); + const events = Array.from(yield* Fiber.join(eventsFiber)); + NodeAssert.equal(events[0]?.type, "user-input.requested"); + NodeAssert.equal(events[0]?.requestId, "codex-async:thread-1:async-question-1"); + NodeAssert.deepEqual(events[0]?.payload, { + responseMode: "message", + questions: [ + { + id: "0", + header: "Question", + question: "Which package manager?", + options: [ + { label: "pnpm", description: "" }, + { label: "npm", description: "" }, + ], + allowCustomAnswer: true, + multiSelect: false, + }, + { + id: "1", + header: "Question", + question: "What should it be named?", + options: [], + allowCustomAnswer: true, + multiSelect: false, + }, + ], + }); + NodeAssert.equal(events[1]?.type, "content.delta"); + }), + ); + it.effect("unwraps Codex token usage payloads for context window events", () => Effect.gen(function* () { const { adapter, runtime } = yield* startLifecycleRuntime(); diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index fa8511ee09a1..b17200d36bea 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -8,6 +8,7 @@ * @module CodexAdapterLive */ import { + EventId, type CanonicalItemType, type CanonicalRequestType, type CodexSettings, @@ -69,6 +70,7 @@ import { } from "./CodexSessionRuntime.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; import { resolveCodexLaunchArgs } from "./codexLaunchArgs.ts"; +import { codexRateLimitsToUpdate } from "./codexUsageLimits.ts"; const isCodexAppServerProcessExitedError = Schema.is(CodexErrors.CodexAppServerProcessExitedError); const isCodexAppServerTransportError = Schema.is(CodexErrors.CodexAppServerTransportError); const isCodexSessionRuntimeThreadIdMissingError = Schema.is( @@ -1445,6 +1447,27 @@ function mapToRuntimeEvents( if (!item) { return []; } + if (item.type === "agentMessage" && item.delivery === "async" && item.questions?.length) { + return [ + { + ...runtimeEventBase(event, canonicalThreadId), + type: "user-input.requested", + requestId: RuntimeRequestId.make(`codex-async:${canonicalThreadId}:${item.id}`), + eventId: EventId.make(`codex-async:${canonicalThreadId}:${item.id}`), + payload: { + responseMode: "message", + questions: item.questions.map((question, index) => ({ + id: String(index), + header: "Question", + question: question.title, + options: (question.options ?? []).map((label) => ({ label, description: "" })), + allowCustomAnswer: true, + multiSelect: false, + })), + }, + }, + ]; + } const itemType = toCanonicalItemType(item.type); if (itemType === "plan") { const detail = itemDetail(itemType, item); @@ -1725,16 +1748,19 @@ function mapToRuntimeEvents( } if (event.method === "account/rateLimits/updated") { - if (!readPayload(EffectCodexSchema.V2AccountRateLimitsUpdatedNotification, event.payload)) { + const payload = readPayload( + EffectCodexSchema.V2AccountRateLimitsUpdatedNotification, + event.payload, + ); + const limits = payload ? codexRateLimitsToUpdate(payload.rateLimits) : undefined; + if (!limits) { return []; } return [ { type: "account.rate-limits.updated", ...runtimeEventBase(event, canonicalThreadId), - payload: { - rateLimits: event.payload ?? {}, - }, + payload: { limits }, }, ]; } diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 4d7efbe2106b..9ffd6ccff9ec 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -33,8 +33,19 @@ import { type ServerProviderDraft, } from "../providerSnapshot.ts"; import { expandHomePath } from "../../pathExpansion.ts"; +import { makeUnavailableUsageLimits } from "../providerUsageLimits.ts"; +import { + codexRateLimitsFailureMessage, + codexRateLimitsToLimits, + type CodexRateLimitSnapshot, +} from "./codexUsageLimits.ts"; import packageJson from "../../../package.json" with { type: "json" }; const isCodexAppServerSpawnError = Schema.is(CodexErrors.CodexAppServerSpawnError); +const RATE_LIMITS_PROBE_TIMEOUT_MS = 3_000; + +type CodexRateLimitsProbe = + | { readonly snapshot: CodexRateLimitSnapshot } + | { readonly failure: string }; const CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER = "2 seconds" as const; @@ -45,6 +56,7 @@ const CODEX_PRESENTATION = { export interface CodexAppServerProviderSnapshot { readonly account: CodexSchema.V2GetAccountResponse; + readonly rateLimits?: CodexRateLimitsProbe; readonly version: string | undefined; readonly models: ReadonlyArray; readonly skills: ReadonlyArray; @@ -72,8 +84,12 @@ function codexAccountAuthLabel(account: CodexSchema.V2GetAccountResponse["accoun if (account.type === "apiKey") return "OpenAI API Key"; if (account.type === "amazonBedrock") return "Amazon Bedrock"; if (account.type !== "chatgpt") return undefined; + return codexPlanLabel(account.planType); +} - switch (account.planType) { +/** Shared with usage-limit sources, which report the same `planType` slugs. */ +export function codexPlanLabel(planType: string | null | undefined): string | undefined { + switch (planType) { case "free": return "ChatGPT Free Subscription"; case "go": @@ -102,7 +118,6 @@ function codexAccountAuthLabel(account: CodexSchema.V2GetAccountResponse["accoun case "unknown": return "ChatGPT Subscription"; default: - account.planType satisfies never; return undefined; } } @@ -394,18 +409,35 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun } satisfies CodexAppServerProviderSnapshot; } - const [skillsResponse, models] = yield* Effect.all( + const [skillsResponse, models, rateLimits] = yield* Effect.all( [ client.request("skills/list", { cwds: [input.cwd], }), requestAllCodexModels(client), + // Usage is an enrichment: a failure or a slow answer degrades to "no + // usage this probe" rather than costing the account and models. + client.request("account/rateLimits/read", undefined).pipe( + Effect.map((response): CodexRateLimitsProbe => ({ snapshot: response.rateLimits })), + Effect.timeoutOption(Duration.millis(RATE_LIMITS_PROBE_TIMEOUT_MS)), + Effect.map( + Option.getOrElse((): CodexRateLimitsProbe => ({ + failure: "Codex did not answer the usage request.", + })), + ), + Effect.catch((error) => + Effect.logDebug("Codex rate-limit read failed.", { cause: error }).pipe( + Effect.as({ failure: codexRateLimitsFailureMessage(error) }), + ), + ), + ), ], { concurrency: "unbounded" }, ); return { account: accountResponse, + rateLimits, version, models: applyPreferredCodexDefaultModel( appendCustomCodexModels(models, input.customModels ?? []), @@ -640,6 +672,16 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu const snapshot = probeResult.success.value; const accountStatus = accountProbeStatus(snapshot.account); + const usageLimits = + snapshot.account.account?.type === "apiKey" + ? makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }) + : snapshot.rateLimits === undefined || "failure" in snapshot.rateLimits + ? makeUnavailableUsageLimits({ + checkedAt, + reason: "probeFailed", + ...(snapshot.rateLimits ? { message: snapshot.rateLimits.failure } : {}), + }) + : codexRateLimitsToLimits({ snapshot: snapshot.rateLimits.snapshot, checkedAt }); return buildServerProvider({ presentation: CODEX_PRESENTATION, @@ -660,6 +702,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu status: accountStatus.status, auth: accountStatus.auth, ...(accountStatus.message ? { message: accountStatus.message } : {}), + usageLimits, }, }); }); diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 7f327cae8fb3..01baf92db73e 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -89,6 +89,7 @@ const runtimeMock = { subscribedEvents: [] as Array>, eventSubscribeObserved: null as (() => void) | null, permissionReplyCalls: [] as Array<{ requestID: string; reply: string }>, + permissionReplyImplementation: null as (() => Promise) | null, questionReplyCalls: [] as Array<{ requestID: string; answers: ReadonlyArray>; @@ -139,6 +140,7 @@ const runtimeMock = { this.state.subscribedEvents = []; this.state.eventSubscribeObserved = null; this.state.permissionReplyCalls.length = 0; + this.state.permissionReplyImplementation = null; this.state.questionReplyCalls.length = 0; this.state.sessionStatus = "idle"; this.state.sessionStatusFailures = 0; @@ -377,6 +379,9 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { }, reply: async ({ requestID, reply }: { requestID: string; reply: string }) => { runtimeMock.state.permissionReplyCalls.push({ requestID, reply }); + if (runtimeMock.state.permissionReplyImplementation) { + await runtimeMock.state.permissionReplyImplementation(); + } }, }, question: { @@ -2623,6 +2628,208 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }), ); + it.effect.each([ + { + name: "a doom-loop ask on the parent session", + requestId: "per_doom_loop", + sessionID: "http://127.0.0.1:9999/session", + permission: "doom_loop", + patterns: ["bash"], + always: [] as string[], + }, + { + name: "a child-session ask", + requestId: "per_child_full", + sessionID: "ses_child_full", + permission: "read", + patterns: ["/repo/settings.env"], + always: ["/repo/settings.env"], + }, + ])( + "auto-approves $name in full access", + ({ requestId, sessionID, permission, patterns, always }) => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId(`thread-full-access-${requestId}`); + runtimeMock.state.subscribedEvents = [ + { + id: "evt-child-created", + type: "session.created", + properties: { + sessionID: "ses_child_full", + info: { + id: "ses_child_full", + parentID: "http://127.0.0.1:9999/session", + title: "Child session", + }, + }, + }, + { + id: "evt-permission", + type: "permission.asked", + properties: { id: requestId, sessionID, permission, patterns, metadata: {}, always }, + }, + { + id: "evt-permission-replied", + type: "permission.replied", + properties: { sessionID, requestID: requestId, reply: "once" }, + }, + // The suppressed ask emits nothing, so an empty question serves as a + // sentinel that closes the collected stream once the pump is past it. + { + id: "evt-sentinel-question", + type: "question.asked", + properties: { + id: "que_sentinel", + sessionID: "http://127.0.0.1:9999/session", + questions: [], + }, + }, + ]; + + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.takeUntil((event) => event.type === "user-input.requested"), + Stream.runCollect, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second"))); + + NodeAssert.deepEqual(runtimeMock.state.permissionReplyCalls, [ + { requestID: requestId, reply: "once" }, + ]); + NodeAssert.equal( + events.some((event) => event.type === "request.opened"), + false, + ); + NodeAssert.equal( + events.some((event) => event.type === "request.resolved"), + false, + ); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("surfaces the approval when the full-access auto-reply fails", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-full-access-reply-failed"); + runtimeMock.state.permissionReplyImplementation = async () => { + throw new Error("reply failed"); + }; + runtimeMock.state.subscribedEvents = [ + { + id: "evt-doom-loop", + type: "permission.asked", + properties: { + id: "per_doom_loop_failed", + sessionID: "http://127.0.0.1:9999/session", + permission: "doom_loop", + patterns: ["bash"], + metadata: {}, + always: [], + }, + }, + ]; + + const openedFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId && event.type === "request.opened"), + Stream.take(1), + Stream.runHead, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const opened = Option.getOrUndefined( + yield* Fiber.join(openedFiber).pipe(Effect.timeout("1 second")), + ); + NodeAssert.equal(opened?.requestId, "per_doom_loop_failed"); + // Exactly one auto-reply attempt: the fallback surfaces the dialog + // instead of retrying the reply. + NodeAssert.deepEqual(runtimeMock.state.permissionReplyCalls, [ + { requestID: "per_doom_loop_failed", reply: "once" }, + ]); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("does not reopen a failed full-access auto-reply after its terminal reply", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-full-access-reply-failed-after-terminal"); + const childId = "ses_full_access_terminal_child"; + const request = permissionRequest("per_failed_after_terminal", childId); + const ancestryAttempted = promiseWithResolvers(); + const releaseReply = promiseWithResolvers(); + // The ask arrives from a child whose ancestry lookup is failing, so it + // is handled on a retry fiber. The terminal reply lands while that + // fiber's auto-reply is still in flight; the reply then fails. The + // request must neither reopen nor emit a stray resolution. + runtimeMock.state.sessionParentById.set(childId, "http://127.0.0.1:9999/session"); + runtimeMock.state.transientErrorSessionIds.add(childId); + runtimeMock.state.sessionGetObserved = (sessionID) => { + if (sessionID === childId) { + ancestryAttempted.resolve(undefined); + } + }; + runtimeMock.state.permissionReplyImplementation = async () => { + await releaseReply.promise; + throw new Error("reply failed"); + }; + const terminalEvent = promiseWithResolvers(); + runtimeMock.state.subscribedEvents = [ + { id: "evt-ask", type: "permission.asked", properties: request }, + terminalEvent.promise, + ]; + + const requestEventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter( + (event) => + event.threadId === threadId && + (event.type === "request.opened" || event.type === "request.resolved"), + ), + Stream.runHead, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + yield* Effect.promise(() => ancestryAttempted.promise); + runtimeMock.state.transientErrorSessionIds.delete(childId); + yield* advanceTestClock(250); + NodeAssert.deepEqual(runtimeMock.state.permissionReplyCalls, [ + { requestID: request.id, reply: "once" }, + ]); + + // Drain the microtask queue so the pump has consumed the terminal reply + // before the in-flight auto-reply is allowed to fail. + terminalEvent.resolve({ + id: "evt-reply", + type: "permission.replied", + properties: { sessionID: childId, requestID: request.id, reply: "once" }, + }); + yield* Effect.promise(() => new Promise((resolve) => setImmediate(resolve))); + releaseReply.resolve(undefined); + yield* advanceTestClock(250); + + NodeAssert.equal(requestEventsFiber.pollUnsafe(), undefined); + yield* Fiber.interrupt(requestEventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + it.effect("routes child-session questions and replies through the parent thread", () => Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index d0b4f0de78ce..6d94e0c09a04 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -328,6 +328,7 @@ interface OpenCodeSessionContext { readonly openCodeSessionId: string; readonly relatedSessionIds: Set; readonly resolvedRequestIds: Set; + readonly autoRepliedRequestIds: Set; readonly emittedTerminalRequestIds: Set; readonly requestRelationRetries: Map; readonly pendingPermissions: Map; @@ -1000,6 +1001,12 @@ export function makeOpenCodeAdapter( const emit = (event: ProviderRuntimeEvent) => Queue.offer(runtimeEvents, event).pipe(Effect.asVoid); + // Synchronous publish for callers that must not yield between a state + // check and the enqueue, e.g. reopening an approval only if its terminal + // event has not landed yet. + const emitUnsafe = (event: ProviderRuntimeEvent) => { + Queue.offerUnsafe(runtimeEvents, event); + }; const writeNativeEvent = ( threadId: ThreadId, event: { @@ -1602,6 +1609,39 @@ export function makeOpenCodeAdapter( return false; }); + // Full access means the user already granted everything, but two upstream + // paths never consult the session ruleset we send: doom-loop detection + // (evaluated against the agent ruleset only) and subagent sessions (which + // keep only deny and external-directory rules). Answer those asks here. + // + // Reply "once", not "always": OpenCode stores "always" grants per + // directory, so on a shared external server an "always" from a full-access + // thread would silently widen what a supervised thread on the same + // directory is allowed to do. + const autoReplyFullAccess = Effect.fn("autoReplyFullAccess")(function* ( + context: OpenCodeSessionContext, + request: PermissionRequest, + ) { + // Mark before awaiting: retry and recovery fibers re-enter the ask path, + // and the matching `permission.replied` can arrive, while the SDK call + // is in flight. Marked ids skip the ask and swallow the terminal event. + context.resolvedRequestIds.add(request.id); + context.autoRepliedRequestIds.add(request.id); + const replied = yield* runOpenCodeSdk("permission.reply", () => + context.client.permission.reply({ requestID: request.id, reply: "once" }), + ).pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + ); + if (!replied) { + // Fall back to the dialog. The id stays resolved so a recovered copy + // of this ask cannot reopen after the user answers; + // `pendingPermissions` gates re-asks while the dialog is open. + context.autoRepliedRequestIds.delete(request.id); + } + return replied; + }); + const emitPendingOpenCodeRequest = Effect.fn("emitPendingOpenCodeRequest")(function* ( context: OpenCodeSessionContext, event: OpenCodeAskedRequestEvent, @@ -1615,14 +1655,27 @@ export function makeOpenCodeAdapter( if (context.pendingPermissions.has(request.id)) { return; } + if ( + context.session.runtimeMode === "full-access" && + (yield* autoReplyFullAccess(context, request)) + ) { + return; + } + const base = yield* buildEventBase({ + threadId: context.session.threadId, + turnId: context.activeTurnId, + requestId: request.id, + raw, + }); + // No yield between this check and the publish: a terminal + // `permission.replied` delivered on the pump in between would leave a + // dialog that can never close. + if (context.emittedTerminalRequestIds.has(request.id)) { + return; + } context.pendingPermissions.set(request.id, request); - yield* emit({ - ...(yield* buildEventBase({ - threadId: context.session.threadId, - turnId: context.activeTurnId, - requestId: request.id, - raw, - })), + emitUnsafe({ + ...base, type: "request.opened", payload: { requestType: mapPermissionToRequestType(request.permission), @@ -1671,6 +1724,9 @@ export function makeOpenCodeAdapter( return; } context.emittedTerminalRequestIds.add(requestId); + if (context.autoRepliedRequestIds.delete(requestId)) { + return; + } if (event.type === "permission.replied") { yield* emit({ ...(yield* buildEventBase({ @@ -2554,6 +2610,7 @@ export function makeOpenCodeAdapter( openCodeSessionId: started.openCodeSession.id, relatedSessionIds: new Set([started.openCodeSession.id]), resolvedRequestIds: new Set(), + autoRepliedRequestIds: new Set(), emittedTerminalRequestIds: new Set(), requestRelationRetries: new Map(), pendingPermissions: new Map(), diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts index 2336184adaaa..18d94ebd0e18 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts @@ -118,6 +118,7 @@ const makeFakeInstance = ( getSnapshot: Effect.succeed({} as unknown as ServerProvider), refresh: Effect.succeed({} as unknown as ServerProvider), streamChanges: Stream.empty, + applyUsageLimits: () => Effect.void, }, adapter, textGeneration: {} as unknown as TextGeneration.TextGeneration["Service"], diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 6942d7f9dd52..6816df464fb8 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -1098,6 +1098,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te Effect.andThen(Effect.never), ), streamChanges: Stream.empty, + applyUsageLimits: () => Effect.void, }, adapter: {} as ProviderInstance["adapter"], textGeneration: {} as ProviderInstance["textGeneration"], @@ -1187,6 +1188,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te getSnapshot: Effect.succeed(provider), refresh: Effect.succeed(provider), streamChanges: Stream.empty, + applyUsageLimits: () => Effect.void, }, snapshotForCwd, adapter: {} as ProviderInstance["adapter"], @@ -1377,6 +1379,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te Effect.as(codexProvider), ), streamChanges: Stream.empty, + applyUsageLimits: () => Effect.void, }, adapter: {} as ProviderInstance["adapter"], textGeneration: {} as ProviderInstance["textGeneration"], @@ -1400,6 +1403,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te Effect.andThen(Ref.get(catalogSnapshot)), ), streamChanges: Stream.empty, + applyUsageLimits: () => Effect.void, }, adapter: {} as ProviderInstance["adapter"], textGeneration: {} as ProviderInstance["textGeneration"], @@ -1521,6 +1525,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te getSnapshot: Effect.succeed(initialProvider), refresh: Effect.succeed(refreshedProvider), streamChanges: Stream.fromPubSub(changes), + applyUsageLimits: () => Effect.void, }, adapter: {} as ProviderInstance["adapter"], textGeneration: {} as ProviderInstance["textGeneration"], @@ -1650,6 +1655,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te getSnapshot: Effect.succeed(initialProvider), refresh: Effect.succeed(authoritativeProvider), streamChanges: Stream.fromPubSub(changes), + applyUsageLimits: () => Effect.void, }, adapter: {} as ProviderInstance["adapter"], textGeneration: {} as ProviderInstance["textGeneration"], @@ -1757,6 +1763,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te getSnapshot: Effect.succeed(cachedProvider), refresh: Effect.die(new Error("simulated refresh failure")), streamChanges: Stream.empty, + applyUsageLimits: () => Effect.void, }, adapter: {} as ProviderInstance["adapter"], textGeneration: {} as ProviderInstance["textGeneration"], @@ -1850,6 +1857,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te getSnapshot: Effect.succeed(provider), refresh: Effect.succeed(provider), streamChanges: Stream.empty, + applyUsageLimits: () => Effect.void, }, adapter: {} as ProviderInstance["adapter"], textGeneration: {} as ProviderInstance["textGeneration"], diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts index 15e23c79010c..a796d1c4038a 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts @@ -204,6 +204,7 @@ describe("ProviderSessionReaper", () => { Layer.provideMerge(Layer.succeed(ProviderService, providerService)), Layer.provideMerge( Layer.succeed(ProjectionSnapshotQuery, { + getUserInputActivity: () => Effect.die("unused"), getCommandReadModel: () => Effect.die("unused"), getSnapshot: () => Effect.die("unused"), getShellSnapshot: () => Effect.die("unused"), diff --git a/apps/server/src/provider/Layers/ProviderUsageLimitsIngestion.ts b/apps/server/src/provider/Layers/ProviderUsageLimitsIngestion.ts new file mode 100644 index 000000000000..fd63ff8a8c68 --- /dev/null +++ b/apps/server/src/provider/Layers/ProviderUsageLimitsIngestion.ts @@ -0,0 +1,44 @@ +/** + * ProviderUsageLimitsIngestionLive — folds `account.rate-limits.updated` + * runtime events into the owning instance's published snapshot. + * + * Adapters normalise their native payloads before emitting, so this layer + * never sees a driver shape: it routes the typed update to the instance and + * lets `ServerProviderShape.applyUsageLimits` merge and republish on the + * instance's own change stream, which `ProviderRegistry` already aggregates. + * + * @module provider/Layers/ProviderUsageLimitsIngestion + */ +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; + +import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; +import { ProviderService } from "../Services/ProviderService.ts"; + +export const ProviderUsageLimitsIngestionLive = Layer.effectDiscard( + Effect.gen(function* () { + const providerService = yield* ProviderService; + const instanceRegistry = yield* ProviderInstanceRegistry; + + yield* providerService.streamEvents.pipe( + Stream.filter((event) => event.type === "account.rate-limits.updated"), + Stream.runForEach((event) => + Effect.gen(function* () { + if (!event.providerInstanceId) { + return; + } + const instance = yield* instanceRegistry.getInstance(event.providerInstanceId); + if (!instance) { + return; + } + const checkedAt = DateTime.formatIso(yield* DateTime.now); + yield* instance.snapshot.applyUsageLimits({ ...event.payload.limits, checkedAt }); + // One bad event must not end the subscriber for every later one. + }).pipe(Effect.ignoreCause({ log: true })), + ), + Effect.forkScoped, + ); + }), +); diff --git a/apps/server/src/provider/Layers/claudeUsageLimits.test.ts b/apps/server/src/provider/Layers/claudeUsageLimits.test.ts new file mode 100644 index 000000000000..a3d326a7dcb2 --- /dev/null +++ b/apps/server/src/provider/Layers/claudeUsageLimits.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { claudeRateLimitEventToUpdate, claudeUsageResponseToLimits } from "./claudeUsageLimits.ts"; + +const checkedAt = "2026-07-18T10:00:00.000Z"; +const noNames = { overageIncluded: undefined } as const; + +describe("claudeUsageResponseToLimits", () => { + it("maps the session, weekly, and model-scoped weekly windows", () => { + expect( + claudeUsageResponseToLimits({ + checkedAt, + response: { + rate_limits_available: true, + rate_limits: { + five_hour: { utilization: 54, resets_at: "2026-07-18T14:39:00Z" }, + seven_day: { utilization: 18.4, resets_at: "2026-07-24T08:59:00+00:00" }, + seven_day_opus: { utilization: 3, resets_at: null }, + // Newer CLIs add this on top of the typed keys; the pinned SDK + // typings do not know it yet. + ...({ + model_scoped: [ + { display_name: "Fable", utilization: 73, resets_at: "2026-07-24T08:59:00Z" }, + { display_name: "Ghost", utilization: null, resets_at: null }, + ], + } as object), + extra_usage: { + is_enabled: false, + monthly_limit: null, + used_credits: null, + utilization: null, + }, + }, + }, + }), + ).toEqual({ + names: { overageIncluded: "Fable" }, + limits: { + checkedAt, + windows: [ + { + id: "five_hour", + kind: "session", + label: "Session", + usedPercent: 54, + windowDurationMins: 300, + resetsAt: "2026-07-18T14:39:00.000Z", + }, + { + id: "seven_day", + kind: "weekly", + label: "Weekly", + usedPercent: 18.4, + windowDurationMins: 10080, + resetsAt: "2026-07-24T08:59:00.000Z", + }, + { + id: "seven_day_fable", + kind: "weekly", + label: "Weekly · Fable", + usedPercent: 73, + windowDurationMins: 10080, + resetsAt: "2026-07-24T08:59:00.000Z", + }, + ], + }, + }); + }); + + it("names the overage-included bucket only from a scoped entry that drew a row", () => { + expect( + claudeUsageResponseToLimits({ + checkedAt, + response: { + rate_limits_available: true, + rate_limits: { + ...({ + model_scoped: [ + { display_name: "Ghost", utilization: null, resets_at: null }, + { display_name: "Fable", utilization: 5, resets_at: null }, + ], + } as object), + }, + }, + }).names, + ).toEqual({ overageIncluded: "Fable" }); + }); + + it("reports API key and Bedrock accounts as unsupported", () => { + expect( + claudeUsageResponseToLimits({ + checkedAt, + response: { rate_limits_available: false, rate_limits: null }, + }).limits, + ).toEqual({ checkedAt, windows: [], unavailable: { reason: "unsupported" } }); + }); + + it("skips a window the endpoint reports without a utilization", () => { + expect( + claudeUsageResponseToLimits({ + checkedAt, + response: { + rate_limits_available: true, + rate_limits: { + five_hour: { utilization: null, resets_at: null }, + seven_day: { utilization: 250, resets_at: null }, + }, + }, + }).limits.windows, + ).toEqual([ + { + id: "seven_day", + kind: "weekly", + label: "Weekly", + usedPercent: 100, + windowDurationMins: 10080, + }, + ]); + }); +}); + +describe("claudeRateLimitEventToUpdate", () => { + it("scales the 0–1 utilization and epoch-second reset onto the probe's window id", () => { + expect( + claudeRateLimitEventToUpdate( + { + status: "allowed_warning", + rateLimitType: "seven_day", + utilization: 0.85, + resetsAt: 1_784_000_000, + }, + noNames, + ), + ).toEqual({ + windows: [ + { + id: "seven_day", + kind: "weekly", + label: "Weekly", + usedPercent: 85, + windowDurationMins: 10080, + resetsAt: "2026-07-14T03:33:20.000Z", + }, + ], + }); + }); + + it("lands the streamed overage-included bucket on the row the probe named", () => { + const event = { + status: "allowed", + rateLimitType: "seven_day_overage_included" as never, + utilization: 0.4, + } as const; + // No probe has named the bucket yet: guessing would open a stray row. + expect(claudeRateLimitEventToUpdate(event, noNames)).toBeUndefined(); + expect(claudeRateLimitEventToUpdate(event, { overageIncluded: "Fable" })).toEqual({ + windows: [ + { + id: "seven_day_fable", + kind: "weekly", + label: "Weekly · Fable", + usedPercent: 40, + windowDurationMins: 10080, + }, + ], + }); + }); + + it("ignores windows the page does not render and events without a utilization", () => { + expect( + claudeRateLimitEventToUpdate( + { status: "allowed", rateLimitType: "seven_day_opus", utilization: 0.1 }, + noNames, + ), + ).toBeUndefined(); + expect( + claudeRateLimitEventToUpdate({ status: "rejected", rateLimitType: "five_hour" }, noNames), + ).toBeUndefined(); + }); +}); diff --git a/apps/server/src/provider/Layers/claudeUsageLimits.ts b/apps/server/src/provider/Layers/claudeUsageLimits.ts new file mode 100644 index 000000000000..b67645102e5a --- /dev/null +++ b/apps/server/src/provider/Layers/claudeUsageLimits.ts @@ -0,0 +1,199 @@ +/** + * Claude Code subscription usage. Both sources produce windows with the same + * ids so a turn-driven `rate_limit_event` lands on the row the SDK's + * `get_usage` read established: + * + * - `get_usage` (on demand, during the capabilities probe) reports every + * window at once as 0–100 percentages with ISO reset times. + * - `rate_limit_event` (streamed during a turn) names one window at a time + * with a 0–1 utilization fraction and an epoch-seconds reset. + * + * @module provider/Layers/claudeUsageLimits + */ +import type { SDKControlGetUsageResponse, SDKRateLimitInfo } from "@anthropic-ai/claude-agent-sdk"; +import type { + ProviderUsageLimitsUpdate, + ServerProviderUsageLimits, + ServerProviderUsageWindow, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; + +import { + clampPercent, + makeUnavailableUsageLimits, + makeUsageLimits, +} from "../providerUsageLimits.ts"; + +const SESSION_MINS = 5 * 60; +const WEEK_MINS = 7 * 24 * 60; + +/** + * The account-wide windows, keyed by the SDK's `rateLimitType`. Model-scoped + * weeklies are additive on top of these: the CLI reports them under + * `rate_limits.model_scoped[]` on `get_usage` and streams the overage-included + * model bucket (Fable today) as `seven_day_overage_included`. + */ +const WINDOWS: Readonly< + Record> +> = { + five_hour: { kind: "session", label: "Session", windowDurationMins: SESSION_MINS }, + seven_day: { kind: "weekly", label: "Weekly", windowDurationMins: WEEK_MINS }, +}; + +/** + * The streamed event names the overage-included bucket by type + * (`seven_day_overage_included`), while `get_usage` names it by the model's + * `display_name`. Which model that is changes over time, so the probe records + * the name it saw and the event mapper reuses it; the mid-turn update then + * lands on the row the probe drew instead of opening a second one. + */ +const OVERAGE_INCLUDED_EVENT_TYPE = "seven_day_overage_included"; + +export interface ClaudeScopedLimitNames { + readonly overageIncluded: string | undefined; +} + +export const makeClaudeScopedLimitNames = Ref.make({ + overageIncluded: undefined, +}); + +function scopedWindowId(displayName: string): string { + return `seven_day_${displayName.toLowerCase().replace(/[^a-z0-9]+/g, "_")}`; +} + +function scopedWindow( + displayName: string, + usedPercent: number, + resetsAt: string | undefined, +): ServerProviderUsageWindow { + return { + id: scopedWindowId(displayName), + kind: "weekly", + label: `Weekly · ${displayName}`, + windowDurationMins: WEEK_MINS, + usedPercent: clampPercent(usedPercent), + ...(resetsAt ? { resetsAt } : {}), + }; +} + +/** + * `model_scoped` shipped in the CLI after the SDK typings we pin, so it is + * read structurally until the `.d.ts` catches up. + */ +interface ModelScopedWindow { + readonly display_name: string; + readonly utilization: number | null; + readonly resets_at: string | null; +} + +function readModelScoped(rateLimits: object): ReadonlyArray { + const raw = (rateLimits as { readonly model_scoped?: unknown }).model_scoped; + if (!Array.isArray(raw)) return []; + return raw.filter( + (entry): entry is ModelScopedWindow => + typeof entry === "object" && + entry !== null && + typeof (entry as ModelScopedWindow).display_name === "string", + ); +} + +function isoFromEpochSeconds(value: number | undefined): string | undefined { + if (value === undefined || !Number.isFinite(value) || value <= 0) return undefined; + const dt = DateTime.make(value * 1000); + return Option.isSome(dt) ? DateTime.formatIso(dt.value) : undefined; +} + +function isoFromString(value: string | null | undefined): string | undefined { + if (!value) return undefined; + const dt = DateTime.make(value); + return Option.isSome(dt) ? DateTime.formatIso(dt.value) : undefined; +} + +function makeWindow( + id: keyof typeof WINDOWS & string, + usedPercent: number, + resetsAt: string | undefined, +): ServerProviderUsageWindow { + const window = WINDOWS[id]!; + return { + id, + ...window, + usedPercent: clampPercent(usedPercent), + ...(resetsAt ? { resetsAt } : {}), + }; +} + +/** + * Utilization is a 0–1 fraction on the streamed event. An overage-included + * event before any probe has named the bucket is dropped: guessing a name + * would draw a row the next probe cannot reconcile. + */ +export function claudeRateLimitEventToUpdate( + info: SDKRateLimitInfo, + names: ClaudeScopedLimitNames, +): ProviderUsageLimitsUpdate | undefined { + const type: string | undefined = info.rateLimitType; + if (!type || typeof info.utilization !== "number") { + return undefined; + } + const usedPercent = info.utilization * 100; + const resetsAt = isoFromEpochSeconds(info.resetsAt); + if (type in WINDOWS) { + return { windows: [makeWindow(type, usedPercent, resetsAt)] }; + } + if (type === OVERAGE_INCLUDED_EVENT_TYPE && names.overageIncluded) { + return { windows: [scopedWindow(names.overageIncluded, usedPercent, resetsAt)] }; + } + return undefined; +} + +/** + * Percentages on the `get_usage` response are already 0–100. Also yields the + * scoped-bucket names the response carried, for the event mapper to reuse. + */ +export function claudeUsageResponseToLimits(input: { + readonly response: Pick; + readonly checkedAt: string; +}): { readonly limits: ServerProviderUsageLimits; readonly names: ClaudeScopedLimitNames } { + const { response, checkedAt } = input; + if (!response.rate_limits_available || !response.rate_limits) { + return { + limits: makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }), + names: { overageIncluded: undefined }, + }; + } + const windows: ServerProviderUsageWindow[] = []; + for (const id of Object.keys(WINDOWS)) { + const window = response.rate_limits[id as "five_hour" | "seven_day"]; + if (!window || typeof window.utilization !== "number") continue; + windows.push(makeWindow(id, window.utilization, isoFromString(window.resets_at))); + } + // The CLI filters `model_scoped` to the overage-included allowlist, which + // today holds one model; the first entry is the one the event refers to. + let overageIncluded: string | undefined; + for (const entry of readModelScoped(response.rate_limits)) { + if (typeof entry.utilization !== "number") continue; + windows.push( + scopedWindow(entry.display_name, entry.utilization, isoFromString(entry.resets_at)), + ); + // Only a bucket that drew a row may receive events; naming one that was + // skipped would let a mid-turn event open a row the probe never showed. + overageIncluded ??= entry.display_name; + } + return { + limits: makeUsageLimits({ checkedAt, windows }), + names: { overageIncluded }, + }; +} + +/** Probe-side helper: map the response and remember the scoped names for events. */ +export const recordClaudeUsageResponse = ( + namesRef: Ref.Ref, + input: Parameters[0], +): Effect.Effect => { + const { limits, names } = claudeUsageResponseToLimits(input); + return Ref.set(namesRef, names).pipe(Effect.as(limits)); +}; diff --git a/apps/server/src/provider/Layers/codexUsageLimits.test.ts b/apps/server/src/provider/Layers/codexUsageLimits.test.ts new file mode 100644 index 000000000000..62cfa0552aab --- /dev/null +++ b/apps/server/src/provider/Layers/codexUsageLimits.test.ts @@ -0,0 +1,103 @@ +import * as CodexErrors from "effect-codex-app-server/errors"; +import { describe, expect, it } from "vite-plus/test"; + +import { + codexRateLimitsFailureMessage, + codexRateLimitsToLimits, + codexRateLimitsToUpdate, +} from "./codexUsageLimits.ts"; + +const checkedAt = "2026-07-18T10:00:00.000Z"; + +describe("codexRateLimitsToLimits", () => { + it("maps primary and secondary onto the session and weekly windows", () => { + expect( + codexRateLimitsToLimits({ + checkedAt, + snapshot: { + planType: "plus", + primary: { usedPercent: 12, resetsAt: 1_784_000_000, windowDurationMins: 300 }, + secondary: { usedPercent: 47, resetsAt: 1_784_500_000, windowDurationMins: 10080 }, + }, + }), + ).toEqual({ + checkedAt, + windows: [ + { + id: "primary", + kind: "session", + label: "Session", + usedPercent: 12, + windowDurationMins: 300, + resetsAt: "2026-07-14T03:33:20.000Z", + }, + { + id: "secondary", + kind: "weekly", + label: "Weekly", + usedPercent: 47, + windowDurationMins: 10080, + resetsAt: "2026-07-19T22:26:40.000Z", + }, + ], + }); + }); + + it("treats a lone duration-less primary as monthly on Free and Go", () => { + expect( + codexRateLimitsToLimits({ + checkedAt, + snapshot: { planType: "free", primary: { usedPercent: 80, resetsAt: null } }, + }).windows, + ).toEqual([ + { + id: "primary", + kind: "monthly", + label: "Monthly", + usedPercent: 80, + windowDurationMins: 43_200, + }, + ]); + }); +}); + +describe("codexRateLimitsToUpdate", () => { + it("carries only the windows the notification names", () => { + expect( + codexRateLimitsToUpdate({ + secondary: { usedPercent: 51, windowDurationMins: 10080 }, + }), + ).toEqual({ + windows: [ + { + id: "secondary", + kind: "weekly", + label: "Weekly", + usedPercent: 51, + windowDurationMins: 10080, + }, + ], + }); + expect(codexRateLimitsToUpdate({ planType: "plus" })).toBeUndefined(); + }); +}); + +describe("codexRateLimitsFailureMessage", () => { + it("keeps the JSON-RPC code and nothing else from a request failure", () => { + expect( + codexRateLimitsFailureMessage( + new CodexErrors.CodexAppServerRequestError({ + code: -32603, + errorMessage: + "failed to fetch codex rate limits: GET https://chatgpt.com/backend-api/wham/usage failed: 401 Unauthorized", + }), + ), + ).toBe("Codex could not read usage (JSON-RPC -32603)."); + }); + + it("phrases a dead process differently from a bad answer", () => { + expect( + codexRateLimitsFailureMessage(new CodexErrors.CodexAppServerProcessExitedError({ code: 1 })), + ).toBe("Codex exited before it could report usage."); + }); +}); diff --git a/apps/server/src/provider/Layers/codexUsageLimits.ts b/apps/server/src/provider/Layers/codexUsageLimits.ts new file mode 100644 index 000000000000..1b6148f0da82 --- /dev/null +++ b/apps/server/src/provider/Layers/codexUsageLimits.ts @@ -0,0 +1,118 @@ +/** + * Codex subscription usage. The `account/rateLimits/read` response and the + * `account/rateLimits/updated` notification carry the same snapshot shape, so + * one mapper serves the status probe and the turn-driven update; both emit + * windows with the same ids so they merge onto the same rows. + * + * @module provider/Layers/codexUsageLimits + */ +import type { + ProviderUsageLimitsUpdate, + ServerProviderUsageLimits, + ServerProviderUsageWindow, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Option from "effect/Option"; +import type * as CodexErrors from "effect-codex-app-server/errors"; + +import { clampPercent, makeUsageLimits } from "../providerUsageLimits.ts"; + +interface CodexRateLimitWindow { + readonly usedPercent: number; + readonly resetsAt?: number | null; + readonly windowDurationMins?: number | null; +} + +/** Structural view of the generated `RateLimitSnapshot`; both messages satisfy it. */ +export interface CodexRateLimitSnapshot { + readonly planType?: string | null; + readonly primary?: CodexRateLimitWindow | null; + readonly secondary?: CodexRateLimitWindow | null; +} + +const SESSION_MINS = 5 * 60; +const WEEK_MINS = 7 * 24 * 60; +const MONTH_MINS = 30 * 24 * 60; + +function isoFromEpochSeconds(value: number | null | undefined): string | undefined { + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return undefined; + const dt = DateTime.make(value * 1000); + return Option.isSome(dt) ? DateTime.formatIso(dt.value) : undefined; +} + +function kindForDuration(mins: number): ServerProviderUsageWindow["kind"] { + if (mins >= MONTH_MINS) return "monthly"; + if (mins >= WEEK_MINS) return "weekly"; + return "session"; +} + +function labelForKind(kind: ServerProviderUsageWindow["kind"]): string { + return kind === "session" ? "Session" : kind === "weekly" ? "Weekly" : "Monthly"; +} + +/** + * `primary` / `secondary` are positions, not durations. Codex usually sends + * `windowDurationMins`; when it does not, paid plans expose the 5-hour and + * weekly pair and Free/Go expose one monthly allowance. + */ +export function codexRateLimitsToWindows( + snapshot: CodexRateLimitSnapshot, +): ReadonlyArray { + const isMonthlyPlan = snapshot.planType === "free" || snapshot.planType === "go"; + const positions = [ + ["primary", snapshot.primary, isMonthlyPlan ? MONTH_MINS : SESSION_MINS], + ["secondary", snapshot.secondary, WEEK_MINS], + ] as const; + const windows: ServerProviderUsageWindow[] = []; + for (const [id, window, fallbackMins] of positions) { + if (!window || !Number.isFinite(window.usedPercent)) continue; + const windowDurationMins = + typeof window.windowDurationMins === "number" ? window.windowDurationMins : fallbackMins; + const kind = kindForDuration(windowDurationMins); + const resetsAt = isoFromEpochSeconds(window.resetsAt); + windows.push({ + id, + kind, + label: labelForKind(kind), + usedPercent: clampPercent(window.usedPercent), + windowDurationMins, + ...(resetsAt ? { resetsAt } : {}), + }); + } + return windows; +} + +export function codexRateLimitsToLimits(input: { + readonly snapshot: CodexRateLimitSnapshot; + readonly checkedAt: string; +}): ServerProviderUsageLimits { + return makeUsageLimits({ + checkedAt: input.checkedAt, + windows: codexRateLimitsToWindows(input.snapshot), + }); +} + +export function codexRateLimitsToUpdate( + snapshot: CodexRateLimitSnapshot, +): ProviderUsageLimitsUpdate | undefined { + const windows = codexRateLimitsToWindows(snapshot); + return windows.length > 0 ? { windows } : undefined; +} + +/** + * A bounded, client-safe reason for a failed `account/rateLimits/read`. The + * raw error is for the log; only the category and, for a JSON-RPC failure, + * the code reach the Limits view. + */ +export function codexRateLimitsFailureMessage(error: CodexErrors.CodexAppServerError): string { + switch (error._tag) { + case "CodexAppServerRequestError": + return `Codex could not read usage (JSON-RPC ${error.code}).`; + case "CodexAppServerSpawnError": + return "Codex could not be started to read usage."; + case "CodexAppServerProcessExitedError": + return "Codex exited before it could report usage."; + default: + return "Codex did not answer the usage request."; + } +} diff --git a/apps/server/src/provider/Services/ServerProvider.ts b/apps/server/src/provider/Services/ServerProvider.ts index 121625129270..5d0486d87ca7 100644 --- a/apps/server/src/provider/Services/ServerProvider.ts +++ b/apps/server/src/provider/Services/ServerProvider.ts @@ -1,4 +1,4 @@ -import type { ServerProvider } from "@t3tools/contracts"; +import type { ProviderUsageLimitsUpdate, ServerProvider } from "@t3tools/contracts"; import type * as Effect from "effect/Effect"; import type * as Stream from "effect/Stream"; import type { ProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; @@ -8,4 +8,12 @@ export interface ServerProviderShape { readonly getSnapshot: Effect.Effect; readonly refresh: Effect.Effect; readonly streamChanges: Stream.Stream; + /** + * Fold a runtime rate-limit update into the published snapshot without + * waiting for the next status probe. Sparse: windows merge by id and an + * update with no usable window leaves the snapshot untouched. + */ + readonly applyUsageLimits: ( + update: ProviderUsageLimitsUpdate & { readonly checkedAt: string }, + ) => Effect.Effect; } diff --git a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts index 7db8ff514ec1..57323e2675e0 100644 --- a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts +++ b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts @@ -18,6 +18,7 @@ import { describe, expect } from "vite-plus/test"; import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; import type * as EffectAcpProtocol from "effect-acp/protocol"; +import * as EffectAcpErrors from "effect-acp/errors"; const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); const mockAgentPath = NodePath.join(__dirname, "../../../scripts/acp-mock-agent.ts"); @@ -69,11 +70,15 @@ describe("AcpSessionRuntime", () => { expect(events.map((event) => event._tag)).toEqual([ "AvailableCommandsUpdated", "ModeChanged", + "ConfigOptionsUpdated", ]); expect(events[0]).toMatchObject({ availableCommands: [{ name: "plan", description: "Native command" }], }); expect(yield* runtime.getModeState).toMatchObject({ currentModeId: "code" }); + expect(events[2]).toMatchObject({ + configOptions: yield* runtime.getConfigOptions, + }); expect( (yield* runtime.getConfigOptions).find((option) => option.category === "model"), ).toMatchObject({ currentValue: "gpt-5.4" }); @@ -81,6 +86,20 @@ describe("AcpSessionRuntime", () => { ); } + it.effect("publishes model changes returned by a config request and live notifications", () => + Effect.gen(function* () { + const runtime = yield* AcpSessionRuntime.make(mockRuntimeOptions); + yield* runtime.start(); + const updates = yield* Stream.toPull( + runtime.getEvents().pipe(Stream.filter((event) => event._tag === "ConfigOptionsUpdated")), + ); + const selected = yield* runtime.setConfigOption("model", "composer-2"); + expect((yield* updates)[0]?.configOptions).toEqual(selected.configOptions); + yield* runtime.request("_test/startup-metadata", {}); + expect((yield* updates)[0]?.configOptions).toEqual(yield* runtime.getConfigOptions); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + it.effect("awaits native resume instead of using the load replay idle fallback", () => Effect.gen(function* () { const resumeStarted = yield* Deferred.make(); @@ -327,7 +346,29 @@ describe("AcpSessionRuntime", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); - it.effect("drains large stderr output and bounds optional logging chunks", () => + it.effect("fails a pending request when the stderr handler rejects the runtime", () => + Effect.gen(function* () { + const failure = new EffectAcpErrors.AcpTransportError({ + detail: "Sign in before continuing.", + cause: undefined, + }); + const runtime = yield* AcpSessionRuntime.make({ + ...mockRuntimeOptions, + spawn: { ...mockRuntimeOptions.spawn, env: { T3_ACP_FLOOD_STDERR: "1" } }, + onStderr: () => Effect.fail(failure), + }); + expect(yield* runtime.start().pipe(Effect.flip)).toBe(failure); + const events = yield* runtime.getEvents().pipe( + Stream.filter((event) => event._tag === "ConnectionTerminated"), + Stream.take(1), + Stream.runCollect, + ); + expect(events[0]?.error).toBe(failure); + expect(yield* runtime.initialize().pipe(Effect.flip)).toBe(failure); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("drains large stderr output and keeps auth-sized logging chunks", () => Effect.gen(function* () { const lengths: Array = []; for (const logStderr of [false, true]) { @@ -348,7 +389,8 @@ describe("AcpSessionRuntime", () => { }).pipe(Effect.scoped); } expect(lengths.length).toBeGreaterThan(0); - expect(Math.max(...lengths)).toBeLessThanOrEqual(8_192); + expect(Math.max(...lengths)).toBeGreaterThanOrEqual(16_384); + expect(Math.max(...lengths)).toBeLessThanOrEqual(32_768); }).pipe(Effect.provide(NodeServices.layer)), ); diff --git a/apps/server/src/provider/acp/AcpRuntimeModel.ts b/apps/server/src/provider/acp/AcpRuntimeModel.ts index b293e0d199a6..89c02dc7f949 100644 --- a/apps/server/src/provider/acp/AcpRuntimeModel.ts +++ b/apps/server/src/provider/acp/AcpRuntimeModel.ts @@ -90,6 +90,11 @@ export type AcpParsedSessionEvent = readonly availableCommands: ReadonlyArray; readonly rawPayload: unknown; } + | { + readonly _tag: "ConfigOptionsUpdated"; + readonly configOptions: ReadonlyArray; + readonly rawPayload: unknown; + } | { readonly _tag: "AssistantItemStarted"; readonly itemId: string; @@ -788,6 +793,14 @@ export function parseSessionUpdateEvent(params: EffectAcpSchema.SessionNotificat let modeId: string | undefined; switch (upd.sessionUpdate) { + case "config_option_update": { + events.push({ + _tag: "ConfigOptionsUpdated", + configOptions: upd.configOptions, + rawPayload: params, + }); + break; + } case "available_commands_update": { events.push({ _tag: "AvailableCommandsUpdated", diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index 6538a47cbcb7..b5894192eed9 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -66,7 +66,8 @@ const defaultSessionLoadTimeout = Duration.seconds(90); const defaultSessionLoadReplayIdleGap = Duration.seconds(2); const defaultCancelTimeout = Duration.seconds(15); const maxStartupMetadataUpdates = 32; -const maxStderrChunkLength = 8_192; +// Antigravity can emit an accepted 16 KiB Google authorization URL on stderr. +const maxStderrChunkLength = 32_768; export interface AcpSpawnInput { readonly command: string; @@ -101,8 +102,8 @@ export interface AcpSessionRuntimeOptions { readonly transformSessionUpdate?: ( notification: EffectAcpSchema.SessionNotification, ) => EffectAcpSchema.SessionNotification; - /** Receives bounded stderr chunks. The provider must redact any secrets before logging. */ - readonly onStderr?: (text: string) => Effect.Effect; + /** Receives bounded stderr chunks. Redact secrets before logging. A failure closes the runtime. */ + readonly onStderr?: (text: string) => Effect.Effect; readonly requestLogger?: (event: AcpSessionRequestLogEvent) => Effect.Effect; readonly protocolLogging?: { readonly logIncoming?: boolean; @@ -352,6 +353,7 @@ export const make = ( Option.none(), ); const stoppingRef = yield* Ref.make(false); + const stderrFailure = yield* Deferred.make(); const runtimeClosed = yield* Deferred.make(); const promptSerializationSemaphore = yield* Semaphore.make(1); const promptDispatchSemaphore = yield* Semaphore.make(1); @@ -399,7 +401,10 @@ export const make = ( ): Effect.Effect => logRequest({ method, payload, status: "started" }).pipe( Effect.flatMap(() => - effect.pipe( + (options.onStderr + ? Effect.raceFirst(effect, Deferred.await(stderrFailure)) + : effect + ).pipe( Effect.tap((result) => logRequest({ method, @@ -447,7 +452,18 @@ export const make = ( yield* child.stderr.pipe( Stream.decodeText(), Stream.runForEach((chunk) => - options.onStderr ? options.onStderr(chunk.slice(-maxStderrChunkLength)) : Effect.void, + (options.onStderr + ? options.onStderr(chunk.slice(-maxStderrChunkLength)) + : Effect.void + ).pipe( + Effect.catch((error) => + Effect.gen(function* () { + yield* Deferred.fail(stderrFailure, error); + yield* recordTermination(error); + yield* child.kill({ forceKillAfter: "1 second" }).pipe(Effect.ignore); + }), + ), + ), ), Effect.ignore, Effect.forkIn(runtimeScope), @@ -614,13 +630,17 @@ export const make = ( }); }); - const updateConfigOptions = ( - response: - | EffectAcpSchema.SetSessionConfigOptionResponse - | EffectAcpSchema.LoadSessionResponse - | EffectAcpSchema.NewSessionResponse - | EffectAcpSchema.ResumeSessionResponse, - ): Effect.Effect => Ref.set(configOptionsRef, sessionConfigOptionsFromSetup(response)); + const updateConfigOptions = Effect.fn("AcpSessionRuntime.updateConfigOptions")(function* ( + response: EffectAcpSchema.SetSessionConfigOptionResponse, + ) { + const configOptions = sessionConfigOptionsFromSetup(response); + yield* Ref.set(configOptionsRef, configOptions); + yield* Queue.offer(eventQueue, { + _tag: "ConfigOptionsUpdated", + configOptions, + rawPayload: response, + }); + }); const updateCurrentModeId = (modeId: string): Effect.Effect => Ref.update(modeStateRef, (current) => diff --git a/apps/server/src/provider/acp/AntigravityAcpSupport.ts b/apps/server/src/provider/acp/AntigravityAcpSupport.ts index c05994c62c9a..cb37df006587 100644 --- a/apps/server/src/provider/acp/AntigravityAcpSupport.ts +++ b/apps/server/src/provider/acp/AntigravityAcpSupport.ts @@ -19,7 +19,7 @@ import type * as EffectAcpSchema from "effect-acp/schema"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { - drainAntigravityStderr, + makeAntigravityStderrHandler, makeAntigravityStdoutTransform, } from "../antigravityAuthSupport.ts"; import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; @@ -73,7 +73,9 @@ export const makeAntigravityAcpRuntime = Effect.fn("makeAntigravityAcpRuntime")( transformStdout: makeAntigravityStdoutTransform( input.onAuthorizationUrl ? { onAuthorizationUrl: input.onAuthorizationUrl } : {}, ), - onStderr: drainAntigravityStderr, + onStderr: makeAntigravityStderrHandler( + input.onAuthorizationUrl ? { onAuthorizationUrl: input.onAuthorizationUrl } : {}, + ), transformSessionUpdate: normalizeAntigravitySessionUpdate, }).pipe( Layer.provide( diff --git a/apps/server/src/provider/acp/AntigravityProtocol.test.ts b/apps/server/src/provider/acp/AntigravityProtocol.test.ts index a40325a1e5cf..4f3368353ddf 100644 --- a/apps/server/src/provider/acp/AntigravityProtocol.test.ts +++ b/apps/server/src/provider/acp/AntigravityProtocol.test.ts @@ -6,6 +6,9 @@ import { extractAntigravityUserInputQuestion, isAntigravityOpenCommand, antigravityApprovalOptions, + antigravitySubagentResult, + isAntigravitySubagentReplayStart, + classifyAntigravitySubagentToolCall, isAntigravityUserInputRequest, makeAntigravityUserInputResponse, normalizeAntigravitySessionUpdate, @@ -17,6 +20,68 @@ import { mergeToolCallState, parseSessionUpdateEvent } from "./AcpRuntimeModel.t const isSessionNotification = Schema.is(EffectAcpSchema.SessionNotification); +describe("native Antigravity subagent tools", () => { + it("recognizes only native invocation titles and excludes MCP tools", () => { + const toolCall = { toolCallId: "trajectory:4", kind: "other", data: {} }; + for (const title of ["Running start_subagent", "Run start_subagent?"]) { + expect(classifyAntigravitySubagentToolCall({ ...toolCall, title }, {})).toBe("subagent"); + expect( + classifyAntigravitySubagentToolCall( + { ...toolCall, title }, + { update: { _meta: { is_mcp_tool_call: true } } }, + ), + ).toBe("mcp"); + } + for (const title of [ + "Running subagent", + "start_subagent", + "Run command", + "Running manage_task", + ]) { + expect(classifyAntigravitySubagentToolCall({ ...toolCall, title }, {})).toBeUndefined(); + } + expect( + classifyAntigravitySubagentToolCall( + { ...toolCall, title: "Running start_subagent", kind: "execute" }, + {}, + ), + ).toBeUndefined(); + }); + + it("recognizes history starts and bounds the native result", () => { + expect( + isAntigravitySubagentReplayStart({ + update: { sessionUpdate: "tool_call", status: "completed", rawOutput: "Done." }, + }), + ).toBe(false); + expect( + isAntigravitySubagentReplayStart({ + update: { sessionUpdate: "tool_call", status: "completed" }, + }), + ).toBe(true); + expect( + isAntigravitySubagentReplayStart({ + update: { sessionUpdate: "tool_call_update", status: "completed" }, + }), + ).toBe(false); + expect( + antigravitySubagentResult({ + toolCallId: "trajectory:4", + data: { rawOutput: " Finished review. " }, + }), + ).toBe("Finished review."); + const result = antigravitySubagentResult({ + toolCallId: "trajectory:4", + data: { rawOutput: `${"x".repeat(16_000)}The result.` }, + }); + expect(result?.length).toBeLessThan(8_100); + expect(result?.endsWith("The result.")).toBe(true); + expect( + antigravitySubagentResult({ toolCallId: "trajectory:4", data: { rawOutput: {} } }), + ).toBeUndefined(); + }); +}); + const questionRequest = { sessionId: "session-1", toolCall: { diff --git a/apps/server/src/provider/acp/AntigravityProtocol.ts b/apps/server/src/provider/acp/AntigravityProtocol.ts index 2d91d2335fd4..72048a8bb7e9 100644 --- a/apps/server/src/provider/acp/AntigravityProtocol.ts +++ b/apps/server/src/provider/acp/AntigravityProtocol.ts @@ -349,3 +349,34 @@ export function normalizeAntigravityToolCall(toolCall: AcpToolCallState): AcpToo export function isAntigravityOpenCommand(toolCall: AcpToolCallState): boolean { return toolCall.kind === "execute" && toolCall.status === "inProgress"; } + +/** ACP 1.1.1 exposes subagent invocations as ordinary tools, without child IDs or models. */ +export function classifyAntigravitySubagentToolCall( + toolCall: AcpToolCallState, + rawPayload: unknown, +): "subagent" | "mcp" | undefined { + if ( + (toolCall.kind !== undefined && toolCall.kind !== "other") || + (toolCall.title !== "Running start_subagent" && toolCall.title !== "Run start_subagent?") + ) + return undefined; + const update = Predicate.isObject(rawPayload) ? rawPayload.update : undefined; + const meta = Predicate.isObject(update) ? update._meta : undefined; + return Predicate.isObject(meta) && meta.is_mcp_tool_call === true ? "mcp" : "subagent"; +} + +/** History sends a completed start before the separate result and its final status. */ +export function isAntigravitySubagentReplayStart(rawPayload: unknown): boolean { + const update = Predicate.isObject(rawPayload) ? rawPayload.update : undefined; + return ( + Predicate.isObject(update) && + update.sessionUpdate === "tool_call" && + update.status === "completed" && + (update.rawOutput === undefined || update.rawOutput === null) + ); +} + +export function antigravitySubagentResult(toolCall: AcpToolCallState): string | undefined { + const output = toolCall.data.rawOutput; + return typeof output === "string" && output.trim() ? boundText(output.trim()) : undefined; +} diff --git a/apps/server/src/provider/antigravityAuthSupport.test.ts b/apps/server/src/provider/antigravityAuthSupport.test.ts index 0e3470c72384..03189018bd31 100644 --- a/apps/server/src/provider/antigravityAuthSupport.test.ts +++ b/apps/server/src/provider/antigravityAuthSupport.test.ts @@ -17,6 +17,7 @@ import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawne import * as AcpErrors from "effect-acp/errors"; import { + ANTIGRAVITY_AUTH_BROWSER_MARKER, ANTIGRAVITY_AUTH_STDOUT_PREFIX, ANTIGRAVITY_PERSONAL_AUTH, ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE, @@ -26,6 +27,7 @@ import { antigravityProfileSettings, buildAntigravityAcpSpawnInput, isAntigravitySignInRequiredError, + makeAntigravityStderrHandler, makeAntigravityStdoutTransform, parseAntigravityAuthorizationUrl, prepareAntigravityProfile, @@ -388,6 +390,102 @@ describe("Antigravity stdout compatibility", () => { ); }); +describe("Antigravity stderr compatibility", () => { + it.effect("forwards fragmented native sign-in URLs from runtime 1.1.1", () => + Effect.gen(function* () { + const urls: string[] = []; + const line = `${ANTIGRAVITY_AUTH_STDOUT_PREFIX}${authorizationUrl}\r\n`; + const handleStderr = makeAntigravityStderrHandler({ + onAuthorizationUrl: (url) => Effect.sync(() => void urls.push(url)), + }); + yield* handleStderr(`native log\n${line.slice(0, 40)}`); + yield* handleStderr(line.slice(40, 90)); + yield* handleStderr(`${line.slice(90)}another native log\n`); + expect(urls).toEqual([authorizationUrl]); + }), + ); + + it.effect("rejects interactive sign-in during normal work", () => + Effect.gen(function* () { + const handleStderr = makeAntigravityStderrHandler(); + const error = yield* handleStderr( + `${ANTIGRAVITY_AUTH_STDOUT_PREFIX}${authorizationUrl}\n`, + ).pipe(Effect.flip); + expect(isAntigravitySignInRequiredError(error)).toBe(true); + }), + ); + + it.effect("preserves failures from the sign-in flow owner", () => + Effect.gen(function* () { + const failure = new AcpErrors.AcpTransportError({ + detail: "The sign-in flow stopped.", + cause: undefined, + }); + const handleStderr = makeAntigravityStderrHandler({ + onAuthorizationUrl: () => Effect.fail(failure), + }); + const error = yield* handleStderr( + `${ANTIGRAVITY_AUTH_STDOUT_PREFIX}${authorizationUrl}\n`, + ).pipe(Effect.flip); + expect(error).toBe(failure); + }), + ); + + it.effect("forwards an accepted browser-helper URL larger than 8 KiB", () => + Effect.gen(function* () { + const urls: string[] = []; + const longAuthorizationUrl = `${authorizationUrl}&scope=${"a".repeat(9_000)}`; + const handleStderr = makeAntigravityStderrHandler({ + onAuthorizationUrl: (url) => Effect.sync(() => void urls.push(url)), + }); + + expect(longAuthorizationUrl.length).toBeGreaterThan(8_192); + yield* handleStderr( + `${ANTIGRAVITY_AUTH_BROWSER_MARKER}${encodeUnknownJson(longAuthorizationUrl)}\n`, + ); + + expect(urls).toEqual([longAuthorizationUrl]); + }), + ); + + it.effect("forwards a fragmented browser-helper URL without exposing other stderr", () => + Effect.gen(function* () { + const urls: string[] = []; + const markerLine = `${ANTIGRAVITY_AUTH_BROWSER_MARKER}${encodeUnknownJson(authorizationUrl)}\n`; + const handleStderr = makeAntigravityStderrHandler({ + onAuthorizationUrl: (url) => Effect.sync(() => void urls.push(url)), + }); + + yield* handleStderr(`native log\n${markerLine.slice(0, 12)}`); + yield* handleStderr(markerLine.slice(12, 70)); + yield* handleStderr(`${markerLine.slice(70)}another native log\n`); + + expect(urls).toEqual([authorizationUrl]); + }), + ); + + it.effect("ignores malformed and similar browser-helper messages", () => + Effect.gen(function* () { + const urls: string[] = []; + const handleStderr = makeAntigravityStderrHandler({ + onAuthorizationUrl: (url) => Effect.sync(() => void urls.push(url)), + }); + + yield* handleStderr( + ` ${ANTIGRAVITY_AUTH_BROWSER_MARKER}${encodeUnknownJson(authorizationUrl)}\n`, + ); + yield* handleStderr(`${ANTIGRAVITY_AUTH_BROWSER_MARKER}${authorizationUrl}\n`); + yield* handleStderr( + `${ANTIGRAVITY_AUTH_BROWSER_MARKER}${encodeUnknownJson("https://example.com")}\n`, + ); + yield* handleStderr(`${ANTIGRAVITY_AUTH_STDOUT_PREFIX}https://example.com\n`); + yield* handleStderr(` ${ANTIGRAVITY_AUTH_STDOUT_PREFIX}${authorizationUrl}\n`); + + expect(urls).toEqual([]); + }), + ); +}); + it.layer(NodeServices.layer)("Antigravity profile preparation", (it) => { it.effect("preflights the no-browser helper and creates private directories only", () => Effect.gen(function* () { diff --git a/apps/server/src/provider/antigravityAuthSupport.ts b/apps/server/src/provider/antigravityAuthSupport.ts index bc1baf9085b0..dd55df5973ba 100644 --- a/apps/server/src/provider/antigravityAuthSupport.ts +++ b/apps/server/src/provider/antigravityAuthSupport.ts @@ -24,9 +24,14 @@ export const ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE = "Sign in to Antigravity in Settings before you continue."; const maxAuthorizationUrlLength = 16_384; +const maxBrowserHelperLineLength = + Math.max(ANTIGRAVITY_AUTH_BROWSER_MARKER.length, ANTIGRAVITY_AUTH_STDOUT_PREFIX.length) + + maxAuthorizationUrlLength + + 2; const maxStdoutLineBytes = 16 * 1024 * 1024; const authPrefixBytes = new TextEncoder().encode(ANTIGRAVITY_AUTH_STDOUT_PREFIX); const decodeUrl = Schema.decodeUnknownEffect(Schema.URLFromString); +const decodeBrowserHelperUrl = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.String)); const ProfileSettingsFile = Schema.Struct({ auth: Schema.Struct({ type: Schema.String }), gcp: Schema.optional( @@ -455,5 +460,42 @@ export function makeAntigravityStdoutTransform( }); } -/** Upstream stderr can contain OAuth URLs, states, and redirect codes. */ -export const drainAntigravityStderr = (_text: string): Effect.Effect => Effect.void; +/** Receives native 1.1.1 sign-in URLs and T3 browser-helper URLs without logging stderr. */ +export function makeAntigravityStderrHandler( + input: { + readonly onAuthorizationUrl?: ( + authorizationUrl: string, + ) => Effect.Effect; + } = {}, +) { + let pending = ""; + const handleLine = (line: string) => { + const message = line.endsWith("\r") ? line.slice(0, -1) : line; + if (message.length > maxBrowserHelperLineLength) { + return Effect.void; + } + const url = message.startsWith(ANTIGRAVITY_AUTH_STDOUT_PREFIX) + ? Effect.succeed(message.slice(ANTIGRAVITY_AUTH_STDOUT_PREFIX.length)) + : message.startsWith(ANTIGRAVITY_AUTH_BROWSER_MARKER) + ? decodeBrowserHelperUrl(message.slice(ANTIGRAVITY_AUTH_BROWSER_MARKER.length)) + : undefined; + if (url === undefined) return Effect.void; + return url.pipe( + Effect.flatMap(parseAntigravityAuthorizationUrl), + Effect.matchEffect({ + onFailure: () => Effect.void, + onSuccess: (request) => + input.onAuthorizationUrl + ? input.onAuthorizationUrl(request.authorizationUrl) + : Effect.fail(authSupportError(ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE)), + }), + ); + }; + + return Effect.fn("antigravityAuthSupport.handleStderr")(function* (text: string) { + const lines = `${pending}${text}`.split("\n"); + pending = lines.pop() ?? ""; + if (pending.length > maxBrowserHelperLineLength) pending = ""; + yield* Effect.forEach(lines, handleLine, { discard: true }); + }); +} diff --git a/apps/server/src/provider/antigravityRelease.ts b/apps/server/src/provider/antigravityRelease.ts index d415ffcdee8d..c33f09cef465 100644 --- a/apps/server/src/provider/antigravityRelease.ts +++ b/apps/server/src/provider/antigravityRelease.ts @@ -1,4 +1,4 @@ -export const ANTIGRAVITY_RELEASE_VERSION = "agy_acp_server_20260818_01_RC01"; +export const ANTIGRAVITY_RELEASE_VERSION = "agy_acp_server_1.1.1"; export interface AntigravityReleaseAsset { readonly version: string; @@ -15,62 +15,62 @@ export interface AntigravityReleaseAsset { }; } -// URLs come from the official registry. Hashes and sizes were checked on 2026-09-02. -// https://github.com/agentclientprotocol/registry/blob/536e378b70a7a6d5f078a9160180e3569a23253c/antigravity-acp/agent.json +// URLs come from the official registry. Hashes and sizes were checked on 2026-09-03. +// https://github.com/agentclientprotocol/registry/blob/81bf71b55e15f630c4fb8a86d20d3088071d2071/antigravity-acp/agent.json const releaseAssets = new Map([ [ "darwin-arm64", { version: ANTIGRAVITY_RELEASE_VERSION, - url: "https://dl.google.com/agy-extensions/releases/macos/agy-acp-server-agy_acp_server_20260818_01_RC01-darwin-arm64.zip", - sha256: "f122ca7e7030a27f9649da4cf1a7d80e12c48c5f6118ff35affc34d56cbf83dd", - archiveBytes: 314_500_221, - executable: { name: "agy_acp_server.par", bytes: 792_105_680 }, - harness: { name: "localharness_external", bytes: 101_551_680 }, + url: "https://dl.google.com/agy-extensions/releases/macos/agy-acp-server-agy_acp_server_1.1.1-darwin-arm64.zip", + sha256: "fdfa915652cdb7ba8085cc8fffed072cbe009251aa2c951aabdda07a8c28a189", + archiveBytes: 316_014_828, + executable: { name: "agy_acp_server.par", bytes: 802_163_856 }, + harness: { name: "localharness_external", bytes: 116_766_704 }, }, ], [ "linux-x64", { version: ANTIGRAVITY_RELEASE_VERSION, - url: "https://dl.google.com/agy-extensions/releases/linux/agy-acp-server-agy_acp_server_20260818_01_RC01-linux-x86_64.zip", - sha256: "ce3f09628575b25497cf5a3c19d073b49acb80f1dab1ff8592919e9c9b8799e1", - archiveBytes: 543_411_011, - executable: { name: "agy_acp_server.par", bytes: 1_529_513_909 }, - harness: { name: "localharness_external", bytes: 117_532_520 }, + url: "https://dl.google.com/agy-extensions/releases/linux/agy-acp-server-agy_acp_server_1.1.1-linux-x86_64.zip", + sha256: "38f62d01b32deb0907b3d39a71ec301fd36369f6ffd1cf262d4af385177f79df", + archiveBytes: 681_969_407, + executable: { name: "agy_acp_server.par", bytes: 1_880_360_328 }, + harness: { name: "localharness_external", bytes: 128_966_920 }, }, ], [ "linux-arm64", { version: ANTIGRAVITY_RELEASE_VERSION, - url: "https://dl.google.com/agy-extensions/releases/linux/agy-acp-server-agy_acp_server_20260818_01_RC01-linux-arm64.zip", - sha256: "70fcdac70684de60f7a0eb16ea497d6cc4498728420f060e0850cfc9a9329b40", - archiveBytes: 524_995_159, - executable: { name: "agy_acp_server.par", bytes: 1_519_373_648 }, - harness: { name: "localharness_external", bytes: 110_601_552 }, + url: "https://dl.google.com/agy-extensions/releases/linux/agy-acp-server-agy_acp_server_1.1.1-linux-arm64.zip", + sha256: "ed69e64b308fcb123ab54bf3277bf9cb0d651064f885ea5aab0ff520c7175398", + archiveBytes: 656_572_786, + executable: { name: "agy_acp_server.par", bytes: 1_862_073_131 }, + harness: { name: "localharness_external", bytes: 122_158_704 }, }, ], [ "win32-x64", { version: ANTIGRAVITY_RELEASE_VERSION, - url: "https://dl.google.com/agy-extensions/releases/windows/agy-acp-server-agy_acp_server_20260818_01_RC01-windows-x86_64.zip", - sha256: "35c7dd169c2794172ce02e9444a6db4a8ed4bb11398be07976cac2ee494f44e6", - archiveBytes: 331_985_114, - executable: { name: "agy_acp_server.exe", bytes: 297_200_088 }, - harness: { name: "localharness_external.exe", bytes: 122_038_424 }, + url: "https://dl.google.com/agy-extensions/releases/windows/agy-acp-server-agy_acp_server_1.1.1-windows-x86_64.zip", + sha256: "47cb50eef14f0a4655d78cfcfda869bcea7aaee5f9787e936bc2935ea612c3b8", + archiveBytes: 468_238_392, + executable: { name: "agy_acp_server.exe", bytes: 430_801_616 }, + harness: { name: "localharness_external.exe", bytes: 130_971_800 }, }, ], [ "win32-arm64", { version: ANTIGRAVITY_RELEASE_VERSION, - url: "https://dl.google.com/agy-extensions/releases/windows/agy-acp-server-agy_acp_server_20260818_01_RC01-windows-arm64.zip", - sha256: "1522056748d45fbc34d0be72b41b99b0637be1b4caad0b34d37eb16d04ccb9c4", - archiveBytes: 332_484_576, - executable: { name: "agy_acp_server.exe", bytes: 301_449_928 }, - harness: { name: "localharness_external.exe", bytes: 114_173_080 }, + url: "https://dl.google.com/agy-extensions/releases/windows/agy-acp-server-agy_acp_server_1.1.1-windows-arm64.zip", + sha256: "35f4b1f47ba6a3fea7b0a3e30010df5ea73a64b4f0e7cf991cddc673ddfbcafc", + archiveBytes: 468_521_191, + executable: { name: "agy_acp_server.exe", bytes: 435_075_816 }, + harness: { name: "localharness_external.exe", bytes: 122_455_704 }, }, ], ]); diff --git a/apps/server/src/provider/makeManagedServerProvider.test.ts b/apps/server/src/provider/makeManagedServerProvider.test.ts index fd50fa13eb08..aa0828e048c9 100644 --- a/apps/server/src/provider/makeManagedServerProvider.test.ts +++ b/apps/server/src/provider/makeManagedServerProvider.test.ts @@ -522,4 +522,137 @@ describe("makeManagedServerProvider", () => { }), ).pipe(Effect.provide(AlwaysRunTestLayer)), ); + + it.effect("applies runtime usage updates onto the published snapshot", () => + Effect.scoped( + Effect.gen(function* () { + const provider = yield* makeManagedServerProvider({ + maintenanceCapabilities, + getSettings: Effect.succeed({ enabled: true }), + streamSettings: Stream.empty, + haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, + initialSnapshot: () => Effect.succeed(initialSnapshot), + checkProvider: Effect.succeed({ + ...refreshedSnapshot, + usageLimits: { + checkedAt: "2026-04-10T00:00:01.000Z", + windows: [ + { id: "five_hour", kind: "session", label: "Session", usedPercent: 10 }, + { + id: "seven_day", + kind: "weekly", + label: "Weekly", + usedPercent: 20, + resetsAt: "2026-04-17T00:00:00.000Z", + }, + ], + }, + } satisfies ServerProvider), + refreshInterval: "1 hour", + }); + yield* Stream.take(provider.streamChanges, 1).pipe(Stream.runDrain); + + const updatesFiber = yield* Stream.take(provider.streamChanges, 1).pipe( + Stream.runCollect, + Effect.forkChild, + ); + yield* Effect.yieldNow; + + // Percent-only weekly update: keeps the probe's reset time. + yield* provider.applyUsageLimits({ + checkedAt: "2026-04-10T00:05:00.000Z", + windows: [{ id: "seven_day", kind: "weekly", label: "Weekly", usedPercent: 25 }], + }); + // No windows: nothing to merge, nothing published. + yield* provider.applyUsageLimits({ checkedAt: "2026-04-10T00:06:00.000Z", windows: [] }); + + const [update] = Array.from(yield* Fiber.join(updatesFiber)); + assert.deepStrictEqual(update?.usageLimits, { + checkedAt: "2026-04-10T00:05:00.000Z", + windows: [ + { id: "five_hour", kind: "session", label: "Session", usedPercent: 10 }, + { + id: "seven_day", + kind: "weekly", + label: "Weekly", + usedPercent: 25, + resetsAt: "2026-04-17T00:00:00.000Z", + }, + ], + }); + assert.deepStrictEqual(yield* provider.getSnapshot, update); + }), + ).pipe(Effect.provide(AlwaysRunTestLayer)), + ); + + it.effect("keeps live usage windows across a failed probe and a stale enrichment", () => + Effect.scoped( + Effect.gen(function* () { + const releaseEnrichment = yield* Deferred.make(); + const refreshCount = yield* Ref.make(0); + const probedLimits = { + checkedAt: "2026-04-10T00:00:01.000Z", + windows: [{ id: "primary", kind: "session", label: "Session", usedPercent: 10 }], + } as const; + const provider = yield* makeManagedServerProvider({ + maintenanceCapabilities, + getSettings: Effect.succeed({ enabled: true }), + streamSettings: Stream.empty, + haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, + initialSnapshot: () => Effect.succeed(initialSnapshot), + checkProvider: Ref.updateAndGet(refreshCount, (count) => count + 1).pipe( + Effect.map((count) => + count === 1 + ? { ...refreshedSnapshot, usageLimits: probedLimits } + : { + ...refreshedSnapshotSecond, + usageLimits: { + checkedAt: "2026-04-10T00:00:03.000Z", + windows: [], + unavailable: { reason: "probeFailed" }, + }, + }, + ), + ), + enrichSnapshot: ({ snapshot, publishSnapshot }) => + Deferred.await(releaseEnrichment).pipe( + Effect.flatMap(() => + publishSnapshot({ + ...enrichedSnapshot, + ...snapshot, + models: enrichedSnapshot.models, + }), + ), + ), + refreshInterval: "1 hour", + }); + yield* Stream.take(provider.streamChanges, 1).pipe(Stream.runDrain); + + const liveWindow = { + id: "primary", + kind: "session", + label: "Session", + usedPercent: 60, + } as const; + yield* provider.applyUsageLimits({ + checkedAt: "2026-04-10T00:00:02.000Z", + windows: [liveWindow], + }); + + // Enrichment computed from the pre-update snapshot lands afterwards. + yield* Deferred.succeed(releaseEnrichment, undefined); + const enriched = yield* Stream.take(provider.streamChanges, 1).pipe( + Stream.runCollect, + Effect.map((chunk) => Array.from(chunk)[0]!), + ); + assert.deepStrictEqual(enriched.models, enrichedSnapshot.models); + assert.deepStrictEqual(enriched.usageLimits?.windows, [liveWindow]); + + // A probe that could not read usage keeps the last good windows. + const refreshed = yield* provider.refresh; + assert.strictEqual(refreshed.message, refreshedSnapshotSecond.message); + assert.deepStrictEqual(refreshed.usageLimits?.windows, [liveWindow]); + }), + ).pipe(Effect.provide(AlwaysRunTestLayer)), + ); }); diff --git a/apps/server/src/provider/makeManagedServerProvider.ts b/apps/server/src/provider/makeManagedServerProvider.ts index a009157144c7..ec3d26e6c87e 100644 --- a/apps/server/src/provider/makeManagedServerProvider.ts +++ b/apps/server/src/provider/makeManagedServerProvider.ts @@ -17,6 +17,7 @@ import * as Semaphore from "effect/Semaphore"; import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; import { ServerSettingsService } from "../serverSettings.ts"; +import { applyUsageLimitsUpdate, resolveUsageLimitsAfterProbe } from "./providerUsageLimits.ts"; import type { ServerProviderShape } from "./Services/ServerProvider.ts"; interface ProviderSnapshotState { @@ -24,6 +25,17 @@ interface ProviderSnapshotState { readonly enrichmentGeneration: number; } +function withUsageLimits( + snapshot: ServerProvider, + usageLimits: ServerProvider["usageLimits"], +): ServerProvider { + if (snapshot.usageLimits === usageLimits) { + return snapshot; + } + const { usageLimits: _previous, ...rest } = snapshot; + return usageLimits ? { ...rest, usageLimits } : rest; +} + export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")(function* < Settings, >(input: { @@ -69,16 +81,16 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( nextSnapshot: ServerProvider, ) { const snapshotToPublish = yield* Ref.modify(snapshotStateRef, (state) => { - if (state.enrichmentGeneration !== generation || Equal.equals(state.snapshot, nextSnapshot)) { + if (state.enrichmentGeneration !== generation) { return [null, state] as const; } - return [ - nextSnapshot, - { - ...state, - snapshot: nextSnapshot, - }, - ] as const; + // Enrichment derives from the snapshot it was handed; a runtime usage + // update that landed since must not be reverted by it. + const merged = withUsageLimits(nextSnapshot, state.snapshot.usageLimits); + if (Equal.equals(state.snapshot, merged)) { + return [null, state] as const; + } + return [merged, { ...state, snapshot: merged }] as const; }); if (snapshotToPublish === null) { return; @@ -138,19 +150,26 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( return state.snapshot; } - const nextSnapshot = yield* input.checkProvider; - const nextGeneration = yield* Ref.modify(snapshotStateRef, (state) => { - const generation = input.enrichSnapshot - ? state.enrichmentGeneration + 1 - : state.enrichmentGeneration; - return [ - generation, - { - snapshot: nextSnapshot, - enrichmentGeneration: generation, - }, - ] as const; - }); + const probedSnapshot = yield* input.checkProvider; + const { snapshot: nextSnapshot, generation: nextGeneration } = yield* Ref.modify( + snapshotStateRef, + (state) => { + const generation = input.enrichSnapshot + ? state.enrichmentGeneration + 1 + : state.enrichmentGeneration; + const snapshot = withUsageLimits( + probedSnapshot, + resolveUsageLimitsAfterProbe({ + published: state.snapshot.usageLimits, + probed: probedSnapshot.usageLimits, + }), + ); + return [ + { snapshot, generation }, + { snapshot, enrichmentGeneration: generation }, + ] as const; + }, + ); yield* Ref.set(settingsRef, nextSettings); yield* PubSub.publish(changesPubSub, nextSnapshot); yield* restartSnapshotEnrichment(nextSettings, nextSnapshot, nextGeneration); @@ -159,6 +178,32 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( const applySnapshot = (nextSettings: Settings, options?: { readonly forceRefresh?: boolean }) => refreshSemaphore.withPermits(1)(applySnapshotBase(nextSettings, options)); + /** + * Runtime usage updates arrive between probes. They patch only + * `usageLimits` on whatever snapshot is published and leave the enrichment + * generation alone, so an in-flight enrichment still lands. + */ + const applyUsageLimits: ServerProviderShape["applyUsageLimits"] = (update) => + Effect.gen(function* () { + const snapshotToPublish = yield* Ref.modify(snapshotStateRef, (state) => { + const usageLimits = applyUsageLimitsUpdate({ + previous: state.snapshot.usageLimits, + update, + checkedAt: update.checkedAt, + }); + // `applyUsageLimitsUpdate` hands back the same object when nothing + // moved, which is the common case for Codex's per-tick notification. + if (usageLimits === state.snapshot.usageLimits) { + return [null, state] as const; + } + const snapshot = withUsageLimits(state.snapshot, usageLimits); + return [snapshot, { ...state, snapshot }] as const; + }); + if (snapshotToPublish !== null) { + yield* PubSub.publish(changesPubSub, snapshotToPublish); + } + }); + const refreshSnapshot = Effect.fn("refreshSnapshot")(function* () { const nextSettings = yield* input.getSettings; return yield* applySnapshot(nextSettings, { forceRefresh: true }); @@ -241,6 +286,7 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( maintenanceCapabilities: input.maintenanceCapabilities, getSnapshot: Ref.get(snapshotStateRef).pipe(Effect.map((state) => state.snapshot)), refresh: refreshSnapshot().pipe(Effect.tapError(Effect.logError), Effect.orDie), + applyUsageLimits, get streamChanges() { return Stream.fromPubSub(changesPubSub); }, diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index adbe110d9408..ff98ba8a00d5 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -7,6 +7,7 @@ import type { ServerProviderSlashCommand, ServerProviderModel, ServerProviderState, + ServerProviderUsageLimits, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as PlatformError from "effect/PlatformError"; @@ -50,6 +51,7 @@ export interface ProviderProbeResult { readonly status: Exclude; readonly auth: ServerProviderAuth; readonly message?: string; + readonly usageLimits?: ServerProviderUsageLimits; } export interface ServerProviderPresentation { @@ -249,6 +251,7 @@ export function buildServerProvider(input: { models: input.models, slashCommands: [...(input.slashCommands ?? [])], skills: [...(input.skills ?? [])], + ...(input.probe.usageLimits ? { usageLimits: input.probe.usageLimits } : {}), ...(versionAdvisory ? { versionAdvisory } : {}), }; } diff --git a/apps/server/src/provider/providerUsageLimits.test.ts b/apps/server/src/provider/providerUsageLimits.test.ts new file mode 100644 index 000000000000..1fc4c4959a44 --- /dev/null +++ b/apps/server/src/provider/providerUsageLimits.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { applyUsageLimitsUpdate, resolveUsageLimitsAfterProbe } from "./providerUsageLimits.ts"; + +const checkedAt = "2026-09-03T12:00:00.000Z"; +const session = { + id: "five_hour", + kind: "session", + label: "Session", + usedPercent: 40, + windowDurationMins: 300, + resetsAt: "2026-09-03T14:00:00.000Z", +} as const; +const weekly = { + id: "seven_day", + kind: "weekly", + label: "Weekly", + usedPercent: 20, + windowDurationMins: 10_080, +} as const; +const published = { checkedAt, windows: [session, weekly] }; + +describe("applyUsageLimitsUpdate", () => { + it("returns the published object itself when no window moved", () => { + // Codex repeats the same numbers beside every token-usage tick; the + // ingestion path relies on identity to skip the publish. + const next = applyUsageLimitsUpdate({ + previous: published, + checkedAt: "2026-09-03T12:00:05.000Z", + update: { + windows: [ + { ...weekly }, + { id: "five_hour", kind: "session", label: "Session", usedPercent: 40 }, + ], + }, + }); + expect(next).toBe(published); + }); + + it("upserts by id and keeps the reset a percent-only update omits", () => { + const next = applyUsageLimitsUpdate({ + previous: published, + checkedAt: "2026-09-03T12:00:05.000Z", + update: { + windows: [{ id: "five_hour", kind: "session", label: "Session", usedPercent: 55 }], + }, + }); + expect(next).not.toBe(published); + expect(next).toEqual({ + checkedAt: "2026-09-03T12:00:05.000Z", + windows: [{ ...session, usedPercent: 55 }, weekly], + }); + }); + + it("leaves an unsupported account and an empty update alone", () => { + const unsupported = { checkedAt, windows: [], unavailable: { reason: "unsupported" as const } }; + expect( + applyUsageLimitsUpdate({ previous: unsupported, checkedAt, update: { windows: [session] } }), + ).toBe(unsupported); + expect( + applyUsageLimitsUpdate({ previous: published, checkedAt, update: { windows: [] } }), + ).toBe(published); + }); +}); + +describe("resolveUsageLimitsAfterProbe", () => { + it("keeps the last good windows through a failed probe but not an unsupported one", () => { + const failed = { checkedAt, windows: [], unavailable: { reason: "probeFailed" as const } }; + const unsupported = { checkedAt, windows: [], unavailable: { reason: "unsupported" as const } }; + expect(resolveUsageLimitsAfterProbe({ published, probed: failed })).toBe(published); + expect(resolveUsageLimitsAfterProbe({ published, probed: unsupported })).toBe(unsupported); + expect(resolveUsageLimitsAfterProbe({ published: undefined, probed: failed })).toBe(failed); + }); +}); diff --git a/apps/server/src/provider/providerUsageLimits.ts b/apps/server/src/provider/providerUsageLimits.ts new file mode 100644 index 000000000000..706825579f0a --- /dev/null +++ b/apps/server/src/provider/providerUsageLimits.ts @@ -0,0 +1,131 @@ +import type { + ProviderUsageLimitsUpdate, + ServerProviderUsageLimits, + ServerProviderUsageWindow, +} from "@t3tools/contracts"; + +const WINDOW_KIND_ORDER: Record = { + session: 0, + weekly: 1, + monthly: 2, + other: 3, +}; + +export function clampPercent(value: number): number { + return Number.isFinite(value) ? Math.max(0, Math.min(100, value)) : 0; +} + +function sortWindows( + windows: Iterable, +): ReadonlyArray { + return [...windows].toSorted( + (left, right) => + WINDOW_KIND_ORDER[left.kind] - WINDOW_KIND_ORDER[right.kind] || + left.id.localeCompare(right.id), + ); +} + +export function makeUsageLimits(input: { + readonly checkedAt: string; + readonly windows: Iterable; +}): ServerProviderUsageLimits { + return { checkedAt: input.checkedAt, windows: sortWindows(input.windows) }; +} + +export function makeUnavailableUsageLimits(input: { + readonly checkedAt: string; + readonly reason: "unsupported" | "probeFailed"; + readonly message?: string; +}): ServerProviderUsageLimits { + return { + checkedAt: input.checkedAt, + windows: [], + unavailable: { + reason: input.reason, + ...(input.message ? { message: input.message } : {}), + }, + }; +} + +/** + * Fold a sparse runtime update into the limits a provider currently + * publishes. Windows upsert by `id`; a window the update omits keeps its + * previous values, and a window that arrives without `resetsAt` or + * `windowDurationMins` keeps whatever the last probe resolved for it. An + * update with no windows leaves `previous` untouched. + * + * An `unsupported` snapshot stays unsupported: an account that cannot have + * subscription windows will not start reporting them mid-turn. + */ +export function applyUsageLimitsUpdate(input: { + readonly previous: ServerProviderUsageLimits | undefined; + readonly update: ProviderUsageLimitsUpdate; + readonly checkedAt: string; +}): ServerProviderUsageLimits | undefined { + const { previous, update } = input; + if (update.windows.length === 0 || previous?.unavailable?.reason === "unsupported") { + return previous; + } + const merged = new Map(previous?.windows.map((window) => [window.id, window] as const)); + // Codex sends this notification beside every token-usage tick, almost + // always with unchanged numbers. Decide "nothing changed" per window on + // the way through so the no-op case never allocates a new snapshot. + let changed = false; + for (const window of update.windows) { + const existing = merged.get(window.id); + const next: ServerProviderUsageWindow = { + ...window, + usedPercent: clampPercent(window.usedPercent), + ...(window.resetsAt === undefined && existing?.resetsAt !== undefined + ? { resetsAt: existing.resetsAt } + : {}), + ...(window.windowDurationMins === undefined && existing?.windowDurationMins !== undefined + ? { windowDurationMins: existing.windowDurationMins } + : {}), + }; + if (existing === undefined || !usageWindowEquals(existing, next)) { + merged.set(window.id, next); + changed = true; + } + } + if (!changed && previous !== undefined && previous.unavailable === undefined) { + return previous; + } + return makeUsageLimits({ checkedAt: input.checkedAt, windows: merged.values() }); +} + +function usageWindowEquals(a: ServerProviderUsageWindow, b: ServerProviderUsageWindow): boolean { + return ( + a.id === b.id && + a.kind === b.kind && + a.label === b.label && + a.usedPercent === b.usedPercent && + a.resetsAt === b.resetsAt && + a.windowDurationMins === b.windowDurationMins + ); +} + +/** + * Choose what to publish after a status probe finishes. A probe that failed + * this time must not wipe bars a previous probe or a turn already + * established, so the last good snapshot stays; `unsupported` is + * authoritative and replaces them. + * + * A successful probe replaces the published windows outright, including any + * runtime update that landed while it was running. That is a deliberate + * trade-off: the Codex and Claude reads take a few seconds at most, the + * probe is the fresher full read in every case except that window, and the + * per-window epoch bookkeeping needed to reconcile the two was more code + * than the sub-second regression it prevented. The next runtime event + * corrects it. + */ +export function resolveUsageLimitsAfterProbe(input: { + readonly published: ServerProviderUsageLimits | undefined; + readonly probed: ServerProviderUsageLimits | undefined; +}): ServerProviderUsageLimits | undefined { + const { published, probed } = input; + if (probed?.unavailable?.reason === "probeFailed" && published && !published.unavailable) { + return published; + } + return probed; +} diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index d972e2e00803..85afe00cb52a 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -6,6 +6,7 @@ import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hos import { AuthAccessTokenType, + AuthStandardClientScopes, AuthEnvironmentBootstrapTokenType, AuthTokenExchangeGrantType, CommandId, @@ -63,6 +64,7 @@ import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as PubSub from "effect/PubSub"; import * as Ref from "effect/Ref"; +import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; @@ -101,6 +103,7 @@ import { import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; import * as GitManager from "./git/GitManager.ts"; import * as EnvironmentTheme from "./environmentTheme.ts"; +import * as UsageLimitSources from "./usage/UsageLimitSources.ts"; import * as Keybindings from "./keybindings.ts"; import * as ExternalLauncher from "./process/externalLauncher.ts"; import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; @@ -152,6 +155,7 @@ import * as ReviewService from "./review/ReviewService.ts"; import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; +import * as PairingGrantStore from "./auth/PairingGrantStore.ts"; import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts"; import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; @@ -482,6 +486,7 @@ const makeBrowserOtlpPayload = (spanName: string) => }); const buildAppUnderTest = (options?: { + onPairingChangesSubscribed?: Effect.Effect; config?: Partial; layers?: { keybindings?: Partial; @@ -740,6 +745,11 @@ const buildAppUnderTest = (options?: { streamChanges: Stream.empty, ...options?.layers?.environmentTheme, }), + Layer.mock(UsageLimitSources.UsageLimitSources)({ + current: Effect.succeed([]), + streamChanges: Stream.empty, + refresh: Effect.void, + }), ), ), Layer.provide( @@ -922,6 +932,7 @@ const buildAppUnderTest = (options?: { ), Layer.provide( Layer.mock(ProjectionSnapshotQuery.ProjectionSnapshotQuery)({ + getUserInputActivity: () => Effect.die("unused"), getCommandReadModel: () => Effect.succeed(makeDefaultOrchestrationReadModel()), getSnapshot: () => Effect.succeed(makeDefaultOrchestrationReadModel()), getShellSnapshot: () => @@ -1108,6 +1119,24 @@ const buildAppUnderTest = (options?: { ...options?.layers?.cloudCliTokenManager, }), ), + Layer.updateService(PairingGrantStore.PairingGrantStore, (grants) => { + const subscribed = options?.onPairingChangesSubscribed; + if (!subscribed) return grants; + return { + ...grants, + streamChanges: Stream.unwrap( + Effect.gen(function* () { + const changes = yield* Queue.unbounded(); + yield* grants.streamChanges.pipe( + Stream.runForEach((change) => Queue.offer(changes, change)), + Effect.forkScoped({ startImmediately: true }), + ); + yield* subscribed; + return Stream.fromQueue(changes); + }), + ), + }; + }), Layer.provideMerge(makeAuthTestLayer()), Layer.provideMerge(ServerSecretStore.layer), Layer.provide(workspaceAndProjectServicesLayer), @@ -1134,16 +1163,19 @@ const parseSessionCookieFromWsUrl = ( }; }; -const wsRpcProtocolLayer = (wsUrl: string) => { +const wsRpcProtocolLayer = (wsUrl: string, onMessage?: (message: string) => void) => { const { cookie, url } = parseSessionCookieFromWsUrl(wsUrl); const webSocketConstructorLayer = Layer.succeed( Socket.WebSocketConstructor, - (socketUrl, protocols) => - new NodeSocket.NodeWS.WebSocket( + (socketUrl, protocols) => { + const socket = new NodeSocket.NodeWS.WebSocket( socketUrl, protocols, cookie ? { headers: { cookie } } : undefined, - ) as unknown as globalThis.WebSocket, + ); + if (onMessage) socket.on("message", (data) => onMessage(data.toString())); + return socket as unknown as globalThis.WebSocket; + }, ); return RpcClient.layerProtocolSocket().pipe( @@ -1159,7 +1191,8 @@ type WsRpcClient = const withWsRpcClient = ( wsUrl: string, f: (client: WsRpcClient) => Effect.Effect, -) => makeWsRpcClient.pipe(Effect.flatMap(f), Effect.provide(wsRpcProtocolLayer(wsUrl))); + onMessage?: (message: string) => void, +) => makeWsRpcClient.pipe(Effect.flatMap(f), Effect.provide(wsRpcProtocolLayer(wsUrl, onMessage))); const appendSessionCookieToWsUrl = (url: string, sessionCookieHeader: string) => { const isAbsoluteUrl = /^[a-zA-Z][a-zA-Z\d+.-]*:/.test(url); @@ -3935,6 +3968,128 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("returns only pairing metadata to access-read HTTP sessions", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + const reader = yield* exchangeAccessToken(defaultDesktopBootstrapToken, { + scope: "access:read", + }); + assert.equal(reader.response.status, 200); + assert.equal(reader.body.scope, "access:read"); + const createdResponse = yield* HttpClient.post("/api/auth/pairing-token", { + headers: { cookie: yield* getAuthenticatedSessionCookieHeader() }, + body: yield* HttpBody.json({ label: "Synthetic phone" }), + }); + const created = (yield* createdResponse.json) as { id: string; credential: string }; + assert.equal(createdResponse.status, 200); + const response = yield* HttpClient.get("/api/auth/pairing-links", { + headers: { authorization: `Bearer ${reader.body.access_token ?? ""}` }, + }); + assert.equal(response.status, 200); + const responseText = yield* response.text; + assert.notInclude(responseText, '"credential"'); + assert.notInclude(responseText, created.credential); + const links = yield* responseJsonEffect< + ReadonlyArray<{ + readonly id: string; + readonly label?: string; + readonly scopes: ReadonlyArray; + }> + >(response); + const listed = links.find((link) => link.id === created.id); + assert.isDefined(listed); + assert.deepInclude(listed, { + label: "Synthetic phone", + scopes: [...AuthStandardClientScopes], + }); + + const unauthorizedCreate = yield* HttpClient.post("/api/auth/pairing-token", { + headers: { authorization: `Bearer ${reader.body.access_token ?? ""}` }, + body: yield* HttpBody.json({}), + }); + assert.equal(unauthorizedCreate.status, 403); + const idExchange = yield* exchangeAccessToken(created.id, { scope: "terminal:operate" }); + assert.equal(idExchange.response.status, 401); + const authorized = yield* exchangeAccessToken(created.credential, { + scope: AuthStandardClientScopes.join(" "), + }); + assert.equal(authorized.response.status, 200); + assert.equal(authorized.body.scope, AuthStandardClientScopes.join(" ")); + const reused = yield* exchangeAccessToken(created.credential, { scope: "terminal:operate" }); + assert.equal(reused.response.status, 401); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("returns only pairing metadata in access-read WebSocket snapshots and updates", () => + Effect.gen(function* () { + const changesSubscribed = yield* Deferred.make(); + yield* buildAppUnderTest({ + onPairingChangesSubscribed: Deferred.succeed(changesSubscribed, undefined).pipe( + Effect.asVoid, + ), + }); + const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); + const createLink = Effect.gen(function* () { + const response = yield* HttpClient.post("/api/auth/pairing-token", { + headers: { cookie: ownerCookie }, + body: yield* HttpBody.json({}), + }); + assert.equal(response.status, 200); + return (yield* response.json) as { id: string; credential: string }; + }); + const initialLink = yield* createLink; + const reader = yield* exchangeAccessToken(defaultDesktopBootstrapToken, { + scope: "access:read", + }); + assert.equal(reader.body.scope, "access:read"); + const ticketResponse = yield* HttpClient.post("/api/auth/websocket-ticket", { + headers: { authorization: `Bearer ${reader.body.access_token ?? ""}` }, + }); + assert.equal(ticketResponse.status, 200); + const { ticket } = (yield* ticketResponse.json) as { ticket: string }; + const wsUrl = `${yield* getWsServerUrl("/ws", { authenticated: false })}?wsTicket=${encodeURIComponent(ticket)}`; + const frames: string[] = []; + yield* withWsRpcClient( + wsUrl, + (client) => + Effect.gen(function* () { + const snapshotReceived = yield* Deferred.make(); + const eventsFiber = yield* client.subscribeAuthAccess({}).pipe( + Stream.tap((event) => + event.type === "snapshot" + ? Deferred.succeed(snapshotReceived, undefined) + : Effect.void, + ), + Stream.takeUntil((event) => event.type === "pairingLinkUpserted"), + Stream.runCollect, + Effect.forkChild, + ); + yield* Deferred.await(snapshotReceived); + yield* Deferred.await(changesSubscribed); + const liveLink = yield* createLink; + const events = yield* Fiber.join(eventsFiber); + const snapshot = events.find((event) => event.type === "snapshot"); + const update = events.find((event) => event.type === "pairingLinkUpserted"); + assert.isDefined(snapshot); + assert.isDefined(update); + assert.isTrue( + snapshot?.payload.pairingLinks.some((link) => link.id === initialLink.id), + ); + assert.equal(update?.payload.id, liveLink.id); + // Inspect the wire frames so client schema decoding cannot hide a leak. + assert.notInclude(frames.join(""), '"credential"'); + assert.notInclude(frames.join(""), initialLink.credential); + assert.notInclude(frames.join(""), liveLink.credential); + const paired = yield* exchangeAccessToken(liveLink.credential, { + scope: AuthStandardClientScopes.join(" "), + }); + assert.equal(paired.response.status, 200); + }), + (frame) => frames.push(frame), + ); + }).pipe(Effect.scoped, Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("lists and revokes pairing links for access management sessions", () => Effect.gen(function* () { yield* buildAppUnderTest({ @@ -3962,7 +4117,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }); const listedLinks = (yield* listResponse.json) as ReadonlyArray<{ readonly id: string; - readonly credential: string; }>; const revokeResponse = yield* HttpClient.post("/api/auth/pairing-links/revoke", { @@ -7304,7 +7458,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { projectionSnapshotQuery: { getThreadDetailSnapshot: () => Effect.gen(function* () { - yield* Effect.sleep("25 millis"); yield* PubSub.publish(liveEvents, messageEvent); return Option.some({ snapshotSequence: 1, thread }); }), @@ -7317,14 +7470,19 @@ it.layer(NodeServices.layer)("server router seam", (it) => { withWsRpcClient(wsUrl, (client) => client[ORCHESTRATION_WS_METHODS.subscribeThread]({ threadId: defaultThreadId, - }).pipe(Stream.take(2), Stream.runCollect), + requestCompletionMarker: true, + }).pipe( + Stream.takeUntil((item) => item.kind === "synchronized"), + Stream.runCollect, + ), ), - ).pipe(Effect.timeout("2 seconds")); + ); assert.equal(items[0]?.kind, "snapshot"); assert.equal(items[1]?.kind, "event"); assert.equal(items[1]?.kind === "event" ? items[1].event.sequence : null, 2); - }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive), + assert.equal(items[2]?.kind, "synchronized"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); it.effect("coalesces buffered live tool updates to the latest state", () => diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 3a93adc6d761..ee0d6936f394 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -41,6 +41,7 @@ import { AntigravityInstallation } from "./provider/AntigravityInstallation.ts"; import { ProviderInstanceRegistry } from "./provider/Services/ProviderInstanceRegistry.ts"; import { ProviderRegistry } from "./provider/Services/ProviderRegistry.ts"; import { ProviderSessionReaperLive } from "./provider/Layers/ProviderSessionReaper.ts"; +import { ProviderUsageLimitsIngestionLive } from "./provider/Layers/ProviderUsageLimitsIngestion.ts"; import * as OpenCodeRuntime from "./provider/opencodeRuntime.ts"; import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; import * as CheckpointStore from "./checkpointing/CheckpointStore.ts"; @@ -118,6 +119,7 @@ import * as NativeTelemetryClient from "./resourceTelemetry/NativeTelemetryClien import * as ResourceAttribution from "./resourceTelemetry/ResourceAttribution.ts"; import * as ResourceMonitorBinary from "./resourceTelemetry/ResourceMonitorBinary.ts"; import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; +import * as UsageLimitSources from "./usage/UsageLimitSources.ts"; import * as UsageService from "./usage/UsageService.ts"; import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts"; import { @@ -407,6 +409,9 @@ const CloudManagedEndpointRuntimeLive = Layer.mergeAll( ); const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe( + // Subscribes to `account.rate-limits.updated` so usage bars track live + // telemetry instead of waiting for the next status probe. + Layer.provideMerge(ProviderUsageLimitsIngestionLive), Layer.provideMerge(ProviderLayerLive), Layer.provideMerge(OrchestrationLayerLive), ); @@ -454,7 +459,9 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( Layer.provideMerge(PersistenceLayerLive), // Both read a user-owned file out of the state directory and stream changes // to clients; neither depends on the other. - Layer.provideMerge(Layer.mergeAll(Keybindings.layer, EnvironmentTheme.layer)), + Layer.provideMerge( + Layer.mergeAll(Keybindings.layer, EnvironmentTheme.layer, UsageLimitSources.layer), + ), Layer.provideMerge(ProviderRegistryLive), // The instance registry is the new routing keystone — text generation, // adapter lookup, and runtime ingestion all resolve `ProviderInstanceId` diff --git a/apps/server/src/serverRuntimeStartup.reconcile.test.ts b/apps/server/src/serverRuntimeStartup.reconcile.test.ts index a7cb97ae9765..1de6a4247811 100644 --- a/apps/server/src/serverRuntimeStartup.reconcile.test.ts +++ b/apps/server/src/serverRuntimeStartup.reconcile.test.ts @@ -66,6 +66,7 @@ const makeProviderService = (liveThreadIds: ReadonlyArray = []) => const queryWithThreads = (threads: ReadonlyArray>) => ({ + getUserInputActivity: () => Effect.die("unused"), getCommandReadModel: () => Effect.succeed({ threads } as never), }) as unknown as ProjectionSnapshotQuery.ProjectionSnapshotQuery["Service"]; @@ -631,6 +632,7 @@ it.effect("does not fail startup when the live provider session inventory cannot let queried = false; return ServerRuntimeStartup.reconcileProviderSessions.pipe( Effect.provideService(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { + getUserInputActivity: () => Effect.die("unused"), getCommandReadModel: () => Effect.sync(() => { queried = true; diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index 52711857ee33..0a8afde249a4 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -118,6 +118,7 @@ it.effect("launchStartupHeartbeat does not block the caller while counts are loa yield* ServerRuntimeStartup.launchStartupHeartbeat.pipe( Effect.provideService(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { + getUserInputActivity: () => Effect.die("unused"), getCommandReadModel: () => Effect.die("unused"), getSnapshot: () => Effect.die("unused"), getShellSnapshot: () => Effect.die("unused"), @@ -185,6 +186,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa autoBootstrapProjectFromCwd: true, } as never), Effect.provideService(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { + getUserInputActivity: () => Effect.die("unused"), getCommandReadModel: () => Effect.die("unused"), getSnapshot: () => Effect.die("unused"), getShellSnapshot: () => Effect.die("unused"), @@ -250,6 +252,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when autoBootstrapProjectFromCwd: true, } as never), Effect.provideService(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { + getUserInputActivity: () => Effect.die("unused"), getCommandReadModel: () => Effect.die("unused"), getSnapshot: () => Effect.die("unused"), getShellSnapshot: () => Effect.die("unused"), @@ -312,6 +315,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa autoBootstrapProjectFromCwd: true, } as never), Effect.provideService(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { + getUserInputActivity: () => Effect.die("unused"), getCommandReadModel: () => Effect.die("unused"), getSnapshot: () => Effect.die("unused"), getShellSnapshot: () => Effect.die("unused"), diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 5a8650b7e405..5f2550534883 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -18,6 +18,7 @@ import { type ModelSelection, type ProviderInstanceConfig, type ProviderInstanceEnvironmentVariable, + type UsageLimitSourceConfig, ProviderDriverKind, ProviderInstanceId, ServerSettings, @@ -133,6 +134,17 @@ function providerEnvironmentSecretName(input: { return `provider-env-${Buffer.from(input.instanceId, "utf8").toString("base64url")}-${Buffer.from(input.name, "utf8").toString("base64url")}`; } +/** + * On disk the hub key is replaced by this marker and the real value lives in + * the secret store, mirroring provider environment secrets. A client that + * sends the marker back means "keep what you have". + */ +const USAGE_LIMIT_SOURCE_KEY_REDACTED = "\u2022\u2022\u2022\u2022\u2022\u2022"; + +export function usageLimitSourceSecretName(sourceId: string): string { + return `usage-limit-source-${Buffer.from(sourceId, "utf8").toString("base64url")}`; +} + function redactProviderEnvironmentVariable( variable: ProviderInstanceEnvironmentVariable, ): ProviderInstanceEnvironmentVariable { @@ -159,7 +171,17 @@ export function redactServerSettingsForClient(settings: ServerSettings): ServerS : instance, ]), ); - return { ...settings, providerInstances }; + // The hub key is a bearer secret; clients only need to know one is set. + const usageLimitSources = Object.fromEntries( + Object.entries(settings.usageLimitSources).map(([id, source]) => [ + id, + { + ...source, + managementKey: source.managementKey.length > 0 ? USAGE_LIMIT_SOURCE_KEY_REDACTED : "", + }, + ]), + ); + return { ...settings, providerInstances, usageLimitSources }; } export class ServerSettingsService extends Context.Service< @@ -512,9 +534,28 @@ const make = Effect.gen(function* () { environment, } satisfies ProviderInstanceConfig; } + const usageLimitSources: Record = {}; + for (const [sourceId, source] of Object.entries(settings.usageLimitSources)) { + if (source.managementKey !== USAGE_LIMIT_SOURCE_KEY_REDACTED) { + usageLimitSources[sourceId] = source; + continue; + } + const secret = yield* secretStore + .get(usageLimitSourceSecretName(sourceId)) + .pipe( + Effect.mapError( + (cause) => new ServerSettingsError({ settingsPath, operation: "read-secret", cause }), + ), + ); + usageLimitSources[sourceId] = { + ...source, + managementKey: Option.isSome(secret) ? textDecoder.decode(secret.value) : "", + }; + } return { ...settings, providerInstances: providerInstances as ServerSettings["providerInstances"], + usageLimitSources: usageLimitSources as ServerSettings["usageLimitSources"], }; }); @@ -630,9 +671,52 @@ const make = Effect.gen(function* () { } } + const usageLimitSources: Record = {}; + for (const [sourceId, source] of Object.entries(next.usageLimitSources)) { + const secretName = usageLimitSourceSecretName(sourceId); + if (source.managementKey === USAGE_LIMIT_SOURCE_KEY_REDACTED) { + // Unchanged from the client's point of view; the store already has it. + usageLimitSources[sourceId] = source; + continue; + } + if (source.managementKey.length === 0) { + yield* secretStore + .remove(secretName) + .pipe( + Effect.mapError( + (cause) => + new ServerSettingsError({ settingsPath, operation: "remove-secret", cause }), + ), + ); + usageLimitSources[sourceId] = source; + continue; + } + yield* secretStore + .set(secretName, textEncoder.encode(source.managementKey)) + .pipe( + Effect.mapError( + (cause) => + new ServerSettingsError({ settingsPath, operation: "write-secret", cause }), + ), + ); + usageLimitSources[sourceId] = { ...source, managementKey: USAGE_LIMIT_SOURCE_KEY_REDACTED }; + } + for (const sourceId of Object.keys(current.usageLimitSources)) { + if (sourceId in next.usageLimitSources) continue; + yield* secretStore + .remove(usageLimitSourceSecretName(sourceId)) + .pipe( + Effect.mapError( + (cause) => + new ServerSettingsError({ settingsPath, operation: "remove-stale-secret", cause }), + ), + ); + } + return { ...next, providerInstances: providerInstances as ServerSettings["providerInstances"], + usageLimitSources: usageLimitSources as ServerSettings["usageLimitSources"], }; }); diff --git a/apps/server/src/usage/UsageLimitSources.ts b/apps/server/src/usage/UsageLimitSources.ts new file mode 100644 index 000000000000..abe7f8e64999 --- /dev/null +++ b/apps/server/src/usage/UsageLimitSources.ts @@ -0,0 +1,202 @@ +/** + * UsageLimitSources — quota from places this environment cannot run turns + * on, today a CLIProxyAPI hub pooling several subscription accounts. + * + * Each configured `settings.usageLimitSources` entry is polled on the + * provider health-check interval and on every settings change, then + * published as one snapshot per source over `subscribeServerConfig`. A source + * that fails keeps its row with `error` set so the user can see it is + * configured but unreachable. Nothing is persisted: like provider status, + * this is live state that re-derives on boot. + * + * @module usage/UsageLimitSources + */ +import { + DEFAULT_PROVIDER_HEALTH_REFRESH_INTERVAL, + type ServerSettings, + type UsageLimitSourceConfig, + type UsageLimitSourceId, + type UsageLimitSourceSnapshot, +} from "@t3tools/contracts"; +import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings"; +import type * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Equal from "effect/Equal"; +import * as Layer from "effect/Layer"; +import * as PubSub from "effect/PubSub"; +import * as Ref from "effect/Ref"; +import * as Semaphore from "effect/Semaphore"; +import type * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { HttpClient, type HttpClientError, HttpClientResponse } from "effect/unstable/http"; + +import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; +import { ServerSettingsService } from "../serverSettings.ts"; +import { cliproxyStatusToAccounts, decodeCliproxyQuotaStatus } from "./cliproxyUsageLimits.ts"; + +const FETCH_TIMEOUT = "10 seconds"; +const QUOTA_STATUS_PATH = "/v0/management/quota-scheduler/status"; + +export class UsageLimitSources extends Context.Service< + UsageLimitSources, + { + readonly current: Effect.Effect>; + /** The current set followed by every change, with repeats dropped. */ + readonly streamChanges: Stream.Stream>; + /** Re-read every source now. Never fails; failures land on the snapshot. */ + readonly refresh: Effect.Effect; + } +>()("t3/usage/UsageLimitSources") {} + +/** + * A bounded, client-safe reason for a failed hub read. The exact failure + * (which can carry the request URL and response body) goes to the log. + */ +function readFailureMessage( + error: HttpClientError.HttpClientError | Schema.SchemaError | Cause.TimeoutError | InvalidUrl, +): string { + switch (error._tag) { + case "InvalidUrl": + return "The hub URL is not valid."; + case "TimeoutError": + return "The hub did not answer in time."; + case "SchemaError": + return "The hub answered with an unexpected shape."; + case "HttpClientError": + return error.reason._tag === "StatusCodeError" + ? `The hub refused the request (HTTP ${error.reason.response.status}).` + : "The hub could not be reached."; + } +} + +class InvalidUrl extends Data.TaggedError("InvalidUrl")<{ + readonly url: string; + readonly cause: unknown; +}> {} + +function sourceLabel(id: string, config: UsageLimitSourceConfig): string { + if (config.label) return config.label; + try { + return new URL(config.url).host; + } catch { + return id; + } +} + +export const make = Effect.gen(function* () { + const httpClient = yield* HttpClient.HttpClient; + const settingsService = yield* ServerSettingsService; + const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy; + const stateRef = yield* Ref.make>([]); + const changes = yield* Effect.acquireRelease( + PubSub.unbounded>(), + PubSub.shutdown, + ); + + const readSource = Effect.fn("UsageLimitSources.readSource")(function* ( + id: UsageLimitSourceId, + config: UsageLimitSourceConfig, + ) { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const base = { id, kind: config.kind, label: sourceLabel(id, config), checkedAt } as const; + if (config.managementKey.length === 0) { + return { ...base, accounts: [], error: "No management key configured." }; + } + const accounts = yield* Effect.try({ + try: () => new URL(QUOTA_STATUS_PATH, config.url).toString(), + catch: (cause) => new InvalidUrl({ url: config.url, cause }), + }).pipe( + Effect.flatMap((url) => + httpClient.get(url, { headers: { Authorization: `Bearer ${config.managementKey}` } }), + ), + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.flatMap((response) => response.json), + Effect.flatMap(decodeCliproxyQuotaStatus), + Effect.map((status) => cliproxyStatusToAccounts(status, checkedAt)), + Effect.timeout(FETCH_TIMEOUT), + Effect.result, + ); + if (accounts._tag === "Failure") { + yield* Effect.logDebug("usage limit source read failed", { id, cause: accounts.failure }); + return { ...base, accounts: [], error: readFailureMessage(accounts.failure) }; + } + return { ...base, accounts: accounts.success }; + }); + + const publish = (next: ReadonlyArray) => + Effect.gen(function* () { + const changed = yield* Ref.modify(stateRef, (previous) => + Equal.equals(previous, next) ? [false, previous] : [true, next], + ); + if (changed) yield* PubSub.publish(changes, next); + }); + + // One refresh at a time: a slow hub read started before a settings change + // must not publish after the change's own refresh and resurrect a removed + // source. Callers queue behind the in-flight run and see current settings. + const refreshLock = yield* Semaphore.make(1); + const refresh = Effect.gen(function* () { + const settings = yield* settingsService.getSettings.pipe( + Effect.orElseSucceed((): ServerSettings | null => null), + ); + const entries = Object.entries(settings?.usageLimitSources ?? {}).filter( + ([, config]) => config.enabled, + ); + const snapshots = yield* Effect.forEach( + entries, + ([id, config]) => readSource(id as UsageLimitSourceId, config), + { concurrency: 4 }, + ); + yield* publish(snapshots); + }).pipe(refreshLock.withPermits(1), Effect.ignoreCause({ log: true })); + + // Settings edits re-read straight away so a new hub shows up without + // waiting for the interval, and a removed one leaves the list. + yield* settingsService.streamChanges.pipe( + Stream.map((settings) => settings.usageLimitSources), + Stream.changes, + Stream.runForEach(() => refresh), + Effect.forkScoped, + ); + + const interval = settingsService.getSettings.pipe( + Effect.map( + (settings) => resolveServerBackgroundActivitySettings(settings).providerHealthRefreshInterval, + ), + Effect.orElseSucceed(() => DEFAULT_PROVIDER_HEALTH_REFRESH_INTERVAL), + ); + yield* Effect.forever( + interval.pipe( + Effect.flatMap((wait) => + Effect.sleep(Duration.toMillis(Duration.fromInputUnsafe(wait)) <= 0 ? "60 seconds" : wait), + ), + Effect.andThen(backgroundPolicy.shouldRunScopeWork({ type: "provider-status" })), + Effect.flatMap((shouldRun) => (shouldRun ? refresh : Effect.void)), + Effect.ignoreCause({ log: true }), + ), + ).pipe(Effect.forkScoped); + + yield* refresh.pipe(Effect.forkScoped); + + return { + current: Ref.get(stateRef), + refresh, + get streamChanges() { + return Stream.unwrap( + Effect.gen(function* () { + const subscription = yield* PubSub.subscribe(changes); + const snapshot = yield* Ref.get(stateRef); + return Stream.concat(Stream.make(snapshot), Stream.fromSubscription(subscription)).pipe( + Stream.changes, + ); + }), + ); + }, + } satisfies UsageLimitSources["Service"]; +}); + +export const layer = Layer.effect(UsageLimitSources, make); diff --git a/apps/server/src/usage/cliproxyUsageLimits.test.ts b/apps/server/src/usage/cliproxyUsageLimits.test.ts new file mode 100644 index 000000000000..19767f3a9270 --- /dev/null +++ b/apps/server/src/usage/cliproxyUsageLimits.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { accountEmailFromAuthFile, cliproxyStatusToAccounts } from "./cliproxyUsageLimits.ts"; + +const checkedAt = "2026-09-03T22:00:00.000Z"; + +describe("cliproxyStatusToAccounts", () => { + // Trimmed from a live `quota-scheduler/status`: one Claude account with a + // hard-limited Fable bucket, one Codex account whose 5h window is unknown. + it("maps each pooled account onto the windows the provider drivers use", () => { + const accounts = cliproxyStatusToAccounts( + { + accounts: { + "claude-jmarminge@gmail.com.json": { + provider: "claude", + fetched_at: "2026-09-03T15:06:07-07:00", + five_hour: { hard_limited: false, known: true, used_percent: 0 }, + seven_day: { + hard_limited: false, + known: true, + reset_at: "2026-09-07T07:59:59Z", + used_percent: 51, + }, + fable: { + hard_limited: true, + known: true, + reset_at: "2026-09-07T07:59:59Z", + used_percent: 100, + }, + }, + "codex-7f42123a-jmarminge@gmail.com-pro.json": { + provider: "codex", + plan: "pro", + fetched_at: "2026-09-03T15:07:07-07:00", + five_hour: { hard_limited: false, known: false, used_percent: 0 }, + weekly: { + hard_limited: false, + known: true, + reset_at: "2026-09-06T19:52:53-07:00", + used_percent: 12, + }, + }, + "xai-someone@example.com.json": { provider: "xai" }, + }, + }, + checkedAt, + ); + + expect(accounts).toEqual([ + { + id: "claude-jmarminge@gmail.com.json", + driver: "claudeAgent", + email: "jmarminge@gmail.com", + plan: "Claude Subscription", + usageLimits: { + checkedAt: "2026-09-03T22:06:07.000Z", + windows: [ + { + id: "five_hour", + kind: "session", + label: "Session", + usedPercent: 0, + windowDurationMins: 300, + }, + { + id: "seven_day", + kind: "weekly", + label: "Weekly", + usedPercent: 51, + windowDurationMins: 10080, + resetsAt: "2026-09-07T07:59:59.000Z", + }, + { + id: "seven_day_fable", + kind: "weekly", + label: "Weekly · Fable", + usedPercent: 100, + windowDurationMins: 10080, + resetsAt: "2026-09-07T07:59:59.000Z", + }, + ], + }, + }, + { + id: "codex-7f42123a-jmarminge@gmail.com-pro.json", + driver: "codex", + email: "jmarminge@gmail.com", + plan: "ChatGPT Pro 20x Subscription", + usageLimits: { + checkedAt: "2026-09-03T22:07:07.000Z", + windows: [ + { + id: "secondary", + kind: "weekly", + label: "Weekly", + usedPercent: 12, + windowDurationMins: 10080, + resetsAt: "2026-09-07T02:52:53.000Z", + }, + ], + }, + }, + ]); + }); +}); + +describe("accountEmailFromAuthFile", () => { + it("pulls the email out of the hub's auth file names", () => { + expect(accountEmailFromAuthFile("claude-julius@ping.gg.json")).toBe("julius@ping.gg"); + expect(accountEmailFromAuthFile("codex-e413dce6-julius@ping.gg-pro.json")).toBe( + "julius@ping.gg", + ); + expect(accountEmailFromAuthFile("claude-first-last@example.com.json")).toBe( + "first-last@example.com", + ); + expect(accountEmailFromAuthFile("mystery.json")).toBeUndefined(); + }); +}); diff --git a/apps/server/src/usage/cliproxyUsageLimits.ts b/apps/server/src/usage/cliproxyUsageLimits.ts new file mode 100644 index 000000000000..cd2b1e277da6 --- /dev/null +++ b/apps/server/src/usage/cliproxyUsageLimits.ts @@ -0,0 +1,160 @@ +/** + * Maps a CLIProxyAPI hub's `quota-scheduler/status` response onto the usage + * limit windows the Limits view renders, one account per pooled auth file. + * + * The hub already normalises each upstream: Claude accounts carry + * `five_hour` / `seven_day` / `fable`, Codex accounts `five_hour` / `weekly`. + * Every window is `{ used_percent, reset_at?, known, hard_limited }`. + * + * @module usage/cliproxyUsageLimits + */ +import { + ProviderDriverKind, + type ServerProviderUsageLimits, + type ServerProviderUsageWindow, + type UsageLimitSourceAccount, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import { codexPlanLabel } from "../provider/Layers/CodexProvider.ts"; +import { clampPercent, makeUsageLimits } from "../provider/providerUsageLimits.ts"; + +const QuotaWindow = Schema.Struct({ + used_percent: Schema.Number, + reset_at: Schema.optional(Schema.String), + known: Schema.optional(Schema.Boolean), + hard_limited: Schema.optional(Schema.Boolean), +}); + +const QuotaAccount = Schema.Struct({ + provider: Schema.String, + plan: Schema.optional(Schema.String), + fetched_at: Schema.optional(Schema.String), + five_hour: Schema.optional(QuotaWindow), + seven_day: Schema.optional(QuotaWindow), + weekly: Schema.optional(QuotaWindow), + fable: Schema.optional(QuotaWindow), +}); + +export const CliproxyQuotaStatus = Schema.Struct({ + accounts: Schema.Record(Schema.String, QuotaAccount), +}); +export type CliproxyQuotaStatus = typeof CliproxyQuotaStatus.Type; +export const decodeCliproxyQuotaStatus = Schema.decodeUnknownEffect(CliproxyQuotaStatus); + +const SESSION_MINS = 5 * 60; +const WEEK_MINS = 7 * 24 * 60; + +/** Window ids match what the provider drivers emit, so rows read the same across sources. */ +const WINDOWS: ReadonlyArray<{ + readonly key: keyof Omit; + readonly id: string; + readonly kind: ServerProviderUsageWindow["kind"]; + readonly label: string; + readonly windowDurationMins: number; +}> = [ + { + key: "five_hour", + id: "five_hour", + kind: "session", + label: "Session", + windowDurationMins: SESSION_MINS, + }, + { + key: "seven_day", + id: "seven_day", + kind: "weekly", + label: "Weekly", + windowDurationMins: WEEK_MINS, + }, + { + key: "weekly", + id: "secondary", + kind: "weekly", + label: "Weekly", + windowDurationMins: WEEK_MINS, + }, + { + key: "fable", + id: "seven_day_fable", + kind: "weekly", + label: "Weekly · Fable", + windowDurationMins: WEEK_MINS, + }, +]; + +const DRIVER_BY_HUB_PROVIDER: Readonly> = { + claude: ProviderDriverKind.make("claudeAgent"), + codex: ProviderDriverKind.make("codex"), +}; + +function isoFromHub(value: string | undefined): string | undefined { + if (!value) return undefined; + const dt = DateTime.make(value); + return Option.isSome(dt) ? DateTime.formatIso(dt.value) : undefined; +} + +/** `claude-julius@ping.gg.json` → `julius@ping.gg`; `codex--x@y-pro.json` → `x@y`. */ +export function accountEmailFromAuthFile(fileName: string): string | undefined { + const stem = fileName.replace(/\.json$/i, ""); + // Strip the provider prefix (and Codex's hash) rather than splitting on + // `-`, so a hyphenated local part such as `first-last@` survives. + return stem.match(/^(?:claude-|codex-[a-z0-9]+-)?([^\s/]+@[^\s/]+?)(?:-[a-z0-9]+)?$/i)?.[1]; +} + +/** + * The hub only reports a plan slug for Codex. Claude accounts carry no tier + * in the scheduler status, so the row says what it is: a Claude subscription. + */ +function planLabel(account: typeof QuotaAccount.Type): string | undefined { + if (account.provider === "codex") return codexPlanLabel(account.plan); + if (account.provider === "claude") return "Claude Subscription"; + return undefined; +} + +export function cliproxyAccountToUsageLimits( + account: typeof QuotaAccount.Type, + checkedAt: string, +): ServerProviderUsageLimits { + const windows: ServerProviderUsageWindow[] = []; + for (const spec of WINDOWS) { + const window = account[spec.key]; + if (!window || window.known === false) continue; + const resetsAt = isoFromHub(window.reset_at); + windows.push({ + id: spec.id, + kind: spec.kind, + label: spec.label, + windowDurationMins: spec.windowDurationMins, + // The hub flags a window it has seen a 429 on; the percent may lag. + usedPercent: window.hard_limited ? 100 : clampPercent(window.used_percent), + ...(resetsAt ? { resetsAt } : {}), + }); + } + return makeUsageLimits({ checkedAt: isoFromHub(account.fetched_at) ?? checkedAt, windows }); +} + +export function cliproxyStatusToAccounts( + status: CliproxyQuotaStatus, + checkedAt: string, +): ReadonlyArray { + const accounts: UsageLimitSourceAccount[] = []; + for (const [fileName, account] of Object.entries(status.accounts)) { + const driver = DRIVER_BY_HUB_PROVIDER[account.provider]; + if (!driver) continue; + const email = accountEmailFromAuthFile(fileName); + const plan = planLabel(account); + accounts.push({ + id: fileName, + driver, + ...(email ? { email } : {}), + ...(plan ? { plan } : {}), + usageLimits: cliproxyAccountToUsageLimits(account, checkedAt), + }); + } + return accounts.toSorted( + (left, right) => left.driver.localeCompare(right.driver) || left.id.localeCompare(right.id), + ); +} diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 1ab424347637..08b474cf42da 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -30,7 +30,11 @@ import { type VcsStatusInput, type VcsStatusResult, } from "@t3tools/contracts"; -import { makeGitVcsDriverCore, splitNullSeparatedGitStdoutPaths } from "./GitVcsDriverCore.ts"; +import { + makeGitVcsDriverCore, + PATCH_RENDER_PREFIX_ARGS, + splitNullSeparatedGitStdoutPaths, +} from "./GitVcsDriverCore.ts"; import * as VcsDriver from "./VcsDriver.ts"; import * as VcsProcess from "./VcsProcess.ts"; @@ -869,6 +873,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( "--no-color", "--no-ext-diff", "--no-textconv", + ...PATCH_RENDER_PREFIX_ARGS, ...(input.ignoreWhitespace ? ["--ignore-all-space"] : []), `${fromRevision}^{commit}`, `${input.toCheckpointRef}^{commit}`, diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index c0621f2c99d7..75f69de952c1 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -819,6 +819,34 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); + it.effect("keeps a/ and b/ patch prefixes when the repository disables them", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(cwd, ["config", "diff.noprefix", "true"]); + yield* git(cwd, ["config", "diff.mnemonicPrefix", "true"]); + yield* git(cwd, ["checkout", "-b", "feature/noprefix"]); + yield* writeTextFile(cwd, "README.md", "# committed change\n"); + yield* git(cwd, ["add", "README.md"]); + yield* git(cwd, ["commit", "-m", "committed change"]); + yield* writeTextFile(cwd, "README.md", "# dirty change\n"); + yield* writeTextFile(cwd, "untracked.txt", "untracked\n"); + + const preview = yield* driver.getReviewDiffPreview({ + cwd, + baseRef: initialBranch, + ignoreWhitespace: false, + }); + + const workingTree = preview.sources.find((source) => source.kind === "working-tree")?.diff; + const branchRange = preview.sources.find((source) => source.kind === "branch-range")?.diff; + assert.include(workingTree, "diff --git a/README.md b/README.md"); + assert.include(workingTree, "+++ b/untracked.txt"); + assert.include(branchRange, "diff --git a/README.md b/README.md"); + }), + ); + it.effect("loads full file contents for working-tree diff expansion", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); @@ -1489,6 +1517,51 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); + it.effect("allows worktree removal to run longer than the default command timeout", () => + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const removalStarted = yield* Deferred.make(); + const delayedRemovalSpawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + if ( + ChildProcess.isStandardCommand(command) && + command.args[0] === "worktree" && + command.args[1] === "remove" + ) { + yield* Deferred.succeed(removalStarted, undefined); + yield* Effect.sleep("31 seconds"); + } + return yield* delegate.spawn(command); + }), + ); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, delayedRemovalSpawner), + Effect.provide(ServerConfigLayer), + ); + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const worktreePath = pathService.join(yield* makeTmpDir("git-worktrees-"), "slow-removal"); + + yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/slow-removal", + }); + + const removal = yield* driver + .removeWorktree({ cwd, path: worktreePath, force: true }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(removalStarted); + yield* TestClock.adjust("31 seconds"); + yield* Fiber.join(removal); + + assert.equal(yield* fileSystem.exists(worktreePath), false); + }), + ); + it.effect("removes the same worktree path twice without failing", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index f1fb1b7a7b18..23f8e6f2a995 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -43,6 +43,7 @@ const DEFAULT_TIMEOUT_MS = 30_000; // take well beyond the default 30s (e.g. a 375k-file repo takes ~40s on an idle // machine). Give it generous headroom while still bounding a genuinely hung git. const WORKTREE_ADD_TIMEOUT_MS = 300_000; +const WORKTREE_REMOVE_TIMEOUT_MS = Duration.toMillis(Duration.minutes(5)); const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; const OUTPUT_TRUNCATED_MARKER = "\n\n[truncated]"; const PREPARED_COMMIT_PATCH_MAX_OUTPUT_BYTES = 49_000; @@ -52,6 +53,10 @@ const RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES = 59_000; const REVIEW_DIFF_PATCH_MAX_OUTPUT_BYTES = 120_000; const REVIEW_UNTRACKED_DIFF_MAX_OUTPUT_BYTES = 80_000; const REVIEW_DIFF_FILE_MAX_OUTPUT_BYTES = 1024 * 1024; +// Patches the clients render are parsed against git's default a/ and b/ path +// prefixes. A repository or global diff.noprefix or diff.mnemonicPrefix would +// otherwise leak into the patch and leave every parsed file unnamed. +export const PATCH_RENDER_PREFIX_ARGS = ["--src-prefix=a/", "--dst-prefix=b/"] as const; const WORKSPACE_FILES_MAX_OUTPUT_BYTES = 120_000; const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15); const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5); @@ -2205,6 +2210,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* "--no-ext-diff", "--no-textconv", "--minimal", + ...PATCH_RENDER_PREFIX_ARGS, "--", "/dev/null", relativePath, @@ -2257,6 +2263,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* "--no-ext-diff", "--no-textconv", "--minimal", + ...PATCH_RENDER_PREFIX_ARGS, ...(input.ignoreWhitespace ? ["--ignore-all-space"] : []), "HEAD", "--", @@ -2293,6 +2300,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* "--no-ext-diff", "--no-textconv", "--minimal", + ...PATCH_RENDER_PREFIX_ARGS, ...(input.ignoreWhitespace ? ["--ignore-all-space"] : []), `${baseRef}...HEAD`, ], @@ -3078,7 +3086,13 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* "GitVcsDriver.removeWorktree", input.cwd, args, - { timeoutMs: 15_000, allowNonZeroExit: true }, + { + // Removing dependency-heavy worktrees is filesystem-bound and can take + // minutes, especially on Windows. Keep it bounded without interrupting + // git midway through cleanup. + timeoutMs: WORKTREE_REMOVE_TIMEOUT_MS, + allowNonZeroExit: true, + }, ); if (result.exitCode === 0) { return; diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 839937cf2ea7..aafdc89d23c6 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -127,6 +127,7 @@ import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; +import * as UsageLimitSources from "./usage/UsageLimitSources.ts"; import * as UsageService from "./usage/UsageService.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; import * as PullRequestService from "./pullRequest/PullRequestService.ts"; @@ -501,6 +502,7 @@ const makeWsRpcLayer = ( const checkpointDiffQuery = yield* CheckpointDiffQuery.CheckpointDiffQuery; const keybindings = yield* Keybindings.Keybindings; const environmentTheme = yield* EnvironmentTheme.EnvironmentThemeService; + const usageLimitSources = yield* UsageLimitSources.UsageLimitSources; const externalLauncher = yield* ExternalLauncher.ExternalLauncher; const remoteOpenTargets = yield* RemoteOpenTargets.RemoteOpenTargets; const gitWorkflow = yield* GitWorkflowService.GitWorkflowService; @@ -1549,7 +1551,9 @@ const makeWsRpcLayer = ( // Attach live delivery before reading either replay or snapshot state. // Otherwise an event published while the snapshot is loading is lost. const liveBuffer = yield* makeThreadLiveEventCoalescer(); - yield* Effect.forkScoped(liveStream.pipe(Stream.runForEach(liveBuffer.offer))); + yield* Effect.forkScoped(liveStream.pipe(Stream.runForEach(liveBuffer.offer)), { + startImmediately: true, + }); const bufferedLiveStream = liveBuffer.stream; // When the client already loaded the snapshot over HTTP it passes @@ -1674,6 +1678,13 @@ const makeWsRpcLayer = ( observeRpcEffect( WS_METHODS.serverRefreshProviders, Effect.gen(function* () { + // An untargeted refresh is "re-read everything's status", which + // includes quota from configured usage-limit sources. Awaited, + // not forked: the RPC scope closes on return and would + // interrupt a fork before the hub answered. + if (input.instanceId === undefined) { + yield* usageLimitSources.refresh; + } let providers = yield* input.cwd !== undefined && input.instanceId !== undefined ? providerRegistry.refreshWorkspaceSnapshot({ instanceId: input.instanceId, @@ -2571,6 +2582,17 @@ const makeWsRpcLayer = ( })), ) : Stream.empty; + // Same gate as themes: an older client dies on an unknown event. + const usageLimitSourceUpdates = + input.usageLimitSources === true + ? usageLimitSources.streamChanges.pipe( + Stream.map((sources) => ({ + version: 1 as const, + type: "usageLimitSourcesUpdated" as const, + payload: { sources }, + })), + ) + : Stream.empty; const settingsUpdates = serverSettings.streamChanges.pipe( Stream.map((settings) => ServerSettings.redactServerSettingsForClient(settings)), Stream.map((settings) => ({ @@ -2588,7 +2610,10 @@ const makeWsRpcLayer = ( keybindingsUpdates, Stream.merge( providerStatuses, - Stream.merge(settingsUpdates, environmentThemeUpdates), + Stream.merge( + settingsUpdates, + Stream.merge(environmentThemeUpdates, usageLimitSourceUpdates), + ), ), ); diff --git a/apps/web/src/browser/BrowserSurfaceSlot.tsx b/apps/web/src/browser/BrowserSurfaceSlot.tsx index a9d3f541ff19..3de3ed586cb0 100644 --- a/apps/web/src/browser/BrowserSurfaceSlot.tsx +++ b/apps/web/src/browser/BrowserSurfaceSlot.tsx @@ -8,6 +8,7 @@ export function BrowserSurfaceSlot(props: { readonly tabId: string; readonly visible: boolean; readonly cornerRadius?: number; + readonly zIndex?: number; readonly layoutVersion?: string | number; readonly className?: string; readonly fitSourceContent?: boolean; @@ -16,12 +17,13 @@ export function BrowserSurfaceSlot(props: { tabId, visible, cornerRadius = 0, + zIndex = 30, layoutVersion, className, fitSourceContent = false, } = props; const elementRef = useRef(null); - const presentationRef = useRef({ visible, cornerRadius }); + const presentationRef = useRef({ visible, cornerRadius, zIndex }); const updateRef = useRef<(() => void) | null>(null); useLayoutEffect(() => { @@ -40,6 +42,7 @@ export function BrowserSurfaceSlot(props: { }, presentation.visible && rect.width > 0 && rect.height > 0, presentation.cornerRadius, + presentation.zIndex, ); if (presentation.visible && !presented) { lease.release(); @@ -53,6 +56,7 @@ export function BrowserSurfaceSlot(props: { }, rect.width > 0 && rect.height > 0, presentation.cornerRadius, + presentation.zIndex, ); } }; @@ -72,9 +76,9 @@ export function BrowserSurfaceSlot(props: { }, [fitSourceContent, tabId]); useLayoutEffect(() => { - presentationRef.current = { visible, cornerRadius }; + presentationRef.current = { visible, cornerRadius, zIndex }; updateRef.current?.(); - }, [cornerRadius, layoutVersion, visible]); + }, [cornerRadius, layoutVersion, visible, zIndex]); return
; } diff --git a/apps/web/src/browser/HostedBrowserWebview.tsx b/apps/web/src/browser/HostedBrowserWebview.tsx index 564a2453b2be..0f01960ce52b 100644 --- a/apps/web/src/browser/HostedBrowserWebview.tsx +++ b/apps/web/src/browser/HostedBrowserWebview.tsx @@ -83,6 +83,7 @@ export function HostedBrowserWebview(props: { fittedSourceContent: current?.fittedSourceContent ?? null, rect: resolveBrowserSurfacePanelRect(state.byTabId, runtimeTabId), visible: current?.visible ?? false, + zIndex: current?.zIndex ?? 30, }; }), ); @@ -259,6 +260,7 @@ export function HostedBrowserWebview(props: { // suspend them, and automation continues to see the macOS guests as inactive. keepPaintableWhenInactive: isMacPlatform(navigator.platform), cornerRadius: presentation.cornerRadius, + zIndex: presentation.zIndex, rect: lastRect, hiddenSize, }); @@ -315,7 +317,7 @@ export function HostedBrowserWebview(props: { } aria-hidden={active ? undefined : true} className={cn( - "absolute flex overflow-hidden bg-background", + "absolute flex overflow-hidden bg-white", active && !layout.fillsPanel && "ring-1 ring-border/70 shadow-sm", )} style={{ diff --git a/apps/web/src/browser/browserSurfaceStore.test.ts b/apps/web/src/browser/browserSurfaceStore.test.ts index 249d3dcb2f44..456377a4d641 100644 --- a/apps/web/src/browser/browserSurfaceStore.test.ts +++ b/apps/web/src/browser/browserSurfaceStore.test.ts @@ -107,6 +107,7 @@ describe("browserSurfaceStore", () => { hidden: { rect: staleRect, visible: false, + zIndex: 30, content: null, fittedSourceContent: null, fitSourceContent: false, @@ -117,6 +118,7 @@ describe("browserSurfaceStore", () => { active: { rect: liveRect, visible: true, + zIndex: 30, content: null, fittedSourceContent: null, fitSourceContent: false, @@ -162,6 +164,17 @@ describe("browserSurfaceStore", () => { }); }); + it("keeps the requested layer with the active surface lease", () => { + const tabId = "layered-browser-surface"; + const lease = acquireBrowserSurface(tabId); + lease.present({ x: 10, y: 20, width: 320, height: 200 }, true, 12, 48); + + expect(useBrowserSurfaceStore.getState().byTabId[tabId]).toMatchObject({ + visible: true, + zIndex: 48, + }); + }); + it("clears fitted presentation state when its lease is released", () => { const tabId = "released-fitted-browser-surface"; const fittedLease = acquireBrowserSurface(tabId, true); diff --git a/apps/web/src/browser/browserSurfaceStore.ts b/apps/web/src/browser/browserSurfaceStore.ts index fe85c9e38b21..a49154ed8def 100644 --- a/apps/web/src/browser/browserSurfaceStore.ts +++ b/apps/web/src/browser/browserSurfaceStore.ts @@ -10,6 +10,7 @@ export interface BrowserSurfaceRect { export interface BrowserSurfacePresentation { readonly rect: BrowserSurfaceRect | null; readonly visible: boolean; + readonly zIndex: number; readonly content: BrowserSurfaceContentPresentation | null; readonly fittedSourceContent: BrowserSurfaceContentPresentation | null; readonly fitSourceContent: boolean; @@ -39,13 +40,19 @@ interface BrowserSurfaceStoreState { rect: BrowserSurfaceRect, visible: boolean, cornerRadius: number, + zIndex: number, ) => void; readonly presentContent: (tabId: string, content: BrowserSurfaceContentPresentation) => void; readonly release: (tabId: string, owner: symbol) => void; } export interface BrowserSurfaceLease { - readonly present: (rect: BrowserSurfaceRect, visible: boolean, cornerRadius?: number) => boolean; + readonly present: ( + rect: BrowserSurfaceRect, + visible: boolean, + cornerRadius?: number, + zIndex?: number, + ) => boolean; readonly release: () => void; } @@ -97,6 +104,7 @@ export const useBrowserSurfaceStore = create()((set) = [tabId]: { rect: current?.rect ?? null, visible: false, + zIndex: current?.zIndex ?? 30, content: current?.content ?? null, fittedSourceContent: fitSourceContent ? (current?.content ?? null) : null, fitSourceContent, @@ -107,7 +115,7 @@ export const useBrowserSurfaceStore = create()((set) = }, }; }), - present: (tabId, owner, rect, visible, cornerRadius) => + present: (tabId, owner, rect, visible, cornerRadius, zIndex) => set((state) => { const current = state.byTabId[tabId]; if (current?.owner !== owner) return state; @@ -115,6 +123,7 @@ export const useBrowserSurfaceStore = create()((set) = current && current.visible === visible && current.cornerRadius === cornerRadius && + current.zIndex === zIndex && rectEquals(current.rect, rect) ) { return state; @@ -122,7 +131,7 @@ export const useBrowserSurfaceStore = create()((set) = return { byTabId: { ...state.byTabId, - [tabId]: { ...current, rect, visible, cornerRadius, updatedAt: Date.now() }, + [tabId]: { ...current, rect, visible, cornerRadius, zIndex, updatedAt: Date.now() }, }, }; }), @@ -136,6 +145,7 @@ export const useBrowserSurfaceStore = create()((set) = [tabId]: { rect: null, visible: false, + zIndex: 30, content, fittedSourceContent: null, fitSourceContent: false, @@ -206,10 +216,10 @@ export function acquireBrowserSurface( useBrowserSurfaceStore.getState().claim(tabId, owner, fitSourceContent); return { - present: (rect, visible, cornerRadius = 0) => { + present: (rect, visible, cornerRadius = 0, zIndex = 30) => { if (released) return false; if (useBrowserSurfaceStore.getState().byTabId[tabId]?.owner !== owner) return false; - useBrowserSurfaceStore.getState().present(tabId, owner, rect, visible, cornerRadius); + useBrowserSurfaceStore.getState().present(tabId, owner, rect, visible, cornerRadius, zIndex); return true; }, release: () => { diff --git a/apps/web/src/browser/hostedBrowserWebviewStyle.test.ts b/apps/web/src/browser/hostedBrowserWebviewStyle.test.ts index 69216796af9f..831167095fa1 100644 --- a/apps/web/src/browser/hostedBrowserWebviewStyle.test.ts +++ b/apps/web/src/browser/hostedBrowserWebviewStyle.test.ts @@ -30,6 +30,7 @@ describe("resolveHostedBrowserWebviewWrapperStyle", () => { active: true, renderingActive: true, cornerRadius: 12, + zIndex: 48, rect: { x: 12, y: 34, width: 360, height: 203 }, hiddenSize: { width: 1280, height: 800 }, }), @@ -39,6 +40,7 @@ describe("resolveHostedBrowserWebviewWrapperStyle", () => { width: 360, height: 203, borderRadius: 12, + zIndex: 48, }); }); diff --git a/apps/web/src/browser/hostedBrowserWebviewStyle.ts b/apps/web/src/browser/hostedBrowserWebviewStyle.ts index a59a4a8b0083..5bdf9b7c4f6d 100644 --- a/apps/web/src/browser/hostedBrowserWebviewStyle.ts +++ b/apps/web/src/browser/hostedBrowserWebviewStyle.ts @@ -23,6 +23,7 @@ export function resolveHostedBrowserWebviewWrapperStyle(input: { readonly renderingActive: boolean; readonly keepPaintableWhenInactive?: boolean; readonly cornerRadius?: number; + readonly zIndex?: number; readonly rect: BrowserSurfaceRect | null; readonly hiddenSize: HostedBrowserWebviewSize; }): HostedBrowserWebviewWrapperStyle { @@ -33,6 +34,7 @@ export function resolveHostedBrowserWebviewWrapperStyle(input: { keepPaintableWhenInactive = false, rect, renderingActive, + zIndex = 30, } = input; if (active && rect) { return { @@ -40,7 +42,7 @@ export function resolveHostedBrowserWebviewWrapperStyle(input: { top: rect.y, width: rect.width, height: rect.height, - zIndex: 30, + zIndex, pointerEvents: "auto", ...(cornerRadius > 0 ? { borderRadius: cornerRadius } : {}), }; diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 0496bef06ef6..07407bcf21b3 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -40,6 +40,7 @@ import { } from "./ui/menu"; import { Separator } from "./ui/separator"; import { ComposerSurface } from "./chat/ComposerSurface"; +import { composerFloatingLayerProps } from "./chat/composerEventScope"; import { measureRestingComposerControls } from "./chat/restingComposerControlsMeasurement"; import { resolveRestingComposerControlsNaturalWidth } from "./composerFooterLayout"; import { cn } from "~/lib/utils"; @@ -160,7 +161,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ {triggerContent} - + {showEnvironmentPicker && availableEnvironments && onEnvironmentChange ? ( <> diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 67f6cbe7b9c0..e968954ec1d0 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -34,6 +34,7 @@ import { vcsEnvironment } from "../state/vcs"; import { cn } from "../lib/utils"; import { parsePullRequestReference } from "../pullRequestReference"; import { getSourceControlPresentation } from "../sourceControlPresentation"; +import { composerFloatingLayerProps } from "./chat/composerEventScope"; import { deriveLocalBranchNameFromRemoteRef, resolveBranchTriggerLabel, @@ -788,7 +789,12 @@ export function BranchToolbarBranchSelector({
- +
- + Workspace diff --git a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx index 6304e37cf88d..fabda55688bc 100644 --- a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx @@ -3,6 +3,7 @@ import { memo, useMemo } from "react"; import type { EnvironmentOption } from "./BranchToolbar.logic"; import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; +import { composerFloatingLayerProps } from "./chat/composerEventScope"; import { Select, SelectGroup, @@ -101,7 +102,7 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir - + Run on {availableEnvironments.map((env) => ( diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 7649d6b50e49..4cc750d45236 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -50,6 +50,7 @@ import { shouldReleaseTimelineAnchorForToolActivity, shouldOpenProactivePullRequest, shouldOpenProactiveTurnDiff, + shouldRenderPreviewMiniPlayer, shouldShowBranchMismatchBanner, shouldShowPlanFollowUpPrompt, shouldWriteThreadErrorToCurrentServerThread, @@ -93,6 +94,27 @@ describe("agent browser close confirmation", () => { }); }); +describe("floating browser preview", () => { + it("only hides the duplicate while the same browser is rendered in the panel", () => { + expect(shouldRenderPreviewMiniPlayer(null, null)).toBe(false); + expect( + shouldRenderPreviewMiniPlayer("tab-1", { + id: "browser:one", + kind: "preview", + resourceId: "tab-1", + }), + ).toBe(false); + expect( + shouldRenderPreviewMiniPlayer("tab-1", { + id: "browser:two", + kind: "preview", + resourceId: "tab-2", + }), + ).toBe(true); + expect(shouldRenderPreviewMiniPlayer("tab-1", { id: "diff", kind: "diff" })).toBe(true); + }); +}); + describe("proactive panels", () => { it("opens a pull request only after a newly observed link appears", () => { expect(shouldOpenProactivePullRequest(undefined, "project:repo:42")).toBe(false); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index cef14f240d97..46ff8c473c6d 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -86,6 +86,19 @@ export function agentControlledBrowserCloseConfirmation( ].join("\n"); } +export function shouldRenderPreviewMiniPlayer( + miniPlayerTabId: string | null, + renderedRightPanelSurface: RightPanelSurface | null, +): boolean { + return ( + miniPlayerTabId !== null && + !( + renderedRightPanelSurface?.kind === "preview" && + renderedRightPanelSurface.resourceId === miniPlayerTabId + ) + ); +} + export function shouldOpenProactivePullRequest( previousTargetKey: string | null | undefined, targetKey: string | null, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 0d33aec7bc20..4ba53f47b6a4 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -366,6 +366,7 @@ import { shouldShowPlanFollowUpPrompt, shouldOpenProactivePullRequest, shouldOpenProactiveTurnDiff, + shouldRenderPreviewMiniPlayer, getStartedThreadModelChangeBlockReason, LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, LastInvokedScriptByProjectSchema, @@ -570,14 +571,24 @@ function eventPathContainsSelector(event: Event, selector: string): boolean { return path.some((target) => target instanceof Element && target.closest(selector)); } -function shouldTypeToFocusComposer(event: KeyboardEvent): boolean { - if (event.defaultPrevented || event.isComposing) return false; - if (event.metaKey || event.ctrlKey || event.altKey) return false; - if (event.key.length !== 1) return false; - +/** + * Whether input that landed outside any editable or interactive element + * should be redirected into the composer. Shared by type-to-focus and + * paste-to-focus so both honour the same surfaces. + */ +function shouldRedirectInputToComposer(event: Event): boolean { + if (event.defaultPrevented) return false; if (eventPathContainsSelector(event, TYPE_TO_FOCUS_EDITABLE_SELECTOR)) return false; if (eventPathContainsSelector(event, TYPE_TO_FOCUS_INTERACTIVE_SELECTOR)) return false; if (document.querySelector(TYPE_TO_FOCUS_FLOATING_LAYER_SELECTOR)) return false; + return true; +} + +function shouldTypeToFocusComposer(event: KeyboardEvent): boolean { + if (event.isComposing) return false; + if (event.metaKey || event.ctrlKey || event.altKey) return false; + if (event.key.length !== 1) return false; + if (!shouldRedirectInputToComposer(event)) return false; // The right-panel surface launcher claims its shortcut letters while it is // visible (data attribute set in RightPanelTabs); those keys open surfaces @@ -590,6 +601,17 @@ function shouldTypeToFocusComposer(event: KeyboardEvent): boolean { return true; } +/** + * Plain text pasted with nothing editable focused, such as after the resting + * composer blurred. Files are left to the composer's own paste handler. + */ +function pasteTextToFocusComposer(event: ClipboardEvent): string | null { + if (!event.clipboardData || event.clipboardData.files.length > 0) return null; + if (!shouldRedirectInputToComposer(event)) return null; + const text = event.clipboardData.getData("text/plain"); + return text.length > 0 ? text : null; +} + function formatOutgoingPrompt(params: { provider: ProviderDriverKind; model: string | null; @@ -1843,10 +1865,13 @@ function ChatViewContent(props: ChatViewProps) { panelAnimationDurationMs, ); const rightPanelPresent = rightPanelPresence.present; - const rightPanelControlsInPanel = - rightPanelPresent && (!shouldUseRightPanelSheet || rightPanelOpen); + const rightPanelControlsInPanel = rightPanelPresent && rightPanelOpen; const renderedRightPanelSurface = rightPanelPresence.value?.activeSurface ?? null; const renderedRightPanelSurfaces = rightPanelPresence.value?.surfaces ?? []; + const previewMiniPlayerVisible = shouldRenderPreviewMiniPlayer( + activePreviewMiniPlayer?.tabId ?? null, + renderedRightPanelSurface, + ); const canMaximizeRightPanel = rightPanelOpen && !shouldUseRightPanelSheet; const rightPanelMaximized = canMaximizeRightPanel && maximizedRightPanelThreadKey === routeThreadKey; @@ -1862,20 +1887,10 @@ function ChatViewContent(props: ChatViewProps) { useEffect(() => { if (!activeThreadRef || !activePreviewMiniPlayer) return; const miniTabStillExists = Boolean(activePreviewState.sessions[activePreviewMiniPlayer.tabId]); - const sameTabOpenInPanel = - previewPanelOpen && - activeRightPanelSurface?.kind === "preview" && - activeRightPanelSurface.resourceId === activePreviewMiniPlayer.tabId; - if (!miniTabStillExists || sameTabOpenInPanel) { + if (!miniTabStillExists) { usePreviewMiniPlayerStore.getState().close(activeThreadRef); } - }, [ - activePreviewMiniPlayer, - activePreviewState.sessions, - activeRightPanelSurface, - activeThreadRef, - previewPanelOpen, - ]); + }, [activePreviewMiniPlayer, activePreviewState.sessions, activeThreadRef]); const existingOpenTerminalThreadKeys = useMemo(() => { const existingThreadKeys = new Set([...serverThreadKeys, ...draftThreadKeys]); @@ -5864,6 +5879,25 @@ function ChatViewContent(props: ChatViewProps) { composerRef, ]); + // Paste-to-focus: the resting composer blurs on a click into the timeline, + // so a paste that follows has no editable target and would be dropped. + // Route it to the composer like a typed key, which also expands it. + useEffect(() => { + const handler = (event: ClipboardEvent) => { + if (!activeThreadId || isCommandPaletteOpen()) return; + if (getTerminalFocusOwner() !== null) return; + if (composerRef.current?.isModelPickerOpen()) return; + const text = pasteTextToFocusComposer(event); + if (text === null) return; + if (composerRef.current?.insertTextAtEnd(text)) { + event.preventDefault(); + event.stopPropagation(); + } + }; + window.addEventListener("paste", handler, true); + return () => window.removeEventListener("paste", handler, true); + }, [activeThreadId, composerRef]); + const onRevertToTurnCount = useCallback( async (turnCount: number) => { const localApi = readLocalApi(); @@ -7416,6 +7450,15 @@ function ChatViewContent(props: ChatViewProps) {
{panelToggleControls}
); + const inlineRightPanelControls = ( +
+ + {panelToggleControls} +
+ ); const rightPanelContent = activeThreadRef ? ( renderedRightPanelSurface?.kind === "preview" ? ( @@ -7576,7 +7619,7 @@ function ChatViewContent(props: ChatViewProps) { reserveNativeControls={reserveTitleBarControlInset && !inlineRightPanelOwnsTitleBar} className="relative bg-background" > - {!shouldUseRightPanelSheet || !rightPanelControlsInPanel ? panelLayoutControls : null} + {!rightPanelControlsInPanel ? panelLayoutControls : null}
- {activeThreadRef && activePreviewMiniPlayer ? ( + {activeThreadRef && activePreviewMiniPlayer && previewMiniPlayerVisible ? ( ( export const OpenCodeIcon: Icon = (props) => ( - + - + diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index 4c8515246c85..b33b4640537c 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -2344,7 +2344,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec diff --git a/apps/web/src/components/RightPanelSheet.tsx b/apps/web/src/components/RightPanelSheet.tsx index e3468034396b..9f4838f5666a 100644 --- a/apps/web/src/components/RightPanelSheet.tsx +++ b/apps/web/src/components/RightPanelSheet.tsx @@ -1,12 +1,16 @@ import { type ReactNode } from "react"; -import { RIGHT_PANEL_SHEET_CLASS_NAME } from "../rightPanelLayout"; +import { + RIGHT_PANEL_SHEET_CLASS_NAME, + RIGHT_PANEL_SHEET_LAYER_CLASS_NAME, +} from "../rightPanelLayout"; import { Sheet, SheetPopup } from "./ui/sheet"; export function RightPanelSheet(props: { animationDurationMs: number; children: ReactNode; open: boolean; + underFloatingPreview?: boolean; onClose: () => void; }) { return ( @@ -23,6 +27,12 @@ export function RightPanelSheet(props: { side="right" showCloseButton={false} keepMounted + {...(props.underFloatingPreview + ? { + backdropClassName: RIGHT_PANEL_SHEET_LAYER_CLASS_NAME, + viewportClassName: RIGHT_PANEL_SHEET_LAYER_CLASS_NAME, + } + : {})} className={RIGHT_PANEL_SHEET_CLASS_NAME} > {props.children} diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 8f1fff8dc728..7f68cd543107 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -990,7 +990,10 @@ export function RightPanelTabs(props: RightPanelTabsProps) { // controls a few pixels higher and the cluster jumps on open. props.mode === "inline" && !props.layoutControls ? "pr-28" : "pr-3", ownsDesktopTitleBar && "drag-region", - ownsDesktopTitleBar && "wco:pr-[calc(var(--workspace-native-controls-inset)+6rem)]", + ownsDesktopTitleBar && + (props.layoutControls + ? "wco:pr-[var(--workspace-native-controls-inset)]" + : "wco:pr-[calc(var(--workspace-native-controls-inset)+6rem)]"), props.mode === "inline" && props.maximized && COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS, )} data-right-panel-tabbar diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 38a26c7ac4e2..cf14c9614734 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -276,6 +276,7 @@ function terminalProcessLabel(count: number): string { function SidebarThreadTooltip({ thread, projectTitle, + projectDisplayName, projectCwd, projectFaviconPath, projectIcon, @@ -291,6 +292,7 @@ function SidebarThreadTooltip({ }: { thread: SidebarThreadSummary; projectTitle: string | null; + projectDisplayName: string | null; projectCwd: string | null; projectFaviconPath: string | null; projectIcon: ProjectIconOverride | null; @@ -321,17 +323,17 @@ function SidebarThreadTooltip({ {thread.title}
- {projectTitle ? ( + {projectDisplayName ? (
-
{projectTitle}
+
{projectDisplayName}
) : null} {environmentLabel ? ( @@ -497,6 +499,7 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: { session: DraftSessionState; composer: ComposerThreadDraftState; projectTitle: string | null; + projectDisplayName: string | null; projectCwd: string | null; projectFaviconPath: string | null; projectIcon: ProjectIconOverride | null; @@ -571,7 +574,7 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: { className="size-4 shrink-0" /> - {props.projectTitle} + {props.projectDisplayName} @@ -609,6 +612,7 @@ interface SidebarDraftRowData { // subscription + closing divider) so per-keystroke composer updates // re-render only this block, never the whole sidebar. Vanishes at count 0. const SidebarDraftBlock = memo(function SidebarDraftBlock(props: { + projectTitleByKey: ReadonlyMap; projectDisplayNameByKey: ReadonlyMap; projectCwdByKey: ReadonlyMap; projectFaviconPathByKey: ReadonlyMap; @@ -706,7 +710,8 @@ const SidebarDraftBlock = memo(function SidebarDraftBlock(props: { draftId={draftId} session={session} composer={composer} - projectTitle={props.projectDisplayNameByKey.get(projectKey) ?? null} + projectTitle={props.projectTitleByKey.get(projectKey) ?? null} + projectDisplayName={props.projectDisplayNameByKey.get(projectKey) ?? null} projectCwd={props.projectCwdByKey.get(projectKey) ?? null} projectFaviconPath={props.projectFaviconPathByKey.get(projectKey) ?? null} projectIcon={props.projectIconByKey.get(projectKey) ?? null} @@ -759,6 +764,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { projectFaviconPath: string | null; projectIcon: ProjectIconOverride | null; projectTitle: string | null; + projectDisplayName: string | null; providerEntryByInstanceId: ReadonlyMap; timestampFormat: TimestampFormat; onThreadClick: (event: ReactMouseEvent, threadRef: ScopedThreadRef) => void; @@ -994,6 +1000,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { - {props.projectTitle ? ( + {props.projectDisplayName ? ( - {props.projectTitle} + {props.projectDisplayName} ) : ( @@ -1669,6 +1676,7 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { projectFaviconPath: string | null; projectIcon: ProjectIconOverride | null; projectTitle: string | null; + projectDisplayName: string | null; environmentLabel: string | null; environmentMachine: EnvironmentMachineKind; providerEntryByInstanceId: ReadonlyMap; @@ -1735,7 +1743,9 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { aria-selected={props.isHighlighted} aria-current={props.isRouteActive ? "page" : undefined} aria-label={ - props.projectTitle ? `${thread.title}, ${props.projectTitle}` : thread.title + props.projectDisplayName + ? `${thread.title}, ${props.projectDisplayName}` + : thread.title } onMouseMove={props.onHighlight} onClick={props.onSelect} @@ -1751,7 +1761,7 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { + new Map(projects.map((project) => [`${project.environmentId}:${project.id}`, project.title])), + [projects], + ); const projectDisplayNameByKey = useMemo( () => new Map( @@ -3631,7 +3648,7 @@ export default function Sidebar() { { - const next = resolveRestingComposerControlsLayout({ ...measurement, hostWidth }); + const next = resolveRestingComposerControlsLayout({ + ...measurement, + hostWidth, + previous: current, + }); return next.hiddenCount === current.hiddenCount && next.visible === current.visible ? current : next; @@ -3542,8 +3547,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const isComposerResting = shouldUseRestingComposerLayout({ isExistingThread: routeKind === "server" && activeThreadId !== null, isMobileViewport, - isFocused: isComposerFocused && !isComposerScrollCollapsed, + isFocused: isComposerFocused, + isScrollCollapsed: isComposerScrollCollapsed, hasExpandedChrome: composerHasExpandedChrome, + collapseOnBlur: settings.composerCollapseOnBlur, }); // The relocated controls live in the context strip whenever the composer is // collapsed for any reason, the desktop resting layout or the phone @@ -3615,8 +3622,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const canTrackComposerScrollGesture = routeKind === "server" && activeThreadId !== null && !isMobileViewport; const canScrollCollapseComposer = - canTrackComposerScrollGesture && !composerHasExpandedChrome && !showInlineTasksBadge; - composerScrollCollapseEligibleRef.current = canScrollCollapseComposer; + canTrackComposerScrollGesture && + settings.composerCollapseOnScroll && + !composerHasExpandedChrome && + !showInlineTasksBadge; + // Scrolling only has something to collapse while the composer is expanded. + // With blur collapse off that includes an unfocused composer, so the wheel + // handler keys off this rather than editor focus. + composerScrollCollapseEligibleRef.current = canScrollCollapseComposer && !isComposerResting; useEffect(() => { if (!canScrollCollapseComposer) { @@ -3657,11 +3670,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) resetComposerScrollGesture(composerScrollGestureRef.current); }; const handleTimelineWheel = (event: WheelEvent) => { - const activeElement = document.activeElement; - const isPromptEditorFocused = - activeElement instanceof HTMLElement && - activeElement.isContentEditable && - composerFormRef.current?.contains(activeElement) === true; if (event.ctrlKey || !(event.target instanceof Element)) { return; } @@ -3695,8 +3703,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) now: window.performance.now(), deltaPx, collapseThresholdPx: COMPOSER_SCROLL_COLLAPSE_THRESHOLD_PX, - collapseEligible: - targetsTimeline && composerScrollCollapseEligibleRef.current && isPromptEditorFocused, + collapseEligible: targetsTimeline && composerScrollCollapseEligibleRef.current, canScrollInGestureDirection, scrollsTowardLogicalEnd: event.deltaY > 0 && isTimelineAtLogicalEnd(), }); @@ -3806,7 +3813,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) activeProviderIconClassName: cn( composerProviderState.modelPickerIconClassName, composerControlsInStrip && - "fill-muted-foreground/70! text-muted-foreground/70! [&_path]:fill-muted-foreground/70! [&_rect]:fill-muted-foreground/70!", + "fill-muted-foreground/70! text-muted-foreground/70! [&_path]:fill-muted-foreground/70! [&_rect]:fill-muted-foreground/70! [&_[data-opencode-hole]]:fill-transparent!", ), } : {})} @@ -4281,7 +4288,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const composerSurface = composerSurfaceRef.current; const composerForm = composerFormRef.current; const activeElement = document.activeElement; - if (activeElement instanceof Element && isInsideComposerFloatingLayer(activeElement)) { + if (isInsideRestingComposerControlScope(activeElement)) { return; } if ( @@ -4291,9 +4298,42 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ) { return; } + if ( + !isMobileViewport && + selectionHoldsComposerOpen(window.getSelection(), getTimelineScrollableNode()) + ) { + // The check runs again once the selection clears. + return; + } setIsComposerFocused(false); }); - }, [isMobileViewport]); + }, [getTimelineScrollableNode, isMobileViewport]); + + // A held collapse settles when the selection goes away, whether the user + // clicked elsewhere, pressed Escape, or used the selection toolbar. + useEffect(() => { + if (isMobileViewport || !isComposerFocused) return; + let wasHolding = false; + const handleSelectionChange = () => { + const holding = selectionHoldsComposerOpen( + window.getSelection(), + getTimelineScrollableNode(), + ); + if (wasHolding && !holding) { + scheduleComposerCollapseCheck(); + } + wasHolding = holding; + }; + document.addEventListener("selectionchange", handleSelectionChange); + return () => { + document.removeEventListener("selectionchange", handleSelectionChange); + }; + }, [ + getTimelineScrollableNode, + isComposerFocused, + isMobileViewport, + scheduleComposerCollapseCheck, + ]); useEffect(() => { if (isMobileViewport || !isComposerFocused) return; @@ -4301,8 +4341,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const isInsideDesktopComposerFocusScope = (target: EventTarget | null) => Boolean( target instanceof Node && - (composerFormRef.current?.contains(target) || - (target instanceof Element && isInsideComposerFloatingLayer(target))), + (composerFormRef.current?.contains(target) || isInsideRestingComposerControlScope(target)), ); const handleFocusIn = (event: FocusEvent) => { if (!isInsideDesktopComposerFocusScope(event.target)) { diff --git a/apps/web/src/components/chat/ExpandedImageDialog.tsx b/apps/web/src/components/chat/ExpandedImageDialog.tsx index 5ab287225ca4..a41c34268c22 100644 --- a/apps/web/src/components/chat/ExpandedImageDialog.tsx +++ b/apps/web/src/components/chat/ExpandedImageDialog.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useEffect, useState, type ReactNode } from "react"; +import { memo, useCallback, useEffect, useRef, useState, type ReactNode } from "react"; import { createPortal } from "react-dom"; import { ChevronLeftIcon, ChevronRightIcon, XIcon } from "lucide-react"; import { Button } from "../ui/button"; @@ -9,6 +9,7 @@ import { OpenMediaLink } from "../media/OpenMediaLink"; import { MediaActions, type MediaActionSource } from "../media/MediaActions"; import { MediaVideoPlayer } from "../media/MediaVideoPlayer"; import { isContextMenuOpen } from "../../contextMenuFallback"; +import { composerFloatingLayerProps } from "./composerEventScope"; interface ExpandedImageDialogProps { preview: ExpandedImagePreview; @@ -79,6 +80,20 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ setImageOffset((current) => current + direction); }, []); + // The element that opened the preview gets focus back on close. Without + // this a close button click leaves focus on the unmounted dialog, and the + // composer that owned the opener reads that as a blur and rests. + const openerRef = useRef(null); + useEffect(() => { + openerRef.current = document.activeElement; + return () => { + const opener = openerRef.current; + if (opener instanceof HTMLElement && opener.isConnected) { + opener.focus({ preventScroll: true }); + } + }; + }, []); + useEffect(() => { const onKeyDown = (event: globalThis.KeyboardEvent) => { if (event.defaultPrevented || isContextMenuOpen()) { @@ -115,6 +130,7 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ return createPortal(
{ expect(isInsideRestingComposerControlScope(target as unknown as EventTarget)).toBe(true); }); + it("recognizes events from the composer context strip controls", () => { + vi.stubGlobal("Element", FakeElement); + + const target = new FakeElement("[data-composer-context-control]"); + expect(isInsideRestingComposerControlScope(target as unknown as EventTarget)).toBe(true); + }); + it("keeps resting image previews focused without expanding their subtree", () => { vi.stubGlobal("Element", FakeElement); diff --git a/apps/web/src/components/chat/composerEventScope.ts b/apps/web/src/components/chat/composerEventScope.ts index 60aedb096156..88e24fd89422 100644 --- a/apps/web/src/components/chat/composerEventScope.ts +++ b/apps/web/src/components/chat/composerEventScope.ts @@ -26,6 +26,7 @@ export function isInsideRestingComposerControlScope(target: EventTarget | null): target instanceof Element && (target.closest('[data-chat-composer-resting-controls="true"]') !== null || target.closest('[data-chat-composer-resting-images="true"]') !== null || + target.closest("[data-composer-context-control]") !== null || isInsideComposerFloatingLayer(target)) ); } diff --git a/apps/web/src/components/chat/composerSelectionHold.test.ts b/apps/web/src/components/chat/composerSelectionHold.test.ts new file mode 100644 index 000000000000..fb4d52552a19 --- /dev/null +++ b/apps/web/src/components/chat/composerSelectionHold.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { selectionHoldsComposerOpen } from "./composerSelectionHold"; + +function node(parent: Node | null = null) { + const self = { + parent, + contains(other: Node | null): boolean { + let cursor: { parent: Node | null } | null = other as unknown as { parent: Node | null }; + while (cursor) { + if (cursor === (self as unknown)) return true; + cursor = cursor.parent as { parent: Node | null } | null; + } + return false; + }, + }; + return self as unknown as Node; +} + +function selection(anchor: Node, collapsed = false) { + return { + isCollapsed: collapsed, + rangeCount: 1, + getRangeAt: () => ({ commonAncestorContainer: anchor }) as Range, + }; +} + +describe("selectionHoldsComposerOpen", () => { + const timeline = node(); + const message = node(timeline); + const elsewhere = node(); + + it("holds for a range inside the timeline", () => { + expect(selectionHoldsComposerOpen(selection(message), timeline)).toBe(true); + }); + + it("ignores a caret with nothing selected", () => { + expect(selectionHoldsComposerOpen(selection(message, true), timeline)).toBe(false); + }); + + it("ignores selections outside the timeline", () => { + expect(selectionHoldsComposerOpen(selection(elsewhere), timeline)).toBe(false); + }); + + it("ignores an empty or missing selection", () => { + expect(selectionHoldsComposerOpen(null, timeline)).toBe(false); + expect( + selectionHoldsComposerOpen( + { isCollapsed: false, rangeCount: 0, getRangeAt: () => ({}) as Range }, + timeline, + ), + ).toBe(false); + expect(selectionHoldsComposerOpen(selection(message), null)).toBe(false); + }); +}); diff --git a/apps/web/src/components/chat/composerSelectionHold.ts b/apps/web/src/components/chat/composerSelectionHold.ts new file mode 100644 index 000000000000..9c75c631e97d --- /dev/null +++ b/apps/web/src/components/chat/composerSelectionHold.ts @@ -0,0 +1,15 @@ +/** + * Whether a live text selection inside the timeline should hold the composer + * open. A drag-select in the conversation blurs the composer, and letting it + * rest mid-gesture reflows the timeline, which dismisses the selection + * toolbar the user was about to use. + */ +export function selectionHoldsComposerOpen( + selection: Pick | null, + timeline: Node | null, +): boolean { + if (!timeline || !selection || selection.isCollapsed || selection.rangeCount === 0) { + return false; + } + return timeline.contains(selection.getRangeAt(0).commonAncestorContainer); +} diff --git a/apps/web/src/components/composerFooterLayout.test.ts b/apps/web/src/components/composerFooterLayout.test.ts index 926816508ec5..89de512e2eaf 100644 --- a/apps/web/src/components/composerFooterLayout.test.ts +++ b/apps/web/src/components/composerFooterLayout.test.ts @@ -79,13 +79,36 @@ describe("shouldUseRestingComposerLayout", () => { isExistingThread: true, isMobileViewport: false, isFocused: false, + isScrollCollapsed: false, hasExpandedChrome: false, + collapseOnBlur: true, }; it("uses the resting layout for an unfocused desktop composer", () => { expect(shouldUseRestingComposerLayout(resting)).toBe(true); }); + it("keeps an unfocused composer expanded when blur collapse is off", () => { + expect(shouldUseRestingComposerLayout({ ...resting, collapseOnBlur: false })).toBe(false); + }); + + it("rests a scroll-collapsed composer even while focused", () => { + expect( + shouldUseRestingComposerLayout({ ...resting, isFocused: true, isScrollCollapsed: true }), + ).toBe(true); + }); + + it("rests a scroll-collapsed composer regardless of the blur preference", () => { + expect( + shouldUseRestingComposerLayout({ + ...resting, + isFocused: true, + isScrollCollapsed: true, + collapseOnBlur: false, + }), + ).toBe(true); + }); + it("keeps new-thread composers expanded", () => { expect(shouldUseRestingComposerLayout({ ...resting, isExistingThread: false })).toBe(false); }); @@ -277,3 +300,82 @@ describe("context strip labels and resting composer controls", () => { ).toEqual({ hiddenCount: 2, visible: true }); }); }); + +describe("resolveRestingComposerControlsLayout hysteresis", () => { + // Same cluster as above: picker 140 natural / 96 minimum plus a 9px + // separator, traits 60, mode 140, overflow 24, gap 4. + const base = { + gap: 4, + naturalFixedWidth: 149, + minimumFixedWidth: 105, + blockWidths: [60, 140], + overflowWidth: 24, + }; + + it("keeps a block in overflow when re-showing it would leave no slack", () => { + // 149 + 60 + 140 + 4 * 2 = 357 fills the host exactly. + expect( + resolveRestingComposerControlsLayout({ + ...base, + hostWidth: 357, + previous: { hiddenCount: 1, visible: true }, + }), + ).toEqual({ hiddenCount: 1, visible: true }); + }); + + it("re-shows a block once the host clears the slack margin", () => { + expect( + resolveRestingComposerControlsLayout({ + ...base, + hostWidth: 358, + previous: { hiddenCount: 1, visible: true }, + }), + ).toEqual({ hiddenCount: 0, visible: true }); + }); + + it("still resolves from scratch when there is no previous layout", () => { + expect(resolveRestingComposerControlsLayout({ ...base, hostWidth: 357 })).toEqual({ + hiddenCount: 0, + visible: true, + }); + }); + + it("settles when the measured picker width jitters below a pixel", () => { + // Regression guard for React error #185 ("Maximum update depth + // exceeded"). The composer re-measures on every render, so the picker's + // recovered natural width can land a fraction of a pixel apart between + // renders. Without hysteresis that flips hiddenCount forever. + let layout = { hiddenCount: 0, visible: true }; + const settled: string[] = []; + for (let index = 0; index < 10; index += 1) { + layout = resolveRestingComposerControlsLayout({ + ...base, + // The picker's recovered natural width lands a half pixel apart + // between renders. + naturalFixedWidth: index % 2 === 0 ? 149 : 149.5, + hostWidth: 357, + previous: layout, + }); + if (index >= 2) settled.push(`${layout.hiddenCount}:${layout.visible}`); + } + expect(new Set(settled).size).toBe(1); + }); + + it("keeps the cluster hidden until its minimum width clears the slack", () => { + // 105 + 24 + 4 = 133 is the exact minimum for the hidden cluster. + expect( + resolveRestingComposerControlsLayout({ + ...base, + hostWidth: 133, + previous: { hiddenCount: 2, visible: false }, + }), + ).toEqual({ hiddenCount: 2, visible: false }); + expect( + resolveRestingComposerControlsLayout({ + ...base, + hostWidth: 134, + previous: { hiddenCount: 2, visible: false }, + }), + ).toEqual({ hiddenCount: 2, visible: true }); + }); +}); diff --git a/apps/web/src/components/composerFooterLayout.ts b/apps/web/src/components/composerFooterLayout.ts index 2ab3b36a1b90..e4435c37c0be 100644 --- a/apps/web/src/components/composerFooterLayout.ts +++ b/apps/web/src/components/composerFooterLayout.ts @@ -27,7 +27,9 @@ export function shouldUseRestingComposerLayout(input: { isExistingThread: boolean; isMobileViewport: boolean; isFocused: boolean; + isScrollCollapsed: boolean; hasExpandedChrome: boolean; + collapseOnBlur: boolean; }): boolean { // Passive draft content is deliberately absent here. Resting only clamps // the prompt row and overlays its actions; non-image attachment and context @@ -37,12 +39,13 @@ export function shouldUseRestingComposerLayout(input: { // deliberately absent here: resting reclaims vertical space at every // desktop width, and where the strip is missing or too narrow the controls // simply return when the composer is focused. - return ( - input.isExistingThread && - !input.isMobileViewport && - !input.isFocused && - !input.hasExpandedChrome - ); + // + // A scroll collapse rests the composer regardless of the blur preference: + // the user asked for it with the gesture, and it lifts on the next + // composer interaction. With blur collapse off, losing focus alone never + // rests the composer. + const collapsed = input.isScrollCollapsed || (input.collapseOnBlur && !input.isFocused); + return input.isExistingThread && !input.isMobileViewport && collapsed && !input.hasExpandedChrome; } export function shouldAnimateComposerRestingTransition(input: { @@ -108,10 +111,15 @@ export function resolveRestingComposerControlsNaturalWidth( * the overflow menu, the picker may contract to its minimum readable width; * below that the whole cluster hides rather than clipping. */ +const RESTING_CONTROLS_SLACK_PX = 1; + export function resolveRestingComposerControlsLayout( - input: RestingComposerControlsMeasurement & { hostWidth: number }, + input: RestingComposerControlsMeasurement & { + hostWidth: number; + previous?: { hiddenCount: number; visible: boolean }; + }, ): { hiddenCount: number; visible: boolean } { - const { blockWidths, hostWidth } = input; + const { blockWidths, hostWidth, previous } = input; let hiddenCount = 0; while ( hiddenCount < blockWidths.length && @@ -119,7 +127,22 @@ export function resolveRestingComposerControlsLayout( ) { hiddenCount += 1; } + // Growing the overflow menu is unconditional, or the controls would clip. + // Shrinking it has to earn a pixel of slack first: the picker is flexible, + // so its natural width is recovered from a truncated label whose + // scrollWidth is integral while the rendered box is fractional. The + // composer re-measures on every render, so without that margin a host + // sitting exactly on a threshold flips a block in and out until React + // gives up with "Maximum update depth exceeded". + if (previous && hiddenCount < previous.hiddenCount) { + if (restingComposerControlsWidth(input, hiddenCount) > hostWidth - RESTING_CONTROLS_SLACK_PX) { + hiddenCount = Math.min(previous.hiddenCount, blockWidths.length); + } + } + const minimumWidth = restingComposerControlsWidth(input, hiddenCount, input.minimumFixedWidth); const visible = - restingComposerControlsWidth(input, hiddenCount, input.minimumFixedWidth) <= hostWidth; + previous && !previous.visible + ? minimumWidth <= hostWidth - RESTING_CONTROLS_SLACK_PX + : minimumWidth <= hostWidth; return { hiddenCount, visible }; } diff --git a/apps/web/src/components/preview/PreviewAutomationHosts.tsx b/apps/web/src/components/preview/PreviewAutomationHosts.tsx index 1faf928b1cf5..54c2e1d9cf68 100644 --- a/apps/web/src/components/preview/PreviewAutomationHosts.tsx +++ b/apps/web/src/components/preview/PreviewAutomationHosts.tsx @@ -20,7 +20,7 @@ import { type ScopedThreadRef, } from "@t3tools/contracts"; import { resolvePreviewViewport } from "@t3tools/shared/previewViewport"; -import { useCallback, useContext, useEffect, useMemo, useState } from "react"; +import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; import { Atom } from "effect/unstable/reactivity"; import { @@ -29,7 +29,7 @@ import { reconcilePreviewServerSessions, updatePreviewServerSnapshot, } from "~/previewStateStore"; -import { usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore"; +import { selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore"; import { resolveBrowserNavigationTarget } from "~/browser/browserTargetResolver"; import { readActiveBrowserRecordingTargets, @@ -59,8 +59,10 @@ import { PreviewAutomationViewportTimeoutError, } from "./previewAutomationErrors"; import { + explicitlySuppressesPreviewMiniPlayer, previewAutomationDefaultViewport, previewAutomationOpenNeedsOverlay, + shouldAutoShowPreviewForAutomationUse, shouldOpenPreviewMiniPlayer, } from "./previewAutomationOpenReadiness"; import { @@ -311,6 +313,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) ); const [automationConnectionAtom] = useState(() => Atom.make(null)); const automationConnectionId = useAtomValue(automationConnectionAtom); + const presentationSuppressedRuntimeTabsRef = useRef(new Map>()); const handleRequest = useCallback( async (request: PreviewAutomationRequest): Promise => { @@ -353,6 +356,21 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) } const readyState = readThreadPreviewState(threadRef); const runtimeTabId = previewRuntimeTabId(threadRef, readyState.serverEpoch, readyTabId); + if (request.operation !== "open") { + const { autoShowFloatingPreview } = await resolveBrowserDefaults(); + if ( + shouldAutoShowPreviewForAutomationUse({ + operation: request.operation, + autoShowFloatingPreview, + presentationSuppressed: + presentationSuppressedRuntimeTabsRef.current + .get(request.threadId) + ?.has(runtimeTabId) ?? false, + }) + ) { + usePreviewMiniPlayerStore.getState().open(threadRef, readyTabId); + } + } browserActivity.release ??= acquireBrowserSurfaceActivity(runtimeTabId); await waitForDesktopOverlay( threadRef, @@ -450,6 +468,32 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) input, (await resolveBrowserDefaults()).autoShowFloatingPreview, ); + const explicitlySuppressed = explicitlySuppressesPreviewMiniPlayer(input); + const suppressedTabs = presentationSuppressedRuntimeTabsRef.current.get( + request.threadId, + ); + if (explicitlySuppressed) { + if (suppressedTabs) { + suppressedTabs.add(activeRuntimeTabId); + } else { + presentationSuppressedRuntimeTabsRef.current.set( + request.threadId, + new Set([activeRuntimeTabId]), + ); + } + const miniPlayer = selectThreadPreviewMiniPlayer( + usePreviewMiniPlayerStore.getState().byThreadKey, + threadRef, + ); + if (miniPlayer?.tabId === activeTabId) { + usePreviewMiniPlayerStore.getState().close(threadRef); + } + } else if (shouldPresentPreview) { + suppressedTabs?.delete(activeRuntimeTabId); + if (suppressedTabs?.size === 0) { + presentationSuppressedRuntimeTabsRef.current.delete(request.threadId); + } + } if (shouldPresentPreview) { usePreviewMiniPlayerStore.getState().open(threadRef, activeTabId); } diff --git a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx index 623928d102ef..dc2d9f5a96ea 100644 --- a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx +++ b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx @@ -19,6 +19,7 @@ import { clampPreviewMiniPlayerSize, PREVIEW_MINI_PLAYER_DEFAULT_SIZE, PREVIEW_MINI_PLAYER_EDGE_GAP, + PREVIEW_MINI_PLAYER_WEBVIEW_Z_INDEX, } from "./previewMiniPlayerLayout"; interface DragState { @@ -243,7 +244,7 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props } } > -
+