diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 97e95027f108..3d4bf10c050e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -104,6 +104,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 @@ -149,6 +152,9 @@ jobs: continue-on-error: true 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/desktop' --filter '!@t3tools/monorepo' test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7c17cb5b79f2..bc52f977a058 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -118,6 +118,8 @@ jobs: node-version-file: package.json cache: true run-install: true + env: + pnpm_config_cache_dir: ${{ runner.temp }}/pnpm-metadata - id: release_meta name: Resolve release version @@ -195,6 +197,14 @@ jobs: --current-tag "${{ steps.release_meta.outputs.tag }}" \ --github-output + # Share only the verification results, not the large registry metadata cache. + - name: Upload dependency verification + continue-on-error: true + uses: actions/upload-artifact@v7 + with: + name: release-dependency-verification + path: ${{ runner.temp }}/pnpm-metadata/lockfile-verified.jsonl + quality: name: Release quality checks needs: [preflight] @@ -227,6 +237,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 @@ -482,7 +495,18 @@ jobs: path: ${{ steps.package_cache_path.outputs.path }} key: windows-release-packages-v1-${{ matrix.arch }}-${{ hashFiles('pnpm-lock.yaml') }} + # pnpm checks the lockfile and policy before reusing this result. A missing + # artifact leaves the cache empty, so installation runs the checks again. + - name: Download dependency verification + continue-on-error: true + uses: actions/download-artifact@v8 + with: + name: release-dependency-verification + path: ${{ runner.temp }}/pnpm-metadata + - name: Install desktop dependencies + env: + pnpm_config_cache_dir: ${{ runner.temp }}/pnpm-metadata run: vp install --filter=@t3tools/desktop... --filter=t3... --filter=@t3tools/scripts... - name: Cache resource monitor @@ -544,12 +568,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 @@ -624,6 +649,7 @@ jobs: - name: Build desktop artifact shell: bash env: + pnpm_config_cache_dir: ${{ runner.temp }}/pnpm-metadata T3CODE_DESKTOP_REUSE_RESOURCE_MONITOR: ${{ steps.resource_monitor_cache.outputs.cache-hit == 'true' }} CSC_LINK: ${{ secrets.CSC_LINK }} CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} diff --git a/AGENTS.md b/AGENTS.md index 2cdfdcb77e82..d72f86069e5e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,7 @@ # AGENTS.md +T3 Code is a multi-provider GUI for coding agents. A Node WebSocket server wraps provider CLIs and agents (ACP Agent, Codex, Claude Code, Cursor, Droid, Fx, Grok, OpenCode, Amp, Copilot, Gemini CLI, Hermes Agent, Kilo, Oh My Pi, Pi, and Antigravity) and serves web, desktop, and mobile clients. + ## Git & GitHub Policy (CRITICAL — DO NOT VIOLATE) - This is a FORK of `pingdotgg/t3code`. The upstream remote is READ-ONLY for us. @@ -19,7 +21,7 @@ When syncing upstream, preserve these fork features unless the user explicitly asks to remove them: -1. Multi-provider runtime support for the built-in drivers: configurable ACP, Codex CLI, Claude Code, Cursor, Droid, Fx, Grok Build, OpenCode, Amp, Copilot, Gemini CLI, Hermes Agent, Kilo, Oh My Pi, and Pi. +1. Multi-provider runtime support for the built-in drivers: configurable ACP, Codex CLI, Claude Code, Cursor, Droid, Fx, Grok Build, OpenCode, Amp, Copilot, Gemini CLI, Hermes Agent, Kilo, Oh My Pi, Pi, and Antigravity. 2. Usage and limit monitoring, including token/context usage snapshots, provider usage events, Codex account rate-limit streams, and the web rate-limit banner/panel UX. 3. Provider management UX, including custom provider instances, per-instance environment/config/model state, custom model slugs, and provider-scoped traits such as reasoning, context window, fast mode, and agent selection. 4. Provider-neutral orchestration reliability, including SQLite event persistence, command receipts, replay/live stream ordering, session restart/reconnect behavior, and projection consistency. @@ -39,7 +41,7 @@ When syncing upstream, preserve these fork features unless the user explicitly a ## Project Snapshot -T3 Code is a multi-provider web GUI for coding agents. This fork supports 15 built-in provider drivers. `BUILT_IN_DRIVERS` in `apps/server/src/provider/builtInDrivers.ts` is the source of truth: +T3 Code is a multi-provider web GUI for coding agents. This fork supports 16 built-in provider drivers. `BUILT_IN_DRIVERS` in `apps/server/src/provider/builtInDrivers.ts` is the source of truth: - **ACP Agent** — configurable executable and arguments for any stdio ACP implementation - **Codex CLI** (v0.37.0+) — JSON-RPC over stdio @@ -49,6 +51,7 @@ T3 Code is a multi-provider web GUI for coding agents. This fork supports 15 bui - **Fx** — `fx acp` over stdio - **Grok Build** — ACP over stdio with xAI protocol extensions - **OpenCode** — SDK CLI server +- **Antigravity** — official Google ACP agent with managed install and Google sign-in - **Amp** — Amp Code headless mode (no `/mode free`) - **Copilot** — GitHub Copilot CLI - **Gemini CLI** — Google Gemini CLI with persistent JSON @@ -104,6 +107,18 @@ Adapters are registered in `provider/Layers/ProviderAdapterRegistry.ts` and look - `apps/server/src/orchestration/Layers/ProjectionPipeline.ts` — Projects events into queryable state. - `apps/server/src/ws.ts` — WebSocket RPC server using Effect's `RpcServer.toHttpEffectWebsocket()`. +### Hit every surface + +The most common defect in this repo is a change that works on the path you tested and is missing everywhere else. Before calling frontend work done, walk this list and say which entries applied: + +- **Entry points.** A behavior reachable from the chat view is usually also reachable from Settings, the command palette, and a keybinding. Fixing one is not fixing the feature. +- **Clients.** Web, desktop (wraps web, adds Electron shell/IPC), and mobile (React Native, separate navigation). Shared logic lives in `packages/client-runtime`. +- **Providers.** ACP Agent, Codex, Claude, Cursor, Droid, Fx, Grok, OpenCode, Antigravity, Amp, Copilot, Gemini CLI, Hermes Agent, Kilo, Oh My Pi, and Pi each have an adapter. Provider-shaped features need a decision per adapter, even if the decision is "not supported here". +- **Contracts.** Anything crossing the wire is typed in `packages/contracts`. Change the schema and the server, web, mobile, and desktop all follow. +- **Reverse states.** If you added a way in, add the way out and the way to see it. Snooze needs unsnooze. Close needs reopen. A one-way door is a bug. +- **Connection modes.** Local, remote/relay, and tunnel behave differently. Multi-device and multi-environment cases are real. +- **Docs.** `docs/` splits by audience. Behavior changes that a user would notice belong in `docs/user/` (shipped-product voice, no repo tooling or source paths); architecture and contributor changes in `docs/internals/`; runbooks in `docs/operations/`; new vocabulary in `docs/internals/glossary.md`. + ### Event Sourcing & Orchestration Provider runtime activity is normalized into canonical `OrchestrationEvent`s by the ingestion layer, persisted in a SQLite event store with sequence-based ordering, and projected into in-memory materialized views. Clients receive ordered events via Effect RPC streams (replay + live merge). Command receipts provide idempotency for reconnects and retries. diff --git a/README.md b/README.md index 3ea3ee636973..5ef13df581c9 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,10 @@ T3 Code is a minimal web GUI for coding agents made by [Pingdotgg](https://githu This fork focuses on expanding provider support, keeping usage and limit monitoring visible, improving persistence layers, and refining provider management across the app. The current branded release is [T3 Code v0.0.36 — ACP Edition](https://github.com/aaditagrawal/t3code/releases/tag/v0.0.36). +Works with your subscriptions on Claude Code, Codex, Cursor, Grok Build, OpenCode, Google Antigravity, and the other supported agents below. If they're set up on your computer, T3 Code can control them. + It supports configurable stdio ACP agents, Codex, Claude Code, Cursor, Droid, Fx, Grok Build, -OpenCode, Amp, Copilot, Gemini CLI, Hermes Agent, Kilo, Oh My Pi, and Pi. +OpenCode, Antigravity, Amp, Copilot, Gemini CLI, Hermes Agent, Kilo, Oh My Pi, and Pi. (NOTE: Amp /mode free is not supported, as Amp Code doesn't support it in headless mode - since they need to show ads for that business model to work.) @@ -28,6 +30,7 @@ Adds full provider adapters (server managers, service layers, runtime layers) fo | Fx | ACP integration through `fx acp` | | Grok Build | ACP adapter with xAI protocol extensions | | OpenCode | Adapter with hostname/port/workspace config | +| Antigravity | Official Google ACP agent with managed install and Google sign-in | | Amp | Adapter + `ampServerManager` for headless Amp sessions | | GitHub Copilot | Adapter + CLI binary resolution + text generation layer | | Gemini CLI | **Enhanced:** Adapter + `geminiCliServerManager` with full test coverage | @@ -105,7 +108,7 @@ bash <(curl -fsSL https://raw.githubusercontent.com/aaditagrawal/t3code/main/scr ### Manual build > [!WARNING] -> You need at least one supported coding agent installed and authorized. See the supported agents list below. +> You need at least one supported coding agent installed and authorized. See the supported agents list below. Antigravity does not require a CLI: enable it in Settings, then use **Install Antigravity** and **Sign in with Google**. ```bash # Prerequisites: Bun >=1.3.9, Node >=24.13.1 @@ -128,6 +131,7 @@ bun run dev - [Amp](https://ampcode.com) - [Kilo](https://kilo.dev) - [OpenCode](https://opencode.ai) +- [Google Antigravity](https://github.com/agentclientprotocol/registry/blob/main/antigravity-acp/agent.json) (enable in Settings, then **Install Antigravity** and **Sign in with Google**) - [Hermes Agent](https://github.com/NousResearch/hermes-agent) (`hermes-acp`; run `hermes-acp --setup` after installation) - [Oh My Pi](https://github.com/can1357/oh-my-pi) (install `@oh-my-pi/pi-coding-agent`; T3 Code launches `omp acp`) - [Pi coding agent](https://www.npmjs.com/package/@earendil-works/pi-coding-agent) (install it together with `pi-acp`, then authenticate with `pi`) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 6ccd311c7835..61f4c4b56016 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 1da1083c089f..39be03c95897 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -111,4 +111,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 60396f45302f..22a00c103d34 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -68,6 +68,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 5d71806e4f93..5331fffdf922 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -227,6 +227,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 75271d76386a..d334b3635080 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -3137,6 +3137,164 @@ describe("PreviewManager", () => { ), ); + effectIt.effect("settles the pick when the annotation screenshot never arrives", () => + withManager((manager) => + Effect.gen(function* () { + let onPicked: ((event: unknown, ...args: unknown[]) => void) | undefined; + fromId.mockReturnValue({ + id: 42, + isDestroyed: () => false, + getType: () => "webview", + getURL: () => "https://example.com", + getTitle: () => "Example", + isLoading: () => false, + isFocused: () => true, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + setAudioMuted: vi.fn(), + isCurrentlyAudible: () => false, + on: vi.fn(), + once: vi.fn(), + off: vi.fn(), + // A wedged compositor leaves `capturePage` pending forever. + capturePage: vi.fn(() => new Promise(() => {})), + ipc: { + on: vi.fn((channel: string, listener: typeof onPicked) => { + if (channel === "preview:element-picked") onPicked = listener; + }), + off: vi.fn(), + removeListener: vi.fn(), + }, + send: webviewSend, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand: vi.fn(async () => undefined), + on: vi.fn(), + off: vi.fn(), + }, + } as never); + + yield* manager.createTab("tab_1"); + yield* manager.registerWebview("tab_1", 42); + const pick = yield* manager.pickElement("tab_1").pipe(Effect.forkChild); + yield* Effect.yieldNow; + + onPicked?.( + {}, + { + id: "annotation_1", + pageUrl: "https://example.com", + pageTitle: "Example", + comment: "Tighten this spacing", + elements: [], + regions: [{ id: "region_1", rect: { x: 5, y: 6, width: 20, height: 30 } }], + strokes: [], + styleChanges: [], + screenshot: null, + createdAt: "2026-06-11T00:00:00.000Z", + }, + null, + "send", + ); + yield* Effect.yieldNow; + expect(pick.pollUnsafe()).toBeUndefined(); + + yield* TestClock.adjust("6 seconds"); + // The pick has to give up on the crop rather than strand the renderer, + // which would leave the composer stuck on "Capturing…". + const result = yield* Fiber.join(pick); + expect(result?.annotation.screenshot).toBeNull(); + expect(result?.screenshotFailed).toBe(true); + expect(result?.submission).toBe("send"); + expect(webviewSend).toHaveBeenCalledWith("preview:annotation-captured"); + }), + ), + ); + + effectIt.effect("a stale capture from a replaced pick never touches the next pick", () => + withManager((manager) => + Effect.gen(function* () { + let onPicked: ((event: unknown, ...args: unknown[]) => void) | undefined; + fromId.mockReturnValue({ + id: 42, + isDestroyed: () => false, + getType: () => "webview", + getURL: () => "https://example.com", + getTitle: () => "Example", + isLoading: () => false, + isFocused: () => true, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + setAudioMuted: vi.fn(), + isCurrentlyAudible: () => false, + on: vi.fn(), + once: vi.fn(), + off: vi.fn(), + capturePage: vi.fn(() => new Promise(() => {})), + ipc: { + on: vi.fn((channel: string, listener: typeof onPicked) => { + if (channel === "preview:element-picked") onPicked = listener; + }), + off: vi.fn(), + removeListener: vi.fn(), + }, + send: webviewSend, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand: vi.fn(async () => undefined), + on: vi.fn(), + off: vi.fn(), + }, + } as never); + const annotation = { + id: "annotation_1", + pageUrl: "https://example.com", + pageTitle: "Example", + comment: "Tighten this spacing", + elements: [], + regions: [{ id: "region_1", rect: { x: 5, y: 6, width: 20, height: 30 } }], + strokes: [], + styleChanges: [], + screenshot: null, + createdAt: "2026-06-11T00:00:00.000Z", + }; + + yield* manager.createTab("tab_1"); + yield* manager.registerWebview("tab_1", 42); + const firstPick = yield* manager.pickElement("tab_1").pipe(Effect.forkChild); + yield* Effect.yieldNow; + // The first pick submits and its crop hangs. + onPicked?.({}, annotation, null, "send"); + yield* Effect.yieldNow; + + // A second pick on the same tab replaces the first, which resumes null. + const secondPick = yield* manager.pickElement("tab_1").pipe(Effect.forkChild); + yield* Effect.yieldNow; + expect(yield* Fiber.join(firstPick)).toBeNull(); + webviewSend.mockClear(); + + // The first pick's crop times out while the second pick is live. It + // must not signal the overlay, which would tear down the second pick. + yield* TestClock.adjust("6 seconds"); + yield* Effect.yieldNow; + expect(webviewSend).not.toHaveBeenCalledWith("preview:annotation-captured"); + expect(secondPick.pollUnsafe()).toBeUndefined(); + + onPicked?.({}, { ...annotation, id: "annotation_2" }, null, "attach"); + yield* TestClock.adjust("6 seconds"); + const result = yield* Fiber.join(secondPick); + expect(result?.annotation.id).toBe("annotation_2"); + expect(result?.submission).toBe("attach"); + }), + ), + ); + effectIt.effect("navigates the guest history when the thumb-button ipc fires", () => withManager((manager) => Effect.gen(function* () { diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 01398721dd58..8af2a460fb3e 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -306,13 +306,24 @@ const normalizeCaptureRect = (value: unknown): PreviewAnnotationRect | null => { }; }; +/** `capturePage` never settles when the guest's compositor is wedged. */ +const ANNOTATION_SCREENSHOT_TIMEOUT = "5 seconds"; + +/** + * Crops the guest for a picked annotation. A stalled `capturePage` resolves to + * `null` after the timeout: the annotation is still sendable without its + * screenshot, and the pick session must settle either way. + */ const captureAnnotationScreenshot = ( tabId: string, wc: Electron.WebContents, cropRect: PreviewAnnotationRect | null, ): Effect.Effect => Effect.tryPromise({ - try: () => + // The unused abort signal is what makes this interruptible, and therefore + // what lets the timeout below fire. Drop the parameter and a stalled + // capture strands the pick session again. + try: (_signal) => wc.capturePage( cropRect ? { @@ -331,7 +342,7 @@ const captureAnnotationScreenshot = ( cause, }), }).pipe( - Effect.map((image) => { + Effect.map((image): PreviewAnnotationPayload["screenshot"] => { const size = image.getSize(); return { dataUrl: image.toDataURL(), @@ -340,6 +351,15 @@ const captureAnnotationScreenshot = ( cropRect: cropRect ?? { x: 0, y: 0, width: size.width, height: size.height }, }; }), + Effect.timeoutOption(ANNOTATION_SCREENSHOT_TIMEOUT), + Effect.flatMap((screenshot) => + Option.isSome(screenshot) + ? Effect.succeed(screenshot.value) + : Effect.logWarning("preview annotation screenshot timed out").pipe( + Effect.annotateLogs({ tabId, webContentsId: wc.id }), + Effect.as(null), + ), + ), ); const findZoomStep = (current: number): number => { @@ -2370,30 +2390,52 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const annotationTheme = yield* Ref.get(annotationThemeRef); return yield* Effect.callback( (resume) => { + // Declared first so cleanup can check slot ownership by identity + // without a type cycle through the cancel effect it builds. + const session: PickSession = { cancel: Effect.suspend(() => cancelPickSession()) }; const cleanup = Effect.fn("PreviewManager.cleanupPickElement")(function* () { yield* attempt({ operation: "pickElement.cleanup", tabId, webContentsId: wc.id }, () => { wc.ipc.removeListener(ELEMENT_PICKED_CHANNEL, onMessage); wc.off("destroyed", onDestroyed); wc.off("did-start-navigation", onNavigated); }).pipe(Effect.ignore); + // Only drop the slot while it is still ours. A newer session may + // already have swapped itself in before cancelling this one. yield* Ref.update(pickSessionsRef, (sessions) => - replaceMap(sessions, (copy) => { - copy.delete(tabId); - }), + sessions.get(tabId) === session + ? replaceMap(sessions, (copy) => { + copy.delete(tabId); + }) + : sessions, ); }); - const settlePick = Effect.fn("PreviewManager.settlePickElement")(function* ( + // Every exit from this session runs through `claimSettle`, so the + // renderer's `pickElement` promise resolves exactly once. The previous + // identity check let a cancelled or replaced session return without + // resuming, which left the composer waiting forever. + let settled = false; + const claimSettle = (): boolean => { + if (settled) return false; + settled = true; + return true; + }; + const finishPick = Effect.fn("PreviewManager.finishPickElement")(function* ( payload: PreviewAnnotationSubmissionResult | null, ) { - const active = (yield* Ref.get(pickSessionsRef)).get(tabId); - if (!active || active.cancel !== cancel) return; yield* cleanup(); resume(Effect.succeed(payload)); }); + const settlePick = Effect.fn("PreviewManager.settlePickElement")(function* ( + payload: PreviewAnnotationSubmissionResult | null, + ) { + if (!claimSettle()) return; + yield* finishPick(payload); + }); const settle = (payload: PreviewAnnotationSubmissionResult | null) => { runFork(settlePick(payload)); }; const cancelPickSession = Effect.fn("PreviewManager.cancelPickSession")(function* () { + if (!claimSettle()) return; yield* cleanup(); const tabs = yield* SynchronizedRef.get(tabsRef); const activeTab = tabs.get(tabId); @@ -2412,7 +2454,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function } resume(Effect.succeed(null)); }); - const cancel = cancelPickSession(); const onMessage = (_event: Electron.IpcMainEvent, ...args: unknown[]): void => { const payload = args[0]; if (!isPreviewAnnotationPayload(payload)) { @@ -2423,19 +2464,32 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const submission = args[2] === "send" ? "send" : "attach"; runFork( captureAnnotationScreenshot(tabId, wc, cropRect).pipe( - Effect.matchEffect({ - onFailure: () => Effect.sync(() => settle({ annotation: payload, submission })), - onSuccess: (screenshot) => - Effect.sync(() => settle({ annotation: { ...payload, screenshot }, submission })), + // The renderer cannot tell a dropped crop from a comment-only + // pick by the null alone, so a failed or timed-out capture is + // flagged on the result. + Effect.match({ + onFailure: (): PreviewAnnotationSubmissionResult => ({ + annotation: payload, + submission, + screenshotFailed: true, + }), + onSuccess: (screenshot): PreviewAnnotationSubmissionResult => + screenshot === null + ? { annotation: payload, submission, screenshotFailed: true } + : { annotation: { ...payload, screenshot }, submission }, }), - Effect.ensuring( - attempt( + Effect.flatMap((result) => { + // A capture that outlives its session must not touch the + // overlay: the preload tears down on the captured signal, and + // by now it may be running a newer pick. + if (!claimSettle()) return Effect.void; + return attempt( { operation: "pickElement.captureComplete", tabId, webContentsId: wc.id }, () => { if (!wc.isDestroyed()) wc.send(ANNOTATION_CAPTURED_CHANNEL); }, - ).pipe(Effect.ignore), - ), + ).pipe(Effect.ignore, Effect.andThen(finishPick(result))); + }), ), ); }; @@ -2449,6 +2503,21 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function if (isMainFrame) settle(null); }; const registerPickElement = Effect.fn("PreviewManager.registerPickElement")(function* () { + // Two picks on one tab can overlap. Swap this session in and cancel + // the previous holder in one step, so no third pick can slip into an + // empty slot in between and the session we push out still resumes + // its renderer. + const replaced = yield* Ref.modify(pickSessionsRef, (sessions) => [ + sessions.get(tabId) ?? null, + replaceMap(sessions, (copy) => { + copy.set(tabId, session); + }), + ]); + if (replaced) yield* replaced.cancel; + // A newer pick may have cancelled this session while the previous + // one was torn down. Cleanup already ran, so attaching listeners now + // would leak them and start an overlay nobody is waiting on. + if (settled) return; yield* attempt({ operation: "pickElement.register", tabId, webContentsId: wc.id }, () => { wc.ipc.on(ELEMENT_PICKED_CHANNEL, onMessage); wc.once("destroyed", onDestroyed); @@ -2456,21 +2525,17 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function if (!wc.isFocused()) wc.focus(); wc.send(START_PICK_CHANNEL, annotationTheme); }); - yield* Ref.update(pickSessionsRef, (sessions) => - replaceMap(sessions, (copy) => { - copy.set(tabId, { cancel }); - }), - ); }); runFork( registerPickElement().pipe( Effect.catch((error: PreviewManagerError) => { + if (!claimSettle()) return Effect.void; resume(Effect.fail(error)); return cleanup(); }), ), ); - return cancel; + return session.cancel; }, ); }); diff --git a/apps/desktop/src/preview/PickPreload.ts b/apps/desktop/src/preview/PickPreload.ts index f315bdcec738..6155c4119ec8 100644 --- a/apps/desktop/src/preview/PickPreload.ts +++ b/apps/desktop/src/preview/PickPreload.ts @@ -1,4 +1,4 @@ -// @effect-diagnostics globalDate:off - This isolated Electron preload does not run inside an Effect runtime. +// @effect-diagnostics globalDate:off globalTimers:off - This isolated Electron preload does not run inside an Effect runtime. import { ipcRenderer } from "electron"; import { getElementContext } from "react-grab/primitives"; import type { @@ -30,6 +30,8 @@ const Z_INDEX_OVERLAY = 2147483646; const PRIMARY = "var(--t3-primary)"; const PRIMARY_FILL = "color-mix(in srgb, var(--t3-primary) 10%, transparent)"; const MAX_MARQUEE_ELEMENTS = 20; +/** Upper bound on one element's React context lookup during submit. */ +const ELEMENT_CONTEXT_TIMEOUT_MS = 5_000; const CONTENT_LAYER_Z_INDEX = 1; const CHROME_LAYER_Z_INDEX = 10; @@ -279,25 +281,67 @@ function toStackFrame(frame: { }; } -async function captureElement(element: Element): Promise { +/** + * Resolves to `null` instead of hanging when `promise` outlives `millis`. + * `getElementContext` walks the inspected page's React internals, and some + * pages leave it pending forever. Without a bound, the whole submit chain + * stalls and the overlay sits on "Capturing…". + */ +function withCaptureTimeout(promise: Promise, millis: number): Promise { + let timer: ReturnType | undefined; + return Promise.race([ + promise, + new Promise((resolve) => { + timer = setTimeout(() => resolve(null), millis); + }), + ]).finally(() => clearTimeout(timer)); +} + +/** Truncation for the DOM-only preview used when React context is unavailable. */ +const HTML_PREVIEW_MAX_CHARS = 500; + +/** + * Describes a picked element. The React context lookup can stall or throw on + * some pages, so the element is never dropped: without context it still + * carries its tag, a short HTML preview, and its rect so the crop stays on the + * pick instead of falling back to the whole viewport. + */ +async function captureElement(element: Element): Promise { + const base = { + pageUrl: location.href, + pageTitle: document.title?.trim() || null, + tagName: element.tagName.toLowerCase(), + pickedAt: new Date().toISOString(), + }; try { - const context = await getElementContext(element); - const stack = (context.stack ?? []).map(toStackFrame); - return { - pageUrl: location.href, - pageTitle: document.title?.trim() || null, - tagName: element.tagName.toLowerCase(), - selector: context.selector, - htmlPreview: context.htmlPreview ?? "", - componentName: context.componentName, - source: stack[0] ?? null, - stack, - styles: context.styles ?? "", - pickedAt: new Date().toISOString(), - }; + const context = await withCaptureTimeout( + Promise.resolve(getElementContext(element)), + ELEMENT_CONTEXT_TIMEOUT_MS, + ); + if (context) { + const stack = (context.stack ?? []).map(toStackFrame); + return { + ...base, + selector: context.selector, + htmlPreview: context.htmlPreview ?? "", + componentName: context.componentName, + source: stack[0] ?? null, + stack, + styles: context.styles ?? "", + }; + } } catch { - return null; + // Fall through to the DOM-only payload. } + return { + ...base, + selector: null, + htmlPreview: element.outerHTML.slice(0, HTML_PREVIEW_MAX_CHARS), + componentName: null, + source: null, + stack: [], + styles: "", + }; } function createButton(label: string, title: string): HTMLButtonElement { @@ -1225,12 +1269,20 @@ function startAnnotation(): void { pendingCapture = true; submit.disabled = true; submit.textContent = "Capturing…"; + // Snapshot everything the annotation will carry before the capture runs. + // The element context lookup can take up to its timeout, and the user can + // keep editing meanwhile; the annotation must describe what they submitted. + const submittedComment = comment.value.trim(); + const submittedRegions = [...regions]; + const submittedStrokes = [...strokes]; + const submittedStyleChanges = Array.from(styleChanges.values(), (change) => ({ ...change })); void Promise.all( Array.from(selected.values()).map(async (target) => { const element = await captureElement(target.element); - if (!element) return null; - for (const change of styleChanges.values()) { - if (change.targetId === target.id) change.selector = element.selector; + for (const change of submittedStyleChanges) { + if (change.targetId === target.id && element.selector !== null) { + change.selector = element.selector; + } } return { id: target.id, @@ -1238,30 +1290,39 @@ function startAnnotation(): void { rect: rectFromDomRect(target.element.getBoundingClientRect()), }; }), - ).then((captured) => { - const elements = captured.filter((target) => target !== null); - const annotation: PreviewAnnotationPayload = { - id: nextId("annotation"), - pageUrl: location.href, - pageTitle: document.title?.trim() || null, - comment: comment.value.trim(), - elements, - regions: [...regions], - strokes: [...strokes], - styleChanges: Array.from(styleChanges.values()), - screenshot: null, - createdAt: new Date().toISOString(), - }; - editor.style.display = "none"; - toolbar.style.display = "none"; - hoverOutline.style.display = "none"; - const screenshotRect = unionRects([ - ...elements.map((target) => target.rect), - ...regions.map((region) => region.rect), - ...strokes.map((stroke) => stroke.bounds), - ]); - ipcRenderer.send(ELEMENT_PICKED_CHANNEL, annotation, screenshotRect, submission); - }); + ) + .then((elements) => { + // The overlay may have been cancelled or replaced while the capture + // ran. A late submit must not deliver into the next pick's listener. + if (finished) return; + const annotation: PreviewAnnotationPayload = { + id: nextId("annotation"), + pageUrl: location.href, + pageTitle: document.title?.trim() || null, + comment: submittedComment, + elements, + regions: submittedRegions, + strokes: submittedStrokes, + styleChanges: submittedStyleChanges, + screenshot: null, + createdAt: new Date().toISOString(), + }; + editor.style.display = "none"; + toolbar.style.display = "none"; + hoverOutline.style.display = "none"; + const screenshotRect = unionRects([ + ...elements.map((target) => target.rect), + ...submittedRegions.map((region) => region.rect), + ...submittedStrokes.map((stroke) => stroke.bounds), + ]); + ipcRenderer.send(ELEMENT_PICKED_CHANNEL, annotation, screenshotRect, submission); + }) + .catch(() => { + // Last resort. Main is waiting on this message, so hand it an empty + // pick rather than leaving the button stuck on "Capturing…" and the + // renderer's pick promise pending. teardown is a no-op once finished. + teardown(true); + }); }; submit.addEventListener("click", () => submitAnnotation("attach")); root.addEventListener("keydown", (event) => { diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 43a8f8056776..5a658090f1d2 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/assets/antigravity.png b/apps/mobile/assets/antigravity.png new file mode 100644 index 000000000000..df1e22dbbd21 Binary files /dev/null and b/apps/mobile/assets/antigravity.png differ diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 7afa1c09b53d..786221268f80 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -56,6 +56,7 @@ import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteSc import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen"; import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen"; import { SettingsProjectGroupingRouteScreen } from "./features/settings/SettingsProjectGroupingRouteScreen"; +import { SettingsProviderSetupRouteScreen } from "./features/settings/SettingsProviderSetupRouteScreen"; import { UsageRouteScreen } from "./features/usage/UsageRouteScreen"; import { SettingsRouteScreen } from "./features/settings/SettingsRouteScreen"; import { ShowcaseCaptureCoordinator } from "./features/showcase/ShowcaseCaptureCoordinator"; @@ -166,6 +167,13 @@ const SettingsContentStack = createNativeStackNavigator({ title: "Add Environment", }, }), + SettingsProviderSetup: createNativeStackScreen({ + screen: SettingsProviderSetupRouteScreen, + linking: "providers/:environmentId/:instanceId", + options: { + title: "Antigravity", + }, + }), SettingsArchive: createNativeStackScreen({ screen: ArchivedThreadsRouteScreen, linking: "archive", diff --git a/apps/mobile/src/components/ProviderIcon.tsx b/apps/mobile/src/components/ProviderIcon.tsx index 4015e92e1bdb..1d85fb5adfa8 100644 --- a/apps/mobile/src/components/ProviderIcon.tsx +++ b/apps/mobile/src/components/ProviderIcon.tsx @@ -1,3 +1,4 @@ +import { Image } from "expo-image"; import { Circle, Path, Rect, Svg } from "react-native-svg"; import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; @@ -12,6 +13,16 @@ export function ProviderIcon(props: ProviderIconProps) { const size = props.size ?? 16; const mono = isDarkMode ? "#e5e5e5" : "#171717"; + if (props.provider?.trim().toLowerCase() === "antigravity") { + return ( + + ); + } + if (props.provider === "fx") { return ( diff --git a/apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx b/apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx index bc8356c956a1..c4679175a75c 100644 --- a/apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx +++ b/apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx @@ -119,6 +119,12 @@ function ConfiguredConnectOnboardingRouteScreen() { + navigation.navigate("SettingsSheet", { + screen: "SettingsContent", + params: { screen: "SettingsProviderSetup", params }, + }) + } showHeader={false} /> ) : ( diff --git a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx index 163e5fcf16f1..f4a5b531d026 100644 --- a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx +++ b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx @@ -26,6 +26,8 @@ import { cn } from "../../lib/cn"; import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types"; import { serverEnvironment } from "../../state/server"; +import { ProviderSetupLink } from "../settings/ProviderSetupLink"; +import type { ProviderSetupRouteParams } from "../settings/SettingsProviderSetupRouteScreen"; import { availableCloudEnvironmentPresentation } from "../cloud/cloudEnvironmentPresentation"; import { hasCloudPublicConfig } from "../cloud/publicConfig"; import { ConnectionStatusDot } from "./ConnectionStatusDot"; @@ -34,6 +36,7 @@ import { type RelayEnvironmentView, useConnectionController } from "./useConnect interface CloudEnvironmentRowsProps { readonly connectedCloudEnvironments: ReadonlyArray; readonly onReconnectEnvironment: (environmentId: EnvironmentId) => void; + readonly onSetupProvider?: (target: ProviderSetupRouteParams) => void; readonly showcaseAvailableEnvironments?: ReadonlyArray; readonly showcaseSignedIn?: boolean; /** @@ -148,6 +151,7 @@ function CloudEnvironmentRowsContent( onDisconnect={() => handleDisconnectCloudEnvironment(environment.environmentId)} errorExpanded={expandedErrorId === environment.environmentId} onToggleError={() => handleToggleCloudError(environment.environmentId)} + onSetupProvider={props.onSetupProvider} /> ))} {availableCloudEnvironments.map((environment, index) => ( @@ -211,29 +215,49 @@ function ConnectedCloudEnvironmentRow(props: { readonly onConnect: () => void; readonly onDisconnect: () => void; readonly onToggleError: () => void; + readonly onSetupProvider?: (target: ProviderSetupRouteParams) => void; }) { const serverConfig = useAtomValue( serverEnvironment.configValueAtom(props.environment.environmentId), ); return ( - { - if (enabled) { - props.onConnect(); - return; - } - props.onDisconnect(); - }} - onToggleError={props.onToggleError} - value={props.environment.connectionState !== "available"} - /> + + { + if (enabled) { + props.onConnect(); + return; + } + props.onDisconnect(); + }} + onToggleError={props.onToggleError} + value={props.environment.connectionState !== "available"} + /> + {props.onSetupProvider + ? serverConfig?.providers + .filter((provider) => provider.setup?.canAuthenticate || provider.setup?.canInstall) + .map((provider) => ( + + props.onSetupProvider?.({ + environmentId: props.environment.environmentId, + instanceId: provider.instanceId, + }) + } + /> + )) + : null} + ); } diff --git a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx index 75d3e8ce7a34..879b89b9b2f4 100644 --- a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx +++ b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx @@ -15,6 +15,8 @@ import { cn } from "../../lib/cn"; import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types"; import { serverEnvironment } from "../../state/server"; +import { ProviderSetupLink } from "../settings/ProviderSetupLink"; +import type { ProviderSetupRouteParams } from "../settings/SettingsProviderSetupRouteScreen"; import { ConnectionStatusDot } from "./ConnectionStatusDot"; function connectionStatusLabel(environment: ConnectedEnvironmentSummary): string | null { @@ -31,6 +33,7 @@ export function ConnectionEnvironmentRow(props: { readonly onToggle: () => void; readonly onReconnect: (environmentId: EnvironmentId) => void; readonly onRemove: (environmentId: EnvironmentId) => void; + readonly onSetupProvider: (target: ProviderSetupRouteParams) => void; readonly onUpdate: ( environmentId: EnvironmentId, updates: { readonly label: string; readonly displayUrl: string }, @@ -179,6 +182,22 @@ export function ConnectionEnvironmentRow(props: { )} + {serverConfig?.providers + .filter((provider) => provider.setup?.canAuthenticate || provider.setup?.canInstall) + .map((provider) => ( + + props.onSetupProvider({ + environmentId: props.environment.environmentId, + instanceId: provider.instanceId, + }) + } + /> + ))} + {props.environment.isRelayManaged ? null : ( + navigation.navigate("SettingsSheet", { + screen: "SettingsContent", + params: { screen: "SettingsProviderSetup", params }, + }) + } /> ))} diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx index b87070a2d623..6c90ece09a34 100644 --- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx +++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx @@ -37,6 +37,7 @@ import { useThreadSelection } from "../../state/use-thread-selection"; import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree"; import { useEnvironmentQuery } from "../../state/query"; import { projectEnvironment } from "../../state/projects"; +import type { AssetUrlFailureReason } from "../../state/asset-url-state"; import { useAdaptiveWorkspaceLayout, useAdaptiveWorkspacePaneRole, @@ -56,6 +57,7 @@ import { preloadWorkspaceFileContents } from "./preload-workspace-file"; import { SourceFileSurface } from "./SourceFileSurface"; import { ThreadFileNavigatorPane } from "./thread-file-navigator-pane"; import { WorkspaceFileImagePreview } from "./WorkspaceFileImagePreview"; +import { WorkspaceFilePreviewError } from "./WorkspaceFilePreviewError"; import { WorkspaceFileVideoPreview } from "./WorkspaceFileVideoPreview"; import { WorkspaceFileWebPreview } from "./WorkspaceFileWebPreview"; import { @@ -104,8 +106,10 @@ function defaultViewMode(path: string | null): FileViewMode { function FileContent(props: { readonly activeMode: FileViewMode; + readonly environmentId: EnvironmentId | null; readonly previewUri: string | null; - readonly previewUnavailable: boolean; + readonly previewFailure: AssetUrlFailureReason | null; + readonly onRetryPreview: () => void; readonly videoSource: MediaVideoPreviewSource | null; readonly mediaSource?: MediaActionsSource; readonly resolveVideoUri: () => Promise; @@ -121,8 +125,22 @@ function FileContent(props: { const isMarkdown = isMarkdownPreviewFile(props.relativePath); const isBrowserFile = isWorkspaceBrowserPreviewPath(props.relativePath); const isImageFile = isWorkspaceImagePreviewPath(props.relativePath); + const isVideoFile = isVideoPreviewFile(props.relativePath); + // Only the surfaces that wait on a signed asset URL can be blocked by one. + const needsAssetUrl = + isVideoFile || (props.activeMode === "preview" && (isImageFile || isBrowserFile)); - if (isVideoPreviewFile(props.relativePath)) { + if (needsAssetUrl && props.previewFailure !== null) { + return ( + + ); + } + + if (isVideoFile) { return ( ); } @@ -588,6 +605,10 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { assetPreviewUri === null || previewRevision === 0 ? assetPreviewUri : `${assetPreviewUri}${assetPreviewUri.includes("?") ? "&" : "?"}revision=${previewRevision}`; + // Remounting the preview after a re-mint is what makes a failed asset URL retryable. + const handleRetryPreview = () => { + void assetPreview.refresh().finally(() => setPreviewRevision((current) => current + 1)); + }; const needsFileContents = relativePath !== null && !isVideoFile && @@ -901,8 +922,10 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { void; +}) { + const { environmentId, onRetry } = props; + const environment = useEnvironmentPresentation(environmentId); + const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, "environment retry"); + const retryConnection = useCallback(() => { + if (environmentId !== null) void retryEnvironment(environmentId); + onRetry(); + }, [environmentId, onRetry, retryEnvironment]); + + if (props.reason === "disconnected") { + return ( + + + + ); + } + + return ( + + + + ); +} diff --git a/apps/mobile/src/features/files/WorkspaceFileVideoPreview.tsx b/apps/mobile/src/features/files/WorkspaceFileVideoPreview.tsx index d45f7325a435..1034696ba636 100644 --- a/apps/mobile/src/features/files/WorkspaceFileVideoPreview.tsx +++ b/apps/mobile/src/features/files/WorkspaceFileVideoPreview.tsx @@ -1,6 +1,5 @@ import { View } from "react-native"; -import { EmptyState } from "../../components/EmptyState"; import { MediaVideoPlayer } from "../../components/MediaVideoPlayer"; import type { MediaVideoPreviewSource } from "../../lib/videoPreviewSource"; @@ -11,21 +10,9 @@ export function WorkspaceFileVideoPreview(props: { readonly uri: string | null; readonly source: MediaVideoPreviewSource | null; readonly resolvePlaybackUri: () => Promise; - readonly unavailable: boolean; }) { const uri = props.uri; - if (props.unavailable) { - return ( - - - - ); - } - return ( void; +}) { + const label = props.provider.displayName ?? props.provider.driver; + return ( + + + + {providerNeedsSetup(props.provider) ? `Set up ${label}` : `Manage ${label}`} + + + + ); +} diff --git a/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx index 793d26511553..d40fc462c87e 100644 --- a/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx @@ -137,6 +137,12 @@ export function SettingsEnvironmentsRouteScreen() { onReconnect={onReconnectEnvironment} onRemove={onRemoveEnvironmentPress} onUpdate={handleUpdateEnvironment} + onSetupProvider={(params) => + navigation.navigate("SettingsSheet", { + screen: "SettingsContent", + params: { screen: "SettingsProviderSetup", params }, + }) + } /> ))} @@ -164,6 +170,12 @@ export function SettingsEnvironmentsRouteScreen() { + navigation.navigate("SettingsSheet", { + screen: "SettingsContent", + params: { screen: "SettingsProviderSetup", params }, + }) + } {...(SHOWCASE_ENABLED ? { showcaseAvailableEnvironments: SHOWCASE_AVAILABLE_CLOUD_ENVIRONMENTS, diff --git a/apps/mobile/src/features/settings/SettingsProviderSetupRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsProviderSetupRouteScreen.tsx new file mode 100644 index 000000000000..61ca7297fd90 --- /dev/null +++ b/apps/mobile/src/features/settings/SettingsProviderSetupRouteScreen.tsx @@ -0,0 +1,574 @@ +import { useAtomValue } from "@effect/atom-react"; +import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; +import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; +import { + ANTIGRAVITY_AUTH_METHODS, + AuthOrchestrationOperateScope, + EnvironmentId, + ProviderInstanceId, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { Alert, Platform, Pressable, ScrollView, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; +import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; +import { ProviderIcon } from "../../components/ProviderIcon"; +import { cn } from "../../lib/cn"; +import { tryCopyTextWithHaptic } from "../../lib/copyTextWithHaptic"; +import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; +import { NativeStackScreenOptions } from "../../native/StackHeader"; +import { appAtomRegistry } from "../../state/atom-registry"; +import { useEnvironments } from "../../state/environments"; +import { useEnvironmentQuery } from "../../state/query"; +import { serverEnvironment } from "../../state/server"; +import { environmentSession } from "../../state/session"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { + antigravityEnabledPatch, + readAntigravityAuthMethod, + resolveProviderSignInPresentation, +} from "./provider-setup-state"; + +export type ProviderSetupRouteParams = { + readonly environmentId: EnvironmentId; + readonly instanceId: ProviderInstanceId; +}; + +const PRIVATE_COMMAND_OPTIONS = { reportFailure: false, reportDefect: false } as const; +const isEnvironmentId = Schema.is(EnvironmentId); +const isProviderInstanceId = Schema.is(ProviderInstanceId); + +function SetupButton(props: { + readonly label: string; + readonly disabled?: boolean; + readonly primary?: boolean; + readonly destructive?: boolean; + readonly onPress: () => void; +}) { + return ( + + + {props.label} + + + ); +} + +/** The same screen stays inside Settings or the current model picker. */ +export function SettingsProviderSetupRouteScreen({ + route, +}: StaticScreenProps) { + if ( + !isEnvironmentId(route.params?.environmentId) || + !isProviderInstanceId(route.params?.instanceId) + ) { + return ( + + This provider link is not valid. + + ); + } + return ( + + ); +} + +function ProviderSetupScreen({ environmentId, instanceId }: ProviderSetupRouteParams) { + const navigation = useNavigation(); + const insets = useSafeAreaInsets(); + const { presentationById } = useEnvironments(); + const environment = presentationById.get(environmentId); + const environmentLabel = environment?.entry.target.label ?? "this environment"; + const isConnected = environment?.connection.phase === "connected"; + const config = useAtomValue(serverEnvironment.configValueAtom(environmentId)); + const provider = config?.providers.find((item) => item.instanceId === instanceId); + const access = useEnvironmentQuery(environmentSession.sessionStateAtom(environmentId)); + const canOperate = + access.data?.authenticated === true && + access.data.scopes?.includes(AuthOrchestrationOperateScope) === true; + const target = { environmentId, input: { instanceId } }; + const authQuery = useEnvironmentQuery( + canOperate && provider?.setup?.canAuthenticate + ? serverEnvironment.providerAuthState(target) + : null, + ); + const installQuery = useEnvironmentQuery( + canOperate && provider?.setup?.canInstall + ? serverEnvironment.providerInstallState(target) + : null, + ); + const auth = authQuery.data; + const installation = installQuery.data; + const startAuth = useAtomCommand(serverEnvironment.startProviderAuth, PRIVATE_COMMAND_OPTIONS); + const completeAuth = useAtomCommand( + serverEnvironment.completeProviderAuth, + PRIVATE_COMMAND_OPTIONS, + ); + const cancelAuth = useAtomCommand(serverEnvironment.cancelProviderAuth, PRIVATE_COMMAND_OPTIONS); + const logout = useAtomCommand(serverEnvironment.logoutProviderAuth, PRIVATE_COMMAND_OPTIONS); + const startInstall = useAtomCommand( + serverEnvironment.startProviderInstall, + PRIVATE_COMMAND_OPTIONS, + ); + const cancelInstall = useAtomCommand( + serverEnvironment.cancelProviderInstall, + PRIVATE_COMMAND_OPTIONS, + ); + const removeInstall = useAtomCommand( + serverEnvironment.removeProviderInstallation, + PRIVATE_COMMAND_OPTIONS, + ); + const updateSettings = useAtomCommand(serverEnvironment.updateSettings, PRIVATE_COMMAND_OPTIONS); + const [callbackUrl, setCallbackUrl] = useState(""); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const busyRef = useRef(false); + const authActive = + auth?.phase === "starting" || auth?.phase === "waiting" || auth?.phase === "verifying"; + const installActive = + installation?.phase === "downloading" || + installation?.phase === "extracting" || + installation?.phase === "verifying"; + const controlsDisabled = busy || !isConnected || !canOperate; + const { + signedIn, + showSignOut, + message: authMessage, + } = resolveProviderSignInPresentation(provider, auth); + const authMethod = readAntigravityAuthMethod( + config?.settings.providerInstances[instanceId]?.config ?? + (instanceId === "antigravity" ? config?.settings.providers.antigravity : undefined), + ); + const usesBrowser = authMethod === "oauth-personal" || authMethod === "oauth-business"; + const methodLabel = + ANTIGRAVITY_AUTH_METHODS.find((method) => method.value === authMethod)?.label ?? + "Google account"; + + // Return URLs never enter saved drafts, navigation params, or diagnostics. + useEffect(() => { + setCallbackUrl(""); + }, [auth?.flowId, auth?.phase]); + + const perform = useCallback( + async (command: () => Promise>, failureMessage: string) => { + if (busyRef.current || !isConnected || !canOperate) return false; + busyRef.current = true; + setBusy(true); + setError(null); + try { + const result = await command(); + if (!AsyncResult.isSuccess(result)) { + setError(failureMessage); + return false; + } + return true; + } catch { + setError(failureMessage); + return false; + } finally { + busyRef.current = false; + setBusy(false); + } + }, + [canOperate, isConnected], + ); + + const setEnabled = (enabled: boolean) => { + const currentConfig = appAtomRegistry.get(serverEnvironment.configValueAtom(environmentId)); + const currentProvider = currentConfig?.providers.find((item) => item.instanceId === instanceId); + if (!currentConfig || !currentProvider) return; + const patch = antigravityEnabledPatch(currentConfig.settings, currentProvider, enabled); + if (!patch) return; + void perform( + () => updateSettings({ environmentId, input: { patch } }), + "Could not change this provider. Reconnect and try again.", + ); + }; + + const title = provider?.displayName ?? "Antigravity"; + return ( + + + {Platform.OS === "android" ? ( + navigation.goBack()} /> + ) : null} + + + + {environmentLabel} + + {!isConnected ? ( + + Reconnect to this environment to continue setup. + + ) : access.error ? ( + + Could not check your environment access. + + + ) : !access.data ? ( + Checking environment access. + ) : !canOperate ? ( + + This connection cannot manage providers. Pair again with permission to operate this + environment. + + ) : null} + {!config ? ( + Loading provider settings. + ) : !provider || provider.driver !== "antigravity" ? ( + + This provider is not available on this environment. + + ) : ( + <> + + + {provider.enabled ? "Enabled" : "Disabled"} + {provider.installed + ? `. Installed${provider.version ? ` ${provider.version}` : ""}` + : ". Not installed"} + + { + if (!provider.enabled) { + setEnabled(true); + return; + } + Alert.alert( + "Disable Antigravity?", + `This stops Antigravity sessions on ${environmentLabel}. Google credentials stay saved.`, + [ + { text: "Cancel", style: "cancel" }, + { text: "Disable", style: "destructive", onPress: () => setEnabled(false) }, + ], + ); + }} + /> + + {provider.setup?.canInstall ? ( + + {installActive ? ( + <> + + {installation.phase === "downloading" + ? `Downloading ${Math.round(installation.downloadedBytes / 1_048_576)}${installation.totalBytes === null ? "" : ` of ${Math.round(installation.totalBytes / 1_048_576)}`} MB` + : installation.phase === "extracting" + ? "Extracting Antigravity files." + : "Checking the Antigravity installation."} + + { + const operationId = installation.operationId; + if (!operationId) return; + void perform( + () => + cancelInstall({ environmentId, input: { instanceId, operationId } }), + "Could not cancel the install. Try again.", + ); + }} + /> + + ) : !provider.installed ? ( + + void perform( + () => startInstall(target), + "Could not start the install. Try again.", + ) + } + /> + ) : null} + {installation?.message ? ( + {installation.message} + ) : null} + {installation?.canRemove && !installActive ? ( + { + Alert.alert( + "Remove the Antigravity install?", + `This removes T3's managed install from ${environmentLabel}. Providers that use it will need it installed again. Google credentials and threads stay.`, + [ + { text: "Cancel", style: "cancel" }, + { + text: "Remove", + style: "destructive", + onPress: () => + void perform( + () => removeInstall(target), + "Could not remove the managed install. Try again.", + ), + }, + ], + ); + }} + /> + ) : null} + {installQuery.error ? ( + + Install status is unavailable. Reconnect and try again. + + ) : null} + + ) : !provider.installed ? ( + + Antigravity cannot be installed on this environment. Use a supported server host or + set an executable path in provider settings. + + ) : null} + {provider.setup?.canAuthenticate ? ( + + {methodLabel} + + {signedIn + ? "Signed in. Credentials stay on this environment." + : provider.auth.status === "unknown" + ? "Sign-in has not been checked." + : usesBrowser + ? "Sign in with the Google account you use for Antigravity." + : "Connect with the credentials set in provider settings on web or desktop."} + + {authActive ? ( + <> + + {auth.phase === "starting" + ? usesBrowser + ? "Starting Google sign-in." + : "Checking credentials." + : auth.phase === "verifying" + ? usesBrowser + ? "Checking Google sign-in." + : "Checking credentials." + : auth.authorizationUrl + ? "Complete sign-in in your browser." + : "Sign-in is open on another client. Complete it there or wait for it to expire."} + + {auth.authorizationUrl && auth.phase === "waiting" ? ( + <> + { + if (!auth.authorizationUrl) return; + void tryOpenExternalUrl(auth.authorizationUrl, "provider-auth").then( + (opened) => { + if (!opened) + setError( + "Could not open Google sign-in. Copy the link and open it in your browser.", + ); + }, + ); + }} + /> + { + if (!auth.authorizationUrl) return; + void tryCopyTextWithHaptic(auth.authorizationUrl, { + target: "provider-sign-in-link", + }).then((copied) => { + if (!copied) setError("Could not copy the sign-in link."); + }); + }} + /> + + After sign-in, the browser will open a 127.0.0.1 address that cannot load + on your phone. Copy that full address and paste it here. + + + { + const flowId = auth.flowId; + if (!flowId) return; + const submittedUrl = callbackUrl.trim(); + setCallbackUrl(""); + void perform( + () => + completeAuth({ + environmentId, + input: { instanceId, flowId, callbackUrl: submittedUrl }, + }), + "Could not complete Google sign-in. Check the return URL and try again.", + ); + }} + /> + + ) : null} + {auth.expiresAt ? ( + + Sign-in expires at{" "} + {new Date(auth.expiresAt).toLocaleTimeString([], { + hour: "numeric", + minute: "2-digit", + })} + . + + ) : null} + {auth.flowId ? ( + { + const flowId = auth.flowId; + if (!flowId) return; + setCallbackUrl(""); + void perform( + () => cancelAuth({ environmentId, input: { instanceId, flowId } }), + "Could not cancel sign-in. Try again.", + ); + }} + /> + ) : null} + + ) : showSignOut ? ( + { + Alert.alert( + usesBrowser ? "Sign out of Google?" : "Disconnect Antigravity?", + `This stops Antigravity sessions for ${title} on ${environmentLabel}. Threads and files stay.`, + [ + { text: "Cancel", style: "cancel" }, + { + text: "Sign out", + style: "destructive", + onPress: () => + void perform( + () => logout(target), + "Could not sign out of Google. Try again.", + ), + }, + ], + ); + }} + /> + ) : ( + + void perform( + () => startAuth(target), + "Could not start Google sign-in. Try again.", + ) + } + /> + )} + {!provider.enabled && !signedIn ? ( + Enable Antigravity to sign in. + ) : !provider.installed && !signedIn ? ( + Install Antigravity to sign in. + ) : null} + {authMessage ? {authMessage} : null} + {authQuery.error ? ( + + Sign-in status is unavailable. Reconnect and try again. + + ) : null} + + ) : null} + {provider.message ? ( + {provider.message} + ) : null} + + )} + {error ? ( + + {error} + + ) : null} + {busy ? ( + + Waiting for the environment. + + ) : null} + + + ); +} diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index 58a2779840c3..41c2076ac7b4 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -46,6 +46,7 @@ import { import { findSharedSettingsMismatches, pickSharedServerSettings, + supportsSharedSettingsSync, } from "@t3tools/client-runtime/state/shared-settings"; import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; import { @@ -553,9 +554,9 @@ const AUTO_SETTLE_DEFAULT_DAYS = DEFAULT_SERVER_SETTINGS.sidebarAutoSettleAfterD /** * Auto-settlement is a user preference that every server has to hold. Mobile - * has no primary environment, so the first connected environment that - * supports it is the reference value. Edits fan out to every connected - * environment, and a mismatch row lets the user push the reference out. + * has no primary environment, so the first eligible sync target provides the + * reference value. Edits fan out to every eligible target, and a mismatch row + * lets the user push the reference out. */ function AutoSettleSettingsRows() { const { environments } = useEnvironments(); @@ -564,12 +565,8 @@ function AutoSettleSettingsRows() { reportFailure: true, }); - const connected = environments.filter( - (environment) => - environment.connection.phase === "connected" && - environment.serverConfig?.environment.capabilities.threadAutoSettlement === true, - ); - const reference = connected[0] ?? null; + const syncTargets = environments.filter(supportsSharedSettingsSync); + const reference = syncTargets[0] ?? null; const referenceSettings = reference?.serverConfig?.settings ?? null; const [daysDraft, setDaysDraft] = useState(null); @@ -579,7 +576,7 @@ function AutoSettleSettingsRows() { } const writeToAll = (patch: ServerSettingsPatch) => { - for (const environment of connected) { + for (const environment of syncTargets) { void updateSettings({ environmentId: environment.environmentId, input: { patch } }); } }; @@ -590,7 +587,7 @@ function AutoSettleSettingsRows() { environments: environments.map((environment) => ({ environmentId: environment.environmentId, label: environment.label, - connected: environment.connection.phase === "connected", + syncEligible: supportsSharedSettingsSync(environment), settings: environment.serverConfig?.settings ?? null, })), }); @@ -600,7 +597,7 @@ function AutoSettleSettingsRows() { const draft = (daysDraft ?? "").trim(); setDaysDraft(null); // Whole-string check so "3.5" and "3days" are rejected instead of - // silently becoming 3 on every connected environment. + // silently becoming 3 on every eligible sync target. const parsed = /^\d+$/.test(draft) ? Number(draft) : Number.NaN; if ( Number.isInteger(parsed) && diff --git a/apps/mobile/src/features/settings/provider-setup-state.test.ts b/apps/mobile/src/features/settings/provider-setup-state.test.ts new file mode 100644 index 000000000000..3f85b41f2c4e --- /dev/null +++ b/apps/mobile/src/features/settings/provider-setup-state.test.ts @@ -0,0 +1,159 @@ +import { + DEFAULT_SERVER_SETTINGS, + ProviderDriverKind, + ProviderInstanceId, + ServerProvider, + resolveProviderInstanceEnabled, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import { describe, expect, it } from "vite-plus/test"; + +import { antigravityEnabledPatch, resolveProviderSignInPresentation } from "./provider-setup-state"; + +const provider = Schema.decodeSync(ServerProvider)({ + instanceId: "antigravity", + driver: "antigravity", + enabled: false, + installed: false, + version: null, + status: "disabled", + auth: { status: "unauthenticated" }, + checkedAt: "2026-09-02T00:00:00.000Z", + models: [], +}); + +describe("resolveProviderSignInPresentation", () => { + const completedFlow = { phase: "succeeded", message: "Google sign-in is complete." } as const; + + it("offers sign-in after saved credentials expire, even if the old flow succeeded", () => { + expect( + resolveProviderSignInPresentation( + { + enabled: true, + auth: { status: "authenticated" }, + }, + completedFlow, + ), + ).toEqual({ + signedIn: true, + showSignOut: true, + message: completedFlow.message, + }); + + expect( + resolveProviderSignInPresentation( + { + enabled: true, + auth: { status: "unauthenticated" }, + }, + completedFlow, + ), + ).toEqual({ + signedIn: false, + showSignOut: false, + message: null, + }); + }); + + it("allows credential cleanup without claiming a disabled unknown account is signed in", () => { + expect( + resolveProviderSignInPresentation( + { + enabled: false, + auth: { status: "unknown" }, + }, + completedFlow, + ), + ).toEqual({ + signedIn: false, + showSignOut: true, + message: null, + }); + }); + + it("keeps progress while Google sign-in is still pending", () => { + const flow = { phase: "verifying", message: "Checking Google sign-in." } as const; + expect( + resolveProviderSignInPresentation( + { + enabled: true, + auth: { status: "unauthenticated" }, + }, + flow, + ), + ).toEqual({ + signedIn: false, + showSignOut: false, + message: flow.message, + }); + }); +}); + +describe("antigravityEnabledPatch", () => { + it("enables a legacy instance without losing its executable path or models", () => { + const settings = { + ...DEFAULT_SERVER_SETTINGS, + providers: { + ...DEFAULT_SERVER_SETTINGS.providers, + antigravity: { + ...DEFAULT_SERVER_SETTINGS.providers.antigravity, + enabled: false, + binaryPath: "/opt/google/agy-acp", + customModels: ["gemini-native"], + }, + }, + }; + const patch = antigravityEnabledPatch(settings, provider, true); + const instance = patch?.providerInstances?.[provider.instanceId]; + + expect(instance).toMatchObject({ + enabled: true, + config: { binaryPath: "/opt/google/agy-acp", customModels: ["gemini-native"] }, + }); + expect(instance?.config).not.toHaveProperty("enabled"); + expect(instance && resolveProviderInstanceEnabled(instance)).toBe(true); + expect(settings.providers.antigravity.enabled).toBe(false); + }); + + it("keeps separate accounts and environment overrides when enabling a custom instance", () => { + const workId = ProviderInstanceId.make("google_work"); + const personalId = ProviderInstanceId.make("google_personal"); + const personal = { driver: ProviderDriverKind.make("antigravity"), enabled: true }; + const settings = { + ...DEFAULT_SERVER_SETTINGS, + providerInstances: { + [personalId]: personal, + [workId]: { + driver: ProviderDriverKind.make("antigravity"), + displayName: "Work Google", + enabled: false, + config: { enabled: false, binaryPath: "/work/agy-acp", futureSetting: "keep" }, + environment: [{ name: "WORK_PROXY", value: "http://proxy", sensitive: false }], + }, + }, + }; + const patch = antigravityEnabledPatch(settings, { ...provider, instanceId: workId }, true); + + expect(patch?.providerInstances?.[personalId]).toBe(personal); + expect(patch?.providerInstances?.[workId]).toMatchObject({ + displayName: "Work Google", + enabled: true, + config: { binaryPath: "/work/agy-acp", futureSetting: "keep" }, + environment: [{ name: "WORK_PROXY", value: "http://proxy", sensitive: false }], + }); + expect(patch?.providers).toBeUndefined(); + }); + + it("does not change a different driver", () => { + expect( + antigravityEnabledPatch( + DEFAULT_SERVER_SETTINGS, + { + ...provider, + driver: ProviderDriverKind.make("codex"), + }, + true, + ), + ).toBeNull(); + }); +}); diff --git a/apps/mobile/src/features/settings/provider-setup-state.ts b/apps/mobile/src/features/settings/provider-setup-state.ts new file mode 100644 index 000000000000..4bb9aebd8f6d --- /dev/null +++ b/apps/mobile/src/features/settings/provider-setup-state.ts @@ -0,0 +1,76 @@ +import { + ANTIGRAVITY_AUTH_METHODS, + type AntigravityAuthMethod, + DEFAULT_SERVER_SETTINGS, + type ProviderAuthState, + type ServerProvider, + type ServerSettings, + type ServerSettingsPatch, +} from "@t3tools/contracts"; + +/** Read the configured method from an instance config. Unknown values fall back to personal. */ +export function readAntigravityAuthMethod(config: unknown): AntigravityAuthMethod { + const value = + config !== null && typeof config === "object" && "authMethod" in config + ? config.authMethod + : undefined; + return ( + ANTIGRAVITY_AUTH_METHODS.find((method) => method.value === value)?.value ?? "oauth-personal" + ); +} + +/** A completed sign-in flow does not prove that saved credentials are still valid. */ +export function resolveProviderSignInPresentation( + provider: Pick | undefined, + flow: Pick | null, +) { + const signedIn = provider?.auth.status === "authenticated"; + return { + signedIn, + showSignOut: signedIn || (provider?.enabled === false && provider.auth.status === "unknown"), + message: flow?.phase === "succeeded" && !signedIn ? null : (flow?.message ?? null), + }; +} + +/** Keep one enabled flag when a legacy provider becomes an explicit instance. */ +export function antigravityEnabledPatch( + settings: ServerSettings, + provider: ServerProvider, + enabled: boolean, +): ServerSettingsPatch | null { + if (provider.driver !== "antigravity") return null; + + const { enabled: _legacyEnabled, ...legacyConfig } = settings.providers.antigravity; + const instance = settings.providerInstances[provider.instanceId] ?? { + driver: provider.driver, + config: legacyConfig, + }; + const config = + instance.config !== null && + typeof instance.config === "object" && + !Array.isArray(instance.config) + ? Object.fromEntries(Object.entries(instance.config).filter(([key]) => key !== "enabled")) + : instance.config; + + return { + ...(provider.instanceId === "antigravity" + ? { providers: { antigravity: DEFAULT_SERVER_SETTINGS.providers.antigravity } } + : {}), + providerInstances: { + ...settings.providerInstances, + [provider.instanceId]: { ...instance, enabled, config }, + }, + }; +} + +/** Setup remains available when the provider has no selectable models. */ +export function providerNeedsSetup(provider: ServerProvider): boolean { + return ( + (provider.setup?.canAuthenticate === true || provider.setup?.canInstall === true) && + (!provider.enabled || + !provider.installed || + provider.auth.status !== "authenticated" || + provider.availability === "unavailable" || + provider.models.length === 0) + ); +} diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index e0ca8fee3249..b02afce3a12f 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -84,7 +84,11 @@ import { type ComposerDraft, } from "../../state/use-composer-drafts"; import { useEnvironmentServerConfig, useProjects } from "../../state/entities"; -import { resolveSelectableModelSelection } from "../../lib/modelOptions"; +import { + isModelSelectionUnavailable, + resolveSelectableModelSelection, +} from "../../lib/modelOptions"; +import { resolveProviderInteractionMode } from "./legacy-plan-mode"; import { deriveThreadTitleFromPrompt } from "../../lib/projectThreadStartTurn"; import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration"; import { enqueueThreadOutboxMessage } from "../../state/thread-outbox"; @@ -173,6 +177,7 @@ export function NewTaskDraftScreen(props: { connectedEnvironments.find( (environment) => environment.environmentId === selectedProject.environmentId, )?.connectionState === "connected"; + const modelUnavailable = environmentConnected && flow.selectedModelOption?.isUnavailable === true; const uploadStates = useAtomValue(composerAttachmentUploadsAtom); const attachmentBlockReason = selectedProject ? composerAttachmentUploadBlockReason({ @@ -862,9 +867,8 @@ export function NewTaskDraftScreen(props: { return; } const draft = getComposerDraftSnapshot(draftKey); - // Snapshot read keeps just-typed selector state; the availability gate - // still applies so a stored selection on a disabled provider falls back - // to the flow's resolved model. + // Read the latest explicit pick. Antigravity selections stay unchanged + // when setup or a catalog change makes them unavailable. const modelSelection = resolveSelectableModelSelection( selectedEnvironmentServerConfig, @@ -876,9 +880,12 @@ export function NewTaskDraftScreen(props: { draft.workspaceSelection?.worktreePath ?? flow.selectedWorktreePath; const startFromOrigin = draft.workspaceSelection?.startFromOrigin ?? flow.startFromOrigin; const runtimeMode = draft.runtimeMode ?? flow.runtimeMode; - const interactionMode = flow.planModeEnabled - ? (draft.interactionMode ?? flow.interactionMode) - : "default"; + const interactionMode = resolveProviderInteractionMode( + selectedEnvironmentServerConfig?.providers.find( + (provider) => provider.instanceId === modelSelection?.instanceId, + ), + flow.planModeEnabled ? (draft.interactionMode ?? flow.interactionMode) : "default", + ); const initialMessageText = draft.text.trim(); if ( @@ -890,6 +897,16 @@ export function NewTaskDraftScreen(props: { ) { return; } + if ( + environmentConnected && + isModelSelectionUnavailable(selectedEnvironmentServerConfig, modelSelection) + ) { + Alert.alert( + "Antigravity model unavailable", + "Open model settings to finish setup or choose another model.", + ); + return; + } // A failed-send restore can leave the draft over the cap on purpose (it // never drops the user's files); starting anyway would upload everything // and have the server reject the turn. @@ -1038,6 +1055,7 @@ export function NewTaskDraftScreen(props: { const isAndroid = Platform.OS === "android"; const canStart = attachmentBlockReason === null && + !modelUnavailable && Boolean(flow.selectedProject) && Boolean(flow.selectedModel) && flow.prompt.trim().length > 0 && @@ -1231,6 +1249,17 @@ export function NewTaskDraftScreen(props: { ) : null} {workspaceControls} + {modelUnavailable ? ( + + Model unavailable. Open model settings. + + ) : null} + Promise; } -const DEFAULT_APPROVAL_OPTIONS = [ +const DEFAULT_APPROVAL_OPTIONS: ReadonlyArray = [ { decision: "accept", label: "Allow once" }, { decision: "acceptForSession", label: "Allow session" }, { decision: "decline", label: "Decline" }, -] satisfies ReadonlyArray; +]; export function PendingApprovalCard(props: PendingApprovalCardProps) { - const options = props.approval.options ?? DEFAULT_APPROVAL_OPTIONS; + const options: ReadonlyArray = + props.approval.options ?? DEFAULT_APPROVAL_OPTIONS; + const warning = options.find((option) => option.warning)?.warning; // Opaque for the same reason as PendingUserInputCard: nothing blurs the feed // behind this card, so a translucent surface bleeds messages through it. return ( @@ -40,6 +42,11 @@ export function PendingApprovalCard(props: PendingApprovalCardProps) { {props.approval.detail} ) : null} + {warning ? ( + + {warning} + + ) : null} {options.map((option) => ( void; readonly onChangeCustomAnswer: ( requestId: ApprovalRequestId, @@ -267,12 +267,13 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { {question.options.map((option) => { - const selected = isPendingUserInputOptionSelected(draft, option.label); + const optionValue = option.value ?? option.label.trim(); + const selected = isPendingUserInputOptionSelected(question, draft, optionValue); const description = option.description !== option.label ? option.description : undefined; return ( @@ -308,16 +309,18 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { ); })} - - props.onChangeCustomAnswer(props.pendingUserInput.requestId, question.id, value) - } - onFocus={() => props.onInputFocusChange?.(true)} - onBlur={() => props.onInputFocusChange?.(false)} - placeholder="Or type a custom answer" - className="min-h-[54px] rounded-2xl border border-adaptive-neutral-200-white-a8 bg-adaptive-white-neutral-950-a70 px-3.5 py-3 font-sans text-base text-adaptive-neutral-950-50" - /> + {question.allowCustomAnswer !== false ? ( + + props.onChangeCustomAnswer(props.pendingUserInput.requestId, question.id, value) + } + onFocus={() => props.onInputFocusChange?.(true)} + onBlur={() => props.onInputFocusChange?.(false)} + placeholder="Or type a custom answer" + className="min-h-[54px] rounded-2xl border border-adaptive-neutral-200-white-a8 bg-adaptive-white-neutral-950-a70 px-3.5 py-3 font-sans text-base text-adaptive-neutral-950-50" + /> + ) : null} ); })} diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 1d8edcd413aa..117967ea18cd 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -60,7 +60,11 @@ import type { DraftComposerAttachment, DraftComposerFileAttachment, } from "../../lib/composerImages"; -import { buildModelOptions, groupByProvider } from "../../lib/modelOptions"; +import { + buildModelOptions, + groupByProvider, + isModelSelectionUnavailable, +} from "../../lib/modelOptions"; import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; import type { RemoteClientConnectionState } from "../../lib/connection"; import { resolveProviderOptionDescriptors } from "../../lib/providerOptions"; @@ -106,12 +110,6 @@ export interface ThreadComposerProps { readonly connectionState: RemoteClientConnectionState; readonly connectionError: string | null; readonly environmentLabel: string | null; - /** - * Message sync phase for the selected thread (drives the status pill): - * "loading" = first fetch, nothing to show yet; "syncing" = cached messages - * are on screen while they reconcile with the server. - */ - readonly threadSyncPhase?: "loading" | "syncing" | null; readonly selectedThread: OrchestrationThreadShell; readonly serverConfig: T3ServerConfig | null; readonly queueCount: number; @@ -221,7 +219,7 @@ export function ComposerSurface(props: { } type ComposerStatusPillState = { - readonly kind: "unavailable" | "reconnecting" | "syncing"; + readonly kind: "unavailable" | "reconnecting"; readonly label: string; }; @@ -229,7 +227,6 @@ function composerConnectionStatus(input: { readonly connectionError: string | null; readonly connectionState: RemoteClientConnectionState; readonly environmentLabel: string | null; - readonly threadSyncPhase?: "loading" | "syncing" | null; }): ComposerStatusPillState | null { const environmentLabel = input.environmentLabel ?? "Environment"; @@ -255,18 +252,6 @@ function composerConnectionStatus(input: { case "available": return { kind: "unavailable", label: `${environmentLabel} is not connected` }; case "connected": - break; - } - - // Connected: the pill is the single loading/sync indicator. One stable - // label per open — "Loading" when starting from scratch, "Syncing" when - // cached messages are already visible. - switch (input.threadSyncPhase) { - case "loading": - return { kind: "syncing", label: "Loading messages..." }; - case "syncing": - return { kind: "syncing", label: "Syncing messages..." }; - default: return null; } } @@ -275,7 +260,7 @@ const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill( readonly onPress: () => void; readonly status: ComposerStatusPillState; }) { - const isReconnecting = props.status.kind !== "unavailable"; + const isReconnecting = props.status.kind === "reconnecting"; return ( 0 ? "Queue" : "Send"; const currentModelSelection = props.selectedThread.modelSelection; const currentRuntimeMode = props.selectedThread.runtimeMode; + const modelUnavailable = + props.connectionState === "connected" && + isModelSelectionUnavailable(props.serverConfig, currentModelSelection); const connectionStatus = composerConnectionStatus({ connectionError: props.connectionError, connectionState: props.connectionState, environmentLabel: props.environmentLabel, - threadSyncPhase: props.threadSyncPhase, }); const selectedProviderStatus = useMemo(() => { if (!props.serverConfig) return null; @@ -357,7 +344,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer selectedProviderStatus, hasThread: true, onChangeDraftMessage: props.onChangeDraftMessage, - onUpdateInteractionMode: props.onUpdateInteractionMode, + onUpdateInteractionMode: + selectedProviderStatus?.showInteractionModeToggle === false + ? undefined + : props.onUpdateInteractionMode, }); const voiceInput = useVoiceInputController({ ownerKey: composerOwnerKey, @@ -383,7 +373,11 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer serverConfig: props.serverConfig, states: uploadStates, }); - const canSend = hasContent && !voiceInput.blocksSubmission && attachmentBlockReason === null; + const canSend = + hasContent && + !voiceInput.blocksSubmission && + attachmentBlockReason === null && + !modelUnavailable; // Keep the feed inset aligned with the card or compact dictation strip. useEffect(() => { @@ -498,6 +492,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer () => ({ ownerId: settingsOwnerId, environmentId: props.environmentId, + providerInstanceId: currentModelSelection.instanceId, providerGroups: threadProviderGroups, selectedModel: currentModelSelection, onSelectModel: (option) => props.onUpdateModelSelection(option.selection), @@ -597,6 +592,12 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer /> ) : null} + {modelUnavailable ? ( + + Model unavailable. Open model settings. + + ) : null} + void; readonly onChangeUserInputCustomAnswer: ( requestId: ApprovalRequestId, @@ -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 75ea0162ca55..b802c5ac3014 100644 --- a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx +++ b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx @@ -1,10 +1,13 @@ import type { EnvironmentId, ModelSelection, + ProviderInstanceId, ProviderOptionDescriptor, ProviderOptionSelection, RuntimeMode, + ServerProvider, } from "@t3tools/contracts"; +import { useAtomValue } from "@effect/atom-react"; import type { LegendListRenderItemProps } from "@legendapp/list/react-native"; import { AnimatedLegendList } from "@legendapp/list/reanimated"; import { HeaderHeightContext } from "@react-navigation/elements"; @@ -49,6 +52,11 @@ import { } from "../../native/StackHeader"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { serverEnvironment } from "../../state/server"; +import { ProviderSetupLink } from "../settings/ProviderSetupLink"; +import { + SettingsProviderSetupRouteScreen, + type ProviderSetupRouteParams, +} from "../settings/SettingsProviderSetupRouteScreen"; import { useAtomCommand } from "../../state/use-atom-command"; import { useNewTaskFlow } from "./new-task-flow-provider"; import { @@ -62,8 +70,10 @@ import { } from "../layout/native-mail-search-toolbar"; import { RUNTIME_MODE_CHOICES, selectableChoices } from "./thread-settings-options"; import { + canCommitPendingModel, modelMatchesCatalogQuery, pendingModelAfterPress, + providerSetupCandidates, providerSectionIsCollapsed, } from "./thread-settings-sheet-state"; @@ -72,7 +82,11 @@ import { * and friends) starts folded so a 300-model catalog cannot bury the list. All * provider headers remain user-collapsible. */ -const PRIMARY_PROVIDER_DRIVERS: ReadonlySet = new Set(["claudeAgent", "codex"]); +const PRIMARY_PROVIDER_DRIVERS: ReadonlySet = new Set([ + "claudeAgent", + "codex", + "antigravity", +]); /** * Keep measured row changes stable, but let catalog mutations use the list's * native bounds so a filtered catalog that underflows returns to the top. @@ -102,7 +116,11 @@ function ModelRow(props: { Legacy ) : null} + {props.option.isUnavailable ? ( + Unavailable + ) : null} {props.option.subtitle ? ( @@ -301,6 +322,7 @@ type ThreadSettingsSubmenuPage = type ThreadSettingsSessionProps = { readonly environmentId: EnvironmentId | null; + readonly providerInstanceId?: ProviderInstanceId; readonly providerGroups: ReadonlyArray; readonly selectedModel: ModelSelection | null; readonly onSelectModel: (option: ModelOption) => void; @@ -353,6 +375,7 @@ export function useExistingThreadSettingsRoutePresentation() { type ThreadSettingsSessionValue = { readonly environmentId: EnvironmentId | null; + readonly providerInstanceId?: ProviderInstanceId; readonly providerGroups: ReadonlyArray; readonly runtimeMode: RuntimeMode; readonly onUpdateRuntimeMode: (mode: RuntimeMode) => void; @@ -364,7 +387,7 @@ type ThreadSettingsSessionValue = { readonly searchQuery: string; readonly showLegacy: boolean; readonly applyOptionChange: (id: string, value: string | boolean) => void; - readonly commitPendingModel: () => void; + readonly commitPendingModel: () => boolean; readonly isApplied: (option: ModelOption) => boolean; readonly isDisplayed: (option: ModelOption) => boolean; readonly pressModel: (option: ModelOption) => void; @@ -422,10 +445,15 @@ function ThreadSettingsSessionProvider( ); const commitPendingModel = useCallback(() => { if (pendingModel) { + if (!canCommitPendingModel(pendingModel, props.providerGroups)) { + Alert.alert("Model unavailable", "Complete provider setup or select another model."); + return false; + } void Haptics.selectionAsync(); props.onSelectModel(pendingModel); } - }, [pendingModel, props.onSelectModel]); + return true; + }, [pendingModel, props.onSelectModel, props.providerGroups]); const applyOptionChange = useCallback( (id: string, value: string | boolean) => { @@ -472,6 +500,7 @@ function ThreadSettingsSessionProvider( const value = useMemo( () => ({ environmentId: props.environmentId, + providerInstanceId: props.providerInstanceId, providerGroups: props.providerGroups, runtimeMode: props.runtimeMode, onUpdateRuntimeMode: props.onUpdateRuntimeMode, @@ -501,6 +530,7 @@ function ThreadSettingsSessionProvider( isApplied, isDisplayed, props.environmentId, + props.providerInstanceId, pendingModel, pressModel, providerFilter, @@ -551,6 +581,11 @@ type ThreadSettingsCatalogItem = readonly isFirst: boolean; readonly isLast: boolean; } + | { + readonly kind: "setup"; + readonly key: string; + readonly provider: ServerProvider; + } | { readonly kind: "empty"; readonly key: "empty"; @@ -612,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)); @@ -760,22 +795,37 @@ function ThreadSettingsOptionsItem(props: { /** One native scroll owner for the model catalog and its related settings. */ function ThreadSettingsMainContent(props: { readonly onOpenSubmenu: (submenu: ThreadSettingsSubmenuPage) => void; + readonly onOpenProviderSetup: (instanceId: ProviderInstanceId) => void; }) { const session = useThreadSettingsSession(); + const config = useAtomValue(serverEnvironment.configValueAtom(session.environmentId)); const catalogItems = useThreadSettingsCatalogItems(session); const [animationsReady, setAnimationsReady] = useState(false); const nativeHeaderHeight = use(HeaderHeightContext) ?? 0; const hasActiveCatalogFilter = session.providerFilter !== null || session.searchQuery.trim().length > 0; const usesTransparentNativeHeader = Platform.OS === "ios" && NATIVE_LIQUID_GLASS_SUPPORTED; + const setupProviders = useMemo( + () => + providerSetupCandidates({ + providers: config?.providers ?? [], + instanceId: session.providerInstanceId, + providerFilter: session.providerFilter, + query: session.searchQuery, + }), + [config?.providers, session.providerInstanceId, session.providerFilter, session.searchQuery], + ); const listItems = useMemo>( () => [ - ...(catalogItems.length === 0 && hasActiveCatalogFilter - ? ([{ kind: "empty", key: "empty" }] as const) - : catalogItems), + ...(catalogItems.length === 0 ? ([{ kind: "empty", key: "empty" }] as const) : catalogItems), + ...setupProviders.map((provider) => ({ + kind: "setup" as const, + key: `setup:${provider.instanceId}`, + provider, + })), { kind: "options", key: "options" }, ], - [catalogItems, hasActiveCatalogFilter], + [catalogItems, setupProviders], ); const renderCatalogItem = useCallback( (itemProps: LegendListRenderItemProps) => { @@ -792,10 +842,19 @@ function ThreadSettingsMainContent(props: { option={item.option} /> ); + } else if (item.kind === "setup") { + content = ( + props.onOpenProviderSetup(item.provider.instanceId)} + /> + ); } else if (item.kind === "empty") { content = ( - No matching models + + {hasActiveCatalogFilter ? "No matching models" : "No available models"} + ); } else { @@ -817,7 +876,7 @@ function ThreadSettingsMainContent(props: { ); }, - [animationsReady, props.onOpenSubmenu], + [animationsReady, hasActiveCatalogFilter, props.onOpenProviderSetup, props.onOpenSubmenu], ); return ( @@ -943,6 +1002,7 @@ function ThreadSettingsChoiceContent(props: { type ThreadSettingsPickerStackParams = { ThreadSettingsModels: undefined; ThreadSettingsChoice: ThreadSettingsSubmenuPage & { readonly title: string }; + ThreadSettingsProviderSetup: ProviderSetupRouteParams; }; type ThreadSettingsPickerPresentation = { @@ -987,7 +1047,7 @@ function ThreadSettingsModelsScreen() { }); }, [isRefreshingProviders, refreshProviderCatalog, session.environmentId]); const commitAndClose = useCallback(() => { - session.commitPendingModel(); + if (!session.commitPendingModel()) return; presentation.onClose(); }, [presentation, session]); const filterMenu = useMemo( @@ -1086,6 +1146,13 @@ function ThreadSettingsModelsScreen() { }} /> { + if (!session.environmentId) return; + navigation.navigate("ThreadSettingsProviderSetup", { + environmentId: session.environmentId, + instanceId, + }); + }} onOpenSubmenu={(submenu) => { const title = submenu.kind === "runtime" @@ -1220,6 +1287,11 @@ function ThreadSettingsPickerNavigator(props: ThreadSettingsPickerPresentation) component={ThreadSettingsChoiceScreen} options={({ route }) => ({ title: route.params.title })} /> + ); 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/legacy-plan-mode.test.ts b/apps/mobile/src/features/threads/legacy-plan-mode.test.ts index e556318855ff..8494d626804c 100644 --- a/apps/mobile/src/features/threads/legacy-plan-mode.test.ts +++ b/apps/mobile/src/features/threads/legacy-plan-mode.test.ts @@ -1,8 +1,43 @@ import { describe, expect, it } from "@effect/vitest"; -import { resolvePendingTaskInteractionMode } from "./legacy-plan-mode"; +import { + resolvePendingTaskInteractionMode, + resolveProviderInteractionMode, +} from "./legacy-plan-mode"; + +describe("resolveProviderInteractionMode", () => { + it("clears saved plan mode when the provider cannot use T3 interaction modes", () => { + expect(resolveProviderInteractionMode({ showInteractionModeToggle: false }, "plan")).toBe( + "default", + ); + }); + + it("keeps supported choices and remains compatible with older server status", () => { + expect(resolveProviderInteractionMode({ showInteractionModeToggle: true }, "plan")).toBe( + "plan", + ); + expect(resolveProviderInteractionMode({}, "plan")).toBe("plan"); + expect(resolveProviderInteractionMode(null, "plan")).toBe("plan"); + expect(resolveProviderInteractionMode(undefined, undefined)).toBe("default"); + }); +}); describe("resolvePendingTaskInteractionMode", () => { + it.each([false, true])( + "clears a queued unsupported plan mode with preferenceLoaded=%s", + (preferenceLoaded) => { + expect( + resolvePendingTaskInteractionMode({ + preferenceLoaded, + planModeEnabled: true, + draftInteractionMode: "plan", + queuedInteractionMode: "plan", + provider: { showInteractionModeToggle: false }, + }), + ).toBe("default"); + }, + ); + it("preserves a queued plan task while the preference is still loading", () => { expect( resolvePendingTaskInteractionMode({ diff --git a/apps/mobile/src/features/threads/legacy-plan-mode.ts b/apps/mobile/src/features/threads/legacy-plan-mode.ts index e7122125fb58..55fcd896c5cd 100644 --- a/apps/mobile/src/features/threads/legacy-plan-mode.ts +++ b/apps/mobile/src/features/threads/legacy-plan-mode.ts @@ -1,8 +1,21 @@ import { DEFAULT_PROVIDER_INTERACTION_MODE, type ProviderInteractionMode, + type ServerProvider, } from "@t3tools/contracts"; +type InteractionModeProvider = Pick; + +/** Normalize saved T3 mode choices without changing native slash commands. */ +export function resolveProviderInteractionMode( + provider: InteractionModeProvider | null | undefined, + interactionMode: ProviderInteractionMode | null | undefined, +): ProviderInteractionMode { + return provider?.showInteractionModeToggle === false + ? DEFAULT_PROVIDER_INTERACTION_MODE + : (interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE); +} + export function resolveLegacyPlanModeEnabled(input: { readonly loaded: boolean; readonly preference: boolean | undefined; @@ -15,7 +28,11 @@ export function resolvePendingTaskInteractionMode(input: { readonly planModeEnabled: boolean; readonly draftInteractionMode: ProviderInteractionMode | undefined; readonly queuedInteractionMode: ProviderInteractionMode | undefined; + readonly provider?: InteractionModeProvider | null; }): ProviderInteractionMode { + if (input.provider?.showInteractionModeToggle === false) { + return DEFAULT_PROVIDER_INTERACTION_MODE; + } if (input.planModeEnabled) { return input.draftInteractionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE; } diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 792bb143a834..6d87f284ebda 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -83,7 +83,10 @@ import { type HomeProjectScope, } from "../home/homeThreadList"; import { useMobileProjectGroupingSettings } from "../../state/project-grouping"; -import { resolvePendingTaskInteractionMode } from "./legacy-plan-mode"; +import { + resolvePendingTaskInteractionMode, + resolveProviderInteractionMode, +} from "./legacy-plan-mode"; import { useLegacyPlanModeState } from "./use-legacy-plan-mode-enabled"; import { resolveNewTaskBranchWorktreePath, @@ -202,7 +205,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { const threads = useThreadShells(); const { savedConnectionsById } = useSavedRemoteConnections(); const groupingSettings = useMobileProjectGroupingSettings(); - const { enabled: planModeEnabled, loaded: planModePreferenceLoaded } = useLegacyPlanModeState(); + const { enabled: legacyPlanModeEnabled, loaded: planModePreferenceLoaded } = + useLegacyPlanModeState(); const projectScopes = useMemo( () => sortHomeProjectScopes({ @@ -412,15 +416,10 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { selectedEnvironmentServerConfig?.settings.newWorktreesStartFromOrigin ?? true; const runtimeMode = selectedProjectDraft.runtimeMode ?? DEFAULT_RUNTIME_MODE; - const interactionMode = planModeEnabled - ? (selectedProjectDraft.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE) - : DEFAULT_PROVIDER_INTERACTION_MODE; - // Stored selections only count while their provider is usable on the - // server; otherwise the server's default model wins instead of silently - // targeting a disabled provider. The draft selection is an explicit pick - // and passes through as-is; the project default (last used, possibly from - // desktop) is implicit and additionally never resolves to a legacy model. + // Antigravity keeps unavailable selections so sign-out or a catalog change + // cannot switch the user's model. Other providers retain their fallback + // rules. Implicit defaults also exclude legacy models for those providers. const draftModelSelection = resolveSelectableModelSelection( selectedEnvironmentServerConfig, selectedProjectDraft.modelSelection ?? null, @@ -474,6 +473,11 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { ) ?? null, [selectedEnvironmentServerConfig, selectedModel?.instanceId], ); + const planModeEnabled = + legacyPlanModeEnabled && selectedProviderStatus?.showInteractionModeToggle !== false; + const interactionMode = planModeEnabled + ? (selectedProjectDraft.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE) + : DEFAULT_PROVIDER_INTERACTION_MODE; const setSelectedModelKey = useCallback( // Options ride along in the same write: a follow-up setSelectedModelOptions // call would rebuild the selection from the stale pre-switch model. @@ -486,10 +490,18 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { return; } const selection = options ? { ...option.selection, options } : option.selection; - updateComposerDraftSettings(selectedProjectDraftKey, { modelSelection: selection }); + const provider = selectedEnvironmentServerConfig?.providers.find( + (candidate) => candidate.instanceId === selection.instanceId, + ); + updateComposerDraftSettings(selectedProjectDraftKey, { + modelSelection: selection, + ...(provider?.showInteractionModeToggle === false + ? { interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE } + : {}), + }); setStickyComposerModelSelection(selection); }, - [modelOptions, selectedProjectDraftKey], + [modelOptions, selectedEnvironmentServerConfig, selectedProjectDraftKey], ); const setSelectedModelOptions = useCallback( (options: ReadonlyArray | undefined) => { @@ -813,10 +825,12 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { const setInteractionMode = useCallback( (value: ProviderInteractionMode) => { if (selectedProjectDraftKey) { - updateComposerDraftSettings(selectedProjectDraftKey, { interactionMode: value }); + updateComposerDraftSettings(selectedProjectDraftKey, { + interactionMode: resolveProviderInteractionMode(selectedProviderStatus, value), + }); } }, - [selectedProjectDraftKey], + [selectedProjectDraftKey, selectedProviderStatus], ); const beginEditingPendingTask = useCallback((messageId: string): boolean => { @@ -859,8 +873,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { } const draft = getComposerDraftSnapshot(selectedProjectDraftKey); const text = draft.text.trim(); - // Same availability gate the composer display applies: a stored - // selection targeting a disabled provider must not ride into the queue. + // Use the displayed selection rules without substituting an unavailable + // Antigravity model while the task is queued. const draftModelSelection = resolveSelectableModelSelection( selectedEnvironmentServerConfig, @@ -895,9 +909,12 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { runtimeMode: draft.runtimeMode ?? DEFAULT_RUNTIME_MODE, interactionMode: resolvePendingTaskInteractionMode({ preferenceLoaded: planModePreferenceLoaded, - planModeEnabled, + planModeEnabled: legacyPlanModeEnabled, draftInteractionMode: draft.interactionMode, queuedInteractionMode: editingPendingTask?.interactionMode, + provider: selectedEnvironmentServerConfig?.providers.find( + (candidate) => candidate.instanceId === draftModelSelection.instanceId, + ), }), creation: { projectId: selectedProject.id, @@ -927,7 +944,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { selectedModel, selectedProject, selectedProjectDraftKey, - planModeEnabled, + legacyPlanModeEnabled, planModePreferenceLoaded, startFromOrigin, workspaceMode, diff --git a/apps/mobile/src/features/threads/provider-catalog-refresh.test.ts b/apps/mobile/src/features/threads/provider-catalog-refresh.test.ts index 565a54074400..281452364b76 100644 --- a/apps/mobile/src/features/threads/provider-catalog-refresh.test.ts +++ b/apps/mobile/src/features/threads/provider-catalog-refresh.test.ts @@ -9,7 +9,7 @@ import { } from "./provider-catalog-refresh"; describe("mobile provider catalog refresh", () => { - it("calls server discovery for the selected environment and deduplicates pending taps", async () => { + it("requests model discovery for the selected environment and deduplicates pending taps", async () => { let resolveRefresh: ((value: "refreshed") => void) | undefined; const refreshProviders = vi.fn( () => @@ -25,7 +25,10 @@ describe("mobile provider catalog refresh", () => { expect(second).toBe(first); expect(refreshProviders).toHaveBeenCalledOnce(); - expect(refreshProviders).toHaveBeenCalledWith({ environmentId, input: {} }); + expect(refreshProviders).toHaveBeenCalledWith({ + environmentId, + input: { refreshModels: true }, + }); resolveRefresh?.("refreshed"); await expect(first).resolves.toBe("refreshed"); diff --git a/apps/mobile/src/features/threads/provider-catalog-refresh.ts b/apps/mobile/src/features/threads/provider-catalog-refresh.ts index 3e79a5b1c7b5..9c0078b03280 100644 --- a/apps/mobile/src/features/threads/provider-catalog-refresh.ts +++ b/apps/mobile/src/features/threads/provider-catalog-refresh.ts @@ -7,10 +7,10 @@ import { type RefreshProvidersTarget = { readonly environmentId: EnvironmentId; - readonly input: Record; + readonly input: { readonly refreshModels: true }; }; -/** Deduplicates taps while the server refresh command is still running. */ +/** Explicit model-picker refresh. Repeated taps share the pending discovery. */ export function createProviderCatalogRefreshRunner( refreshProviders: (target: RefreshProvidersTarget) => Promise, ) { @@ -18,7 +18,7 @@ export function createProviderCatalogRefreshRunner( return (environmentId: EnvironmentId): Promise => { if (pending) return pending; - pending = refreshProviders({ environmentId, input: {} }).finally(() => { + pending = refreshProviders({ environmentId, input: { refreshModels: true } }).finally(() => { pending = null; }); return pending; 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 ? ( { }), ).toBe(pressed); }); + + it("cannot save a staged model after sign-out removes it from the catalog", () => { + const pending = modelOption("gemini-native"); + const group = { providerKey: "codex", providerLabel: "Codex", models: [pending] }; + + expect(canCommitPendingModel(pending, [group])).toBe(true); + expect(canCommitPendingModel(pending, [])).toBe(false); + expect( + canCommitPendingModel(pending, [ + { + ...group, + models: [{ ...pending, isUnavailable: true }], + }, + ]), + ).toBe(false); + }); +}); + +const decodeServerProvider = Schema.decodeSync(ServerProvider); + +function setupProvider(overrides: Partial = {}): ServerProvider { + return decodeServerProvider({ + instanceId: "antigravity", + driver: "antigravity", + displayName: "Antigravity", + enabled: false, + installed: false, + version: null, + status: "disabled", + auth: { status: "unauthenticated" }, + checkedAt: "2026-09-02T00:00:00.000Z", + setup: { canAuthenticate: true, canInstall: true }, + models: [], + ...overrides, + }); +} + +describe("providerSetupCandidates", () => { + const unfiltered = { providerFilter: null, query: "" }; + + it("offers setup without a selectable model and after sign-out", () => { + const disabled = setupProvider(); + const signedOut = setupProvider({ enabled: true, installed: true }); + + expect(providerSetupCandidates({ providers: [disabled], ...unfiltered })).toEqual([disabled]); + expect(providerSetupCandidates({ providers: [signedOut], ...unfiltered })).toEqual([signedOut]); + }); + + it("uses the selected environment's status for identical instance IDs", () => { + const offlineAccount = setupProvider(); + const readyAccount = setupProvider({ + enabled: true, + installed: true, + auth: { status: "authenticated" }, + models: [{ slug: "gemini-native", name: "Gemini", isCustom: false, capabilities: null }], + }); + + expect(providerSetupCandidates({ providers: [offlineAccount], ...unfiltered })).toHaveLength(1); + expect(providerSetupCandidates({ providers: [readyAccount], ...unfiltered })).toEqual([]); + }); + + it("limits existing threads to their provider and respects search", () => { + const personal = setupProvider(); + const work = setupProvider({ + instanceId: ProviderInstanceId.make("google_work"), + displayName: "Work Google", + }); + + expect( + providerSetupCandidates({ + providers: [personal, work], + ...unfiltered, + instanceId: work.instanceId, + }), + ).toEqual([work]); + expect( + providerSetupCandidates({ + providers: [personal, work], + providerFilter: work.instanceId, + query: "work", + }), + ).toEqual([work]); + expect( + providerSetupCandidates({ + providers: [personal, work], + providerFilter: null, + query: "no-match", + }), + ).toEqual([]); + }); }); diff --git a/apps/mobile/src/features/threads/thread-settings-sheet-state.ts b/apps/mobile/src/features/threads/thread-settings-sheet-state.ts index 1e417b925d9e..1b545098c53f 100644 --- a/apps/mobile/src/features/threads/thread-settings-sheet-state.ts +++ b/apps/mobile/src/features/threads/thread-settings-sheet-state.ts @@ -1,4 +1,26 @@ -import type { ModelOption } from "../../lib/modelOptions"; +import type { ProviderInstanceId, ServerProvider } from "@t3tools/contracts"; +import type { ModelOption, ProviderGroup } from "../../lib/modelOptions"; +import { providerNeedsSetup } from "../settings/provider-setup-state"; + +/** Read setup choices from this environment, not the selectable model list. */ +export function providerSetupCandidates(input: { + readonly providers: ReadonlyArray; + readonly instanceId?: ProviderInstanceId; + readonly providerFilter: string | null; + readonly query: string; +}): ReadonlyArray { + const query = input.query.trim().toLocaleLowerCase(); + return input.providers.filter( + (provider) => + providerNeedsSetup(provider) && + (input.instanceId === undefined || provider.instanceId === input.instanceId) && + (input.providerFilter === null || provider.instanceId === input.providerFilter) && + (query.length === 0 || + [provider.displayName ?? "", provider.driver, provider.instanceId].some((label) => + label.toLocaleLowerCase().includes(query), + )), + ); +} /** Match the terms a user can actually see or recognize in the model picker. */ export function modelMatchesCatalogQuery(input: { @@ -31,6 +53,16 @@ export function pendingModelAfterPress(input: { return input.current?.key === input.pressed.key ? input.current : input.pressed; } +/** A model can disappear while its setup page is open inside the picker. */ +export function canCommitPendingModel( + pending: ModelOption, + groups: ReadonlyArray, +): boolean { + return groups.some((group) => + group.models.some((model) => model.key === pending.key && !model.isUnavailable), + ); +} + /** * Primary and selected providers start open; all other catalogs start closed. * A user's disclosure tap inverts that default until the picker is dismissed. diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.test.ts b/apps/mobile/src/features/threads/use-composer-command-menu.test.ts index 9684de05c80a..5ae248b6cde8 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.test.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from "vite-plus/test"; +import { ProviderDriverKind } from "@t3tools/contracts"; vi.mock("../../state/queries", () => ({ useComposerPathSearch: () => ({ entries: [], isPending: false }), @@ -10,10 +11,93 @@ vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => vi.fn(), })); -import { composerSelectionAtEnd } from "./use-composer-command-menu"; +import { + buildComposerSlashCommandItems, + composerSelectionAtEnd, + resolveComposerCommandSelection, +} from "./use-composer-command-menu"; describe("composerSelectionAtEnd", () => { it("resets a changed draft owner to the new draft end", () => { expect(composerSelectionAtEnd("queued task 🧪")).toEqual({ start: 14, end: 14 }); }); }); + +describe("mobile slash commands", () => { + const antigravity = { + driver: ProviderDriverKind.make("antigravity"), + showInteractionModeToggle: false, + slashCommands: [{ name: "plan", description: "Plan with Antigravity" }], + }; + + it.each([false, true])( + "keeps native /plan with legacy mode enabled=%s", + (allowInteractionMode) => { + const items = buildComposerSlashCommandItems({ + query: "pl", + atMessageStart: true, + hasThread: true, + allowInteractionMode, + selectedProviderStatus: antigravity, + }); + + expect(items).toHaveLength(1); + expect(items[0]?.type).toBe("provider-slash-command"); + const item = items[0]; + if (!item) throw new Error("Expected the native plan command"); + expect( + resolveComposerCommandSelection({ + draftMessage: "/pl", + trigger: { rangeStart: 0, rangeEnd: 3 }, + item, + allowInteractionMode, + }), + ).toEqual({ text: "/plan ", cursor: 6, interactionMode: null }); + }, + ); + + it("does not offer a native command inside the message", () => { + expect( + buildComposerSlashCommandItems({ + query: "plan", + atMessageStart: false, + hasThread: false, + allowInteractionMode: true, + selectedProviderStatus: antigravity, + }), + ).toEqual([]); + }); + + it("still applies the T3 plan command for supported providers", () => { + const items = buildComposerSlashCommandItems({ + query: "plan", + atMessageStart: true, + hasThread: true, + allowInteractionMode: true, + selectedProviderStatus: { + driver: ProviderDriverKind.make("codex"), + slashCommands: [], + }, + }); + const item = items[0]; + if (!item) throw new Error("Expected the T3 plan command"); + expect( + resolveComposerCommandSelection({ + draftMessage: "/plan", + trigger: { rangeStart: 0, rangeEnd: 5 }, + item, + allowInteractionMode: true, + }), + ).toEqual({ text: "", cursor: 0, interactionMode: "plan" }); + + // A provider switch can invalidate an open menu before a tap arrives. + expect( + resolveComposerCommandSelection({ + draftMessage: "/plan", + trigger: { rangeStart: 0, rangeEnd: 5 }, + item, + allowInteractionMode: false, + }), + ).toEqual({ text: "/plan ", cursor: 6, interactionMode: null }); + }); +}); diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.ts b/apps/mobile/src/features/threads/use-composer-command-menu.ts index 9de5ffae724c..3cf762b4a150 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.ts @@ -3,6 +3,7 @@ import { detectComposerTrigger, replaceTextRange, serializeComposerFileLink, + type ComposerTrigger, } from "@t3tools/shared/composerTrigger"; import { insertRankedSearchResult, @@ -30,6 +31,107 @@ export function composerSelectionAtEnd(draftMessage: string): ComposerEditorSele return { start: draftMessage.length, end: draftMessage.length }; } +export function buildComposerSlashCommandItems(input: { + readonly query: string; + readonly atMessageStart: boolean; + readonly hasThread: boolean; + readonly allowInteractionMode: boolean; + readonly selectedProviderStatus: Pick< + ServerProvider, + "driver" | "slashCommands" | "showInteractionModeToggle" + > | null; +}): ComposerCommandItem[] { + const query = input.query.toLowerCase(); + const allowInteractionMode = + input.allowInteractionMode && input.selectedProviderStatus?.showInteractionModeToggle !== false; + const builtIn = [ + { + id: "cmd:model", + type: "slash-command", + command: "model", + label: "/model", + description: "Switch model", + }, + { + id: "cmd:plan", + type: "slash-command", + command: "plan", + label: "/plan", + description: "Switch to plan mode", + }, + { + id: "cmd:default", + type: "slash-command", + command: "default", + label: "/default", + description: "Switch to default mode", + }, + ] satisfies ComposerCommandItem[]; + const items: ComposerCommandItem[] = builtIn.filter( + (item) => item.command.includes(query) && (item.command === "model" || allowInteractionMode), + ); + + // Providers expand commands only at the start of a message. T3 commands + // change local state and do not have this restriction. + if (!input.atMessageStart) return items; + for (const command of input.selectedProviderStatus?.slashCommands ?? []) { + if (!command.name.toLowerCase().includes(query)) continue; + if ( + !input.hasThread && + input.selectedProviderStatus?.driver === "codex" && + command.name === "feedback" + ) { + continue; + } + items.push({ + id: `pcmd:${command.name}`, + type: "provider-slash-command", + command, + label: `/${command.name}`, + description: command.description ?? "", + }); + } + return items; +} + +export function resolveComposerCommandSelection(input: { + readonly draftMessage: string; + readonly trigger: Pick; + readonly item: ComposerCommandItem; + readonly allowInteractionMode: boolean; +}): { + readonly text: string; + readonly cursor: number; + readonly interactionMode: ProviderInteractionMode | null; +} { + const { draftMessage, trigger, item } = input; + if ( + input.allowInteractionMode && + item.type === "slash-command" && + (item.command === "plan" || item.command === "default") + ) { + return { + ...replaceTextRange(draftMessage, trigger.rangeStart, trigger.rangeEnd, ""), + interactionMode: item.command, + }; + } + + let replacement = ""; + if (item.type === "path") { + replacement = `${serializeComposerFileLink(item.path)} `; + } else if (item.type === "skill") { + replacement = `$${item.skill.name} `; + } else if (item.type === "slash-command") { + replacement = `/${item.command} `; + } else if (item.type === "provider-slash-command") { + replacement = `/${item.command.name} `; + } + return { + ...replaceTextRange(draftMessage, trigger.rangeStart, trigger.rangeEnd, replacement), + interactionMode: null, + }; +} + /** Shared autocomplete for thread composers and unsent new-task drafts. */ export function useComposerCommandMenu({ draftMessage, @@ -79,7 +181,6 @@ export function useComposerCommandMenu({ selectedProviderStatus ? resolveProviderSkillsForCwd(selectedProviderStatus, projectCwd) : [], [projectCwd, selectedProviderStatus], ); - const slashCommands = selectedProviderStatus?.slashCommands ?? []; const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, { reportFailure: false, }); @@ -157,59 +258,13 @@ export function useComposerCommandMenu({ if (trigger.kind === "slash-command") { const q = trigger.query.toLowerCase(); - const allBuiltIn = [ - { - id: "cmd:model", - type: "slash-command" as const, - command: "model", - label: "/model", - description: "Switch model", - }, - { - id: "cmd:plan", - type: "slash-command" as const, - command: "plan", - label: "/plan", - description: "Switch to plan mode", - }, - { - id: "cmd:default", - type: "slash-command" as const, - command: "default", - label: "/default", - description: "Switch to default mode", - }, - ]; - const builtIn = allBuiltIn.filter( - (item) => - item.command.includes(q) && - (item.command === "model" || onUpdateInteractionMode !== undefined), - ); - - // A provider expands a slash command only when it opens the whole - // message; elsewhere it arrives as literal text. Built-ins apply - // locally and skills insert a `$` mention the server dispatches from - // any position, so only provider commands are position-gated. - const providerCommands: ComposerCommandItem[] = []; - const expandableCommands = trigger.rangeStart === 0 ? slashCommands : []; - for (const command of expandableCommands) { - if (!command.name.toLowerCase().includes(q)) continue; - // Codex feedback uploads an existing thread's session and logs. - if ( - !hasThread && - selectedProviderStatus?.driver === "codex" && - command.name === "feedback" - ) { - continue; - } - providerCommands.push({ - id: `pcmd:${command.name}`, - type: "provider-slash-command", - command, - label: `/${command.name}`, - description: command.description ?? "", - }); - } + const commandItems = buildComposerSlashCommandItems({ + query: q, + atMessageStart: trigger.rangeStart === 0, + hasThread, + allowInteractionMode: onUpdateInteractionMode !== undefined, + selectedProviderStatus, + }); const skillItems = getProviderSkillsForSlashMenu(skills, true) .filter((skill) => matchesSlashSkillQuery(skill, q)) @@ -221,7 +276,7 @@ export function useComposerCommandMenu({ description: skill.shortDescription ?? skill.description ?? "", })); - return [...builtIn, ...providerCommands, ...skillItems]; + return [...commandItems, ...skillItems]; } if (trigger.kind === "skill") { @@ -328,7 +383,6 @@ export function useComposerCommandMenu({ pathSearch.entries, selectedProviderStatus, skills, - slashCommands, trigger, ]); @@ -336,38 +390,27 @@ export function useComposerCommandMenu({ (item: ComposerCommandItem) => { if (!trigger) return; - if ( - item.type === "slash-command" && - (item.command === "plan" || item.command === "default") - ) { - const result = replaceTextRange(draftMessage, trigger.rangeStart, trigger.rangeEnd, ""); - setSelection({ start: result.cursor, end: result.cursor }); - onChangeDraftMessage(result.text); - onUpdateInteractionMode?.(item.command); - return; - } - - let replacement = ""; - if (item.type === "path") { - replacement = `${serializeComposerFileLink(item.path)} `; - } else if (item.type === "skill") { - replacement = `$${item.skill.name} `; - } else if (item.type === "slash-command") { - replacement = `/${item.command} `; - } else if (item.type === "provider-slash-command") { - replacement = `/${item.command.name} `; - } - - const result = replaceTextRange( + const result = resolveComposerCommandSelection({ draftMessage, - trigger.rangeStart, - trigger.rangeEnd, - replacement, - ); + trigger, + item, + allowInteractionMode: + onUpdateInteractionMode !== undefined && + selectedProviderStatus?.showInteractionModeToggle !== false, + }); setSelection({ start: result.cursor, end: result.cursor }); onChangeDraftMessage(result.text); + if (result.interactionMode !== null) { + onUpdateInteractionMode?.(result.interactionMode); + } }, - [draftMessage, onChangeDraftMessage, onUpdateInteractionMode, trigger], + [ + draftMessage, + onChangeDraftMessage, + onUpdateInteractionMode, + selectedProviderStatus?.showInteractionModeToggle, + trigger, + ], ); return { diff --git a/apps/mobile/src/features/threads/use-project-actions.ts b/apps/mobile/src/features/threads/use-project-actions.ts index e9722e7db49c..1aa4618f2c64 100644 --- a/apps/mobile/src/features/threads/use-project-actions.ts +++ b/apps/mobile/src/features/threads/use-project-actions.ts @@ -19,12 +19,14 @@ import { prepareTurnAttachments, validateDraftFileAttachments } from "../../lib/ import { makeTurnCommandMetadata, type TurnCommandMetadata } from "../../lib/commandMetadata"; import { buildProjectThreadStartTurnInput } from "../../lib/projectThreadStartTurn"; import { randomHex } from "../../lib/uuid"; +import { isModelSelectionUnavailable } from "../../lib/modelOptions"; import { useAtomCommand } from "../../state/use-atom-command"; import { scheduleUnusedComposerAttachmentCleanup } from "../../state/use-composer-drafts"; import { setPendingConnectionError } from "../../state/use-remote-environment-registry"; import { validateProjectThreadCreation } from "./projectThreadCreationValidation"; import { appAtomRegistry } from "../../state/atom-registry"; import { serverEnvironment } from "../../state/server"; +import { resolveProviderInteractionMode } from "./legacy-plan-mode"; export function useCreateProjectThread() { const startTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false }); @@ -110,6 +112,22 @@ export function useCreateProjectThread() { return AsyncResult.failure(Cause.fail(new Error(preparedAttachmentError))); } + const serverConfig = appAtomRegistry.get( + serverEnvironment.configValueAtom(input.project.environmentId), + ); + const providerError = !serverConfig + ? "Provider settings are still loading. Try again." + : isModelSelectionUnavailable(serverConfig, input.modelSelection) + ? "Antigravity model unavailable. Open model settings to finish setup or choose another model." + : null; + if (providerError !== null) { + setPendingConnectionError(providerError); + return AsyncResult.failure(Cause.fail(new Error(providerError))); + } + const provider = serverConfig?.providers.find( + (candidate) => candidate.instanceId === input.modelSelection.instanceId, + ); + const result = await startTurn({ environmentId: input.project.environmentId, input: buildProjectThreadStartTurnInput({ @@ -124,7 +142,7 @@ export function useCreateProjectThread() { uploadedAttachments: prepared.attachments, modelSelection: input.modelSelection, runtimeMode: input.runtimeMode, - interactionMode: input.interactionMode, + interactionMode: resolveProviderInteractionMode(provider, input.interactionMode), workspaceMode: input.envMode, branch: input.branch, worktreePath: input.worktreePath, diff --git a/apps/mobile/src/lib/copyTextWithHaptic.test.ts b/apps/mobile/src/lib/copyTextWithHaptic.test.ts index a9e8cb049fdb..9a6b1a8910de 100644 --- a/apps/mobile/src/lib/copyTextWithHaptic.test.ts +++ b/apps/mobile/src/lib/copyTextWithHaptic.test.ts @@ -18,12 +18,7 @@ vi.mock("expo-haptics", () => ({ selectionAsync: mocks.selectionAsync, })); -import { - CopyTextClipboardWriteError, - CopyTextHapticFeedbackError, - copyTextWithHaptic, - tryCopyTextWithHaptic, -} from "./copyTextWithHaptic"; +import { copyTextWithHaptic, tryCopyTextWithHaptic } from "./copyTextWithHaptic"; describe("copyTextWithHaptic", () => { beforeEach(() => { @@ -69,36 +64,29 @@ describe("copyTextWithHaptic", () => { }); it("reports structured failures without including clipboard contents", async () => { - const clipboardCause = new Error("native clipboard failure"); - const hapticCause = new Error("native haptic failure"); + const content = "https://accounts.google.com/auth?state=private-state&code=private-code"; + const clipboardCause = new Error(`Cannot copy ${content}`); + const hapticCause = new Error(`Native failure for ${content}`); const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); mocks.setStringAsync.mockRejectedValueOnce(clipboardCause); mocks.impactAsync.mockRejectedValueOnce(hapticCause); - copyTextWithHaptic("secret clipboard contents", { target: "connection-trace-id" }); + await tryCopyTextWithHaptic(content, { target: "provider-sign-in-link" }); - await vi.waitFor(() => { - expect(consoleError).toHaveBeenCalledTimes(2); - }); - - const failures = consoleError.mock.calls.map(([failure]) => failure); - const clipboardError = failures.find( - (failure) => failure instanceof CopyTextClipboardWriteError, + expect(consoleError).toHaveBeenCalledWith( + "Failed to copy provider-sign-in-link to the clipboard.", + expect.objectContaining({ + _tag: "CopyTextClipboardWriteError", + target: "provider-sign-in-link", + }), ); - expect(clipboardError).toBeInstanceOf(CopyTextClipboardWriteError); - expect(clipboardError).toMatchObject({ - target: "connection-trace-id", - cause: clipboardCause, - }); - expect((clipboardError as Error).message).not.toContain("secret clipboard contents"); - - const hapticError = failures.find((failure) => failure instanceof CopyTextHapticFeedbackError); - expect(hapticError).toBeInstanceOf(CopyTextHapticFeedbackError); - expect(hapticError).toMatchObject({ - target: "connection-trace-id", - feedback: "light-impact", - cause: hapticCause, - }); - expect((hapticError as Error).message).not.toContain("secret clipboard contents"); + expect(consoleError).toHaveBeenCalledWith( + "Failed to trigger light-impact haptic feedback after copying provider-sign-in-link.", + expect.objectContaining({ _tag: "CopyTextHapticFeedbackError", feedback: "light-impact" }), + ); + const diagnostics = JSON.stringify(consoleError.mock.calls); + expect(diagnostics).not.toContain("private-state"); + expect(diagnostics).not.toContain("private-code"); + expect(diagnostics).not.toContain("cause"); }); }); diff --git a/apps/mobile/src/lib/copyTextWithHaptic.ts b/apps/mobile/src/lib/copyTextWithHaptic.ts index 3a7da03b5ea3..679db441aea9 100644 --- a/apps/mobile/src/lib/copyTextWithHaptic.ts +++ b/apps/mobile/src/lib/copyTextWithHaptic.ts @@ -44,12 +44,8 @@ export async function tryCopyTextWithHaptic( await Clipboard.setStringAsync(value); return true; } catch (cause) { - console.error( - new CopyTextClipboardWriteError({ - target, - cause, - }), - ); + const error = new CopyTextClipboardWriteError({ target, cause }); + console.error(error.message, { _tag: error._tag, target, stack: error.stack }); return false; } })(); @@ -62,13 +58,8 @@ export async function tryCopyTextWithHaptic( await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); } } catch (cause) { - console.error( - new CopyTextHapticFeedbackError({ - target, - feedback, - cause, - }), - ); + const error = new CopyTextHapticFeedbackError({ target, feedback, cause }); + console.error(error.message, { _tag: error._tag, target, feedback, stack: error.stack }); } })(); diff --git a/apps/mobile/src/lib/modelOptions.test.ts b/apps/mobile/src/lib/modelOptions.test.ts index c507672fb245..98896b990ec4 100644 --- a/apps/mobile/src/lib/modelOptions.test.ts +++ b/apps/mobile/src/lib/modelOptions.test.ts @@ -5,6 +5,7 @@ import { ProviderInstanceId, type ModelSelection, type ServerConfig } from "@t3t import { buildModelOptions, groupByProvider, + isModelSelectionUnavailable, resolveDefaultableModelSelection, resolveNewTaskModelSelection, resolveSelectableModelSelection, @@ -191,10 +192,153 @@ describe("mobile model options", () => { expect(resolveSelectableModelSelection(config, usable)).toBe(usable); expect(resolveSelectableModelSelection(config, disabled)).toBeNull(); expect(resolveSelectableModelSelection(config, removed)).toBeNull(); - // No config (environment offline) — nothing to validate against. + expect(isModelSelectionUnavailable(config, disabled)).toBe(false); + // An offline environment has no config to validate. expect(resolveSelectableModelSelection(null, disabled)).toBe(disabled); }); + describe("Antigravity selections", () => { + const selection = { + instanceId: ProviderInstanceId.make("google_work"), + model: "gemini-3.1-pro-high", + options: [{ id: "native-option", value: "saved/opaque-choice" }], + }; + const model = { + slug: selection.model, + name: "Gemini 3.1 Pro High", + subProvider: "Google", + isCustom: false, + isDefault: true, + isLegacy: true, + capabilities: { + optionDescriptors: [ + { + id: "native-option", + label: "Native option", + type: "select", + options: [{ id: "current/default", label: "Default", isDefault: true }], + currentValue: "current/default", + }, + ], + }, + }; + const config = { + providers: [ + { + instanceId: selection.instanceId, + driver: "antigravity", + displayName: "Google Work", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + models: [model], + }, + ], + } as unknown as ServerConfig; + + it.each([ + ["disabled", { enabled: false }], + ["uninstalled", { installed: false }], + ["signed out", { auth: { status: "unauthenticated" } }], + ["unavailable", { availability: "unavailable" }], + ] as const)("keeps a %s provider's selection and known model details", (_state, update) => { + const unavailableConfig = { + ...config, + providers: config.providers.map((provider) => ({ ...provider, ...update })), + }; + + expect(resolveSelectableModelSelection(unavailableConfig, selection)).toBe(selection); + expect(resolveDefaultableModelSelection(unavailableConfig, selection)).toBe(selection); + expect(isModelSelectionUnavailable(unavailableConfig, selection)).toBe(true); + expect(buildModelOptions(unavailableConfig, null)).toEqual([]); + const [option] = buildModelOptions(unavailableConfig, selection); + expect(option).toMatchObject({ + key: `google_work:${selection.model}`, + label: model.name, + subtitle: "Google", + providerKey: "google_work", + providerLabel: "Google Work", + providerDriver: "antigravity", + isDefault: false, + isLegacy: true, + isUnavailable: true, + capabilities: model.capabilities, + }); + expect(option?.selection).toBe(selection); + }); + + it("keeps an exact selection when its model leaves and returns to the catalog", () => { + const changedConfig = { + ...config, + providers: config.providers.map((provider) => ({ + ...provider, + models: provider.models.map((model) => ({ ...model, slug: "gemini-3.1-pro-low" })), + })), + }; + + expect(resolveDefaultableModelSelection(changedConfig, selection)).toBe(selection); + expect(isModelSelectionUnavailable(changedConfig, selection)).toBe(true); + const options = buildModelOptions(changedConfig, selection); + const missing = options.find((option) => option.selection.model === selection.model); + expect(missing).toMatchObject({ + label: selection.model, + providerLabel: "Google Work", + providerDriver: "antigravity", + isUnavailable: true, + capabilities: null, + }); + expect(missing?.selection).toBe(selection); + expect( + resolveNewTaskModelSelection({ + draftSelection: null, + projectDefaultSelection: resolveDefaultableModelSelection(changedConfig, selection), + stickySelection: null, + modelOptions: options, + }), + ).toBe(selection); + + const [restored] = buildModelOptions(config, selection); + expect(isModelSelectionUnavailable(config, selection)).toBe(false); + expect(restored?.isUnavailable).not.toBe(true); + expect(restored?.selection).toBe(selection); + expect(resolveDefaultableModelSelection(config, selection)).toBe(selection); + expect(buildModelOptions(config, null)[0]?.selection.options).toBeUndefined(); + }); + + it("uses configured instance metadata when provider status is missing", () => { + const missingStatusConfig = { + providers: [], + settings: { + providerInstances: { + [selection.instanceId]: { driver: "antigravity", displayName: "Google Work" }, + }, + }, + } as unknown as ServerConfig; + + expect(resolveDefaultableModelSelection(missingStatusConfig, selection)).toBe(selection); + expect(isModelSelectionUnavailable(missingStatusConfig, selection)).toBe(true); + expect(buildModelOptions(missingStatusConfig, selection)).toMatchObject([ + { + providerDriver: "antigravity", + providerLabel: "Google Work", + isUnavailable: true, + selection, + }, + ]); + }); + + it("keeps offline selections without assuming that an unknown instance is Antigravity", () => { + const unknownConfig = { ...config, providers: [] }; + + expect(resolveDefaultableModelSelection(null, selection)).toBe(selection); + expect(isModelSelectionUnavailable(null, selection)).toBe(false); + expect(buildModelOptions(null, selection)[0]?.selection).toBe(selection); + expect(buildModelOptions(null, selection)[0]?.isUnavailable).not.toBe(true); + expect(isModelSelectionUnavailable(unknownConfig, selection)).toBe(false); + expect(resolveSelectableModelSelection(unknownConfig, selection)).toBeNull(); + }); + }); + it("keeps legacy models out of implicit defaults", () => { const config = { providers: [ @@ -253,5 +397,15 @@ describe("mobile model options", () => { expect(resolve(null, project, sticky)).toBe(project); expect(resolve(null, null, sticky)).toBe(sticky); expect(resolve(null, null, null)).toBe(providerDefault.selection); + + const unavailable = { ...providerDefault, isUnavailable: true }; + expect( + resolveNewTaskModelSelection({ + draftSelection: null, + projectDefaultSelection: null, + stickySelection: null, + modelOptions: [unavailable], + }), + ).toBeNull(); }); }); diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts index 13e62181a43b..4e8295733141 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -17,6 +17,7 @@ export type ModelOption = { readonly providerDriver: string; readonly isDefault: boolean; readonly isLegacy: boolean; + readonly isUnavailable?: boolean; readonly capabilities: ModelCapabilities | null; readonly selection: ModelSelection; }; @@ -60,12 +61,34 @@ function normalizeSelectionOptions( }; } +/** Whether a known Antigravity selection needs setup or a different model. */ +export function isModelSelectionUnavailable( + config: T3ServerConfig | null | undefined, + selection: ModelSelection | null | undefined, +): boolean { + if (!config || !selection) { + return false; + } + const provider = config.providers.find( + (candidate) => candidate.instanceId === selection.instanceId, + ); + const driver = + provider?.driver ?? config.settings?.providerInstances[selection.instanceId]?.driver; + return ( + driver === "antigravity" && + (!provider || + !provider.enabled || + !provider.installed || + provider.auth.status === "unauthenticated" || + provider.availability === "unavailable" || + !provider.models.some((model) => model.slug === selection.model)) + ); +} + /** - * A stored model selection is only usable when its provider instance is - * currently enabled, installed, and authenticated on the server. Returns the - * selection unchanged when usable, otherwise `null` so callers fall through to - * the server's default model. A missing config (environment offline) cannot be - * validated, so stored selections pass through untouched. + * Keep Antigravity selections when setup or catalog changes make them + * unavailable. Other providers fall through to the server default when they + * are disabled, missing, or signed out. Without config, keep stored selections. */ export function resolveSelectableModelSelection( config: T3ServerConfig | null | undefined, @@ -77,6 +100,11 @@ export function resolveSelectableModelSelection( const provider = config.providers.find( (candidate) => candidate.instanceId === selection.instanceId, ); + const driver = + provider?.driver ?? config.settings?.providerInstances[selection.instanceId]?.driver; + if (driver === "antigravity") { + return selection; + } return provider && provider.enabled && provider.installed && @@ -86,11 +114,9 @@ export function resolveSelectableModelSelection( } /** - * Like resolveSelectableModelSelection, but additionally rejects legacy - * models. Used for implicit defaults (stored draft, project last-used): a - * new thread should never quietly start on a legacy model, so those fall - * through to the provider's default instead. Explicit picks in the settings - * sheet are unaffected. + * Reject legacy models for implicit defaults, except Antigravity selections, + * which must not silently change after a catalog update. Explicit picks in + * the settings sheet are unaffected. */ export function resolveDefaultableModelSelection( config: T3ServerConfig | null | undefined, @@ -102,7 +128,7 @@ export function resolveDefaultableModelSelection( } const provider = config.providers.find((candidate) => candidate.instanceId === usable.instanceId); const model = provider?.models.find((candidate) => candidate.slug === usable.model); - return model?.isLegacy === true ? null : usable; + return provider?.driver !== "antigravity" && model?.isLegacy === true ? null : usable; } export function resolveNewTaskModelSelection(input: { @@ -115,8 +141,8 @@ export function resolveNewTaskModelSelection(input: { input.draftSelection ?? input.projectDefaultSelection ?? input.stickySelection ?? - input.modelOptions.find((option) => option.isDefault)?.selection ?? - input.modelOptions[0]?.selection ?? + input.modelOptions.find((option) => option.isDefault && !option.isUnavailable)?.selection ?? + input.modelOptions.find((option) => !option.isUnavailable)?.selection ?? null ); } @@ -128,7 +154,12 @@ export function buildModelOptions( const options = new Map(); for (const provider of config?.providers ?? []) { - if (!provider.enabled || !provider.installed || provider.auth.status === "unauthenticated") { + if ( + !provider.enabled || + !provider.installed || + provider.auth.status === "unauthenticated" || + (provider.driver === "antigravity" && provider.availability === "unavailable") + ) { continue; } @@ -162,20 +193,39 @@ export function buildModelOptions( if (existing) { options.set(key, { ...existing, - selection: normalizeSelectionOptions(fallbackModelSelection, existing.capabilities), + selection: + existing.providerDriver === "antigravity" + ? fallbackModelSelection + : normalizeSelectionOptions(fallbackModelSelection, existing.capabilities), }); } else { - const providerLabel = fallbackModelSelection.instanceId; + const provider = config?.providers.find( + (candidate) => candidate.instanceId === fallbackModelSelection.instanceId, + ); + const instanceConfig = config?.settings?.providerInstances[fallbackModelSelection.instanceId]; + const model = provider?.models.find( + (candidate) => candidate.slug === fallbackModelSelection.model, + ); + const providerDriver = + provider?.driver ?? instanceConfig?.driver ?? fallbackModelSelection.instanceId; + const providerLabel = providerDisplayLabel({ + driver: providerDriver, + displayName: provider?.displayName ?? instanceConfig?.displayName, + instanceId: fallbackModelSelection.instanceId, + }); options.set(key, { key, - label: fallbackModelSelection.model, - subtitle: "", + label: model?.name ?? fallbackModelSelection.model, + subtitle: model?.subProvider ?? "", providerKey: fallbackModelSelection.instanceId, providerLabel, - providerDriver: fallbackModelSelection.instanceId, + providerDriver, isDefault: false, - isLegacy: false, - capabilities: null, + isLegacy: model?.isLegacy === true, + ...(isModelSelectionUnavailable(config, fallbackModelSelection) + ? { isUnavailable: true } + : {}), + capabilities: model?.capabilities ?? null, selection: fallbackModelSelection, }); } diff --git a/apps/mobile/src/lib/openExternalUrl.test.ts b/apps/mobile/src/lib/openExternalUrl.test.ts index 5a69cbdd43bd..8a96ae533664 100644 --- a/apps/mobile/src/lib/openExternalUrl.test.ts +++ b/apps/mobile/src/lib/openExternalUrl.test.ts @@ -55,4 +55,20 @@ describe("tryOpenExternalUrl", () => { expect(diagnosticText).not.toContain("token=secret"); expect(diagnosticText).not.toContain("browser-unavailable-secret-sentinel"); }); + + it("keeps provider sign-in URLs unchanged and out of failure logs", async () => { + const url = + "https://accounts.google.com/o/oauth2/v2/auth?state=private-state&code_challenge=private-challenge&redirect_uri=http%3A%2F%2F127.0.0.1%3A43123%2F"; + openURL.mockRejectedValue(new Error(`Cannot open ${url}`)); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); + + await expect(tryOpenExternalUrl(url, "provider-auth")).resolves.toBe(false); + + expect(openURL).toHaveBeenCalledWith(url); + const diagnostics = JSON.stringify(consoleError.mock.calls); + expect(diagnostics).toContain("accounts.google.com"); + expect(diagnostics).not.toContain("private-state"); + expect(diagnostics).not.toContain("private-challenge"); + expect(diagnostics).not.toContain("43123"); + }); }); diff --git a/apps/mobile/src/lib/openExternalUrl.ts b/apps/mobile/src/lib/openExternalUrl.ts index 10e6378bc000..45b7788cb3d9 100644 --- a/apps/mobile/src/lib/openExternalUrl.ts +++ b/apps/mobile/src/lib/openExternalUrl.ts @@ -1,7 +1,12 @@ import * as Schema from "effect/Schema"; import { Linking } from "react-native"; -const ExternalUrlTarget = Schema.Literals(["file-preview", "markdown-link", "pull-request"]); +const ExternalUrlTarget = Schema.Literals([ + "file-preview", + "markdown-link", + "pull-request", + "provider-auth", +]); export type ExternalUrlTarget = typeof ExternalUrlTarget.Type; diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 61dfc0d3e859..7a4a0325a92c 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -16,6 +16,7 @@ import { buildPendingUserInputAnswers, buildThreadFeed, derivePendingApprovals, + derivePendingUserInputs, deriveThreadFeedPresentation, isPendingUserInputOptionSelected, setPendingUserInputCustomAnswer, @@ -74,15 +75,48 @@ const multiSelectQuestion = { multiSelect: true, } as const; +const nativeQuestion = { + id: "choice", + header: "File", + question: "Which file should be used?", + options: [ + { label: "Use this", description: "First file", value: " choice " }, + { label: "Use this", description: "Second file", value: "choice" }, + ], + multiSelect: false, + allowCustomAnswer: false, +} as const; + describe("pending user input answers", () => { + it("preserves native choice values and custom-answer rules from activities", () => { + const requested = makeActivity({ + id: EventId.make("native-question"), + kind: "user-input.requested", + summary: "User input requested", + createdAt: "2026-09-02T00:00:00.000Z", + payload: { + requestId: "interaction_1", + questions: [nativeQuestion, singleSelectQuestion], + }, + }); + + expect(derivePendingUserInputs([requested])).toEqual([ + { + requestId: "interaction_1", + createdAt: requested.createdAt, + questions: [nativeQuestion, singleSelectQuestion], + }, + ]); + }); + it("replaces single-select options and toggles multi-select options", () => { expect( togglePendingUserInputOptionSelection( singleSelectQuestion, - { selectedOptionLabels: ["Go"] }, + { selectedOptionValues: ["Go"] }, "Node.js", ), - ).toEqual({ customAnswer: "", selectedOptionLabels: ["Node.js"] }); + ).toEqual({ customAnswer: "", selectedOptionValues: ["Node.js"] }); const orders = togglePendingUserInputOptionSelection(multiSelectQuestion, undefined, "Orders"); const ordersAndListings = togglePendingUserInputOptionSelection( @@ -92,18 +126,18 @@ describe("pending user input answers", () => { ); expect(ordersAndListings).toEqual({ customAnswer: "", - selectedOptionLabels: ["Orders", "Listings"], + selectedOptionValues: ["Orders", "Listings"], }); expect( togglePendingUserInputOptionSelection(multiSelectQuestion, ordersAndListings, "Orders"), - ).toEqual({ customAnswer: "", selectedOptionLabels: ["Listings"] }); + ).toEqual({ customAnswer: "", selectedOptionValues: ["Listings"] }); const paddedOrders = togglePendingUserInputOptionSelection( multiSelectQuestion, undefined, " Orders ", ); - expect(paddedOrders).toEqual({ customAnswer: "", selectedOptionLabels: ["Orders"] }); + expect(paddedOrders).toEqual({ customAnswer: "", selectedOptionValues: ["Orders"] }); expect( togglePendingUserInputOptionSelection(multiSelectQuestion, paddedOrders, " Orders "), ).toEqual({ customAnswer: "" }); @@ -112,8 +146,8 @@ describe("pending user input answers", () => { it("builds array answers for multi-select questions", () => { expect( buildPendingUserInputAnswers([singleSelectQuestion, multiSelectQuestion], { - runtime: { selectedOptionLabels: ["Go"] }, - scope: { selectedOptionLabels: ["Orders", "Listings"] }, + runtime: { selectedOptionValues: ["Go"] }, + scope: { selectedOptionValues: ["Orders", "Listings"] }, }), ).toEqual({ runtime: "Go", @@ -124,23 +158,85 @@ describe("pending user input answers", () => { it("clears selected options while a custom answer is active", () => { expect( setPendingUserInputCustomAnswer( - { selectedOptionLabels: ["Orders", "Listings"] }, + multiSelectQuestion, + { selectedOptionValues: ["Orders", "Listings"] }, "Orders first", ), ).toEqual({ customAnswer: "Orders first" }); }); - it("matches selected chips against normalized option labels", () => { + it("matches selected options against normalized legacy labels", () => { expect( - isPendingUserInputOptionSelected({ selectedOptionLabels: ["Orders"] }, " Orders "), + isPendingUserInputOptionSelected( + multiSelectQuestion, + { selectedOptionValues: ["Orders"] }, + " Orders ", + ), ).toBe(true); expect( isPendingUserInputOptionSelected( - { selectedOptionLabels: ["Orders"], customAnswer: "Orders first" }, + multiSelectQuestion, + { selectedOptionValues: ["Orders"], customAnswer: "Orders first" }, " Orders ", ), ).toBe(false); }); + + it("keeps custom answers enabled for legacy questions", () => { + expect( + buildPendingUserInputAnswers([singleSelectQuestion], { + runtime: { selectedOptionValues: ["Go"], customAnswer: " Use Bun " }, + }), + ).toEqual({ runtime: "Use Bun" }); + }); + + it("keeps duplicate labels and whitespace-sensitive native values separate", () => { + const first = togglePendingUserInputOptionSelection(nativeQuestion, undefined, " choice "); + expect(isPendingUserInputOptionSelected(nativeQuestion, first, " choice ")).toBe(true); + expect(isPendingUserInputOptionSelected(nativeQuestion, first, "choice")).toBe(false); + expect(buildPendingUserInputAnswers([nativeQuestion], { choice: first })).toEqual({ + choice: " choice ", + }); + + const second = togglePendingUserInputOptionSelection(nativeQuestion, first, "choice"); + expect(isPendingUserInputOptionSelected(nativeQuestion, second, " choice ")).toBe(false); + expect(isPendingUserInputOptionSelected(nativeQuestion, second, "choice")).toBe(true); + expect(buildPendingUserInputAnswers([nativeQuestion], { choice: second })).toEqual({ + choice: "choice", + }); + }); + + it("keeps exact native values in multi-select answers", () => { + const question = { ...nativeQuestion, multiSelect: true }; + const first = togglePendingUserInputOptionSelection(question, undefined, " choice "); + const both = togglePendingUserInputOptionSelection(question, first, "choice"); + expect(buildPendingUserInputAnswers([question], { choice: both })).toEqual({ + choice: [" choice ", "choice"], + }); + + const second = togglePendingUserInputOptionSelection(question, both, " choice "); + expect(buildPendingUserInputAnswers([question], { choice: second })).toEqual({ + choice: ["choice"], + }); + }); + + it("ignores custom answers when a question only accepts choices", () => { + const draft = { selectedOptionValues: [" choice "], customAnswer: "Other" }; + expect(setPendingUserInputCustomAnswer(nativeQuestion, draft, "Custom text")).toBe(draft); + expect(isPendingUserInputOptionSelected(nativeQuestion, draft, " choice ")).toBe(true); + expect(buildPendingUserInputAnswers([nativeQuestion], { choice: draft })).toEqual({ + choice: " choice ", + }); + }); + + it.each([ + { customAnswer: "Other" }, + { selectedOptionValues: ["Use this"] }, + { selectedOptionValues: ["not offered"] }, + { selectedOptionValues: [" choice "] }, + ])("requires an offered value for a choice-only question: %j", (draft) => { + expect(buildPendingUserInputAnswers([nativeQuestion], { choice: draft })).toBeNull(); + }); }); describe("pending approvals", () => { @@ -2025,6 +2121,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 0e17c7bd720a..fb6434d4fa98 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -52,7 +52,7 @@ export interface PendingUserInput { } export interface PendingUserInputDraftAnswer { - readonly selectedOptionLabels?: ReadonlyArray; + readonly selectedOptionValues?: ReadonlyArray; readonly customAnswer?: string; } @@ -241,6 +241,7 @@ function parseUserInputQuestions( return { label: record.label, description: record.description, + ...(typeof record.value === "string" ? { value: record.value } : {}), }; }) .filter((option): option is UserInputQuestion["options"][number] => option !== null); @@ -253,6 +254,9 @@ function parseUserInputQuestions( question: question.question, options, multiSelect: question.multiSelect === true, + ...(typeof question.allowCustomAnswer === "boolean" + ? { allowCustomAnswer: question.allowCustomAnswer } + : {}), }; }) .filter((question): question is UserInputQuestion => question !== null); @@ -268,7 +272,23 @@ function normalizeDraftAnswer(value: string | undefined): string | null { return trimmed.length > 0 ? trimmed : null; } -function normalizeSelectedOptionLabels( +function resolvePendingUserInputOptionValue( + question: UserInputQuestion, + value: string, +): string | null { + if (question.options.some((option) => option.value === value)) { + return value; + } + + const label = value.trim(); + return label.length > 0 && + question.options.some((option) => option.value === undefined && option.label.trim() === label) + ? label + : null; +} + +function normalizeSelectedOptionValues( + question: UserInputQuestion, value: ReadonlyArray | undefined, ): ReadonlyArray { if (!Array.isArray(value)) { @@ -276,7 +296,11 @@ function normalizeSelectedOptionLabels( } return Array.from( - new Set(value.map((entry) => entry.trim()).filter((entry) => entry.length > 0)), + new Set( + value + .map((entry) => resolvePendingUserInputOptionValue(question, entry)) + .filter((entry): entry is string => entry !== null), + ), ); } @@ -284,29 +308,28 @@ function resolvePendingUserInputAnswer( question: UserInputQuestion, draft: PendingUserInputDraftAnswer | undefined, ): string | ReadonlyArray | null { - const customAnswer = normalizeDraftAnswer(draft?.customAnswer); + const customAnswer = + question.allowCustomAnswer === false ? null : normalizeDraftAnswer(draft?.customAnswer); if (customAnswer) { return customAnswer; } - const selectedOptionLabels = normalizeSelectedOptionLabels(draft?.selectedOptionLabels); + const selectedOptionValues = normalizeSelectedOptionValues(question, draft?.selectedOptionValues); if (question.multiSelect) { - return selectedOptionLabels.length > 0 ? selectedOptionLabels : null; + return selectedOptionValues.length > 0 ? selectedOptionValues : null; } - return selectedOptionLabels[0] ?? null; + 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; } @@ -315,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")) ); } @@ -326,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 = @@ -337,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; } @@ -362,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.kind === "account.rate-limits.updated") continue; @@ -408,9 +429,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" || @@ -474,6 +493,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; } @@ -1075,6 +1097,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" || @@ -1760,54 +1784,72 @@ export function derivePendingUserInputs( } export function setPendingUserInputCustomAnswer( + question: UserInputQuestion, draft: PendingUserInputDraftAnswer | undefined, customAnswer: string, ): PendingUserInputDraftAnswer { - const selectedOptionLabels = + if (question.allowCustomAnswer === false) { + return draft ?? {}; + } + + const selectedOptionValues = customAnswer.trim().length > 0 ? undefined - : normalizeSelectedOptionLabels(draft?.selectedOptionLabels); + : normalizeSelectedOptionValues(question, draft?.selectedOptionValues); return { customAnswer, - ...(selectedOptionLabels && selectedOptionLabels.length > 0 ? { selectedOptionLabels } : {}), + ...(selectedOptionValues && selectedOptionValues.length > 0 ? { selectedOptionValues } : {}), }; } export function isPendingUserInputOptionSelected( + question: UserInputQuestion, draft: PendingUserInputDraftAnswer | undefined, - optionLabel: string, + optionValue: string, ): boolean { - if (normalizeDraftAnswer(draft?.customAnswer)) { + if (question.allowCustomAnswer !== false && normalizeDraftAnswer(draft?.customAnswer)) { return false; } - return normalizeSelectedOptionLabels(draft?.selectedOptionLabels).includes(optionLabel.trim()); + const resolvedOptionValue = resolvePendingUserInputOptionValue(question, optionValue); + return ( + resolvedOptionValue !== null && + normalizeSelectedOptionValues(question, draft?.selectedOptionValues).includes( + resolvedOptionValue, + ) + ); } export function togglePendingUserInputOptionSelection( question: UserInputQuestion, draft: PendingUserInputDraftAnswer | undefined, - optionLabel: string, + optionValue: string, ): PendingUserInputDraftAnswer { - const normalizedOptionLabel = optionLabel.trim(); + const resolvedOptionValue = resolvePendingUserInputOptionValue(question, optionValue); + if (resolvedOptionValue === null) { + return draft ?? {}; + } if (question.multiSelect) { - const selectedOptionLabels = normalizeSelectedOptionLabels(draft?.selectedOptionLabels); - const nextSelectedOptionLabels = selectedOptionLabels.includes(normalizedOptionLabel) - ? selectedOptionLabels.filter((label) => label !== normalizedOptionLabel) - : [...selectedOptionLabels, normalizedOptionLabel]; + const selectedOptionValues = normalizeSelectedOptionValues( + question, + draft?.selectedOptionValues, + ); + const nextSelectedOptionValues = selectedOptionValues.includes(resolvedOptionValue) + ? selectedOptionValues.filter((value) => value !== resolvedOptionValue) + : [...selectedOptionValues, resolvedOptionValue]; return { customAnswer: "", - ...(nextSelectedOptionLabels.length > 0 - ? { selectedOptionLabels: nextSelectedOptionLabels } + ...(nextSelectedOptionValues.length > 0 + ? { selectedOptionValues: nextSelectedOptionValues } : {}), }; } return { customAnswer: "", - selectedOptionLabels: [normalizedOptionLabel], + selectedOptionValues: [resolvedOptionValue], }; } @@ -1819,7 +1861,7 @@ export function buildPendingUserInputAnswers( for (const question of questions) { const answer = resolvePendingUserInputAnswer(question, draftAnswers[question.id]); - if (!answer) { + if (answer === null) { return null; } answers[question.id] = answer; diff --git a/apps/mobile/src/state/asset-url-state.test.ts b/apps/mobile/src/state/asset-url-state.test.ts new file mode 100644 index 000000000000..bcc0d070ff81 --- /dev/null +++ b/apps/mobile/src/state/asset-url-state.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { deriveAssetUrlState } from "./asset-url-state"; + +const SUCCESS = { _tag: "Success" as const, url: "https://environment.example/api/assets/abc" }; + +describe("deriveAssetUrlState", () => { + it("passes a resolved URL through on a live environment", () => { + expect(deriveAssetUrlState({ connectionPhase: "connected", shared: SUCCESS })).toEqual(SUCCESS); + }); + + it("waits while the query is pending and the environment is still reachable", () => { + for (const connectionPhase of ["available", "connecting", "connected"] as const) { + expect(deriveAssetUrlState({ connectionPhase, shared: { _tag: "Loading" } })).toEqual({ + _tag: "Loading", + }); + } + }); + + it("stops waiting once the environment is offline, retrying, or in error", () => { + for (const connectionPhase of ["offline", "reconnecting", "error"] as const) { + expect(deriveAssetUrlState({ connectionPhase, shared: { _tag: "Loading" } })).toEqual({ + _tag: "Failure", + reason: "disconnected", + }); + } + }); + + // A dead environment fails the URL query itself, so the query outcome alone + // cannot tell a missing file from a missing connection. + it("reports disconnected when the query failed while the environment is down", () => { + for (const connectionPhase of ["available", "offline", "reconnecting", "error"] as const) { + expect(deriveAssetUrlState({ connectionPhase, shared: { _tag: "Failure" } })).toEqual({ + _tag: "Failure", + reason: "disconnected", + }); + } + }); + + it("does not hand out a resolved URL for a disconnected environment", () => { + for (const connectionPhase of ["offline", "reconnecting", "error"] as const) { + expect(deriveAssetUrlState({ connectionPhase, shared: SUCCESS })).toEqual({ + _tag: "Failure", + reason: "disconnected", + }); + } + }); + + it("reports a failed query on a connected environment", () => { + expect( + deriveAssetUrlState({ connectionPhase: "connected", shared: { _tag: "Failure" } }), + ).toEqual({ _tag: "Failure", reason: "failed" }); + }); +}); diff --git a/apps/mobile/src/state/asset-url-state.ts b/apps/mobile/src/state/asset-url-state.ts new file mode 100644 index 000000000000..d70e42410fa4 --- /dev/null +++ b/apps/mobile/src/state/asset-url-state.ts @@ -0,0 +1,47 @@ +import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; +import type { AssetUrlState as SharedAssetUrlState } from "@t3tools/client-runtime/state/assets"; + +export type AssetUrlFailureReason = "disconnected" | "failed"; + +/** The shared state plus a reason on failure, so previews can offer the right retry. */ +export type AssetUrlState = + | { readonly _tag: "Loading" } + | { readonly _tag: "Failure"; readonly reason: AssetUrlFailureReason } + | Extract; + +/** + * Folds the shared asset URL state with the environment connection phase. A + * dead environment wins over everything else: even a resolved URL is + * unreachable there, and a failed query is caused by the outage, not the file. + */ +export function deriveAssetUrlState(input: { + readonly connectionPhase: EnvironmentConnectionPhase; + readonly shared: SharedAssetUrlState; +}): AssetUrlState { + if ( + input.connectionPhase === "offline" || + input.connectionPhase === "reconnecting" || + input.connectionPhase === "error" + ) { + return { _tag: "Failure", reason: "disconnected" }; + } + if (input.shared._tag === "Success") { + return input.shared; + } + switch (input.connectionPhase) { + // "available" is the idle, not yet dialled state. A pending query there is + // still on its way, but the query atom fails at once while idle, so a + // failure means the environment is not connected rather than the file is + // missing. + case "available": + return input.shared._tag === "Failure" + ? { _tag: "Failure", reason: "disconnected" } + : { _tag: "Loading" }; + case "connecting": + return { _tag: "Loading" }; + case "connected": + return input.shared._tag === "Failure" + ? { _tag: "Failure", reason: "failed" } + : { _tag: "Loading" }; + } +} diff --git a/apps/mobile/src/state/assets.ts b/apps/mobile/src/state/assets.ts index af6300d8ffa6..400bdb6b705a 100644 --- a/apps/mobile/src/state/assets.ts +++ b/apps/mobile/src/state/assets.ts @@ -1,35 +1,64 @@ import { useAtomValue } from "@effect/atom-react"; import { - type AssetUrlState, + type EnvironmentConnectionPhase, + presentConnectionState, +} from "@t3tools/client-runtime/connection"; +import { assetUrlStateFromResult, createAssetEnvironmentAtoms, EMPTY_ASSET_URL_ATOM, } from "@t3tools/client-runtime/state/assets"; import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { useCallback } from "react"; +import { environmentCatalog } from "../connection/catalog"; import { connectionAtomRuntime } from "../connection/runtime"; +import { type AssetUrlState, deriveAssetUrlState } from "./asset-url-state"; import { usePreparedConnection } from "./session"; import { useAtomQueryRunner } from "./use-atom-query-runner"; -export type { AssetUrlState } from "@t3tools/client-runtime/state/assets"; +export type { AssetUrlFailureReason, AssetUrlState } from "./asset-url-state"; export const assetEnvironment = createAssetEnvironmentAtoms(connectionAtomRuntime); +const EMPTY_CONNECTION_STATE_ATOM = Atom.make(AsyncResult.initial(false)).pipe( + Atom.withLabel("mobile-asset-connection-state:empty"), +); + +function useConnectionPhase(environmentId: EnvironmentId | null): EnvironmentConnectionPhase { + const state = useAtomValue( + environmentId === null + ? EMPTY_CONNECTION_STATE_ATOM + : environmentCatalog.stateAtom(environmentId), + ); + const value = Option.getOrNull(AsyncResult.value(state)); + return value === null ? "available" : presentConnectionState(value).phase; +} + export function useAssetUrlState( environmentId: EnvironmentId | null, resource: AssetResource | null, ): AssetUrlState { const preparedConnection = usePreparedConnection(environmentId); + const connectionPhase = useConnectionPhase(environmentId); const result = useAtomValue( environmentId === null || resource === null ? EMPTY_ASSET_URL_ATOM : assetEnvironment.createUrl({ environmentId, input: { resource } }), ); - return assetUrlStateFromResult( + const shared = assetUrlStateFromResult( result, preparedConnection._tag === "Some" ? preparedConnection.value.httpBaseUrl : null, ); + return deriveAssetUrlState({ + connectionPhase, + // A failure left over from an outage is re-queried as soon as the + // connection returns. While that re-query is in flight it is not a verdict + // on the file, so it reads as loading rather than a false "unavailable". + shared: shared._tag === "Failure" && result.waiting ? { _tag: "Loading" } : shared, + }); } export function useAssetUrl( diff --git a/apps/mobile/src/state/thread-outbox-model.ts b/apps/mobile/src/state/thread-outbox-model.ts index ed1d289cee12..9895d51952ad 100644 --- a/apps/mobile/src/state/thread-outbox-model.ts +++ b/apps/mobile/src/state/thread-outbox-model.ts @@ -18,12 +18,14 @@ import { type ProjectId as ProjectIdType, type ProviderInteractionMode as ProviderInteractionModeType, type RuntimeMode as RuntimeModeType, + type ServerProvider, } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; import { DraftComposerAttachmentSchema } from "../lib/composer-image-schema"; import type { DraftComposerAttachment } from "../lib/composerImages"; import { scopedThreadKey } from "../lib/scopedEntities"; +import { resolveProviderInteractionMode } from "../features/threads/legacy-plan-mode"; const THREAD_OUTBOX_SCHEMA_VERSION = 3; const THREAD_OUTBOX_MAX_RETRY_DELAY_MS = 16_000; @@ -93,11 +95,19 @@ export interface ThreadSettingsSnapshot { export function resolveQueuedThreadSettings( message: QueuedThreadMessage, thread: ThreadSettingsSnapshot, + providers: ReadonlyArray> = [], ): ThreadSettingsSnapshot { + const modelSelection = message.modelSelection ?? thread.modelSelection; + const provider = providers.find( + (candidate) => candidate.instanceId === modelSelection.instanceId, + ); return { - modelSelection: message.modelSelection ?? thread.modelSelection, + modelSelection, runtimeMode: message.runtimeMode ?? thread.runtimeMode, - interactionMode: message.interactionMode ?? thread.interactionMode, + interactionMode: resolveProviderInteractionMode( + provider, + message.interactionMode ?? thread.interactionMode, + ), }; } @@ -184,11 +194,9 @@ export type ThreadOutboxDispatchStep = | { readonly step: "send" }; /** - * Orders the resolved delivery action against the file-capability gate. The - * gate applies only to a message that will send: a message whose thread - * already exists (or is gone) must be removed even while the server config is - * still loading, and a missing config defers with a retry instead of parking - * the message forever. + * Wait for provider and file capabilities before sending. Cleanup does not + * need config: a creation whose thread exists, or a message whose thread is + * gone, can still be removed while config loads. */ export function resolveThreadOutboxDispatchStep(input: { readonly deliveryAction: ThreadOutboxDeliveryAction; @@ -199,12 +207,12 @@ export function resolveThreadOutboxDispatchStep(input: { if (input.deliveryAction !== "send") { return { step: input.deliveryAction }; } - if (input.fileAttachments.length === 0) { - return { step: "send" }; - } if (input.serverConfig === null) { return { step: "retry" }; } + if (input.fileAttachments.length === 0) { + return { step: "send" }; + } const maxBytes = input.serverConfig.maxFileUploadBytes; if (maxBytes === undefined) { return { step: "restore", reason: "This server does not support file attachments." }; diff --git a/apps/mobile/src/state/thread-outbox.test.ts b/apps/mobile/src/state/thread-outbox.test.ts index 0069064f3785..17e47aceaf45 100644 --- a/apps/mobile/src/state/thread-outbox.test.ts +++ b/apps/mobile/src/state/thread-outbox.test.ts @@ -150,6 +150,62 @@ describe("thread outbox", () => { ).toBe(false); }); + it("normalizes queued plan mode against the queued provider, not the current thread", () => { + const codex = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.6-sol" }; + const antigravity = { + instanceId: ProviderInstanceId.make("google-personal"), + model: "gemini-test-thinking", + options: [{ id: "native-option", value: "keep-this-choice" }], + }; + const providers = [ + { instanceId: codex.instanceId, showInteractionModeToggle: true }, + { instanceId: antigravity.instanceId, showInteractionModeToggle: false }, + ]; + const message = { + ...queuedMessage({ messageId: "queued-plan", createdAt: "2026-09-02T10:00:00.000Z" }), + text: "/plan inspect the project", + modelSelection: antigravity, + interactionMode: "plan", + } satisfies QueuedThreadMessage; + + expect( + resolveQueuedThreadSettings( + message, + { modelSelection: codex, runtimeMode: "approval-required", interactionMode: "plan" }, + providers, + ), + ).toEqual({ + modelSelection: antigravity, + runtimeMode: "approval-required", + interactionMode: "default", + }); + expect( + resolveQueuedThreadSettings( + { ...message, modelSelection: codex }, + { + modelSelection: antigravity, + runtimeMode: "approval-required", + interactionMode: "default", + }, + providers, + ).interactionMode, + ).toBe("plan"); + }); + + it("normalizes a legacy queued message that inherits unsupported plan mode", () => { + const modelSelection = { + instanceId: ProviderInstanceId.make("google-personal"), + model: "gemini-test-thinking", + }; + expect( + resolveQueuedThreadSettings( + queuedMessage({ messageId: "legacy-plan", createdAt: "2026-09-02T10:00:00.000Z" }), + { modelSelection, runtimeMode: "approval-required", interactionMode: "plan" }, + [{ instanceId: modelSelection.instanceId, showInteractionModeToggle: false }], + ).interactionMode, + ).toBe("default"); + }); + it("backs off queued delivery retries and caps them at sixteen seconds", () => { expect([1, 2, 3, 4, 5, 6].map(threadOutboxRetryDelayMs)).toEqual([ 1_000, 2_000, 4_000, 8_000, 16_000, 16_000, @@ -938,13 +994,20 @@ describe("thread outbox", () => { ).toEqual({ step: "send" }); }); - it("sends a message without file attachments before the server config loads", () => { + it("waits for provider capabilities before sending text-only queued messages", () => { expect( resolveThreadOutboxDispatchStep({ deliveryAction: "send", fileAttachments: [], serverConfig: null, }), + ).toEqual({ step: "retry" }); + expect( + resolveThreadOutboxDispatchStep({ + deliveryAction: "send", + fileAttachments: [], + serverConfig: { maxFileUploadBytes: undefined }, + }), ).toEqual({ step: "send" }); }); diff --git a/apps/mobile/src/state/usage.ts b/apps/mobile/src/state/usage.ts index cce91b65a6d0..f5bdc0d0858b 100644 --- a/apps/mobile/src/state/usage.ts +++ b/apps/mobile/src/state/usage.ts @@ -16,6 +16,7 @@ import { type UsageSummary, type UsageSummaryInput, } from "@t3tools/contracts"; +import { runAtomCommand } from "@t3tools/client-runtime/state/runtime"; import { mergeUsage, type EnvironmentUsage, type MergedUsage } from "@t3tools/shared/usageMerge"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; @@ -100,12 +101,21 @@ export function useUsage(input: UsageSummaryInput): UsageView { // Refreshing only the derived atom would re-read the per-environment SWR // queries within their stale window and change nothing. Refresh each // environment's query so pull-to-refresh always rescans. + // + // Each environment refetches model pricing first, so a model released since + // its last daily fetch gets priced by the rescan. The rescan runs whether or + // not the refetch succeeds: an offline environment still recounts tokens. const refresh = useCallback(() => { const input = JSON.parse(windowKey) as UsageSummaryInput; for (const environment of environments) { - appAtomRegistry.refresh( - serverEnvironment.usageSummary({ environmentId: environment.environmentId, input }), - ); + const { environmentId } = environment; + const query = serverEnvironment.usageSummary({ environmentId, input }); + void runAtomCommand( + appAtomRegistry, + serverEnvironment.refreshUsageRates, + { environmentId, input: {} }, + { reportFailure: false }, + ).finally(() => appAtomRegistry.refresh(query)); } }, [environments, windowKey]); diff --git a/apps/mobile/src/state/use-selected-thread-requests.ts b/apps/mobile/src/state/use-selected-thread-requests.ts index 30b3a0704f8e..6208a806819d 100644 --- a/apps/mobile/src/state/use-selected-thread-requests.ts +++ b/apps/mobile/src/state/use-selected-thread-requests.ts @@ -31,7 +31,7 @@ const userInputDraftsByRequestKeyAtom = Atom.make< function setUserInputDraftOption( requestKey: string, question: UserInputQuestion, - label: string, + value: string, ): void { const current = appAtomRegistry.get(userInputDraftsByRequestKeyAtom); appAtomRegistry.set(userInputDraftsByRequestKeyAtom, { @@ -41,7 +41,7 @@ function setUserInputDraftOption( [question.id]: togglePendingUserInputOptionSelection( question, current[requestKey]?.[question.id], - label, + value, ), }, }); @@ -49,7 +49,7 @@ function setUserInputDraftOption( function setUserInputDraftCustomAnswer( requestKey: string, - questionId: string, + question: UserInputQuestion, customAnswer: string, ): void { const current = appAtomRegistry.get(userInputDraftsByRequestKeyAtom); @@ -57,8 +57,9 @@ function setUserInputDraftCustomAnswer( ...current, [requestKey]: { ...current[requestKey], - [questionId]: setPendingUserInputCustomAnswer( - current[requestKey]?.[questionId], + [question.id]: setPendingUserInputCustomAnswer( + question, + current[requestKey]?.[question.id], customAnswer, ), }, @@ -108,27 +109,30 @@ export function useSelectedThreadRequests() { : null; const onSelectUserInputOption = useCallback( - (requestId: ApprovalRequestId, question: UserInputQuestion, label: string) => { + (requestId: ApprovalRequestId, question: UserInputQuestion, value: string) => { if (!selectedThreadShell) { return; } const requestKey = scopedRequestKey(selectedThreadShell.environmentId, requestId); - setUserInputDraftOption(requestKey, question, label); + setUserInputDraftOption(requestKey, question, value); }, [selectedThreadShell], ); const onChangeUserInputCustomAnswer = useCallback( (requestId: ApprovalRequestId, questionId: string, customAnswer: string) => { - if (!selectedThreadShell) { + const question = activePendingUserInputs + .find((request) => request.requestId === requestId) + ?.questions.find((entry) => entry.id === questionId); + if (!selectedThreadShell || !question) { return; } const requestKey = scopedRequestKey(selectedThreadShell.environmentId, requestId); - setUserInputDraftCustomAnswer(requestKey, questionId, customAnswer); + setUserInputDraftCustomAnswer(requestKey, question, customAnswer); }, - [selectedThreadShell], + [activePendingUserInputs, selectedThreadShell], ); const onRespondToApproval = useCallback( diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 66e57802d1a6..294647daa9cf 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -5,6 +5,7 @@ import * as Cause from "effect/Cause"; import { CommandId, + DEFAULT_PROVIDER_INTERACTION_MODE, MessageId, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, type EnvironmentId, @@ -24,6 +25,8 @@ import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime" import { deriveActiveWorkStartedAt } from "@t3tools/shared/orchestrationTiming"; import { makeQueuedMessageMetadata } from "../lib/commandMetadata"; +import { isModelSelectionUnavailable } from "../lib/modelOptions"; +import { resolveProviderInteractionMode } from "../features/threads/legacy-plan-mode"; import { convertPastedImagesToAttachments, pasteComposerClipboard, @@ -145,7 +148,15 @@ export function useThreadComposerState() { const selectedThread = selectedThreadDetail ?? selectedThreadShell; const modelSelection = selectedDraft?.modelSelection ?? selectedThread?.modelSelection ?? null; const runtimeMode = selectedDraft?.runtimeMode ?? selectedThread?.runtimeMode ?? null; - const interactionMode = selectedDraft?.interactionMode ?? selectedThread?.interactionMode ?? null; + const selectedProvider = selectedEnvironmentRuntime?.serverConfig?.providers.find( + (provider) => provider.instanceId === modelSelection?.instanceId, + ); + const interactionMode = selectedThread + ? resolveProviderInteractionMode( + selectedProvider, + selectedDraft?.interactionMode ?? selectedThread.interactionMode, + ) + : null; const selectedThreadSessionActivity = useMemo(() => { const selectedThread = selectedThreadDetail ?? selectedThreadShell; @@ -207,8 +218,20 @@ export function useThreadComposerState() { return null; } - const provider = selectedEnvironmentRuntime?.serverConfig?.providers.find( - (entry) => entry.instanceId === thread.modelSelection.instanceId, + const modelSelection = draft.modelSelection ?? thread.modelSelection; + const serverConfig = selectedEnvironmentRuntime?.serverConfig; + if ( + selectedEnvironmentRuntime?.connectionState === "connected" && + isModelSelectionUnavailable(serverConfig, modelSelection) + ) { + Alert.alert( + "Antigravity model unavailable", + "Open model settings to finish setup or choose another model.", + ); + return null; + } + const provider = serverConfig?.providers.find( + (entry) => entry.instanceId === modelSelection.instanceId, ); const feedbackCommand = attachments.length === 0 && @@ -285,9 +308,12 @@ export function useThreadComposerState() { commandId: CommandId.make(metadata.commandId), text, attachments, - modelSelection: draft.modelSelection ?? thread.modelSelection, + modelSelection, runtimeMode: draft.runtimeMode ?? thread.runtimeMode, - interactionMode: draft.interactionMode ?? thread.interactionMode, + interactionMode: resolveProviderInteractionMode( + provider, + draft.interactionMode ?? thread.interactionMode, + ), createdAt: metadata.createdAt, }); clearComposerDraftContent(threadKey, { deferAttachmentCleanup: true }); @@ -455,9 +481,17 @@ export function useThreadComposerState() { if (!selectedThreadKey) { return; } - updateComposerDraftSettings(selectedThreadKey, { modelSelection: value }); + const provider = selectedEnvironmentRuntime?.serverConfig?.providers.find( + (candidate) => candidate.instanceId === value.instanceId, + ); + updateComposerDraftSettings(selectedThreadKey, { + modelSelection: value, + ...(provider?.showInteractionModeToggle === false + ? { interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE } + : {}), + }); }, - [selectedThreadKey], + [selectedEnvironmentRuntime?.serverConfig, selectedThreadKey], ); const onUpdateRuntimeMode = useCallback( @@ -475,9 +509,17 @@ export function useThreadComposerState() { if (!selectedThreadKey) { return; } - updateComposerDraftSettings(selectedThreadKey, { interactionMode: value }); + const modelSelection = + getComposerDraftSnapshot(selectedThreadKey).modelSelection ?? + selectedThread?.modelSelection; + const provider = selectedEnvironmentRuntime?.serverConfig?.providers.find( + (candidate) => candidate.instanceId === modelSelection?.instanceId, + ); + updateComposerDraftSettings(selectedThreadKey, { + interactionMode: resolveProviderInteractionMode(provider, value), + }); }, - [selectedThreadKey], + [selectedEnvironmentRuntime?.serverConfig, selectedThread?.modelSelection, selectedThreadKey], ); return { diff --git a/apps/mobile/src/state/use-thread-outbox-drain.test.ts b/apps/mobile/src/state/use-thread-outbox-drain.test.ts index d27f07962d60..698e3f5a85f1 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.test.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.test.ts @@ -80,6 +80,11 @@ vi.mock("./entities", () => ({ useThreadShells: () => [], })); +vi.mock("./server", async () => { + const { Atom } = await import("effect/unstable/reactivity"); + return { serverEnvironment: { configValueAtom: Atom.family(() => Atom.make(null)) } }; +}); + vi.mock("./threads", () => ({ threadEnvironment: {}, })); diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index de6a538b52ef..bd3d730e0265 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -20,8 +20,10 @@ import { scopedProjectKey, scopedThreadKey } from "../lib/scopedEntities"; import { buildProjectThreadStartTurnInput } from "../lib/projectThreadStartTurn"; import { prepareTurnAttachments, type PreparedTurnAttachments } from "../lib/attachmentUpload"; import { randomHex } from "../lib/uuid"; +import { isModelSelectionUnavailable } from "../lib/modelOptions"; import { appAtomRegistry } from "./atom-registry"; import { useProjects, useServerConfigs, useThreadShells } from "./entities"; +import { serverEnvironment } from "./server"; import { confirmThreadOutboxMessageQueued, ensureThreadOutboxLoaded, @@ -630,7 +632,17 @@ export function useThreadOutboxDrain(): void { const sendQueuedMessage = useCallback( async (queuedMessage: QueuedThreadMessage, thread: EnvironmentThreadShell) => { - const settings = resolveQueuedThreadSettings(queuedMessage, thread); + const serverConfig = appAtomRegistry.get( + serverEnvironment.configValueAtom(queuedMessage.environmentId), + ); + if (!serverConfig) return false; + const settings = resolveQueuedThreadSettings(queuedMessage, thread, serverConfig.providers); + if (isModelSelectionUnavailable(serverConfig, settings.modelSelection)) { + return restoreQueuedMessage( + queuedMessage, + "Antigravity model unavailable. Open model settings to finish setup or choose another model.", + ); + } const { reportFailure } = makeDeliveryHelpers(queuedMessage); if (!modelSelectionsEqual(settings.modelSelection, thread.modelSelection)) { @@ -686,8 +698,7 @@ export function useThreadOutboxDrain(): void { try { const preparedResult = await prepareQueuedMessageAttachments( queuedMessage, - serverConfigs.get(queuedMessage.environmentId)?.environment.capabilities - .attachmentUploads === true, + serverConfig.environment.capabilities.attachmentUploads === true, ); if (preparedResult.status === "abandoned") { return true; @@ -715,6 +726,21 @@ export function useThreadOutboxDrain(): void { if (!isQueuedMessagePayloadCurrent(persistedMessage, deliveryRevision)) { return true; } + const currentConfig = appAtomRegistry.get( + serverEnvironment.configValueAtom(queuedMessage.environmentId), + ); + if (!currentConfig) return false; + if (isModelSelectionUnavailable(currentConfig, settings.modelSelection)) { + return restoreQueuedMessage( + persistedMessage, + "Antigravity model unavailable. Open model settings to finish setup or choose another model.", + ); + } + const sendSettings = resolveQueuedThreadSettings( + queuedMessage, + settings, + currentConfig.providers, + ); const deliveryResult = await startTurn({ environmentId: queuedMessage.environmentId, input: { @@ -726,9 +752,9 @@ export function useThreadOutboxDrain(): void { text: queuedMessage.text, attachments: prepared.attachments, }, - modelSelection: settings.modelSelection, - runtimeMode: settings.runtimeMode, - interactionMode: settings.interactionMode, + modelSelection: sendSettings.modelSelection, + runtimeMode: sendSettings.runtimeMode, + interactionMode: sendSettings.interactionMode, createdAt: queuedMessage.createdAt, }, }); @@ -760,7 +786,6 @@ export function useThreadOutboxDrain(): void { startTurn, updateThreadMetadata, restoreQueuedMessage, - serverConfigs, ], ); @@ -774,14 +799,32 @@ export function useThreadOutboxDrain(): void { if (modelSelection === undefined) { return false; } + const serverConfig = appAtomRegistry.get( + serverEnvironment.configValueAtom(queuedMessage.environmentId), + ); + if (!serverConfig) return false; + const settings = resolveQueuedThreadSettings( + queuedMessage, + { + modelSelection, + runtimeMode: DEFAULT_RUNTIME_MODE, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + }, + serverConfig.providers, + ); + if (isModelSelectionUnavailable(serverConfig, settings.modelSelection)) { + return restoreQueuedMessage( + queuedMessage, + "Antigravity model unavailable. Open model settings to finish setup or choose another model.", + ); + } let prepared: PreparedTurnAttachments; let persistedMessage: QueuedThreadMessage; let deliveryRevision: number; try { const preparedResult = await prepareQueuedMessageAttachments( queuedMessage, - serverConfigs.get(queuedMessage.environmentId)?.environment.capabilities - .attachmentUploads === true, + serverConfig.environment.capabilities.attachmentUploads === true, ); if (preparedResult.status === "abandoned") { return true; @@ -809,6 +852,21 @@ export function useThreadOutboxDrain(): void { if (!isQueuedMessagePayloadCurrent(persistedMessage, deliveryRevision)) { return true; } + const currentConfig = appAtomRegistry.get( + serverEnvironment.configValueAtom(queuedMessage.environmentId), + ); + if (!currentConfig) return false; + if (isModelSelectionUnavailable(currentConfig, settings.modelSelection)) { + return restoreQueuedMessage( + persistedMessage, + "Antigravity model unavailable. Open model settings to finish setup or choose another model.", + ); + } + const sendSettings = resolveQueuedThreadSettings( + queuedMessage, + settings, + currentConfig.providers, + ); const deliveryResult = await startTurn({ environmentId: queuedMessage.environmentId, input: buildProjectThreadStartTurnInput({ @@ -821,9 +879,9 @@ export function useThreadOutboxDrain(): void { text: queuedMessage.text.trim(), attachments: queuedMessage.attachments, uploadedAttachments: prepared.attachments, - modelSelection, - runtimeMode: queuedMessage.runtimeMode ?? DEFAULT_RUNTIME_MODE, - interactionMode: queuedMessage.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE, + modelSelection: sendSettings.modelSelection, + runtimeMode: sendSettings.runtimeMode, + interactionMode: sendSettings.interactionMode, workspaceMode: creation.workspaceMode, branch: creation.branch, worktreePath: creation.worktreePath, @@ -859,7 +917,7 @@ export function useThreadOutboxDrain(): void { } return false; }, - [makeDeliveryHelpers, restoreQueuedMessage, serverConfigs, startTurn], + [makeDeliveryHelpers, restoreQueuedMessage, startTurn], ); useEffect(() => { @@ -945,8 +1003,8 @@ export function useThreadOutboxDrain(): void { environmentConnected: environment?.connectionState === "connected", threadBusy: thread?.session?.status === "running" || thread?.session?.status === "starting", }); - // The delivery action resolves first; the file-capability gate applies - // only to a message that will send. Gating earlier would restore a + // The delivery action resolves first; capability checks apply only to + // a message that will send. Checking earlier would restore a // creation whose startTurn already made the thread as a duplicate draft // instead of removing it. const serverConfig = serverConfigs.get(nextQueuedMessage.environmentId); diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index db030312a78a..f5fb02fafa66 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -47,6 +47,7 @@ import { ProviderEventLoggers, } from "../src/provider/Layers/ProviderEventLoggers.ts"; import { ProviderService } from "../src/provider/Services/ProviderService.ts"; +import { ProviderAuthService } from "../src/provider/Services/ProviderAuthService.ts"; import { AnalyticsService } from "../src/telemetry/AnalyticsService.ts"; import { CheckpointReactorLive } from "../src/orchestration/Layers/CheckpointReactor.ts"; import * as RepositoryIdentityResolver from "../src/project/RepositoryIdentityResolver.ts"; @@ -338,6 +339,11 @@ export const makeOrchestrationIntegrationHarness = ( generateThreadTitle: () => Effect.succeed({ title: "New thread" }), } as unknown as TextGeneration["Service"]); const providerCommandReactorLayer = ProviderCommandReactorLive.pipe( + Layer.provide( + Layer.mock(ProviderAuthService)({ + tryHandlePromptCommand: () => Effect.succeed(false), + }), + ), Layer.provideMerge(runtimeServicesLayer), Layer.provideMerge(gitWorkflowLayer), Layer.provideMerge(textGenerationLayer), diff --git a/apps/server/integration/orphanedProviderSessionStartup.integration.test.ts b/apps/server/integration/orphanedProviderSessionStartup.integration.test.ts index 434d9902777a..d3e55c4b9ceb 100644 --- a/apps/server/integration/orphanedProviderSessionStartup.integration.test.ts +++ b/apps/server/integration/orphanedProviderSessionStartup.integration.test.ts @@ -116,6 +116,7 @@ const startupDependencies = Layer.mergeAll( stopSession: () => Effect.die("unused"), listSessions: () => Effect.succeed([]), getCapabilities: () => Effect.die("unused"), + assertConversationRollbackSupported: () => Effect.die("unused"), getInstanceInfo: () => Effect.die("unused"), rollbackConversation: () => Effect.die("unused"), uploadFeedback: () => Effect.die("unused"), diff --git a/apps/server/package.json b/apps/server/package.json index 5aee9fe64928..8e0a7ef4ede5 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -36,7 +36,8 @@ "effect": "catalog:", "msgpackr-extract": "3.0.4", "node-pty": "^1.1.0", - "yaml": "catalog:" + "yaml": "catalog:", + "yauzl": "^3.4.0" }, "devDependencies": { "@effect/vitest": "catalog:", @@ -46,6 +47,7 @@ "@t3tools/web": "workspace:*", "@types/bun": "1.3.14", "@types/node": "catalog:", + "@types/yauzl": "^3.4.0", "effect-acp": "workspace:*", "effect-codex-app-server": "workspace:*", "vite-plus": "catalog:" diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index da2a3acc226d..7a5b0197bc19 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -2,6 +2,7 @@ // @effect-diagnostics nodeBuiltinImport:off import * as NodeFS from "node:fs"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Scope from "effect/Scope"; @@ -14,6 +15,7 @@ import type * as AcpSchema from "effect-acp/schema"; const requestLogPath = process.env.T3_ACP_REQUEST_LOG_PATH; const exitLogPath = process.env.T3_ACP_EXIT_LOG_PATH; +const antigravityProfile = process.env.T3_ACP_ANTIGRAVITY === "1"; const emitToolCalls = process.env.T3_ACP_EMIT_TOOL_CALLS === "1"; const emitInterleavedAssistantToolCalls = process.env.T3_ACP_EMIT_INTERLEAVED_ASSISTANT_TOOL_CALLS === "1"; @@ -33,6 +35,9 @@ const emitForeignSessionUpdates = process.env.T3_ACP_EMIT_FOREIGN_SESSION_UPDATE const availableCommandsTiming = process.env.T3_ACP_AVAILABLE_COMMANDS_TIMING; const availableCommandsDelayMs = Number(process.env.T3_ACP_AVAILABLE_COMMANDS_DELAY_MS ?? "25"); const emitEmptyAvailableCommands = process.env.T3_ACP_EMPTY_AVAILABLE_COMMANDS === "1"; +const waitForResumeRelease = process.env.T3_ACP_WAIT_FOR_RESUME_RELEASE === "1"; +const completeFirstPromptOnCancel = process.env.T3_ACP_COMPLETE_FIRST_PROMPT_ON_CANCEL === "1"; +const floodStderr = process.env.T3_ACP_FLOOD_STDERR === "1"; const hangPromptForever = process.env.T3_ACP_HANG_PROMPT_FOREVER === "1"; const hangFirstPromptForever = process.env.T3_ACP_HANG_FIRST_PROMPT_FOREVER === "1"; const emitLateUpdateAfterCancel = process.env.T3_ACP_EMIT_LATE_UPDATE_AFTER_CANCEL === "1"; @@ -66,8 +71,8 @@ const permissionRequestCount = Math.max( ); const sessionId = "mock-session-1"; -let currentModeId = "ask"; -let currentModelId = "default"; +let currentModeId = antigravityProfile ? "default" : "ask"; +let currentModelId = antigravityProfile ? "gemini-test-low" : "default"; let parameterizedModelPicker = false; let currentReasoning = "medium"; let currentContext = "272k"; @@ -113,6 +118,26 @@ process.once("exit", (code) => { }); function configOptions(): ReadonlyArray { + if (antigravityProfile) { + return [ + { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: currentModelId, + options: antigravityModels.map((model) => ({ value: model.modelId, name: model.name })), + }, + { + id: "mode", + name: "Mode", + category: "mode", + type: "select", + currentValue: currentModeId, + options: availableModes.map((mode) => ({ value: mode.id, name: mode.name })), + }, + ]; + } if (parameterizedModelPicker) { const baseOptions: Array = [ { @@ -272,23 +297,34 @@ function availableModels(): ReadonlyArray<{ })); } -const availableModes: ReadonlyArray = [ - { - id: "ask", - name: "Ask", - description: "Request permission before making any changes", - }, - { - id: "architect", - name: "Architect", - description: "Design and plan software systems without implementation", - }, - { - id: "code", - name: "Code", - description: "Write and modify code with full tool access", - }, -]; +const antigravityModels = [ + { modelId: "gemini-test-low", name: "Gemini Test Low" }, + { modelId: "gemini-test-high", name: "Gemini Test High" }, +] satisfies ReadonlyArray; + +const availableModes: ReadonlyArray = antigravityProfile + ? [ + { id: "default", name: "Default" }, + { id: "auto_edit", name: "Auto edit" }, + { id: "yolo", name: "YOLO" }, + ] + : [ + { + id: "ask", + name: "Ask", + description: "Request permission before making any changes", + }, + { + id: "architect", + name: "Architect", + description: "Design and plan software systems without implementation", + }, + { + id: "code", + name: "Code", + description: "Write and modify code with full tool access", + }, + ]; function modeState(): AcpSchema.SessionModeState { return { @@ -318,6 +354,9 @@ const grokAcpModels: ReadonlyArray = [ ]; function modelState(): AcpSchema.SessionModelState { + if (antigravityProfile) { + return { currentModelId, availableModels: antigravityModels }; + } const modelId = grokAcpModels.some((model) => model.modelId === currentModelId) ? currentModelId : "grok-4.6"; @@ -330,14 +369,49 @@ function modelState(): AcpSchema.SessionModelState { const program = Effect.gen(function* () { const agent = yield* EffectAcpAgent.AcpAgent; const programScope = yield* Scope.Scope; + const resumeRelease = yield* Deferred.make(); + const nativeCancelRequested = yield* Deferred.make(); + const nativeCancelRelease = yield* Deferred.make(); + const publishAntigravityCommands = (targetSessionId: string) => + agent.client.sessionUpdate({ + sessionId: targetSessionId, + update: { + sessionUpdate: "available_commands_update", + availableCommands: [ + { name: "plan", description: "Plan a task", input: { hint: "task" } }, + { name: "logout", description: "Sign out" }, + ], + }, + }); yield* agent.handleInitialize((request) => - Effect.sync(() => { + Effect.gen(function* () { + if (floodStderr) { + yield* Effect.promise( + () => + new Promise((resolve) => { + process.stderr.write("stderr".repeat(350_000), () => resolve()); + }), + ); + } parameterizedModelPicker = request.clientCapabilities?._meta?.parameterizedModelPicker === true; + if (antigravityProfile) { + return { + protocolVersion: 1, + agentInfo: { name: "antigravity-acp", version: "mock" }, + agentCapabilities: { + loadSession: true, + sessionCapabilities: { resume: {} }, + auth: { logout: {} }, + promptCapabilities: { image: true, embeddedContext: true }, + }, + authMethods: [{ id: "oauth-personal", name: "Sign in with Google" }], + }; + } return { protocolVersion: 1, - agentCapabilities: { loadSession: true }, + agentCapabilities: { loadSession: true, sessionCapabilities: { resume: {} } }, // Grok advertises model state before any session exists; the provider // health check reads it from here without authenticating. _meta: { modelState: modelState() }, @@ -345,7 +419,22 @@ const program = Effect.gen(function* () { }), ); - yield* agent.handleAuthenticate(() => Effect.succeed({})); + // Mirrors the real agent: the API key method reads GEMINI_API_KEY from the + // process environment and rejects when it is missing. + yield* agent.handleAuthenticate((request) => + !antigravityProfile || request.methodId === "oauth-personal" + ? Effect.succeed({}) + : request.methodId === "gemini-api-key" && process.env.GEMINI_API_KEY + ? Effect.succeed({}) + : Effect.fail( + AcpError.AcpRequestError.invalidParams( + `Mock Antigravity rejected auth method ${request.methodId}.`, + ), + ), + ); + if (antigravityProfile) { + yield* agent.handleLogout(() => Effect.succeed({})); + } const availableCommandsUpdate = { sessionId, @@ -372,6 +461,9 @@ const program = Effect.gen(function* () { .sessionUpdate(availableCommandsUpdate) .pipe(Effect.delay(availableCommandsDelayMs), Effect.forkIn(programScope)); } + if (antigravityProfile) { + yield* publishAntigravityCommands(sessionId); + } return { sessionId, modes: modeState(), @@ -381,6 +473,30 @@ const program = Effect.gen(function* () { }), ); + yield* agent.handleResumeSession((request) => + Effect.gen(function* () { + yield* agent.client.sessionUpdate({ + sessionId: request.sessionId, + update: { + sessionUpdate: "user_message_chunk", + content: { type: "text", text: "native-resume-started" }, + }, + }); + if (waitForResumeRelease) { + yield* Deferred.await(resumeRelease); + } + if (antigravityProfile) { + yield* publishAntigravityCommands(request.sessionId); + } + return { + modes: modeState(), + models: modelState(), + configOptions: configOptions(), + _meta: { nativeResume: true }, + }; + }), + ); + const emitLoadReplayNotifications = (requestedSessionId: string) => { writeJsonRpcNotification("session/update", { _meta: { isReplay: true }, @@ -445,7 +561,7 @@ const program = Effect.gen(function* () { yield* agent.handleSetSessionModel((request) => Effect.gen(function* () { - if (!grokAcpModels.some((model) => model.modelId === request.modelId)) { + if (!modelState().availableModels.some((model) => model.modelId === request.modelId)) { return yield* AcpError.AcpRequestError.invalidParams( `Unknown mock model id: ${request.modelId}`, { @@ -500,6 +616,16 @@ const program = Effect.gen(function* () { Effect.gen(function* () { const cancelledSessionId = String(sessionId ?? "mock-session-1"); cancelledSessions.add(cancelledSessionId); + if (completeFirstPromptOnCancel) { + yield* Deferred.succeed(nativeCancelRequested, undefined); + yield* agent.client.sessionUpdate({ + sessionId: cancelledSessionId, + update: { + sessionUpdate: "agent_thought_chunk", + content: { type: "text", text: "native-cancel-received" }, + }, + }); + } if (emitLateUpdateAfterCancel) { yield* Effect.sleep("50 millis"); yield* Effect.sync(() => { @@ -520,6 +646,38 @@ const program = Effect.gen(function* () { const requestedSessionId = String(request.sessionId ?? sessionId); promptCount += 1; + if (completeFirstPromptOnCancel && promptCount === 1) { + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "tool_call", + toolCallId: "native-cancel-tool", + title: "Long command", + kind: "execute", + status: "in_progress", + }, + }); + yield* Deferred.await(nativeCancelRequested); + yield* Deferred.await(nativeCancelRelease); + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId: "native-cancel-tool", + status: "failed", + content: [{ type: "content", content: { type: "text", text: "Cancelled." } }], + }, + }); + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "Request cancelled." }, + }, + }); + return { stopReason: "cancelled", _meta: { nativeCancel: true } }; + } + if (Number.isFinite(promptDelayMs) && promptDelayMs > 0) { yield* Effect.sleep(`${promptDelayMs} millis`); } @@ -1102,6 +1260,60 @@ const program = Effect.gen(function* () { ); yield* agent.handleUnknownExtRequest((method, params) => { + if (method === "_test/environment") { + return Effect.succeed({ + inherited: process.env.T3_ACP_RUNTIME_AMBIENT === "sentinel", + explicit: process.env.T3_ACP_RUNTIME_EXPLICIT === "kept", + }); + } + if (method === "_test/release-resume") { + return Deferred.succeed(resumeRelease, undefined).pipe(Effect.as({})); + } + if (method === "_test/finish-cancel") { + return Deferred.succeed(nativeCancelRelease, undefined).pipe(Effect.as({})); + } + if (method === "_test/startup-metadata") { + return Effect.gen(function* () { + for (const [metadataSessionId, commandName, modeId] of [ + [sessionId, "plan", "code"], + ["child-session", "foreign-command", "ask"], + ] as const) { + yield* agent.client.sessionUpdate({ + sessionId: metadataSessionId, + update: { + sessionUpdate: "available_commands_update", + availableCommands: [{ name: commandName, description: "Native command" }], + }, + }); + yield* agent.client.sessionUpdate({ + sessionId: metadataSessionId, + update: { sessionUpdate: "current_mode_update", currentModeId: modeId }, + }); + yield* agent.client.sessionUpdate({ + sessionId: metadataSessionId, + update: { + sessionUpdate: "config_option_update", + configOptions: configOptions().map((option) => + option.type === "select" && option.category === "model" + ? { + ...option, + currentValue: metadataSessionId === sessionId ? "gpt-5.4" : "default", + } + : option, + ), + }, + }); + yield* agent.client.sessionUpdate({ + sessionId: metadataSessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "Startup transcript must not replay." }, + }, + }); + } + return {}; + }); + } if (method === "cursor/list_available_models") { return Effect.succeed({ models: availableModels(), @@ -1148,6 +1360,10 @@ const program = Effect.gen(function* () { return Effect.succeed({}); }); + yield* agent.handleUnknownExtNotification((method) => + method === "_test/exit" ? Effect.sync(() => process.exit(19)) : Effect.void, + ); + return yield* Effect.never; }).pipe( Effect.provide( diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 11b60289ddb6..621672fc3991 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -33,6 +33,15 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.serverGetConfig]: AuthOrchestrationReadScope, [WS_METHODS.serverRefreshProviders]: AuthOrchestrationOperateScope, [WS_METHODS.serverUpdateProvider]: AuthOrchestrationOperateScope, + [WS_METHODS.providerAuthStart]: AuthOrchestrationOperateScope, + [WS_METHODS.providerAuthComplete]: AuthOrchestrationOperateScope, + [WS_METHODS.providerAuthCancel]: AuthOrchestrationOperateScope, + [WS_METHODS.providerAuthLogout]: AuthOrchestrationOperateScope, + [WS_METHODS.providerAuthSubscribe]: AuthOrchestrationOperateScope, + [WS_METHODS.providerInstallStart]: AuthOrchestrationOperateScope, + [WS_METHODS.providerInstallCancel]: AuthOrchestrationOperateScope, + [WS_METHODS.providerInstallSubscribe]: AuthOrchestrationReadScope, + [WS_METHODS.providerInstallRemove]: AuthOrchestrationOperateScope, [WS_METHODS.serverUpdateServer]: AuthOrchestrationOperateScope, [WS_METHODS.serverUpdateServerWithProgress]: AuthOrchestrationOperateScope, [WS_METHODS.serverCommitDesktopUpdate]: AuthOrchestrationOperateScope, @@ -53,6 +62,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.serverGetResourceTelemetryHistory]: AuthOrchestrationReadScope, [WS_METHODS.serverRetryResourceTelemetry]: AuthOrchestrationOperateScope, [WS_METHODS.serverGetUsageSummary]: AuthOrchestrationReadScope, + [WS_METHODS.serverRefreshUsageRates]: AuthOrchestrationReadScope, [WS_METHODS.serverSignalProcess]: AuthOrchestrationOperateScope, [WS_METHODS.serverReportClientActivity]: AuthOrchestrationReadScope, [WS_METHODS.serverReportHostPowerState]: AuthOrchestrationOperateScope, 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/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 6cddee85c9ca..c81061e39c33 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -21,6 +21,7 @@ import { } from "@t3tools/contracts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Clock from "effect/Clock"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; @@ -28,6 +29,7 @@ import * as ManagedRuntime from "effect/ManagedRuntime"; import * as PubSub from "effect/PubSub"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; +import { it as effectIt } from "@effect/vitest"; import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import * as CheckpointStore from "../../checkpointing/CheckpointStore.ts"; @@ -56,6 +58,7 @@ import { type ProviderServiceShape, } from "../../provider/Services/ProviderService.ts"; import { checkpointRefForThreadTurn } from "../../checkpointing/Utils.ts"; +import { ProviderValidationError } from "../../provider/Errors.ts"; import { ServerConfig } from "../../config.ts"; import * as WorkspaceEntries from "../../workspace/WorkspaceEntries.ts"; import * as WorkspacePaths from "../../workspace/WorkspacePaths.ts"; @@ -87,6 +90,9 @@ function createProviderServiceHarness( const rollbackConversation = vi.fn( (_input: { readonly threadId: ThreadId; readonly numTurns: number }) => Effect.void, ); + const assertConversationRollbackSupported = vi.fn< + ProviderServiceShape["assertConversationRollbackSupported"] + >(() => Effect.void); const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as Effect.Effect; @@ -113,6 +119,7 @@ function createProviderServiceHarness( stopSession: () => unsupported(), listSessions, getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }), + assertConversationRollbackSupported, getInstanceInfo: (instanceId) => Effect.succeed({ instanceId, @@ -137,6 +144,7 @@ function createProviderServiceHarness( return { service, + assertConversationRollbackSupported, rollbackConversation, emit, }; @@ -1002,6 +1010,89 @@ describe("CheckpointReactor", () => { ).toBe(true); }); + effectIt.effect("rejects unsupported rewind before changing files, checkpoints, or history", () => + Effect.gen(function* () { + const harness = yield* Effect.promise(() => + createHarness({ providerName: ProviderDriverKind.make("antigravity") }), + ); + const threadId = ThreadId.make("thread-1"); + const createdAt = "2026-01-01T00:00:00.000Z"; + const checked = yield* Deferred.make(); + harness.provider.assertConversationRollbackSupported.mockImplementation(() => + Deferred.succeed(checked, undefined).pipe( + Effect.andThen( + Effect.fail( + new ProviderValidationError({ + operation: "ProviderService.assertConversationRollbackSupported", + issue: "Provider 'antigravity' does not support conversation rewind.", + }), + ), + ), + ), + ); + + for (const turnCount of [1, 2]) { + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make(`cmd-unsupported-rewind-message-${turnCount}`), + threadId, + message: { + messageId: MessageId.make(`message-unsupported-rewind-${turnCount}`), + role: "user", + text: `Keep message ${turnCount}`, + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt, + }); + yield* harness.engine.dispatch({ + type: "thread.turn.diff.complete", + commandId: CommandId.make(`cmd-unsupported-rewind-diff-${turnCount}`), + threadId, + turnId: asTurnId(`turn-unsupported-rewind-${turnCount}`), + completedAt: createdAt, + checkpointRef: checkpointRefForThreadTurn(threadId, turnCount), + status: "ready", + files: [], + checkpointTurnCount: turnCount, + createdAt, + }); + } + const before = (yield* Effect.promise(() => harness.readModel())).threads.find( + (thread) => thread.id === threadId, + ); + + yield* harness.engine.dispatch({ + type: "thread.checkpoint.revert", + commandId: CommandId.make("cmd-unsupported-rewind"), + threadId, + turnCount: 1, + createdAt, + }); + yield* Deferred.await(checked); + yield* Effect.promise(() => harness.drain()); + + const after = (yield* Effect.promise(() => harness.readModel())).threads.find( + (thread) => thread.id === threadId, + ); + expect(after?.checkpoints).toEqual(before?.checkpoints); + expect(after?.messages).toEqual(before?.messages); + expect(after?.latestTurn).toEqual(before?.latestTurn); + expect(after?.activities).toContainEqual( + expect.objectContaining({ + kind: "checkpoint.revert.failed", + payload: expect.objectContaining({ + detail: expect.stringContaining("does not support conversation rewind"), + }), + }), + ); + expect(harness.provider.rollbackConversation).not.toHaveBeenCalled(); + expect(NodeFS.readFileSync(NodePath.join(harness.cwd, "README.md"), "utf8")).toBe("v3\n"); + expect(gitRefExists(harness.cwd, checkpointRefForThreadTurn(threadId, 2))).toBe(true); + }), + ); + it("executes provider revert and emits thread.reverted for checkpoint revert requests", async () => { const harness = await createHarness(); const createdAt = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.ts index ebf402bb4fc0..4ee94c67a5e3 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.ts @@ -773,6 +773,7 @@ const make = Effect.gen(function* () { }).pipe(Effect.catch(() => Effect.void)); return; } + yield* providerService.assertConversationRollbackSupported(event.payload.threadId); const restored = yield* checkpointStore.restoreCheckpoint({ cwd: sessionRuntime.value.cwd, diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index d52985fa62b6..5db103d5b70e 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -9,6 +9,7 @@ import { ProviderSession, ProviderDriverKind, ProviderInstanceId, + ProviderSetupError, } from "@t3tools/contracts"; import { createModelSelection } from "@t3tools/shared/model"; import { @@ -31,6 +32,7 @@ import * as ManagedRuntime from "effect/ManagedRuntime"; import * as PubSub from "effect/PubSub"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; import { it as effectIt } from "@effect/vitest"; import { afterEach, describe, expect, it, vi } from "vite-plus/test"; @@ -44,6 +46,7 @@ import { ProviderService, type ProviderServiceShape, } from "../../provider/Services/ProviderService.ts"; +import { ProviderAuthService } from "../../provider/Services/ProviderAuthService.ts"; import { makeProviderRegistryLayer } from "../../provider/testUtils/providerRegistryMock.ts"; import { TextGeneration } from "../../textGeneration/TextGeneration.ts"; import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts"; @@ -109,7 +112,10 @@ async function waitFor( describe("ProviderCommandReactor", () => { let runtime: ManagedRuntime.ManagedRuntime< - OrchestrationEngineService | ProviderCommandReactor | ProjectionSnapshotQuery, + | OrchestrationEngineService + | ProviderCommandReactor + | ProjectionSnapshotQuery + | SqlClient.SqlClient, unknown > | null = null; let scope: Scope.Closeable | null = null; @@ -172,6 +178,7 @@ describe("ProviderCommandReactor", () => { readonly startSessionEffect?: ( session: ProviderSession, ) => Effect.Effect; + readonly tryHandlePromptCommandEffect?: ProviderAuthService["Service"]["tryHandlePromptCommand"]; }) { const now = "2026-01-01T00:00:00.000Z"; const baseDir = @@ -180,6 +187,9 @@ describe("ProviderCommandReactor", () => { const { stateDir } = deriveServerPathsSync(baseDir, undefined); createdStateDirs.add(stateDir); const runtimeEventPubSub = Effect.runSync(PubSub.unbounded()); + const tryHandlePromptCommand = vi.fn( + input?.tryHandlePromptCommandEffect ?? (() => Effect.succeed(false)), + ); let nextSessionIndex = 1; const runtimeSessions: Array = []; const modelSelection = input?.threadModelSelection ?? { @@ -348,10 +358,17 @@ describe("ProviderCommandReactor", () => { Effect.succeed({ sessionModelSwitch: input?.sessionModelSwitch ?? "in-session", }), + assertConversationRollbackSupported: () => unsupported(), getInstanceInfo: (instanceId) => { const raw = String(instanceId); const driverKind = ProviderDriverKind.make( - raw.startsWith("claude") ? "claudeAgent" : raw.startsWith("codex") ? "codex" : raw, + raw.startsWith("claude") + ? "claudeAgent" + : raw.startsWith("codex") + ? "codex" + : raw.startsWith("antigravity") + ? "antigravity" + : raw, ); return Effect.succeed({ instanceId, @@ -421,6 +438,7 @@ describe("ProviderCommandReactor", () => { Layer.provideMerge(reactorOrchestrationLayer), Layer.provideMerge(projectionSnapshotLayer), Layer.provideMerge(Layer.succeed(ProviderService, service)), + Layer.provide(Layer.mock(ProviderAuthService, { tryHandlePromptCommand })), Layer.provideMerge(makeProviderRegistryLayer(providerSnapshots as never)), Layer.provideMerge( Layer.mock(GitWorkflowService.GitWorkflowService)({ @@ -445,6 +463,7 @@ describe("ProviderCommandReactor", () => { }), ), Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(SqlitePersistenceMemory), Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)), Layer.provideMerge(NodeServices.layer), ); @@ -531,6 +550,18 @@ describe("ProviderCommandReactor", () => { return { engine, readModel: () => Effect.runPromise(snapshotQuery.getSnapshot()), + readPendingTurnStarts: () => + runtime!.runPromise( + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + return yield* sql<{ readonly threadId: string }>` + SELECT thread_id AS "threadId" + FROM projection_turns + WHERE turn_id IS NULL AND state = 'pending' + `; + }), + ), + tryHandlePromptCommand, startSession, sendTurn, interruptTurn, @@ -553,6 +584,220 @@ describe("ProviderCommandReactor", () => { }; } + effectIt.effect.each(["new", "ready", "stopped"] as const)( + "handles sign-out for a %s thread before worktree repair, text helpers, or startup", + (sessionStatus) => + Effect.gen(function* () { + const instanceId = ProviderInstanceId.make("antigravity-personal"); + const handled = yield* Deferred.make(); + const harness = yield* Effect.promise(() => + createHarness({ + ...(sessionStatus === "new" + ? {} + : { + threadModelSelection: { instanceId, model: "gemini-3.1-pro" }, + }), + tryHandlePromptCommandEffect: () => + Deferred.succeed(handled, undefined).pipe(Effect.as(true)), + }), + ); + const threadId = ThreadId.make("thread-1"); + const createdAt = "2026-01-01T00:00:00.000Z"; + if (sessionStatus !== "new") { + yield* harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-sign-out-bound-session"), + threadId, + session: { + threadId, + providerInstanceId: instanceId, + providerName: "antigravity", + status: sessionStatus, + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: createdAt, + }, + createdAt, + }); + } + yield* harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-sign-out-worktree"), + threadId, + title: "New thread", + branch: "t3code/1234abcd", + worktreePath: NodePath.join(harness.stateDir, "missing-worktree"), + }); + + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-provider-sign-out"), + threadId, + message: { + messageId: MessageId.make("message-provider-sign-out"), + role: "user", + text: "/logout", + attachments: [], + }, + modelSelection: { + instanceId: + sessionStatus === "new" ? instanceId : ProviderInstanceId.make("antigravity-other"), + model: "gemini-3.1-pro", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt, + }); + yield* Deferred.await(handled); + yield* Effect.promise(() => harness.drain()); + + const thread = (yield* Effect.promise(() => harness.readModel())).threads.find( + (entry) => entry.id === threadId, + ); + expect(thread?.session).toMatchObject({ + status: "stopped", + providerName: "antigravity", + providerInstanceId: instanceId, + activeTurnId: null, + lastError: null, + }); + expect(thread?.messages.map((message) => message.text)).toEqual(["/logout"]); + expect(thread?.activities).toContainEqual( + expect.objectContaining({ kind: "provider.auth.signed-out", tone: "info", turnId: null }), + ); + expect(yield* Effect.promise(() => harness.readPendingTurnStarts())).toEqual([]); + expect(harness.tryHandlePromptCommand).toHaveBeenCalledWith({ + instanceId, + text: "/logout", + hasAttachments: false, + }); + expect(harness.pruneWorktrees).not.toHaveBeenCalled(); + expect(harness.createWorktree).not.toHaveBeenCalled(); + expect(harness.generateThreadTitle).not.toHaveBeenCalled(); + expect(harness.generateBranchName).not.toHaveBeenCalled(); + expect(harness.startSession).not.toHaveBeenCalled(); + expect(harness.sendTurn).not.toHaveBeenCalled(); + }), + ); + + effectIt.effect("clears a failed sign-out request without sending it as a prompt", () => + Effect.gen(function* () { + const instanceId = ProviderInstanceId.make("antigravity-personal"); + const handled = yield* Deferred.make(); + const harness = yield* Effect.promise(() => + createHarness({ + threadModelSelection: { instanceId, model: "gemini-3.1-pro" }, + tryHandlePromptCommandEffect: () => + Deferred.succeed(handled, undefined).pipe( + Effect.andThen( + Effect.fail( + new ProviderSetupError({ + instanceId, + operation: "logout", + detail: "The provider could not sign out. Try again.", + }), + ), + ), + ), + }), + ); + const threadId = ThreadId.make("thread-1"); + + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-provider-sign-out-failed"), + threadId, + message: { + messageId: MessageId.make("message-provider-sign-out-failed"), + role: "user", + text: "/logout", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: "2026-01-01T00:00:00.000Z", + }); + yield* Deferred.await(handled); + yield* Effect.promise(() => harness.drain()); + + const thread = (yield* Effect.promise(() => harness.readModel())).threads.find( + (entry) => entry.id === threadId, + ); + expect(thread?.session).toMatchObject({ + status: "error", + activeTurnId: null, + lastError: expect.stringContaining("The provider could not sign out. Try again."), + }); + expect(thread?.activities).toContainEqual( + expect.objectContaining({ kind: "provider.turn.start.failed", tone: "error" }), + ); + expect( + thread?.activities.some((activity) => activity.kind === "provider.auth.signed-out"), + ).toBe(false); + expect(yield* Effect.promise(() => harness.readPendingTurnStarts())).toEqual([]); + expect(harness.startSession).not.toHaveBeenCalled(); + expect(harness.sendTurn).not.toHaveBeenCalled(); + }), + ); + + effectIt.effect.each([ + { label: "a command mention", text: "What does /logout do?", attachments: [] }, + { + label: "a command with an attachment", + text: "/logout", + attachments: [ + { + type: "file" as const, + id: "attached-notes", + name: "notes.txt", + mimeType: "text/plain", + sizeBytes: 8, + }, + ], + }, + { label: "another provider's command", text: "/logout", attachments: [] }, + ])("sends $label when the provider auth handler leaves it unhandled", ({ text, attachments }) => + Effect.gen(function* () { + const started = yield* Deferred.make(); + const harness = yield* Effect.promise(() => + createHarness({ + startSessionEffect: (session) => + Deferred.succeed(started, undefined).pipe(Effect.as(session)), + }), + ); + + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-provider-command-unhandled"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: MessageId.make("message-provider-command-unhandled"), + role: "user", + text, + attachments, + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: "2026-01-01T00:00:00.000Z", + }); + yield* Deferred.await(started); + yield* Effect.promise(() => harness.drain()); + + expect(harness.tryHandlePromptCommand).toHaveBeenCalledWith({ + instanceId: ProviderInstanceId.make("codex"), + text, + hasAttachments: attachments.length > 0, + }); + expect(harness.sendTurn).toHaveBeenCalledWith( + expect.objectContaining({ + input: text, + ...(attachments.length > 0 ? { attachments } : {}), + }), + ); + }), + ); + it("reacts to thread.turn.start by ensuring session and sending provider turn", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 6cdb9fae821c..c789683bf3a0 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -33,6 +33,7 @@ import { increment, orchestrationEventsProcessedTotal } from "../../observabilit import { ProviderAdapterRequestError } from "../../provider/Errors.ts"; import type { ProviderServiceError } from "../../provider/Errors.ts"; import { TextGeneration } from "../../textGeneration/TextGeneration.ts"; +import { ProviderAuthService } from "../../provider/Services/ProviderAuthService.ts"; import { ProviderService } from "../../provider/Services/ProviderService.ts"; import { ProviderRegistry } from "../../provider/Services/ProviderRegistry.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; @@ -309,6 +310,7 @@ const make = Effect.gen(function* () { const crypto = yield* Crypto.Crypto; const orchestrationEngine = yield* OrchestrationEngineService; const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; + const providerAuthService = yield* ProviderAuthService; const providerService = yield* ProviderService; const providerRegistry = yield* ProviderRegistry; const gitWorkflow = yield* GitWorkflowService; @@ -1174,39 +1176,6 @@ const make = Effect.gen(function* () { return; } - yield* ensureThreadWorktree(thread); - - const isFirstUserMessageTurn = - thread.messages.filter((entry) => entry.role === "user").length === 1; - if (isFirstUserMessageTurn) { - const project = yield* resolveProject(thread.projectId); - const generationCwd = - resolveThreadWorkspaceCwd({ - thread, - projects: project ? [project] : [], - }) ?? process.cwd(); - const generationInput = { - messageText: assistantCitationsToPlainText(message.text), - ...(message.attachments !== undefined ? { attachments: message.attachments } : {}), - ...(event.payload.titleSeed !== undefined ? { titleSeed: event.payload.titleSeed } : {}), - }; - - yield* maybeGenerateAndRenameWorktreeBranchForFirstTurn({ - threadId: event.payload.threadId, - branch: thread.branch, - worktreePath: thread.worktreePath, - ...generationInput, - }).pipe(Effect.forkScoped); - - if (canReplaceThreadTitle(thread.title, event.payload.titleSeed)) { - yield* maybeGenerateThreadTitleForFirstTurn({ - threadId: event.payload.threadId, - cwd: generationCwd, - ...generationInput, - }).pipe(Effect.forkScoped); - } - } - const handleTurnStartFailure = (cause: Cause.Cause) => { if (Cause.hasInterruptsOnly(cause)) { return Effect.void; @@ -1243,6 +1212,90 @@ const make = Effect.gen(function* () { ), ); + const authCommandHandled = yield* Effect.gen(function* () { + // Native account commands belong to the thread's existing provider session. + const instanceId = + thread.session?.providerInstanceId ?? + event.payload.modelSelection?.instanceId ?? + thread.modelSelection.instanceId; + const handled = yield* providerAuthService.tryHandlePromptCommand({ + instanceId, + text: message.text, + hasAttachments: (message.attachments?.length ?? 0) > 0, + }); + if (!handled) { + return false; + } + + const instanceInfo = yield* providerService.getInstanceInfo(instanceId); + yield* setThreadSession({ + threadId: thread.id, + session: { + threadId: thread.id, + status: "stopped", + providerName: instanceInfo.driverKind, + providerInstanceId: instanceId, + runtimeMode: thread.runtimeMode, + activeTurnId: null, + lastError: null, + updatedAt: event.payload.createdAt, + }, + createdAt: event.payload.createdAt, + }); + yield* orchestrationEngine.dispatch({ + type: "thread.activity.append", + commandId: yield* serverCommandId("provider-sign-out"), + threadId: thread.id, + activity: { + id: yield* serverEventId(), + tone: "info", + kind: "provider.auth.signed-out", + summary: "Provider signed out", + payload: { providerInstanceId: instanceId }, + turnId: null, + createdAt: event.payload.createdAt, + }, + createdAt: event.payload.createdAt, + }); + return true; + }).pipe(Effect.catchCause((cause) => recoverTurnStartFailure(cause).pipe(Effect.as(true)))); + if (authCommandHandled) { + return; + } + + yield* ensureThreadWorktree(thread); + + const isFirstUserMessageTurn = + thread.messages.filter((entry) => entry.role === "user").length === 1; + if (isFirstUserMessageTurn) { + const project = yield* resolveProject(thread.projectId); + const generationCwd = + resolveThreadWorkspaceCwd({ + thread, + projects: project ? [project] : [], + }) ?? process.cwd(); + const generationInput = { + messageText: assistantCitationsToPlainText(message.text), + ...(message.attachments !== undefined ? { attachments: message.attachments } : {}), + ...(event.payload.titleSeed !== undefined ? { titleSeed: event.payload.titleSeed } : {}), + }; + + yield* maybeGenerateAndRenameWorktreeBranchForFirstTurn({ + threadId: event.payload.threadId, + branch: thread.branch, + worktreePath: thread.worktreePath, + ...generationInput, + }).pipe(Effect.forkScoped); + + if (canReplaceThreadTitle(thread.title, event.payload.titleSeed)) { + yield* maybeGenerateThreadTitleForFirstTurn({ + threadId: event.payload.threadId, + cwd: generationCwd, + ...generationInput, + }).pipe(Effect.forkScoped); + } + } + const sendTurnRequest = yield* buildSendTurnRequestForThread({ threadId: event.payload.threadId, messageText: message.text, diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index a4640179af99..0d8c4f874909 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -111,6 +111,7 @@ function createProviderServiceHarness() { stopSession: () => unsupported(), listSessions: () => Effect.succeed([...runtimeSessions]), getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }), + assertConversationRollbackSupported: () => unsupported(), getInstanceInfo: (instanceId) => { const driverKind = ProviderDriverKind.make(String(instanceId)); return Effect.succeed({ diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts index 738c9109f0fb..d67475710df1 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts @@ -17,6 +17,7 @@ import { assert, describe, it } from "@effect/vitest"; import * as Crypto from "effect/Crypto"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as PubSub from "effect/PubSub"; import * as Queue from "effect/Queue"; @@ -134,6 +135,7 @@ interface HarnessOptions { readonly settings?: ServerSettings; readonly branchPullRequest?: GitManager["Service"]["branchPullRequest"]; readonly pullRequestSummary?: PullRequestService["Service"]["summary"]; + readonly existingWorktreePaths?: ReadonlyArray; readonly onDispatch?: ( command: AutoSettleCommand, ) => Effect.Effect; @@ -237,6 +239,9 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: Layer.succeed(ServerSettingsService, serverSettings), Layer.succeed(ServerActivation, Deferred.await(activation)), Layer.succeed(Crypto.Crypto, testCrypto), + FileSystem.layerNoop({ + exists: (path) => Effect.succeed(options.existingWorktreePaths?.includes(path) ?? false), + }), ); return { @@ -649,6 +654,43 @@ describe("ThreadSettlementReactor", () => { ), ); + it.effect("looks up the branch pull request from a thread's live worktree", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot( + [ + makeThread("live-worktree", { + branch: "feature/live", + worktreePath: "/workspace/project-root/.worktrees/live", + }), + makeThread("deleted-worktree", { + branch: "feature/deleted", + worktreePath: "/workspace/project-root/.worktrees/deleted", + }), + ], + [makeProject(PROJECT_ID, "/workspace/project-root")], + ), + existingWorktreePaths: ["/workspace/project-root/.worktrees/live"], + }); + + yield* Effect.gen(function* () { + const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; + yield* startHarness(reactor, fixture.activation, fixture.snapshotReads); + + assert.deepStrictEqual( + new Set(yield* Ref.get(fixture.branchCalls)), + new Set([ + { cwd: "/workspace/project-root/.worktrees/live", branch: "feature/live" }, + { cwd: "/workspace/project-root", branch: "feature/deleted" }, + ]), + ); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + it.effect("carries the snapshot guard and survives a stale dispatch rejection", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.ts b/apps/server/src/orchestration/ThreadSettlementReactor.ts index 9dd7cd5e76fd..9867a855a85e 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.ts @@ -5,6 +5,7 @@ import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Schedule from "effect/Schedule"; import type * as Scope from "effect/Scope"; @@ -37,6 +38,7 @@ export const make = Effect.gen(function* () { const git = yield* GitManager.GitManager; const pullRequests = yield* PullRequestService.PullRequestService; const crypto = yield* Crypto.Crypto; + const fileSystem = yield* FileSystem.FileSystem; const sweep = Effect.fn("ThreadSettlementReactor.sweep")(function* ( mergedPullRequest: PullRequestService.PullRequestMergeEvent | null, @@ -54,6 +56,26 @@ export const make = Effect.gen(function* () { mergedPullRequest.repository.toLowerCase() && thread.linkedPullRequest.number === mergedPullRequest.number)), ); + // Use the same cwd as the sidebar so both paths share GitManager's PR cache. + const lookupCwdByThreadId = new Map(); + yield* Effect.forEach( + candidates, + (thread) => + Effect.gen(function* () { + const project = projects.get(thread.projectId); + if (project === undefined || thread.linkedPullRequest != null) return; + const worktreeExists = + thread.worktreePath !== null && + (yield* fileSystem.exists(thread.worktreePath).pipe(Effect.orElseSucceed(() => false))); + lookupCwdByThreadId.set( + thread.id, + worktreeExists && thread.worktreePath !== null + ? thread.worktreePath + : project.workspaceRoot, + ); + }), + { concurrency: 8, discard: true }, + ); const lookupKey = (thread: (typeof candidates)[number]) => { if (thread.linkedPullRequest != null) { return JSON.stringify([ @@ -64,11 +86,9 @@ export const make = Effect.gen(function* () { ]); } if (thread.branch === null) return JSON.stringify(["none", thread.id]); - const project = projects.get(thread.projectId); + const cwd = lookupCwdByThreadId.get(thread.id); return JSON.stringify( - project === undefined - ? ["missing-project", thread.id] - : ["branch", project.workspaceRoot, thread.branch], + cwd === undefined ? ["missing-project", thread.id] : ["branch", cwd, thread.branch], ); }; const groups = Map.groupBy(candidates, lookupKey); @@ -100,11 +120,11 @@ export const make = Effect.gen(function* () { } satisfies SettlementPullRequest; } if (thread.branch === null) return null; - const project = projects.get(thread.projectId); - if (project === undefined) { + const cwd = lookupCwdByThreadId.get(thread.id); + if (cwd === undefined) { return yield* Effect.die(new Error("thread project not found")); } - return yield* git.branchPullRequest({ cwd: project.workspaceRoot, branch: thread.branch }); + return yield* git.branchPullRequest({ cwd, branch: thread.branch }); }); yield* Effect.forEach( diff --git a/apps/server/src/provider/AntigravityAuth.test.ts b/apps/server/src/provider/AntigravityAuth.test.ts new file mode 100644 index 000000000000..f009255e90a2 --- /dev/null +++ b/apps/server/src/provider/AntigravityAuth.test.ts @@ -0,0 +1,527 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { ProviderInstanceId, ProviderSetupError, type ProviderAuthState } from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as Option from "effect/Option"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; +import * as AcpErrors from "effect-acp/errors"; +import type * as AcpSchema from "effect-acp/schema"; + +import { + makeAntigravityAuth, + type AntigravityAuth, + type AntigravityAuthRuntime, +} from "./AntigravityAuth.ts"; +import type { AcpSessionRuntimeStartResult } from "./acp/AcpSessionRuntime.ts"; + +const instanceId = ProviderInstanceId.make("antigravity-auth-test"); +const owner = "t3-auth-session-owner"; +const otherOwner = "t3-auth-session-other"; +const authorizationUrl = + "https://accounts.google.com/o/oauth2/v2/auth?response_type=code&redirect_uri=http%3A%2F%2F127.0.0.1%3A51234%2F&state=test-state"; +const callbackUrl = "http://127.0.0.1:51234/?state=test-state&code=test-code"; + +const initialized = { + protocolVersion: 1, + authMethods: [{ id: "oauth-personal", name: "Log in with Google" }], + agentCapabilities: { auth: { logout: {} } }, +} satisfies AcpSchema.InitializeResponse; +const started: AcpSessionRuntimeStartResult = { + sessionId: "native-session", + initializeResult: initialized, + sessionSetupResult: { + sessionId: "native-session", + models: { + currentModelId: "gemini-test", + availableModels: [{ modelId: "gemini-test", name: "Gemini test" }], + }, + }, + modelConfigId: "model", +}; + +const phase = (auth: AntigravityAuth, value: ProviderAuthState["phase"], sessionId = owner) => + auth.controller.subscribe(sessionId).pipe( + Stream.filter((state) => state.phase === value), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + +const makeHarness = Effect.fn("makeAuthTestHarness")(function* ( + options: { + readonly interactive?: boolean; + readonly authorizationUrls?: ReadonlyArray; + readonly supportsLogout?: boolean; + readonly beforeInitialize?: Effect.Effect; + readonly forwardCallback?: Effect.Effect; + } = {}, +) { + const authenticated = yield* Deferred.make(); + 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* () { + events.push("process-close"); + yield* Deferred.succeed(closed, undefined); + }), + ); + return { + initialize: () => + Effect.gen(function* () { + events.push("initialize"); + yield* options.beforeInitialize ?? Effect.void; + return options.supportsLogout === false + ? { ...initialized, agentCapabilities: {} } + : initialized; + }), + start: () => + Effect.gen(function* () { + events.push("authenticate"); + if (options.interactive !== false && input.onAuthorizationUrl) { + for (const url of options.authorizationUrls ?? [authorizationUrl]) { + yield* input.onAuthorizationUrl(url); + } + } + yield* Deferred.await(authenticated); + events.push("session-new"); + yield* Deferred.await(discovered); + return started; + }), + request: (method) => + Effect.sync(() => { + events.push(method); + return {}; + }), + } satisfies AntigravityAuthRuntime; + }), + onAuthenticated: () => + Effect.sync(() => { + catalog = ["gemini-test"]; + events.push("catalog-published"); + }), + onSignedOut: Effect.sync(() => { + catalog = []; + events.push("catalog-cleared"); + }), + forwardCallback: () => + options.forwardCallback ?? + Effect.sync(() => { + forwarded += 1; + }), + }); + return { + auth, + authenticated, + discovered, + closed, + 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(); + const state = yield* harness.auth.controller.start(owner); + assert.isNotNull(state.flowId); + const waiting = yield* phase(harness.auth, "waiting"); + assert.equal(waiting.authorizationUrl, authorizationUrl); + + const other = yield* phase(harness.auth, "waiting", otherOwner); + assert.isNull(other.authorizationUrl); + assert.isNull(other.flowId); + const stolen = yield* harness.auth.controller + .complete(otherOwner, { flowId: state.flowId!, callbackUrl }) + .pipe(Effect.exit); + assert.isTrue(Exit.isFailure(stolen)); + assert.equal(harness.forwarded(), 0); + + const verifying = yield* harness.auth.controller.complete(owner, { + flowId: state.flowId!, + callbackUrl, + }); + assert.equal(verifying.phase, "verifying"); + assert.equal(harness.forwarded(), 1); + assert.deepEqual(harness.catalog(), ["previous-account-model"]); + + yield* Deferred.succeed(harness.authenticated, undefined); + yield* Deferred.succeed(harness.discovered, undefined); + const succeeded = yield* phase(harness.auth, "succeeded"); + assert.deepEqual(harness.catalog(), ["gemini-test"]); + assert.isNull(succeeded.authorizationUrl); + assert.isNull(succeeded.expiresAt); + assert.equal(harness.events.at(-1), "process-close"); + }), + ); + + it.effect("does not call callback HTTP success a successful Google sign-in", () => + 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* Deferred.fail( + harness.authenticated, + AcpErrors.AcpRequestError.internalError(`access_denied ${callbackUrl}`), + ); + const failed = yield* phase(harness.auth, "failed"); + assert.include(failed.message ?? "", "not approved"); + assert.notInclude(failed.message ?? "", "test-code"); + assert.isNull(failed.authorizationUrl); + assert.deepEqual(harness.catalog(), ["previous-account-model"]); + yield* Deferred.await(harness.closed); + }), + ); + + it.effect("accepts direct local or cached completion without a callback RPC", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ interactive: false }); + yield* harness.auth.controller.start(owner); + yield* Deferred.succeed(harness.authenticated, undefined); + yield* Deferred.succeed(harness.discovered, undefined); + yield* phase(harness.auth, "succeeded"); + assert.equal(harness.forwarded(), 0); + assert.deepEqual(harness.catalog(), ["gemini-test"]); + }), + ); + + it.effect("rejects mismatched callbacks without sending any HTTP request", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + const state = yield* harness.auth.controller.start(owner); + yield* phase(harness.auth, "waiting"); + for (const invalidUrl of [ + callbackUrl.replace("51234", "51235"), + callbackUrl.replace("test-state", "wrong-state"), + callbackUrl.replace("/?", "/other?"), + `${callbackUrl}&state=test-state`, + ]) { + const result = yield* harness.auth.controller + .complete(owner, { flowId: state.flowId!, callbackUrl: invalidUrl }) + .pipe(Effect.exit); + assert.isTrue(Exit.isFailure(result)); + } + assert.equal(harness.forwarded(), 0); + assert.equal((yield* phase(harness.auth, "waiting")).authorizationUrl, authorizationUrl); + yield* harness.auth.controller.cancel(owner, state.flowId!); + }), + ); + + it.effect("fails the flow when delivery fails after the requesting client disconnects", () => + Effect.gen(function* () { + const deliveryGate = yield* Deferred.make(); + const harness = yield* makeHarness({ + forwardCallback: Deferred.await(deliveryGate).pipe( + Effect.andThen( + Effect.fail( + new ProviderSetupError({ + instanceId, + operation: "complete", + detail: "loopback refused", + }), + ), + ), + ), + }); + const state = yield* harness.auth.controller.start(owner); + yield* phase(harness.auth, "waiting"); + // The client sends the callback, then its socket drops before Google answers. + const request = yield* harness.auth.controller + .complete(owner, { flowId: state.flowId!, callbackUrl }) + .pipe(Effect.forkScoped); + yield* phase(harness.auth, "verifying"); + yield* Fiber.interrupt(request); + yield* Deferred.succeed(deliveryGate, undefined); + const failed = yield* phase(harness.auth, "failed"); + assert.include(failed.message ?? "", "Could not deliver"); + assert.isTrue(harness.events.includes("process-close")); + }), + ); + + it.effect("cancel closes the owned process without forwarding a denial", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + const state = yield* harness.auth.controller.start(owner); + yield* phase(harness.auth, "waiting"); + const wrongOwner = yield* harness.auth.controller + .cancel(otherOwner, state.flowId!) + .pipe(Effect.exit); + assert.isTrue(Exit.isFailure(wrongOwner)); + const cancelled = yield* harness.auth.controller.cancel(owner, state.flowId!); + assert.equal(cancelled.phase, "cancelled"); + assert.isNull(cancelled.authorizationUrl); + assert.equal(harness.forwarded(), 0); + yield* Deferred.await(harness.closed); + assert.deepEqual(harness.catalog(), ["previous-account-model"]); + }), + ); + + it.effect("expires the flow at the official deadline and removes its URL", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + const state = yield* harness.auth.controller.start(owner); + yield* phase(harness.auth, "waiting"); + yield* TestClock.adjust("300 seconds"); + const failed = yield* phase(harness.auth, "failed"); + assert.include(failed.message ?? "", "expired"); + assert.isNull(failed.authorizationUrl); + yield* Deferred.await(harness.closed); + const late = yield* harness.auth.controller + .complete(owner, { flowId: state.flowId!, callbackUrl }) + .pipe(Effect.exit); + assert.isTrue(Exit.isFailure(late)); + assert.equal(harness.forwarded(), 0); + }), + ); + + it.effect("survives subscriber disconnect and does not replace a competing client's flow", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + const first = yield* harness.auth.controller.start(owner); + yield* phase(harness.auth, "waiting"); + const second = yield* harness.auth.controller.start(owner); + assert.equal(first.flowId, second.flowId); + const competing = yield* harness.auth.controller.start(otherOwner).pipe(Effect.exit); + assert.isTrue(Exit.isFailure(competing)); + const resumed = yield* phase(harness.auth, "waiting"); + assert.equal(resumed.flowId, first.flowId); + assert.deepEqual(harness.events, ["process-open", "authenticate"]); + yield* harness.auth.controller.cancel(owner, first.flowId!); + }), + ); + + it.effect("sign-out closes admission and every process before fresh native logout", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + const processScope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(processScope, Exit.void)); + const stop = Effect.gen(function* () { + harness.events.push("chat-close"); + yield* Scope.close(processScope, Exit.void); + }); + yield* harness.auth.withProcess(stop, Effect.void).pipe(Scope.provide(processScope)); + const stopSessions = Effect.gen(function* () { + harness.events.push("sessions-stop"); + const denied = yield* harness.auth + .withProcess( + Effect.void, + Effect.sync(() => harness.events.push("late-process")), + ) + .pipe(Effect.scoped, Effect.exit); + assert.isTrue(Exit.isFailure(denied)); + }); + const result = yield* harness.auth.controller.logout(stopSessions); + assert.equal(result.phase, "idle"); + assert.deepEqual(harness.events, [ + "sessions-stop", + "chat-close", + "process-open", + "initialize", + "logout", + "catalog-cleared", + "process-close", + ]); + assert.deepEqual(harness.catalog(), []); + yield* harness.auth.withProcess(Effect.void, Effect.void).pipe(Effect.scoped); + }), + ); + + 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", + () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + const entering = yield* Deferred.make(); + const continueStartup = yield* Deferred.make(); + const processScope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(processScope, Exit.void)); + const task = Effect.gen(function* () { + yield* Deferred.succeed(entering, undefined); + yield* Deferred.await(continueStartup); + harness.events.push("late-spawn"); + }); + const startup = yield* harness.auth + .withProcess(Scope.close(processScope, Exit.void), task) + .pipe(Scope.provide(processScope), Effect.forkScoped); + yield* Deferred.await(entering); + yield* harness.auth.controller.logout(Effect.void); + yield* Deferred.succeed(continueStartup, undefined); + assert.isTrue(Exit.isFailure(yield* Fiber.await(startup))); + assert.notInclude(harness.events, "late-spawn"); + assert.include(harness.events, "logout"); + }), + ); + + it.effect("failed session stopping still closes owned processes and skips native logout", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + const processScope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(processScope, Exit.void)); + const stop = Effect.gen(function* () { + harness.events.push("chat-close"); + yield* Scope.close(processScope, Exit.void); + }); + yield* harness.auth.withProcess(stop, Effect.void).pipe(Scope.provide(processScope)); + const result = yield* harness.auth.controller + .logout( + Effect.fail( + new ProviderSetupError({ + instanceId, + operation: "stopSessions", + detail: "Stop failed.", + }), + ), + ) + .pipe(Effect.exit); + assert.isTrue(Exit.isFailure(result)); + assert.deepEqual(harness.events, ["chat-close"]); + assert.deepEqual(harness.catalog(), ["previous-account-model"]); + }), + ); + + it.effect("finishes sign-out when the requesting client disconnects", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + const stopping = yield* Deferred.make(); + const continueStop = yield* Deferred.make(); + const request = yield* harness.auth.controller + .logout( + Effect.gen(function* () { + yield* Deferred.succeed(stopping, undefined); + yield* Deferred.await(continueStop); + }), + ) + .pipe(Effect.forkScoped); + yield* Deferred.await(stopping); + yield* Fiber.interrupt(request); + yield* Deferred.succeed(continueStop, undefined); + const result = yield* harness.auth.controller.subscribe(owner).pipe( + Stream.filter((state) => state.message === "Signed out of Google."), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + assert.equal(result.phase, "idle"); + assert.deepEqual(harness.catalog(), []); + assert.include(harness.events, "logout"); + }), + ); + + it.effect("does not call logout unless the official process advertises it", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ supportsLogout: false }); + const result = yield* harness.auth.controller.logout(Effect.void).pipe(Effect.exit); + assert.isTrue(Exit.isFailure(result)); + assert.deepEqual(harness.events, ["process-open", "initialize", "process-close"]); + assert.deepEqual(harness.catalog(), ["previous-account-model"]); + }), + ); +}); diff --git a/apps/server/src/provider/AntigravityAuth.ts b/apps/server/src/provider/AntigravityAuth.ts new file mode 100644 index 000000000000..c7118bccad83 --- /dev/null +++ b/apps/server/src/provider/AntigravityAuth.ts @@ -0,0 +1,539 @@ +import { + ProviderSetupError, + type ProviderAuthState, + type ProviderInstanceId, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import * as AcpErrors from "effect-acp/errors"; + +import type { AcpSessionRuntime, AcpSessionRuntimeStartResult } from "./acp/AcpSessionRuntime.ts"; +import { + parseAntigravityAuthorizationUrl, + type AntigravityAuthorizationUrl, +} from "./antigravityAuthSupport.ts"; +import { + forwardAntigravityCallback, + validateAntigravityCallbackUrl, +} from "./antigravityCallback.ts"; +import type { ProviderAuthController } from "./Services/ProviderAuthService.ts"; + +const AUTH_TIMEOUT_MS = 300_000; +const FORWARDING_FAILED_MESSAGE = "Could not deliver the sign-in response. Start sign-in again."; +const isSetupError = Schema.is(ProviderSetupError); +const isAcpRequestError = Schema.is(AcpErrors.AcpRequestError); + +interface AuthSnapshot { + readonly ownerSessionId: string | null; + readonly state: ProviderAuthState; +} + +interface AuthFlow { + readonly id: string; + readonly ownerSessionId: string; + readonly expiresAtMillis: number; + state: ProviderAuthState; + pending: AntigravityAuthorizationUrl | undefined; + callbackSent: boolean; + fiber: Fiber.Fiber | undefined; + forwarding: Fiber.Fiber | undefined; +} + +interface OwnedProcess { + readonly stop: Effect.Effect; + startup: Fiber.Fiber | undefined; +} + +export interface AntigravityAuth { + readonly controller: ProviderAuthController; + /** Tracks startup and the process scope so sign-out cannot leave cached credentials in memory. */ + readonly withProcess: ( + stop: Effect.Effect, + task: Effect.Effect, + ) => Effect.Effect; +} + +export type AntigravityAuthRuntime = Pick< + AcpSessionRuntime["Service"], + "initialize" | "start" | "request" +>; + +export interface AntigravityAuthOptions< + Runtime extends AntigravityAuthRuntime = AcpSessionRuntime["Service"], +> { + readonly instanceId: ProviderInstanceId; + readonly makeRuntime: (input: { + readonly onAuthorizationUrl?: (url: string) => Effect.Effect; + }) => Effect.Effect; + readonly onAuthenticated: ( + result: AcpSessionRuntimeStartResult, + runtime: Runtime, + ) => Effect.Effect; + readonly onSignedOut: Effect.Effect; + readonly forwardCallback?: (callback: URL) => Effect.Effect; + /** False for API key methods, which authenticate without a Google sign-in page. */ + readonly usesBrowser?: boolean; +} + +function visibleSnapshot(snapshot: AuthSnapshot, ownerSessionId: string): ProviderAuthState { + if (snapshot.ownerSessionId === null || snapshot.ownerSessionId === ownerSessionId) { + return snapshot.state; + } + const busy = ["starting", "waiting", "verifying"].includes(snapshot.state.phase); + return { + ...snapshot.state, + flowId: null, + authorizationUrl: null, + expiresAt: null, + ...(busy ? { message: "Sign-in is in progress in another client." } : {}), + }; +} + +function safeAuthFailure(cause: Cause.Cause, usesBrowser: boolean): string { + const error = Cause.findErrorOption(cause); + if (Option.isSome(error)) { + if (isSetupError(error.value)) { + return error.value.detail; + } + if (isAcpRequestError(error.value)) { + if (error.value.errorMessage.includes("SUBSCRIPTION_REQUIRED")) { + return "Google requires an eligible Antigravity subscription for this account."; + } + if (/access_denied|denied access|cancelled/i.test(error.value.errorMessage)) { + return "Google sign-in was not approved. Start sign-in again."; + } + if (!usesBrowser && error.value.code === -32602) { + return "Antigravity rejected the configured credentials. Check the provider settings."; + } + } + } + return usesBrowser + ? "Google sign-in failed. Start sign-in again." + : "Antigravity could not authenticate with the configured credentials."; +} + +/** Owns one instance's explicit sign-in and all process admission around sign-out. */ +export const makeAntigravityAuth = Effect.fn("makeAntigravityAuth")(function* < + Runtime extends AntigravityAuthRuntime, +>( + options: AntigravityAuthOptions, +): Effect.fn.Return { + const crypto = yield* Crypto.Crypto; + const instanceScope = yield* Scope.Scope; + const usesBrowser = options.usesBrowser ?? true; + const lock = yield* Semaphore.make(1); + const closed = yield* Deferred.make(); + const emptyState: ProviderAuthState = { + instanceId: options.instanceId, + phase: "idle", + flowId: null, + authorizationUrl: null, + expiresAt: null, + message: null, + }; + const snapshot = yield* SubscriptionRef.make({ + ownerSessionId: null, + state: emptyState, + }); + const processes = new Set(); + let activeFlow: AuthFlow | undefined; + let operation: "idle" | "auth" | "logout" | "cancel" | "closed" = "idle"; + + const setupError = (name: string, detail: string) => + new ProviderSetupError({ instanceId: options.instanceId, operation: name, detail }); + const currentState = (ownerSessionId: string) => + SubscriptionRef.get(snapshot).pipe( + Effect.map((value) => visibleSnapshot(value, ownerSessionId)), + ); + const publishFlow = (flow: AuthFlow, state: ProviderAuthState) => { + flow.state = state; + return SubscriptionRef.set(snapshot, { ownerSessionId: flow.ownerSessionId, state }); + }; + const stopOwnedProcesses = Effect.suspend(() => + Effect.forEach( + Array.from(processes), + (owned) => + Effect.gen(function* () { + if (owned.startup) { + yield* Fiber.interrupt(owned.startup); + } + yield* owned.stop; + }), + { discard: true, concurrency: "unbounded" }, + ), + ); + + const withProcess: AntigravityAuth["withProcess"] = (stop, task) => + Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const scope = yield* Scope.Scope; + const owned: OwnedProcess = { stop, startup: undefined }; + const fiber = yield* lock.withPermits(1)( + Effect.gen(function* () { + if (operation !== "idle") { + return yield* setupError( + "startProcess", + "Antigravity sign-in or sign-out is in progress. Try again after it finishes.", + ); + } + processes.add(owned); + yield* Scope.addFinalizer( + scope, + Effect.sync(() => { + processes.delete(owned); + }), + ); + const child = yield* restore(task).pipe(Effect.forkIn(scope)); + owned.startup = child; + return child; + }), + ); + // Propagate interruption after the exit wait so concurrent stop waiters stay attached. + return yield* restore(Fiber.await(fiber)).pipe( + Effect.flatMap((result) => result), + Effect.ensuring(Fiber.interrupt(fiber)), + Effect.ensuring( + Effect.sync(() => { + owned.startup = undefined; + }), + ), + ); + }), + ); + + const finishFlow = (flow: AuthFlow, result: Exit.Exit) => + lock.withPermits(1)( + Effect.gen(function* () { + if (activeFlow !== flow) return; + activeFlow = undefined; + operation = "idle"; + flow.pending = undefined; + yield* publishFlow(flow, { + ...flow.state, + phase: Exit.isSuccess(result) ? "succeeded" : "failed", + authorizationUrl: null, + expiresAt: null, + message: Exit.isSuccess(result) + ? usesBrowser + ? "Signed in with Google." + : "Connected to Antigravity." + : safeAuthFailure(result.cause, usesBrowser), + }); + }), + ); + + const receiveAuthorizationUrl = (flow: AuthFlow, url: string) => + parseAntigravityAuthorizationUrl(url).pipe( + Effect.flatMap((authorization) => + lock.withPermits(1)( + 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, + }); + } + flow.pending = authorization; + yield* publishFlow(flow, { + ...flow.state, + phase: "waiting", + authorizationUrl: authorization.authorizationUrl, + message: + "Open the Google sign-in link. If you are remote, paste the redirect URL here.", + }); + }), + ), + ), + ); + + const runSignIn = (flow: AuthFlow, stopSessions: Effect.Effect) => + Effect.gen(function* () { + yield* stopSessions.pipe(Effect.ensuring(stopOwnedProcesses)); + const runtime = yield* options.makeRuntime({ + onAuthorizationUrl: (url) => receiveAuthorizationUrl(flow, url), + }); + const started = yield* runtime.start(); + yield* lock.withPermits(1)( + Effect.gen(function* () { + if (activeFlow !== flow) return; + flow.pending = undefined; + yield* publishFlow(flow, { + ...flow.state, + phase: "verifying", + authorizationUrl: null, + message: "Checking Antigravity access and models.", + }); + }), + ); + yield* options.onAuthenticated(started, runtime); + }).pipe( + Effect.scoped, + Effect.timeoutOrElse({ + duration: AUTH_TIMEOUT_MS, + orElse: () => + Effect.fail(setupError("start", "Google sign-in expired. Start sign-in again.")), + }), + Effect.exit, + Effect.flatMap((result) => finishFlow(flow, result)), + ); + + const stopFlow = (flow: AuthFlow, phase: "cancelled" | "failed", message: string) => + Effect.uninterruptible( + Effect.gen(function* () { + const detached = yield* lock.withPermits(1)( + Effect.gen(function* () { + if (activeFlow !== flow) return false; + activeFlow = undefined; + operation = "cancel"; + flow.pending = undefined; + yield* publishFlow(flow, { + ...flow.state, + phase, + authorizationUrl: null, + expiresAt: null, + message, + }); + return true; + }), + ); + if (!detached) return; + if (flow.forwarding) yield* Fiber.interrupt(flow.forwarding); + if (flow.fiber) yield* Fiber.interrupt(flow.fiber); + yield* lock.withPermits(1)( + Effect.sync(() => { + if (operation === "cancel") operation = "idle"; + }), + ); + }), + ); + + const requireFlow = (ownerSessionId: string, flowId: string, name: string) => + Effect.gen(function* () { + const flow = activeFlow; + if (!flow || flow.id !== flowId || flow.ownerSessionId !== ownerSessionId) { + return yield* setupError(name, "This sign-in is no longer active in this client."); + } + const now = yield* Clock.currentTimeMillis; + if (now >= flow.expiresAtMillis) { + return yield* setupError(name, "Google sign-in expired. Start sign-in again."); + } + return flow; + }); + + const controller: ProviderAuthController = { + start: (ownerSessionId, stopSessions = Effect.void) => + lock.withPermits(1)( + Effect.uninterruptible( + Effect.gen(function* () { + if (activeFlow?.ownerSessionId === ownerSessionId && operation === "auth") { + return activeFlow.state; + } + if (operation !== "idle") { + return yield* setupError("start", "Antigravity setup is already in progress."); + } + const flowId = yield* crypto.randomUUIDv4.pipe( + Effect.mapError(() => + setupError("start", "Could not start Google sign-in. Try again."), + ), + ); + const expiresAtMillis = (yield* Clock.currentTimeMillis) + AUTH_TIMEOUT_MS; + const state: ProviderAuthState = { + ...emptyState, + phase: "starting", + flowId, + expiresAt: DateTime.formatIso(DateTime.makeUnsafe(expiresAtMillis)), + message: usesBrowser ? "Starting Google sign-in." : "Checking credentials.", + }; + const flow: AuthFlow = { + id: flowId, + ownerSessionId, + expiresAtMillis, + state, + pending: undefined, + callbackSent: false, + fiber: undefined, + forwarding: undefined, + }; + activeFlow = flow; + operation = "auth"; + yield* publishFlow(flow, state); + flow.fiber = yield* runSignIn(flow, stopSessions).pipe( + Effect.interruptible, + Effect.forkIn(instanceScope), + ); + return state; + }), + ), + ), + complete: Effect.fn("AntigravityAuth.complete")(function* (ownerSessionId, input) { + const pending = yield* lock.withPermits(1)( + Effect.gen(function* () { + const flow = yield* requireFlow(ownerSessionId, input.flowId, "complete"); + if (!flow.pending || flow.callbackSent) { + return yield* setupError( + "complete", + flow.callbackSent + ? "The sign-in response was already sent. Wait for Google to finish." + : "Wait for the Google sign-in link before you send a redirect URL.", + ); + } + const callback = yield* validateAntigravityCallbackUrl( + options.instanceId, + flow.pending, + input.callbackUrl, + ); + flow.callbackSent = true; + yield* publishFlow(flow, { + ...flow.state, + phase: "verifying", + authorizationUrl: null, + message: "Waiting for Google to finish sign-in.", + }); + // The instance owns delivery and its failure handling. The RPC that + // sent the callback may disconnect before Google answers, and the + // flow must still settle instead of sitting at "verifying" until + // the deadline. + const forwarding = yield* ( + options.forwardCallback?.(callback) ?? + forwardAntigravityCallback(options.instanceId, callback) + ).pipe( + // stopFlow interrupts this fiber, so it runs from a sibling fiber. + Effect.tapError(() => + stopFlow(flow, "failed", FORWARDING_FAILED_MESSAGE).pipe( + Effect.forkIn(instanceScope), + ), + ), + Effect.interruptible, + Effect.forkIn(instanceScope), + ); + flow.forwarding = forwarding; + return { flow, forwarding }; + }), + ); + const forwarded = yield* Fiber.await(pending.forwarding); + if (Exit.isFailure(forwarded)) { + return yield* setupError("complete", FORWARDING_FAILED_MESSAGE); + } + return pending.flow.state; + }), + cancel: Effect.fn("AntigravityAuth.cancel")(function* (ownerSessionId, flowId) { + const flow = yield* lock.withPermits(1)(requireFlow(ownerSessionId, flowId, "cancel")); + yield* stopFlow(flow, "cancelled", "Google sign-in was cancelled."); + return flow.state; + }), + logout: Effect.fn("AntigravityAuth.logout")(function* (stopSessions) { + const task = Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const flow = yield* lock.withPermits(1)( + Effect.gen(function* () { + if (operation !== "idle" && operation !== "auth") { + return yield* setupError("logout", "Antigravity setup is already stopping."); + } + operation = "logout"; + const currentFlow = activeFlow; + activeFlow = undefined; + if (currentFlow) { + currentFlow.pending = undefined; + yield* publishFlow(currentFlow, { + ...currentFlow.state, + phase: "cancelled", + authorizationUrl: null, + expiresAt: null, + message: "Google sign-in was cancelled by sign-out.", + }); + } + return currentFlow; + }), + ); + const stopRemaining = Effect.gen(function* () { + if (flow?.forwarding) yield* Fiber.interrupt(flow.forwarding); + if (flow?.fiber) yield* Fiber.interrupt(flow.fiber); + yield* stopOwnedProcesses; + }); + const result = yield* restore( + Effect.gen(function* () { + yield* stopSessions.pipe(Effect.ensuring(stopRemaining)); + const runtime = yield* options.makeRuntime({}); + const initialized = yield* runtime.initialize(); + if (!initialized.agentCapabilities?.auth?.logout) { + return yield* setupError( + "logout", + "This Antigravity version does not support sign-out. Update the provider.", + ); + } + yield* runtime.request("logout", {}); + yield* options.onSignedOut; + }).pipe( + Effect.scoped, + Effect.timeoutOrElse({ + duration: "90 seconds", + orElse: () => Effect.fail(setupError("logout", "Antigravity sign-out timed out.")), + }), + ), + ).pipe(Effect.exit); + yield* lock.withPermits(1)( + Effect.gen(function* () { + operation = "idle"; + yield* SubscriptionRef.set(snapshot, { + ownerSessionId: null, + state: { + ...emptyState, + phase: Exit.isSuccess(result) ? "idle" : "failed", + message: Exit.isSuccess(result) + ? "Signed out of Google." + : "Antigravity sign-out failed. Try again.", + }, + }); + }), + ); + if (Exit.isFailure(result)) { + const failure = Cause.findErrorOption(result.cause); + return yield* Option.isSome(failure) && isSetupError(failure.value) + ? failure.value + : setupError("logout", "Antigravity sign-out failed. Try again."); + } + return yield* currentState(""); + }), + ); + const worker = yield* task.pipe(Effect.forkIn(instanceScope)); + return yield* Fiber.await(worker).pipe(Effect.flatMap((result) => result)); + }), + subscribe: (ownerSessionId) => + SubscriptionRef.changes(snapshot).pipe( + Stream.map((value) => visibleSnapshot(value, ownerSessionId)), + Stream.interruptWhen(Deferred.await(closed)), + ), + isLogoutPrompt: (text, hasAttachments) => !hasAttachments && text.trim() === "/logout", + }; + + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + operation = "closed"; + const flow = activeFlow; + activeFlow = undefined; + if (flow) { + flow.pending = undefined; + if (flow.forwarding) yield* Fiber.interrupt(flow.forwarding); + if (flow.fiber) yield* Fiber.interrupt(flow.fiber); + } + yield* stopOwnedProcesses; + yield* Deferred.succeed(closed, undefined); + }), + ); + + return { controller, withProcess }; +}); diff --git a/apps/server/src/provider/AntigravityInstallation.test.ts b/apps/server/src/provider/AntigravityInstallation.test.ts new file mode 100644 index 000000000000..8d6b62731cfd --- /dev/null +++ b/apps/server/src/provider/AntigravityInstallation.test.ts @@ -0,0 +1,909 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import { + HostProcessArchitecture, + HostProcessEnvironment, + HostProcessPlatform, +} from "@t3tools/shared/hostProcess"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; +import * as Queue from "effect/Queue"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import * as NodeCrypto from "node:crypto"; + +import { + makeAntigravityInstallation, + type AntigravityExecutable, + type AntigravityInstallation, + type AntigravityInstallationOptions, +} from "./AntigravityInstallation.ts"; +import { ANTIGRAVITY_AUTH_BROWSER_MARKER } from "./antigravityAuthSupport.ts"; +import type { AntigravityReleaseAsset } from "./antigravityRelease.ts"; + +const serverContents = "antigravity runtime\n"; +const harnessContents = "local harness\n"; +const previousReleaseId = "1".repeat(64); +const previousVersion = "fixture-old"; +const encodeJsonString = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); + +// Small ZIPs made with Python's zipfile module. The unsafe entries are intentional. +const zipFixtures = { + complete: + "UEsDBBQAAAAIAAAAIl1zEy/oFAAAABQAAAASAAAAYWd5X2FjcF9zZXJ2ZXIucGFyS8wryUwvSizLLKlUKCoFcnJTuQBQSwMEFAAAAAgAAAAiXV9yAykQAAAADgAAABUAAABsb2NhbGhhcm5lc3NfZXh0ZXJuYWzLyU9OzFHISCzKSy0u5gIAUEsBAhQDFAAAAAgAAAAiXXMTL+gUAAAAFAAAABIAAAAAAAAAAAAAAO2BAAAAAGFneV9hY3Bfc2VydmVyLnBhclBLAQIUAxQAAAAIAAAAIl1fcgMpEAAAAA4AAAAVAAAAAAAAAAAAAADtgUQAAABsb2NhbGhhcm5lc3NfZXh0ZXJuYWxQSwUGAAAAAAIAAgCDAAAAhwAAAAAA", + missingHarness: + "UEsDBBQAAAAIAAAAIl1zEy/oFAAAABQAAAASAAAAYWd5X2FjcF9zZXJ2ZXIucGFyS8wryUwvSizLLKlUKCoFcnJTuQBQSwECFAMUAAAACAAAACJdcxMv6BQAAAAUAAAAEgAAAAAAAAAAAAAA7YEAAAAAYWd5X2FjcF9zZXJ2ZXIucGFyUEsFBgAAAAABAAEAQAAAAEQAAAAAAA==", + duplicate: + "UEsDBBQAAAAIAAAAIl1zEy/oFAAAABQAAAASAAAAYWd5X2FjcF9zZXJ2ZXIucGFyS8wryUwvSizLLKlUKCoFcnJTuQBQSwMEFAAAAAgAAAAiXXMTL+gUAAAAFAAAABIAAABhZ3lfYWNwX3NlcnZlci5wYXJLzCvJTC9KLMssqVQoKgVyclO5AFBLAQIUAxQAAAAIAAAAIl1zEy/oFAAAABQAAAASAAAAAAAAAAAAAADtgQAAAABhZ3lfYWNwX3NlcnZlci5wYXJQSwECFAMUAAAACAAAACJdcxMv6BQAAAAUAAAAEgAAAAAAAAAAAAAA7YFEAAAAYWd5X2FjcF9zZXJ2ZXIucGFyUEsFBgAAAAACAAIAgAAAAIgAAAAAAA==", + traversal: + "UEsDBBQAAAAIAAAAIl1zEy/oFAAAABQAAAAVAAAALi5cYWd5X2FjcF9zZXJ2ZXIucGFyS8wryUwvSizLLKlUKCoFcnJTuQBQSwMEFAAAAAgAAAAiXV9yAykQAAAADgAAABUAAABsb2NhbGhhcm5lc3NfZXh0ZXJuYWzLyU9OzFHISCzKSy0u5gIAUEsBAhQDFAAAAAgAAAAiXXMTL+gUAAAAFAAAABUAAAAAAAAAAAAAAO2BAAAAAC4uXGFneV9hY3Bfc2VydmVyLnBhclBLAQIUAxQAAAAIAAAAIl1fcgMpEAAAAA4AAAAVAAAAAAAAAAAAAADtgUcAAABsb2NhbGhhcm5lc3NfZXh0ZXJuYWxQSwUGAAAAAAIAAgCGAAAAigAAAAAA", + symlink: + "UEsDBBQAAAAIAAAAIl1zEy/oFAAAABQAAAASAAAAYWd5X2FjcF9zZXJ2ZXIucGFyS8wryUwvSizLLKlUKCoFcnJTuQBQSwMEFAAAAAgAAAAiXV9yAykQAAAADgAAABUAAABsb2NhbGhhcm5lc3NfZXh0ZXJuYWzLyU9OzFHISCzKSy0u5gIAUEsBAhQDFAAAAAgAAAAiXXMTL+gUAAAAFAAAABIAAAAAAAAAAAAAAO2BAAAAAGFneV9hY3Bfc2VydmVyLnBhclBLAQIUAxQAAAAIAAAAIl1fcgMpEAAAAA4AAAAVAAAAAAAAAAAAAAD/oUQAAABsb2NhbGhhcm5lc3NfZXh0ZXJuYWxQSwUGAAAAAAIAAgCDAAAAhwAAAAAA", + oversizedMember: + "UEsDBBQAAAAIAAAAIl0WGThFFQAAABUAAAASAAAAYWd5X2FjcF9zZXJ2ZXIucGFyS8wryUwvSizLLKlUKCoFcnJTuSoAUEsDBBQAAAAIAAAAIl1fcgMpEAAAAA4AAAAVAAAAbG9jYWxoYXJuZXNzX2V4dGVybmFsy8lPTsxRyEgsykstLuYCAFBLAQIUAxQAAAAIAAAAIl0WGThFFQAAABUAAAASAAAAAAAAAAAAAADtgQAAAABhZ3lfYWNwX3NlcnZlci5wYXJQSwECFAMUAAAACAAAACJdX3IDKRAAAAAOAAAAFQAAAAAAAAAAAAAA7YFFAAAAbG9jYWxoYXJuZXNzX2V4dGVybmFsUEsFBgAAAAACAAIAgwAAAIgAAAAAAA==", + windows: + "UEsDBBQAAAAIAAAAIl1zEy/oFAAAABQAAAASAAAAYWd5X2FjcF9zZXJ2ZXIuZXhlS8wryUwvSizLLKlUKCoFcnJTuQBQSwMEFAAAAAgAAAAiXV9yAykQAAAADgAAABkAAABsb2NhbGhhcm5lc3NfZXh0ZXJuYWwuZXhly8lPTsxRyEgsykstLuYCAFBLAQIUAxQAAAAIAAAAIl1zEy/oFAAAABQAAAASAAAAAAAAAAAAAADtgQAAAABhZ3lfYWNwX3NlcnZlci5leGVQSwECFAMUAAAACAAAACJdX3IDKRAAAAAOAAAAGQAAAAAAAAAAAAAA7YFEAAAAbG9jYWxoYXJuZXNzX2V4dGVybmFsLmV4ZVBLBQYAAAAAAgACAIcAAACLAAAAAAA=", +}; + +const completeArchive = Buffer.from(zipFixtures.complete, "base64"); + +function releaseAsset(archive: Uint8Array = completeArchive, platform: NodeJS.Platform = "linux") { + return { + version: "fixture-new", + url: "https://dl.google.com/antigravity-test.zip", + sha256: NodeCrypto.createHash("sha256").update(archive).digest("hex"), + archiveBytes: archive.byteLength, + executable: { + name: platform === "win32" ? "agy_acp_server.exe" : "agy_acp_server.par", + bytes: Buffer.byteLength(serverContents), + }, + harness: { + name: platform === "win32" ? "localharness_external.exe" : "localharness_external", + bytes: Buffer.byteLength(harnessContents), + }, + } satisfies AntigravityReleaseAsset; +} + +const writeRelease = Effect.fn("test.writeAntigravityRelease")(function* ( + managedDirectory: string, + asset: AntigravityReleaseAsset, + active = true, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = path.join(managedDirectory, "versions", asset.sha256); + yield* fs.makeDirectory(directory, { recursive: true }); + yield* fs.writeFileString(path.join(directory, asset.executable.name), serverContents, { + mode: 0o755, + }); + yield* fs.writeFileString(path.join(directory, asset.harness.name), harnessContents, { + mode: 0o755, + }); + yield* fs.writeFileString( + path.join(directory, ".install-complete.json"), + encodeJsonString({ + releaseId: asset.sha256, + version: asset.version, + executable: asset.executable, + harness: asset.harness, + }), + ); + if (active) { + yield* fs.writeFileString( + path.join(managedDirectory, "active.json"), + encodeJsonString({ releaseId: asset.sha256 }), + ); + } +}); + +interface HarnessOptions { + readonly baseDir?: string; + readonly asset?: AntigravityReleaseAsset | null; + readonly archive?: Buffer; + readonly body?: Stream.Stream | undefined; + readonly contentLength?: number; + readonly contentEncoding?: string; + readonly platform?: NodeJS.Platform; + readonly path?: string; + readonly previous?: boolean; + readonly fileSystem?: FileSystem.FileSystem; + readonly validate?: AntigravityInstallationOptions["validate"]; + readonly useDefaultValidation?: boolean; +} + +const makeHarness = Effect.fn("test.makeAntigravityInstallation")(function* ( + options: HarnessOptions = {}, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = + options.baseDir ?? (yield* fs.makeTempDirectoryScoped({ prefix: "t3-agy-test-" })); + const platform = options.platform ?? "linux"; + const archive = options.archive ?? completeArchive; + const asset = options.asset === undefined ? releaseAsset(archive, platform) : options.asset; + const managedDirectory = path.join(baseDir, "tools", "antigravity-acp", `${platform}-x64`); + if (options.previous) { + yield* writeRelease(managedDirectory, { + ...releaseAsset(archive, platform), + sha256: previousReleaseId, + version: previousVersion, + }); + } + const stagingReleased = yield* Deferred.make(); + const requests: string[] = []; + const validations: Array<{ executable: AntigravityExecutable; version: string }> = []; + const installationFs = options.fileSystem ?? fs; + const trackedFs = FileSystem.FileSystem.of({ + ...installationFs, + makeTempDirectoryScoped: (settings) => + settings?.prefix === ".install-" + ? Effect.acquireRelease(installationFs.makeTempDirectory(settings), (directory) => + fs + .remove(directory, { recursive: true, force: true }) + .pipe(Effect.orDie, Effect.andThen(Deferred.succeed(stagingReleased, undefined))), + ) + : installationFs.makeTempDirectoryScoped(settings), + }); + const installation = yield* makeAntigravityInstallation({ + baseDir, + releaseAsset: asset, + ...(options.useDefaultValidation + ? {} + : { + validate: (executable: AntigravityExecutable, version: string) => + Effect.sync(() => validations.push({ executable, version })).pipe( + Effect.andThen(options.validate?.(executable, version) ?? Effect.void), + ), + }), + }).pipe( + Effect.provideService(FileSystem.FileSystem, trackedFs), + Effect.provideService(HostProcessPlatform, platform), + Effect.provideService(HostProcessArchitecture, "x64"), + Effect.provideService(HostProcessEnvironment, { PATH: options.path ?? "" }), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.sync(() => { + requests.push(request.url); + const response = HttpClientResponse.fromWeb( + request, + new Response(null, { + headers: { + ...(options.contentLength === undefined + ? {} + : { "content-length": String(options.contentLength) }), + ...(options.contentEncoding === undefined + ? {} + : { "content-encoding": options.contentEncoding }), + }, + }), + ); + return Object.defineProperty(response, "stream", { + value: + options.body ?? + Stream.make( + archive.subarray(0, 31), + archive.subarray(31, 149), + archive.subarray(149), + ), + }); + }), + ), + ), + ); + return { installation, fs, path, baseDir, requests, validations, stagingReleased }; +}); + +const terminalState = (installation: AntigravityInstallation["Service"]) => + installation.changes.pipe( + Stream.filter((state) => ["succeeded", "failed", "cancelled"].includes(state.phase)), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + +const expectPreviousRelease = Effect.fn("test.expectPreviousAntigravityRelease")(function* ( + installation: AntigravityInstallation["Service"], +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const resolved = yield* installation.resolve(); + expect(resolved.version).toBe(previousVersion); + expect(resolved.managedVersionDirectory).toBe( + path.join(installation.managedDirectory, "versions", previousReleaseId), + ); + expect(yield* fs.readFileString(resolved.executablePath)).toBe(serverContents); + expect(yield* fs.readFileString(resolved.harnessPath)).toBe(harnessContents); + expect((yield* installation.state).installedVersion).toBe(previousVersion); +}); + +it.layer(NodeServices.layer)("Antigravity installation", (it) => { + it.effect("verifies both files before activating a streamed download", () => + Effect.gen(function* () { + const enteredValidation = yield* Deferred.make(); + const finishValidation = yield* Deferred.make(); + const { installation, fs, path, validations, requests, stagingReleased } = yield* makeHarness( + { + previous: true, + validate: () => + Deferred.succeed(enteredValidation, undefined).pipe( + Effect.andThen(Deferred.await(finishValidation)), + ), + }, + ); + const initial = yield* installation.changes.pipe( + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + expect(initial).toMatchObject({ phase: "idle", installedVersion: previousVersion }); + const started = yield* installation.start; + expect(started).toMatchObject({ phase: "downloading", downloadedBytes: 0 }); + yield* Deferred.await(enteredValidation); + yield* expectPreviousRelease(installation); + const validation = validations[0]; + expect(validation?.version).toBe("fixture-new"); + if (!validation) return yield* Effect.die("Expected runtime validation."); + expect(yield* fs.readFileString(validation.executable.executablePath)).toBe(serverContents); + expect(yield* fs.readFileString(validation.executable.harnessPath)).toBe(harnessContents); + + yield* Deferred.succeed(finishValidation, undefined); + expect(yield* terminalState(installation)).toMatchObject({ + phase: "succeeded", + operationId: started.operationId, + downloadedBytes: completeArchive.byteLength, + installedVersion: "fixture-new", + }); + yield* Deferred.await(stagingReleased); + const selected = yield* installation.resolve(); + expect(selected).toMatchObject({ source: "managed", version: "fixture-new" }); + expect(path.dirname(selected.harnessPath)).toBe(path.dirname(selected.executablePath)); + expect(yield* fs.readFileString(selected.executablePath)).toBe(serverContents); + expect(yield* fs.readFileString(selected.harnessPath)).toBe(harnessContents); + expect(yield* fs.readDirectory(path.join(installation.managedDirectory, "versions"))).toEqual( + expect.arrayContaining([previousReleaseId, releaseAsset().sha256]), + ); + expect(requests).toEqual([releaseAsset().url]); + }), + ); + + it.effect.each([ + { + name: "the expected release", + agentName: "antigravity-acp", + version: "fixture-new", + valid: true, + }, + { name: "a different agent", agentName: "other-agent", version: "fixture-new", valid: false }, + { + name: "a different version", + agentName: "antigravity-acp", + version: "other-version", + valid: false, + }, + ])("validates $name with initialize only and removes the disposable profile", (testCase) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const encoder = new TextEncoder(); + const decodeRequest = Schema.decodeUnknownEffect( + Schema.fromJsonString( + Schema.Struct({ + id: Schema.Union([Schema.String, Schema.Number]), + method: Schema.String, + }), + ), + ); + const methods: string[] = []; + const profiles = new Set(); + let closedRuntimes = 0; + const spawner = ChildProcessSpawner.make( + Effect.fn("test.spawnAntigravityValidator")(function* (command) { + if (command._tag !== "StandardCommand") { + return yield* Effect.die("Expected one validation process."); + } + const profile = command.options.env?.GEMINI_HOME; + if (!profile) return yield* Effect.die("Expected a disposable validation profile."); + profiles.add(profile); + const helper = command.args[0] === "-e"; + const output = yield* Queue.unbounded(); + const exited = yield* Deferred.make(); + const terminate = Deferred.succeed(exited, ChildProcessSpawner.ExitCode(0)).pipe( + Effect.asVoid, + ); + yield* Effect.addFinalizer(() => + terminate.pipe( + Effect.andThen(Queue.shutdown(output)), + Effect.andThen( + Effect.sync(() => { + if (!helper) closedRuntimes += 1; + }), + ), + ), + ); + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(helper ? 1 : 2), + exitCode: helper + ? Effect.succeed(ChildProcessSpawner.ExitCode(0)) + : Deferred.await(exited), + isRunning: Deferred.isDone(exited).pipe(Effect.map((done) => !done)), + kill: () => terminate, + unref: Effect.succeed(Effect.void), + stdin: Sink.forEach((bytes: Uint8Array) => + Effect.gen(function* () { + const request = yield* decodeRequest(new TextDecoder().decode(bytes)).pipe( + Effect.orDie, + ); + methods.push(request.method); + yield* Queue.offer( + output, + encoder.encode( + `${encodeJsonString({ + jsonrpc: "2.0", + id: request.id, + ...(request.method === "initialize" + ? { + result: { + protocolVersion: 1, + agentInfo: { name: testCase.agentName, version: testCase.version }, + agentCapabilities: { + loadSession: true, + sessionCapabilities: { resume: {} }, + auth: { logout: {} }, + }, + authMethods: [{ id: "oauth-personal", name: "Google" }], + }, + } + : { + error: { + code: -32601, + message: "Validation must not sign in or create sessions.", + }, + }), + })}\n`, + ), + ); + }), + ), + stdout: helper ? Stream.empty : Stream.fromQueue(output), + stderr: helper + ? Stream.make( + encoder.encode( + `${ANTIGRAVITY_AUTH_BROWSER_MARKER}${encodeJsonString(command.args.at(-1))}\n`, + ), + ) + : Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + }), + ); + const { installation, stagingReleased } = yield* makeHarness({ + previous: true, + useDefaultValidation: true, + }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)); + yield* installation.start; + expect((yield* terminalState(installation)).phase).toBe( + testCase.valid ? "succeeded" : "failed", + ); + yield* Deferred.await(stagingReleased); + expect(methods).toEqual(["initialize"]); + expect(closedRuntimes).toBe(1); + expect(profiles.size).toBe(1); + for (const profile of profiles) { + expect(yield* fs.exists(profile)).toBe(false); + } + if (testCase.valid) { + expect((yield* installation.resolve()).version).toBe("fixture-new"); + } else { + yield* expectPreviousRelease(installation); + } + }), + ); + + it.effect("accepts an encoded Content-Length when the body is compressed", () => + Effect.gen(function* () { + // dl.google.com gzips the archive and reports the encoded size. The decoded + // stream is still checked byte for byte and by hash. + const { installation, validations } = yield* makeHarness({ + contentLength: completeArchive.byteLength - 1_000, + contentEncoding: "gzip", + }); + yield* installation.start; + const state = yield* terminalState(installation); + expect(state.phase).toBe("succeeded"); + expect(validations).toHaveLength(1); + }), + ); + + it.effect.each([ + { name: "checksum mismatch", asset: { ...releaseAsset(), sha256: "2".repeat(64) } }, + { name: "short download", archive: completeArchive.subarray(0, -1), asset: releaseAsset() }, + { + name: "oversized download", + archive: Buffer.concat([completeArchive, Buffer.from("extra")]), + asset: releaseAsset(), + }, + { name: "wrong Content-Length", contentLength: completeArchive.byteLength + 1 }, + { name: "missing harness", archive: Buffer.from(zipFixtures.missingHarness, "base64") }, + { name: "duplicate executable", archive: Buffer.from(zipFixtures.duplicate, "base64") }, + { name: "path traversal", archive: Buffer.from(zipFixtures.traversal, "base64") }, + { name: "symbolic link", archive: Buffer.from(zipFixtures.symlink, "base64") }, + { name: "oversized member", archive: Buffer.from(zipFixtures.oversizedMember, "base64") }, + ])("rejects $name before runtime validation", (options) => + Effect.gen(function* () { + const { installation, validations, stagingReleased, fs, path } = yield* makeHarness({ + ...options, + previous: true, + }); + yield* installation.start; + const state = yield* terminalState(installation); + expect(state.phase).toBe("failed"); + expect(state.message).toBeTruthy(); + expect(validations).toEqual([]); + yield* Deferred.await(stagingReleased); + yield* expectPreviousRelease(installation); + expect(yield* fs.readDirectory(path.join(installation.managedDirectory, "versions"))).toEqual( + [previousReleaseId], + ); + }), + ); + + it.effect.each(["download", "extract", "active pointer"] as const)( + "preserves the old runtime after an ENOSPC error during %s", + (stage) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const noSpace = PlatformError.systemError({ + _tag: "Unknown", + module: "FileSystem", + method: "write", + description: "ENOSPC: no space left on device", + }); + const fileSystem = FileSystem.FileSystem.of({ + ...fs, + sink: (target, options) => + (stage === "download" && target.endsWith("download.zip")) || + (stage === "extract" && target.endsWith("agy_acp_server.par")) + ? fs.sink(target, options).pipe(Sink.mapInputEffect(() => Effect.fail(noSpace))) + : fs.sink(target, options), + writeFileString: (target, content, options) => + stage === "active pointer" && target.endsWith("contents.tmp") + ? Effect.fail(noSpace) + : fs.writeFileString(target, content, options), + }); + const { installation, stagingReleased, path } = yield* makeHarness({ + previous: true, + fileSystem, + }); + yield* installation.start; + expect((yield* terminalState(installation)).phase).toBe("failed"); + yield* Deferred.await(stagingReleased); + yield* expectPreviousRelease(installation); + expect( + (yield* fs.readDirectory(path.join(installation.managedDirectory, "versions"))).some( + (name) => name.startsWith(".install-"), + ), + ).toBe(false); + expect(yield* fs.readDirectory(installation.managedDirectory)).toEqual([ + "active.json", + "versions", + ]); + }), + ); + + it.effect.each(["downloading", "extracting", "verifying"] as const)( + "cancels during %s and waits for open resources to close", + (phase) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const entered = yield* Deferred.make(); + const release = yield* Deferred.make(); + const interrupted = yield* Deferred.make(); + const barrier = Deferred.succeed(entered, undefined).pipe( + Effect.andThen(Deferred.await(release)), + Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined)), + ); + const { installation, stagingReleased, path, validations } = yield* makeHarness({ + previous: true, + body: + phase === "downloading" + ? Stream.concat( + Stream.make(completeArchive.subarray(0, 31)), + Stream.fromEffect(barrier.pipe(Effect.as(completeArchive.subarray(31)))), + ) + : undefined, + validate: phase === "verifying" ? () => barrier : undefined, + fileSystem: FileSystem.FileSystem.of({ + ...fs, + sink: (target, options) => + phase === "extracting" && target.endsWith("agy_acp_server.par") + ? fs + .sink(target, options) + .pipe( + Sink.mapInputEffect((chunk: Uint8Array) => barrier.pipe(Effect.as(chunk))), + ) + : fs.sink(target, options), + }), + }); + const started = yield* installation.start; + yield* Deferred.await(entered); + expect((yield* installation.state).phase).toBe(phase); + expect( + (yield* installation.cancel(started.operationId ?? "missing-operation-id")).phase, + ).toBe("cancelled"); + yield* Deferred.await(interrupted); + yield* Deferred.await(stagingReleased); + yield* expectPreviousRelease(installation); + expect(validations).toHaveLength(phase === "verifying" ? 1 : 0); + expect( + yield* fs.readDirectory(path.join(installation.managedDirectory, "versions")), + ).toEqual([previousReleaseId]); + }), + ); + + it.effect.each([ + { name: "the committed install", restart: false }, + { name: "a newer install", restart: true }, + ])("does not fail $name when old pointer cleanup fails", (testCase) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cleanupStarted = yield* Deferred.make(); + const releaseCleanup = yield* Deferred.make(); + const nextValidationStarted = yield* Deferred.make(); + const releaseNextValidation = yield* Deferred.make(); + const firstWorker = yield* Deferred.make>(); + const cleanupError = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "remove", + description: "EPERM: pointer temp directory is in use", + }); + let firstPointer = true; + let validationCount = 0; + const { installation, stagingReleased } = yield* makeHarness({ + previous: true, + validate: () => { + validationCount += 1; + return validationCount === 2 + ? Deferred.succeed(nextValidationStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseNextValidation)), + ) + : Effect.void; + }, + fileSystem: FileSystem.FileSystem.of({ + ...fs, + makeTempDirectoryScoped: (settings) => { + if (settings?.prefix !== "active.json." || !firstPointer) { + return fs.makeTempDirectoryScoped(settings); + } + firstPointer = false; + return Effect.fiber.pipe( + Effect.tap((worker) => Deferred.succeed(firstWorker, worker)), + Effect.andThen( + Effect.acquireRelease(fs.makeTempDirectory(settings), () => + Deferred.succeed(cleanupStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseCleanup)), + Effect.andThen(Effect.die(cleanupError)), + ), + ), + ), + ); + }, + }), + }); + yield* Effect.gen(function* () { + const first = yield* installation.start; + yield* Deferred.await(cleanupStarted); + expect(yield* installation.state).toMatchObject({ + operationId: first.operationId, + phase: "succeeded", + installedVersion: "fixture-new", + }); + const current = testCase.restart ? yield* installation.start : first; + if (testCase.restart) yield* Deferred.await(nextValidationStarted); + + yield* Deferred.succeed(releaseCleanup, undefined); + yield* Fiber.await(yield* Deferred.await(firstWorker)); + yield* Deferred.await(stagingReleased); + expect(yield* installation.state).toMatchObject({ + operationId: current.operationId, + phase: testCase.restart ? "verifying" : "succeeded", + installedVersion: "fixture-new", + }); + expect((yield* installation.resolve()).version).toBe("fixture-new"); + + yield* Deferred.succeed(releaseNextValidation, undefined); + expect(yield* terminalState(installation)).toMatchObject({ + operationId: current.operationId, + phase: "succeeded", + installedVersion: "fixture-new", + }); + }).pipe( + Effect.ensuring( + Deferred.succeed(releaseCleanup, undefined).pipe( + Effect.andThen(Deferred.succeed(releaseNextValidation, undefined)), + ), + ), + ); + }), + ); + + it.effect( + "shares one install across callers and keeps it alive after the caller scope closes", + () => + Effect.gen(function* () { + const entered = yield* Deferred.make(); + const release = yield* Deferred.make(); + const { installation, requests, stagingReleased } = yield* makeHarness({ + body: Stream.fromEffect( + Deferred.succeed(entered, undefined).pipe( + Effect.andThen(Deferred.await(release)), + Effect.as(completeArchive), + ), + ), + }); + const callerScope = yield* Scope.make(); + const started = yield* installation.start.pipe(Scope.provide(callerScope)); + yield* Deferred.await(entered); + yield* Scope.close(callerScope, Exit.void); + const concurrent = yield* Effect.all([installation.start, installation.start], { + concurrency: "unbounded", + }); + expect(concurrent.map((state) => state.operationId)).toEqual([ + started.operationId, + started.operationId, + ]); + expect( + yield* installation.changes.pipe(Stream.runHead, Effect.map(Option.getOrThrow)), + ).toMatchObject({ phase: "downloading", operationId: started.operationId }); + yield* Deferred.succeed(release, undefined); + expect((yield* terminalState(installation)).phase).toBe("succeeded"); + yield* Deferred.await(stagingReleased); + + const next = yield* installation.start; + expect(next.operationId).not.toBe(started.operationId); + expect( + yield* installation + .cancel(started.operationId ?? "missing-operation-id") + .pipe(Effect.flip), + ).toMatchObject({ operation: "cancel" }); + expect((yield* terminalState(installation)).phase).toBe("succeeded"); + expect(requests).toHaveLength(1); + }), + ); + + it.effect("honors explicit paths and reports invalid overrides without falling back", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-agy-path-test-" }); + const externalDirectory = path.join(baseDir, "external"); + const externalExecutable = path.join(externalDirectory, "agy_acp_server.par"); + const externalHarness = path.join(externalDirectory, "localharness_external"); + yield* fs.makeDirectory(externalDirectory); + yield* fs.writeFileString(externalExecutable, "external server", { mode: 0o755 }); + yield* fs.writeFileString(externalHarness, "external harness", { mode: 0o755 }); + const { installation } = yield* makeHarness({ + baseDir, + path: externalDirectory, + previous: true, + }); + yield* expectPreviousRelease(installation); + expect(yield* installation.resolve(undefined, { PATH: externalDirectory })).toMatchObject({ + source: "managed", + version: previousVersion, + }); + expect(yield* installation.resolve(externalExecutable)).toMatchObject({ + executablePath: externalExecutable, + source: "override", + managedVersionDirectory: null, + }); + expect(yield* installation.resolve("agy_acp_server.par")).toMatchObject({ + source: "override", + }); + yield* fs.remove(externalHarness); + expect(yield* installation.resolve(externalExecutable).pipe(Effect.flip)).toMatchObject({ + operation: "resolve", + }); + expect( + yield* installation.resolve(path.join(baseDir, "missing")).pipe(Effect.flip), + ).toMatchObject({ + operation: "resolve", + }); + yield* expectPreviousRelease(installation); + yield* fs.writeFileString(externalHarness, "external harness", { mode: 0o755 }); + yield* installation.remove(); + expect(yield* installation.resolve()).toMatchObject({ + source: "path", + executablePath: externalExecutable, + }); + const isolated = yield* makeHarness({ baseDir }); + expect(yield* isolated.installation.resolve().pipe(Effect.flip)).toMatchObject({ + operation: "resolve", + }); + expect( + yield* isolated.installation.resolve(undefined, { PATH: externalDirectory }), + ).toMatchObject({ + source: "path", + executablePath: externalExecutable, + }); + expect( + yield* isolated.installation.resolve("agy_acp_server.par", { PATH: externalDirectory }), + ).toMatchObject({ source: "override", executablePath: externalExecutable }); + }), + ); + + it.effect("keeps leased releases available while new sessions resolve the new release", () => + Effect.gen(function* () { + const { installation, fs, stagingReleased } = yield* makeHarness({ previous: true }); + const processScope = yield* Scope.make(); + const oldExecutable = yield* installation.acquire().pipe(Scope.provide(processScope)); + yield* installation.start; + expect((yield* terminalState(installation)).phase).toBe("succeeded"); + yield* Deferred.await(stagingReleased); + const current = yield* installation.resolve(); + expect(current.version).toBe("fixture-new"); + expect(current.executablePath).not.toBe(oldExecutable.executablePath); + expect(yield* fs.readFileString(oldExecutable.executablePath)).toBe(serverContents); + expect(yield* installation.remove().pipe(Effect.flip)).toMatchObject({ operation: "remove" }); + yield* Scope.close(processScope, Exit.void); + yield* installation.remove(); + expect(yield* fs.exists(installation.managedDirectory)).toBe(false); + }), + ); + + it.effect( + "removes an incomplete active release before reinstalling without a PATH fallback", + () => + Effect.gen(function* () { + const { installation, baseDir, fs, path } = yield* makeHarness({ previous: true }); + const previous = yield* installation.resolve(); + yield* fs.remove(previous.harnessPath); + const externalDirectory = path.join(baseDir, "external"); + yield* fs.makeDirectory(externalDirectory); + yield* fs.writeFileString( + path.join(externalDirectory, "agy_acp_server.par"), + "external server", + { + mode: 0o755, + }, + ); + yield* fs.writeFileString( + path.join(externalDirectory, "localharness_external"), + "external harness", + { + mode: 0o755, + }, + ); + const restarted = yield* makeHarness({ baseDir, path: externalDirectory }); + expect(yield* restarted.installation.state).toMatchObject({ + phase: "failed", + installedVersion: null, + canRemove: true, + }); + expect(yield* restarted.installation.resolve().pipe(Effect.flip)).toMatchObject({ + operation: "resolve", + }); + yield* restarted.installation.remove(); + expect(yield* restarted.installation.state).toMatchObject({ + phase: "idle", + canRemove: false, + }); + expect(yield* fs.exists(installation.managedDirectory)).toBe(false); + yield* restarted.installation.start; + expect(yield* terminalState(restarted.installation)).toMatchObject({ + phase: "succeeded", + installedVersion: "fixture-new", + canRemove: true, + }); + yield* Deferred.await(restarted.stagingReleased); + expect((yield* restarted.installation.resolve()).source).toBe("managed"); + }), + ); + + it.effect( + "blocks removal of custom managed paths and leaves external executables and profiles intact", + () => + Effect.gen(function* () { + const { installation, fs, path, baseDir } = yield* makeHarness({ previous: true }); + const managed = yield* installation.resolve(); + const externalDirectory = path.join(baseDir, "external"); + const profileDirectory = path.join(baseDir, "providers", "antigravity", "profile"); + yield* fs.makeDirectory(externalDirectory); + yield* fs.makeDirectory(profileDirectory, { recursive: true }); + const externalExecutable = path.join(externalDirectory, "agy_acp_server.par"); + const externalHarness = path.join(externalDirectory, "localharness_external"); + const profilePath = path.join(profileDirectory, "preferences.json"); + yield* fs.writeFileString(externalExecutable, "external server", { mode: 0o755 }); + yield* fs.writeFileString(externalHarness, "external harness", { mode: 0o755 }); + yield* fs.writeFileString(profilePath, "{}"); + expect( + yield* installation.remove([managed.executablePath]).pipe(Effect.flip), + ).toMatchObject({ + operation: "remove", + }); + yield* expectPreviousRelease(installation); + yield* installation.remove([externalExecutable]); + expect(yield* installation.state).toMatchObject({ + phase: "idle", + operationId: null, + installedVersion: null, + }); + expect(yield* fs.readFileString(externalExecutable)).toBe("external server"); + expect(yield* fs.readFileString(externalHarness)).toBe("external harness"); + expect(yield* fs.readFileString(profilePath)).toBe("{}"); + }), + ); + + it.effect( + "reuses an immutable Windows release and preserves the pointer when rename is denied", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const archive = Buffer.from(zipFixtures.windows, "base64"); + const asset = releaseAsset(archive, "win32"); + let denyPointerRename = true; + const renameTargets: string[] = []; + const { installation, requests, validations } = yield* makeHarness({ + previous: true, + platform: "win32", + archive, + fileSystem: FileSystem.FileSystem.of({ + ...fs, + rename: (source, target) => { + renameTargets.push(target); + return denyPointerRename + ? Effect.fail( + PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "rename", + pathOrDescriptor: target, + description: "EPERM: file is in use", + }), + ) + : fs.rename(source, target); + }, + }), + }); + yield* writeRelease(installation.managedDirectory, asset, false); + yield* installation.start; + expect((yield* terminalState(installation)).phase).toBe("failed"); + yield* expectPreviousRelease(installation); + expect(renameTargets).toEqual([path.join(installation.managedDirectory, "active.json")]); + + denyPointerRename = false; + yield* installation.start; + expect((yield* terminalState(installation)).phase).toBe("succeeded"); + expect((yield* installation.resolve()).version).toBe("fixture-new"); + expect(requests).toEqual([]); + expect(validations).toHaveLength(2); + expect(renameTargets).toEqual([ + path.join(installation.managedDirectory, "active.json"), + path.join(installation.managedDirectory, "active.json"), + ]); + }), + ); + + it.effect("reports unsupported hosts without downloading or changing state", () => + Effect.gen(function* () { + const { installation, requests } = yield* makeHarness({ asset: null, platform: "darwin" }); + expect(yield* installation.start.pipe(Effect.flip)).toMatchObject({ operation: "start" }); + expect(yield* installation.resolve().pipe(Effect.flip)).toMatchObject({ + operation: "resolve", + }); + expect(yield* installation.state).toMatchObject({ phase: "idle", operationId: null }); + expect(requests).toEqual([]); + }), + ); +}); diff --git a/apps/server/src/provider/AntigravityInstallation.ts b/apps/server/src/provider/AntigravityInstallation.ts new file mode 100644 index 000000000000..5e89d2e3d543 --- /dev/null +++ b/apps/server/src/provider/AntigravityInstallation.ts @@ -0,0 +1,950 @@ +// @effect-diagnostics nodeBuiltinImport:off - Effect has no incremental digest or free-space query. +import * as EffectNodeStream from "@effect/platform-node/NodeStream"; +import { ProviderDriverKind, type ProviderInstallState } from "@t3tools/contracts"; +import { + HostProcessArchitecture, + HostProcessEnvironment, + HostProcessPlatform, +} from "@t3tools/shared/hostProcess"; +import * as Clock from "effect/Clock"; +import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import * as NodeCrypto from "node:crypto"; +import * as NodeFSP from "node:fs/promises"; +import type * as NodeStream from "node:stream"; +import * as Yauzl from "yauzl"; + +import { ServerConfig } from "../config.ts"; +import { makeAntigravityAcpRuntime } from "./acp/AntigravityAcpSupport.ts"; +import { + buildAntigravityAcpSpawnInput, + prepareAntigravityProfile, +} from "./antigravityAuthSupport.ts"; +import { + resolveAntigravityReleaseAsset, + type AntigravityReleaseAsset, +} from "./antigravityRelease.ts"; + +const DRIVER = ProviderDriverKind.make("antigravity"); +const DOWNLOAD_TIMEOUT = "45 minutes"; +const VALIDATION_TIMEOUT = "90 seconds"; +const FREE_SPACE_MARGIN = 256 * 1024 * 1024; +const RECORD_MAX_BYTES = 8 * 1024; +const RELEASE_RECORD = ".install-complete.json"; + +const ReleaseId = Schema.String.check(Schema.isPattern(/^[a-f0-9]{64}$/u)); +const ActiveRelease = Schema.Struct({ releaseId: ReleaseId }); +const InstalledRelease = Schema.Struct({ + releaseId: ReleaseId, + version: Schema.String, + executable: Schema.Struct({ name: Schema.String, bytes: Schema.Number }), + harness: Schema.Struct({ name: Schema.String, bytes: Schema.Number }), +}); +type InstalledRelease = typeof InstalledRelease.Type; +const encodeActiveRelease = Schema.encodeEffect(Schema.fromJsonString(ActiveRelease)); +const encodeInstalledRelease = Schema.encodeEffect(Schema.fromJsonString(InstalledRelease)); + +export class AntigravityInstallationError extends Schema.TaggedErrorClass()( + "AntigravityInstallationError", + { + operation: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message() { + return this.detail; + } +} +const isInstallationError = Schema.is(AntigravityInstallationError); + +export interface AntigravityExecutable { + readonly executablePath: string; + readonly harnessPath: string; + readonly source: "override" | "managed" | "path"; + readonly version: string | null; + readonly managedVersionDirectory: string | null; +} + +interface AntigravityInstallationService { + readonly managedDirectory: string; + readonly resolve: ( + binaryPath?: string, + environment?: NodeJS.ProcessEnv, + ) => Effect.Effect; + /** Hold the lease until the spawned process has exited. */ + readonly acquire: ( + binaryPath?: string, + environment?: NodeJS.ProcessEnv, + ) => Effect.Effect; + readonly start: Effect.Effect; + readonly cancel: ( + operationId: string, + ) => Effect.Effect; + readonly state: Effect.Effect; + readonly changes: Stream.Stream; + readonly remove: ( + protectedBinaryPaths?: ReadonlyArray, + ) => Effect.Effect; +} + +export class AntigravityInstallation extends Context.Service< + AntigravityInstallation, + AntigravityInstallationService +>()("t3/provider/AntigravityInstallation") { + static readonly layer = Layer.effect( + AntigravityInstallation, + Effect.gen(function* () { + const config = yield* ServerConfig; + return yield* makeAntigravityInstallation({ baseDir: config.baseDir }); + }), + ); +} + +export interface AntigravityInstallationOptions { + readonly baseDir: string; + readonly releaseAsset?: AntigravityReleaseAsset | null; + readonly validate?: ( + executable: AntigravityExecutable, + expectedVersion: string, + ) => Effect.Effect; +} + +const installationError = (operation: string, detail: string, cause?: unknown) => + new AntigravityInstallationError({ + operation, + detail, + ...(cause === undefined ? {} : { cause }), + }); + +const wrapFailure = (operation: string, detail: string) => (cause: unknown) => + isInstallationError(cause) ? cause : installationError(operation, detail, cause); + +function executableNames(platform: NodeJS.Platform) { + return platform === "win32" + ? { executable: "agy_acp_server.exe", harness: "localharness_external.exe" } + : { executable: "agy_acp_server.par", harness: "localharness_external" }; +} + +function isRunning(state: ProviderInstallState) { + return ( + state.phase === "downloading" || state.phase === "extracting" || state.phase === "verifying" + ); +} + +/** Open only a verified local archive. Entries stay lazy and extraction stays bounded. */ +const openArchive = Effect.fn("AntigravityInstallation.openArchive")(function* ( + archivePath: string, +) { + const opened = yield* Effect.acquireRelease( + Effect.callback< + { + readonly zip: Yauzl.ZipFile; + readonly error: () => AntigravityInstallationError | undefined; + readonly close: Effect.Effect; + }, + AntigravityInstallationError + >((resume) => { + Yauzl.open( + archivePath, + { lazyEntries: true, autoClose: false, validateEntrySizes: true, strictFileNames: true }, + (error, zip) => { + if (error || !zip) { + resume( + Effect.fail( + installationError("extract", "Could not open the verified archive.", error), + ), + ); + return; + } + let closed = false; + let archiveError: AntigravityInstallationError | undefined; + zip.on("close", () => { + closed = true; + }); + zip.on("error", (cause: unknown) => { + archiveError = installationError("extract", "The archive could not be read.", cause); + }); + resume( + Effect.succeed({ + zip, + error: () => archiveError, + close: Effect.callback((finish) => { + if (closed) { + finish(Effect.void); + return; + } + const onClose = () => { + zip.removeListener("error", onError); + finish(Effect.void); + }; + const onError = (cause: unknown) => { + zip.removeListener("close", onClose); + finish( + Effect.die(installationError("extract", "Could not close the archive.", cause)), + ); + }; + zip.once("close", onClose); + zip.once("error", onError); + zip.close(); + }), + }), + ); + }, + ); + }), + (opened) => opened.close, + ); + + const next = Effect.callback((resume) => { + const existingError = opened.error(); + if (existingError) { + resume(Effect.fail(existingError)); + return; + } + const cleanup = () => { + opened.zip.removeListener("entry", onEntry); + opened.zip.removeListener("end", onEnd); + opened.zip.removeListener("error", onError); + }; + const onEntry = (entry: Yauzl.Entry) => { + cleanup(); + resume(Effect.succeed(entry)); + }; + const onEnd = () => { + cleanup(); + resume(Effect.succeed(null)); + }; + const onError = (cause: unknown) => { + cleanup(); + resume(Effect.fail(installationError("extract", "The archive could not be read.", cause))); + }; + opened.zip.once("entry", onEntry); + opened.zip.once("end", onEnd); + opened.zip.once("error", onError); + opened.zip.readEntry(); + return Effect.sync(cleanup); + }); + + const streamEntry = (entry: Yauzl.Entry) => + Effect.acquireRelease( + Effect.callback((resume) => { + opened.zip.openReadStream(entry, (cause, readable) => { + resume( + cause || !readable + ? Effect.fail( + installationError("extract", "Could not read an archive member.", cause), + ) + : Effect.succeed(readable), + ); + }); + }), + (readable) => + Effect.sync(() => { + readable.destroy(); + }), + ); + return { entryCount: opened.zip.entryCount, next, streamEntry }; +}); + +export const makeAntigravityInstallation = Effect.fn("AntigravityInstallation.make")(function* ( + options: AntigravityInstallationOptions, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const crypto = yield* Crypto.Crypto; + const http = yield* HttpClient.HttpClient; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const serviceScope = yield* Effect.scope; + const platform = yield* HostProcessPlatform; + const arch = yield* HostProcessArchitecture; + const environment = yield* HostProcessEnvironment; + const releaseAsset = + options.releaseAsset === undefined + ? resolveAntigravityReleaseAsset(platform, arch) + : options.releaseAsset; + const names = executableNames(platform); + const managedDirectory = path.join( + options.baseDir, + "tools", + "antigravity-acp", + `${platform}-${arch}`, + ); + const versionsDirectory = path.join(managedDirectory, "versions"); + const activePath = path.join(managedDirectory, "active.json"); + const gate = yield* Semaphore.make(1); + const leases = new Map(); + let running: { readonly operationId: string; readonly fiber: Fiber.Fiber } | undefined; + const state = yield* SubscriptionRef.make({ + driver: DRIVER, + operationId: null, + phase: "idle", + downloadedBytes: 0, + totalBytes: releaseAsset?.archiveBytes ?? null, + version: releaseAsset?.version ?? null, + installedVersion: null, + canRemove: false, + message: null, + }); + + const readRecord = Effect.fn("AntigravityInstallation.readRecord")(function* ( + filePath: string, + schema: Schema.Codec, + ) { + const info = yield* fs.stat(filePath); + if (info.type !== "File" || Number(info.size) > RECORD_MAX_BYTES) { + return yield* installationError( + "resolve", + "The managed runtime record is invalid. Reinstall Antigravity.", + ); + } + const contents = yield* fs.readFileString(filePath); + return yield* Schema.decodeUnknownEffect(Schema.fromJsonString(schema))(contents); + }); + + const executableFile = Effect.fn("AntigravityInstallation.executableFile")(function* ( + filePath: string, + bytes?: number, + ) { + const info = yield* fs.stat(filePath).pipe(Effect.option); + return ( + Option.isSome(info) && + info.value.type === "File" && + (bytes === undefined || Number(info.value.size) === bytes) && + (platform === "win32" || (info.value.mode & 0o111) !== 0) + ); + }); + + const completedRelease = Effect.fn("AntigravityInstallation.completedRelease")(function* ( + releaseId: string, + ) { + const directory = path.join(versionsDirectory, releaseId); + const record = yield* readRecord(path.join(directory, RELEASE_RECORD), InstalledRelease); + if ( + record.releaseId !== releaseId || + record.executable.name !== names.executable || + record.harness.name !== names.harness || + !Number.isSafeInteger(record.executable.bytes) || + record.executable.bytes <= 0 || + !Number.isSafeInteger(record.harness.bytes) || + record.harness.bytes <= 0 || + !record.version.trim() || + !(yield* executableFile(path.join(directory, names.executable), record.executable.bytes)) || + !(yield* executableFile(path.join(directory, names.harness), record.harness.bytes)) + ) { + return yield* installationError( + "resolve", + "The managed Antigravity runtime is incomplete. Reinstall it.", + ); + } + return { + executablePath: path.join(directory, names.executable), + harnessPath: path.join(directory, names.harness), + source: "managed", + version: record.version, + managedVersionDirectory: directory, + } satisfies AntigravityExecutable; + }); + + const fromExternal = Effect.fn("AntigravityInstallation.fromExternal")(function* ( + candidate: string, + source: "override" | "path", + ) { + if (!(yield* executableFile(candidate))) return null; + const executablePath = yield* fs.realPath(candidate); + const directory = path.dirname(executablePath); + const harnessPath = path.join(directory, names.harness); + if (!(yield* executableFile(harnessPath))) return null; + const realVersions = yield* fs.realPath(versionsDirectory).pipe(Effect.option); + if ( + Option.isSome(realVersions) && + path.dirname(directory) === realVersions.value && + /^[a-f0-9]{64}$/u.test(path.basename(directory)) + ) { + const installed = yield* completedRelease(path.basename(directory)); + return { ...installed, executablePath, harnessPath, source } satisfies AntigravityExecutable; + } + return { + executablePath, + harnessPath, + source, + version: null, + managedVersionDirectory: null, + } satisfies AntigravityExecutable; + }); + + const pathCandidates = (binary: string, processEnvironment = environment) => { + const pathValue = + platform === "win32" + ? Object.entries(processEnvironment).findLast(([key]) => key.toUpperCase() === "PATH")?.[1] + : processEnvironment.PATH; + return (pathValue ?? "") + .split(platform === "win32" ? ";" : ":") + .map((directory) => directory.trim().replace(/^"|"$/gu, "")) + .filter((directory) => directory.length > 0) + .map((directory) => path.resolve(directory, binary)); + }; + + const resolve: AntigravityInstallationService["resolve"] = Effect.fn( + "AntigravityInstallation.resolve", + )( + function* (binaryPath?: string, processEnvironment?: NodeJS.ProcessEnv) { + const override = binaryPath?.trim(); + if (override) { + const candidates = + path.isAbsolute(override) || override.includes("/") || override.includes("\\") + ? [path.resolve(override)] + : pathCandidates(override, processEnvironment); + for (const candidate of candidates) { + const selected = yield* fromExternal(candidate, "override"); + if (selected) return selected; + } + return yield* installationError( + "resolve", + "The custom Antigravity executable or its localharness_external sibling is missing or not executable.", + ); + } + if (yield* fs.exists(activePath)) { + const active = yield* readRecord(activePath, ActiveRelease); + return yield* completedRelease(active.releaseId); + } + for (const candidate of pathCandidates(names.executable, processEnvironment)) { + const selected = yield* fromExternal(candidate, "path"); + if (selected) return selected; + } + return yield* installationError( + "resolve", + releaseAsset + ? "Antigravity is not installed. Install it in this environment or set a custom executable path." + : `Google does not publish an Antigravity runtime for ${platform}-${arch}. Use a supported environment or a custom executable.`, + ); + }, + Effect.mapError( + wrapFailure( + "resolve", + "Could not read the Antigravity installation. Reinstall it or set a custom executable path.", + ), + ), + ); + + const acquire = (binaryPath?: string, processEnvironment?: NodeJS.ProcessEnv) => + Effect.acquireRelease( + gate.withPermit( + Effect.gen(function* () { + const executable = yield* resolve(binaryPath, processEnvironment); + const directory = executable.managedVersionDirectory; + if (directory) leases.set(directory, (leases.get(directory) ?? 0) + 1); + return executable; + }), + ), + (executable) => + gate.withPermit( + Effect.sync(() => { + const directory = executable.managedVersionDirectory; + if (!directory) return; + const remaining = (leases.get(directory) ?? 1) - 1; + if (remaining > 0) leases.set(directory, remaining); + else leases.delete(directory); + }), + ), + ); + + const validate = + options.validate ?? + Effect.fn("AntigravityInstallation.validate")( + function* (executable: AntigravityExecutable, expectedVersion: string) { + const profileDirectory = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-antigravity-validate-", + }); + const profile = yield* prepareAntigravityProfile({ + profileDirectory, + platform, + baseEnv: environment, + }); + const runtime = yield* makeAntigravityAcpRuntime({ + spawn: buildAntigravityAcpSpawnInput({ + installation: executable, + profile, + cwd: profileDirectory, + baseEnv: environment, + }), + cwd: profileDirectory, + childProcessSpawner: spawner, + clientInfo: { name: "t3-code", version: "0.0.0" }, + }); + const initialized = yield* runtime.initialize(); + if ( + initialized.agentInfo?.name !== "antigravity-acp" || + initialized.agentInfo.version !== expectedVersion || + initialized.protocolVersion !== 1 || + initialized.agentCapabilities?.loadSession !== true || + !initialized.agentCapabilities.sessionCapabilities?.resume || + !initialized.agentCapabilities.auth?.logout || + !initialized.authMethods?.some((method) => method.id === "oauth-personal") + ) { + return yield* installationError( + "verify", + "The downloaded runtime did not identify as the expected Google Antigravity release.", + ); + } + }, + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(Crypto.Crypto, crypto), + Effect.mapError( + wrapFailure( + "verify", + "The downloaded Antigravity runtime could not start in this environment.", + ), + ), + ); + + const install = Effect.fn("AntigravityInstallation.install")( + function* (asset: AntigravityReleaseAsset) { + const report = (phase: ProviderInstallState["phase"], message: string | null) => + SubscriptionRef.update(state, (current) => ({ ...current, phase, message })); + yield* fs.makeDirectory(versionsDirectory, { recursive: true }); + yield* SubscriptionRef.update(state, (current) => ({ ...current, canRemove: true })); + const destination = path.join(versionsDirectory, asset.sha256); + const activate = Effect.fn("AntigravityInstallation.activate")( + function* () { + const pointerDirectory = yield* fs.makeTempDirectoryScoped({ + directory: managedDirectory, + prefix: "active.json.", + }); + const pointerPath = path.join(pointerDirectory, "contents.tmp"); + yield* fs.writeFileString( + pointerPath, + yield* encodeActiveRelease({ releaseId: asset.sha256 }), + { flag: "wx", mode: 0o600 }, + ); + yield* fs.rename(pointerPath, activePath); + // The pointer commits the install. Later temp cleanup cannot undo it. + yield* SubscriptionRef.update( + state, + (current) => + ({ + ...current, + phase: "succeeded", + installedVersion: asset.version, + message: null, + }) satisfies ProviderInstallState, + ); + }, + Effect.scoped, + Effect.mapError( + wrapFailure( + "activate", + "Could not activate Antigravity. The previous runtime is unchanged. Check for locked files and try again.", + ), + ), + Effect.uninterruptible, + ); + + if (yield* fs.exists(destination)) { + const existing = yield* completedRelease(asset.sha256); + if (existing.version !== asset.version) { + return yield* installationError( + "verify", + "The existing managed release has the wrong version. Remove it before reinstalling.", + ); + } + yield* report("verifying", "Checking the installed runtime."); + yield* validate(existing, asset.version).pipe( + Effect.scoped, + Effect.timeout(VALIDATION_TIMEOUT), + ); + yield* activate(); + return; + } + + const available = yield* Effect.tryPromise(() => + NodeFSP.statfs(versionsDirectory, { bigint: true }), + ).pipe(Effect.option); + const required = + asset.archiveBytes + asset.executable.bytes + asset.harness.bytes + FREE_SPACE_MARGIN; + if ( + Option.isSome(available) && + available.value.bavail * available.value.bsize < BigInt(required) + ) { + return yield* installationError( + "download", + `Antigravity needs at least ${Math.ceil(required / 1024 / 1024)} MiB of free space to install.`, + ); + } + const staging = yield* fs.makeTempDirectoryScoped({ + directory: versionsDirectory, + prefix: ".install-", + }); + const archivePath = path.join(staging, "download.zip"); + const pairDirectory = path.join(staging, "runtime"); + yield* fs.makeDirectory(pairDirectory); + const hash = NodeCrypto.createHash("sha256"); + let downloadedBytes = 0; + let lastProgressAt = yield* Clock.currentTimeMillis; + yield* Effect.gen(function* () { + const response = yield* http + .execute(HttpClientRequest.get(asset.url)) + .pipe(Effect.flatMap(HttpClientResponse.filterStatusOk)); + // dl.google.com gzips the zip when the client accepts it, so + // `content-length` is the encoded size. The decoded stream is still + // checked against the pinned byte count and hash below. + const contentLength = response.headers["content-length"]; + const contentEncoding = response.headers["content-encoding"]?.trim().toLowerCase(); + const identityBody = contentEncoding === undefined || contentEncoding === "identity"; + if ( + identityBody && + contentLength !== undefined && + Number(contentLength) !== asset.archiveBytes + ) { + return yield* installationError( + "download", + "The Antigravity download size did not match the pinned release.", + ); + } + yield* response.stream.pipe( + Stream.tap((chunk) => + Effect.gen(function* () { + downloadedBytes += chunk.byteLength; + if (downloadedBytes > asset.archiveBytes) { + return yield* installationError( + "download", + "The Antigravity download exceeded the pinned release size.", + ); + } + hash.update(chunk); + const now = yield* Clock.currentTimeMillis; + if (now - lastProgressAt >= 250 || downloadedBytes === asset.archiveBytes) { + lastProgressAt = now; + yield* SubscriptionRef.update(state, (current) => ({ + ...current, + downloadedBytes, + })); + } + }), + ), + Stream.run(fs.sink(archivePath, { flag: "wx", mode: 0o600 })), + ); + }).pipe(Effect.timeout(DOWNLOAD_TIMEOUT)); + if (downloadedBytes !== asset.archiveBytes || hash.digest("hex") !== asset.sha256) { + return yield* installationError( + "download", + "The Antigravity download failed its size or SHA-256 check. Nothing was installed.", + ); + } + + yield* report("extracting", "Extracting the verified runtime."); + yield* Effect.gen(function* () { + const archive = yield* openArchive(archivePath); + if (archive.entryCount !== 2) { + return yield* installationError( + "extract", + "The archive must contain exactly the Antigravity executable and its harness.", + ); + } + const seen = new Set(); + for (;;) { + const entry = yield* archive.next; + if (!entry) break; + const expected = [asset.executable, asset.harness].find( + (file) => file.name === entry.fileName, + ); + const unixType = (entry.externalFileAttributes >>> 16) & 0o170000; + if ( + !expected || + seen.has(entry.fileName) || + entry.fileName.includes("/") || + entry.fileName.includes("\\") || + (unixType !== 0 && unixType !== 0o100000) || + (entry.externalFileAttributes & 0x10) !== 0 || + (entry.generalPurposeBitFlag & 1) !== 0 || + ![0, 8].includes(entry.compressionMethod) || + entry.uncompressedSize !== expected.bytes + ) { + return yield* installationError( + "extract", + "The archive contains an unexpected, unsafe, or incorrectly sized member.", + ); + } + seen.add(entry.fileName); + yield* Effect.gen(function* () { + const readable = yield* archive.streamEntry(entry); + let extractedBytes = 0; + yield* EffectNodeStream.fromReadable({ + evaluate: () => readable, + onError: wrapFailure("extract", "Could not extract the Antigravity runtime."), + }).pipe( + Stream.tap((chunk) => + Effect.gen(function* () { + extractedBytes += chunk.byteLength; + if (extractedBytes > expected.bytes) { + return yield* installationError( + "extract", + "An archive member exceeded its pinned size.", + ); + } + }), + ), + Stream.run( + fs.sink(path.join(pairDirectory, entry.fileName), { flag: "wx", mode: 0o700 }), + ), + ); + if (extractedBytes !== expected.bytes) { + return yield* installationError("extract", "An archive member was truncated."); + } + }).pipe(Effect.scoped); + } + if (!seen.has(asset.executable.name) || !seen.has(asset.harness.name)) { + return yield* installationError( + "extract", + "The archive is missing the Antigravity executable or its harness.", + ); + } + }).pipe(Effect.scoped); + yield* fs.remove(archivePath); + if (platform !== "win32") { + yield* fs.chmod(path.join(pairDirectory, asset.executable.name), 0o755); + yield* fs.chmod(path.join(pairDirectory, asset.harness.name), 0o755); + } + yield* report("verifying", "Checking the downloaded runtime."); + yield* validate( + { + executablePath: path.join(pairDirectory, asset.executable.name), + harnessPath: path.join(pairDirectory, asset.harness.name), + source: "managed", + version: asset.version, + managedVersionDirectory: pairDirectory, + }, + asset.version, + ).pipe(Effect.scoped, Effect.timeout(VALIDATION_TIMEOUT)); + const record: InstalledRelease = { + releaseId: asset.sha256, + version: asset.version, + executable: asset.executable, + harness: asset.harness, + }; + yield* fs.writeFileString( + path.join(pairDirectory, RELEASE_RECORD), + yield* encodeInstalledRelease(record), + { flag: "wx", mode: 0o600 }, + ); + yield* fs.rename(pairDirectory, destination).pipe( + Effect.catch((cause) => + completedRelease(asset.sha256).pipe( + Effect.flatMap((existing) => + existing.version === asset.version + ? validate(existing, asset.version).pipe( + Effect.scoped, + Effect.timeout(VALIDATION_TIMEOUT), + ) + : Effect.fail( + installationError( + "activate", + "Another installation published a different Antigravity release.", + ), + ), + ), + Effect.mapError(() => + installationError( + "activate", + "Could not publish the Antigravity runtime. The previous release is unchanged. Try again.", + cause, + ), + ), + ), + ), + ); + yield* activate(); + }, + Effect.scoped, + Effect.mapError( + wrapFailure( + "install", + "Could not install Antigravity. Check free disk space and directory access, then try again.", + ), + ), + ); + + const start = gate + .withPermit( + Effect.gen(function* () { + const current = yield* SubscriptionRef.get(state); + if (isRunning(current)) return current; + if (!releaseAsset) { + return yield* installationError( + "start", + `Google does not publish an Antigravity runtime for ${platform}-${arch}. Use a supported remote environment or a custom executable.`, + ); + } + const operationId = yield* crypto.randomUUIDv4; + const next: ProviderInstallState = { + driver: DRIVER, + operationId, + phase: "downloading", + downloadedBytes: 0, + totalBytes: releaseAsset.archiveBytes, + version: releaseAsset.version, + installedVersion: current.installedVersion, + canRemove: current.canRemove, + message: "Downloading Google's official Antigravity runtime.", + }; + yield* SubscriptionRef.set(state, next); + const work = install(releaseAsset).pipe( + Effect.onExit((exit) => + Exit.isFailure(exit) + ? SubscriptionRef.update(state, (value) => { + if (value.operationId !== operationId || value.phase === "succeeded") + return value; + const error = Cause.findErrorOption(exit.cause); + const cancelled = Cause.hasInterruptsOnly(exit.cause); + return { + ...value, + phase: cancelled ? "cancelled" : "failed", + message: cancelled + ? "Installation cancelled. The previous runtime is unchanged." + : Option.isSome(error) + ? error.value.detail + : "Could not finish the Antigravity installation. Check disk space and directory access.", + } satisfies ProviderInstallState; + }) + : Effect.void, + ), + Effect.ignoreCause, + Effect.ensuring( + Effect.sync(() => { + if (running?.operationId === operationId) running = undefined; + }), + ), + ); + const fiber = yield* Effect.forkIn(Effect.interruptible(work), serviceScope); + running = { operationId, fiber }; + return next; + }).pipe(Effect.uninterruptible), + ) + .pipe(Effect.mapError(wrapFailure("start", "Could not start the Antigravity installation."))); + + const cancel = Effect.fn("AntigravityInstallation.cancel")(function* (operationId: string) { + return yield* gate.withPermit( + Effect.gen(function* () { + const current = yield* SubscriptionRef.get(state); + if (current.operationId !== operationId) { + return yield* installationError( + "cancel", + "This installation is no longer current. Refresh its status before cancelling.", + ); + } + if (running?.operationId === operationId && isRunning(current)) { + yield* Fiber.interrupt(running.fiber); + } + return yield* SubscriptionRef.get(state); + }), + ); + }); + + const remove = Effect.fn("AntigravityInstallation.remove")( + function* (protectedBinaryPaths: ReadonlyArray = []) { + yield* gate.withPermit( + Effect.gen(function* () { + if (isRunning(yield* SubscriptionRef.get(state)) || leases.size > 0) { + return yield* installationError( + "remove", + "Stop Antigravity sessions and sign-in flows before removing its managed runtime.", + ); + } + const realManaged = yield* fs.realPath(managedDirectory).pipe(Effect.option); + if (Option.isSome(realManaged)) { + for (const binaryPath of protectedBinaryPaths) { + if (!binaryPath.trim()) continue; + const selected = yield* resolve(binaryPath).pipe(Effect.option); + if (Option.isSome(selected) && selected.value.managedVersionDirectory) { + return yield* installationError( + "remove", + "A provider instance has a custom path inside this managed runtime. Clear that path before removing it.", + ); + } + const resolved = yield* fs.realPath(binaryPath).pipe(Effect.option); + const candidate = Option.getOrElse(resolved, () => path.resolve(binaryPath)); + if (candidate.startsWith(`${realManaged.value}${path.sep}`)) { + return yield* installationError( + "remove", + "A provider instance has a custom path inside this managed runtime. Clear that path before removing it.", + ); + } + } + } + yield* fs.remove(managedDirectory, { recursive: true, force: true }); + yield* SubscriptionRef.update( + state, + (current) => + ({ + ...current, + operationId: null, + phase: "idle", + downloadedBytes: 0, + installedVersion: null, + canRemove: false, + message: null, + }) satisfies ProviderInstallState, + ); + }).pipe(Effect.uninterruptible), + ); + }, + Effect.mapError( + wrapFailure( + "remove", + "Could not remove the managed Antigravity runtime. Check for open processes and try again.", + ), + ), + ); + + yield* Effect.gen(function* () { + const canRemove = yield* fs.exists(managedDirectory); + yield* SubscriptionRef.update(state, (current) => ({ ...current, canRemove })); + if (!(yield* fs.exists(activePath))) return; + const active = yield* readRecord(activePath, ActiveRelease); + const installed = yield* completedRelease(active.releaseId); + yield* SubscriptionRef.update(state, (current) => ({ + ...current, + installedVersion: installed.version, + })); + }).pipe( + Effect.catch(() => + SubscriptionRef.update( + state, + (current) => + ({ + ...current, + phase: "failed", + message: "The managed Antigravity runtime is incomplete. Remove it and reinstall.", + }) satisfies ProviderInstallState, + ), + ), + ); + + return AntigravityInstallation.of({ + managedDirectory, + resolve, + acquire, + start, + cancel, + state: SubscriptionRef.get(state), + changes: SubscriptionRef.changes(state), + remove, + }); +}); diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.test.ts b/apps/server/src/provider/Drivers/AntigravityDriver.test.ts new file mode 100644 index 000000000000..922a27df5768 --- /dev/null +++ b/apps/server/src/provider/Drivers/AntigravityDriver.test.ts @@ -0,0 +1,438 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import { + ANTIGRAVITY_DEFAULT_MODEL, + ProviderInstanceId, + type AntigravitySettings, +} from "@t3tools/contracts"; +import { + HostProcessEnvironment, + HostProcessExecutablePath, + 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"; +import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { + AntigravityInstallation, + AntigravityInstallationError, + type AntigravityExecutable, +} from "../AntigravityInstallation.ts"; +import { + ANTIGRAVITY_AUTH_STDOUT_PREFIX, + resolveAntigravityProfileDirectory, +} from "../antigravityAuthSupport.ts"; +import { NoOpProviderEventLoggers, ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; +import * as ModelManifest from "../ModelManifest.ts"; +import { AntigravityDriver } from "./AntigravityDriver.ts"; + +const hostPlatform = HostProcessPlatform.defaultValue(); +const windowsHost = hostPlatform === "win32"; +const decodeRequest = Schema.decodeEffect( + Schema.fromJsonString( + Schema.Struct({ + method: Schema.optional(Schema.String), + params: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), + }), + ), +); +const blockedCredentialKeys = new Set([ + "GEMINI_API_KEY", + "GOOGLE_API_KEY", + "GOOGLE_APPLICATION_CREDENTIALS", + "GOOGLE_GENAI_USE_VERTEXAI", +]); + +function shellQuote(value: string): string { + return `'${value.replaceAll("'", `'"'"'`)}'`; +} + +const makeHarness = Effect.fn("makeAntigravityDriverHarness")(function* ( + options: { readonly config?: Partial } = {}, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const config = yield* ServerConfig; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const nodePath = yield* HostProcessExecutablePath; + const baseEnv = yield* HostProcessEnvironment; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-antigravity-driver-" }); + const instanceId = ProviderInstanceId.make(path.basename(root)); + const mockAgentPath = yield* path.fromFileUrl( + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), + ); + const requestLog = path.join(root, "requests.jsonl"); + const profileDirectory = resolveAntigravityProfileDirectory(config.stateDir, instanceId); + const instancePath = `${path.join(root, "instance-bin")}:${baseEnv.PATH ?? ""}`; + + const makeExecutable = Effect.fn("AntigravityDriverTest.makeExecutable")(function* ( + name: string, + loginRequired = false, + ) { + const directory = path.join(root, name); + const executablePath = path.join(directory, "agy_acp_server.par"); + const harnessPath = path.join(directory, "localharness_external"); + yield* fs.makeDirectory(directory, { recursive: true }); + const authorizationUrl = + "https://accounts.google.com/o/oauth2/v2/auth?response_type=code&redirect_uri=http%3A%2F%2F127.0.0.1%3A51234%2F&state=fixture-state"; + yield* fs.writeFileString( + executablePath, + [ + "#!/bin/sh", + ...(loginRequired + ? [`printf '%s\\n' ${shellQuote(ANTIGRAVITY_AUTH_STDOUT_PREFIX + authorizationUrl)}`] + : []), + `exec ${shellQuote(nodePath)} ${shellQuote(mockAgentPath)} "$@"`, + "", + ].join("\n"), + ); + yield* fs.writeFileString(harnessPath, "#!/bin/sh\nexit 99\n"); + yield* fs.chmod(executablePath, 0o755); + yield* fs.chmod(harnessPath, 0o755); + return { + executablePath, + harnessPath, + source: "managed", + version: name, + managedVersionDirectory: directory, + } satisfies AntigravityExecutable; + }); + + 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, beforeAcquire: Effect.void }; + const acquisitions: Array<{ binaryPath: string | undefined; path: string | undefined }> = []; + const releases: Array = []; + const launches: Array<{ + command: string; + args: ReadonlyArray; + cwd: string | undefined; + extendEnv: boolean | undefined; + profileDirectory: string | undefined; + harnessPath: string | undefined; + forceFileStorage: string | undefined; + credentialKeys: ReadonlyArray; + geminiApiKey: string | undefined; + handle: ChildProcessSpawner.ChildProcessHandle; + }> = []; + + const installation = Layer.mock(AntigravityInstallation)({ + managedDirectory: root, + acquire: (binaryPath, environment) => + Effect.gen(function* () { + acquisitions.push({ binaryPath, path: environment?.PATH }); + yield* controls.beforeAcquire; + if (controls.failResolution) { + return yield* new AntigravityInstallationError({ + operation: "resolve", + detail: "Fixture resolution failed.", + }); + } + const selected = controls.selected; + yield* Effect.addFinalizer(() => + Effect.sync(() => { + releases.push(selected.version); + }), + ); + return selected; + }), + }); + const observedSpawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + if (command._tag !== "StandardCommand") + return yield* Effect.die("Unexpected process pipeline."); + const handle = yield* spawner.spawn(command); + const environment = command.options.env ?? {}; + launches.push({ + command: command.command, + args: command.args, + cwd: command.options.cwd, + extendEnv: command.options.extendEnv, + profileDirectory: environment.GEMINI_HOME, + harnessPath: environment.ANTIGRAVITY_HARNESS_PATH, + forceFileStorage: environment.AGY_ACP_FORCE_FILE_STORAGE, + credentialKeys: Object.keys(environment).filter((key) => + blockedCredentialKeys.has(key.toUpperCase()), + ), + geminiApiKey: environment.GEMINI_API_KEY, + handle, + }); + return handle; + }), + ); + const instance = yield* AntigravityDriver.create({ + instanceId, + displayName: "Google test account", + enabled: false, + config: { ...AntigravityDriver.defaultConfig(), ...options.config }, + environment: [ + { name: "PATH", value: instancePath }, + { name: "T3_ACP_ANTIGRAVITY", value: "1" }, + { name: "T3_ACP_REQUEST_LOG_PATH", value: requestLog }, + { name: "GEMINI_API_KEY", value: "must-not-be-used" }, + { name: "google_api_key", value: "must-not-be-used" }, + { name: "GOOGLE_APPLICATION_CREDENTIALS", value: "/must-not-be-used.json" }, + { name: "GOOGLE_GENAI_USE_VERTEXAI", value: "true" }, + { name: "GEMINI_HOME", value: "/must-not-be-used" }, + { name: "ANTIGRAVITY_HARNESS_PATH", value: "/must-not-be-used" }, + { name: "BROWSER", value: "must-not-run" }, + ].map((variable) => ({ ...variable, sensitive: false })), + }).pipe( + Effect.provide(installation), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, observedSpawner), + ); + const refresh = instance.refreshModels; + if (!refresh) return yield* Effect.die("Antigravity does not expose model refresh."); + const readRequests = Effect.gen(function* () { + if (!(yield* fs.exists(requestLog))) return []; + const text = yield* fs.readFileString(requestLog); + return yield* Effect.forEach(text.split(/\r?\n/u).filter(Boolean), (line) => + decodeRequest(line), + ); + }); + const assertClosed = Effect.gen(function* () { + for (const launch of launches) { + // Cancelled startup can report a signal instead of a numeric exit code. + yield* launch.handle.exitCode.pipe(Effect.ignore); + expect(yield* launch.handle.isRunning).toBe(false); + if (launch.cwd) expect(yield* fs.exists(launch.cwd)).toBe(false); + } + }); + return { + instance, + refresh, + fs, + profileDirectory, + instancePath, + first, + second, + signedOut, + controls, + acquisitions, + releases, + launches, + readRequests, + assertClosed, + }; +}); + +const testLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-antigravity-driver-config-", +}).pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge( + Layer.mock(BackgroundPolicy.BackgroundPolicy)({ + shouldRunScopeWork: () => Effect.succeed(false), + }), + ), + Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), + Layer.provideMerge(ModelManifest.layerTest), +); + +it.layer(testLayer)("AntigravityDriver", (it) => { + it.effect.skipIf(windowsHost)("does not launch a process for a disabled instance", () => + Effect.gen(function* () { + const h = yield* makeHarness(); + const snapshot = yield* h.instance.snapshot.refresh; + expect(snapshot.status).toBe("disabled"); + expect(h.acquisitions).toEqual([]); + expect(h.launches).toEqual([]); + expect(yield* h.fs.exists(h.profileDirectory)).toBe(false); + }).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", + () => + Effect.gen(function* () { + const h = yield* makeHarness(); + yield* h.refresh(); + const snapshot = yield* h.instance.snapshot.getSnapshot; + expect(snapshot.status).toBe("disabled"); + expect(snapshot.auth.status).toBe("authenticated"); + expect(snapshot.models.map((model) => model.slug)).toEqual([ + "gemini-test-low", + "gemini-test-high", + ]); + expect(snapshot.models[0]?.aliases).toContain(ANTIGRAVITY_DEFAULT_MODEL); + // The mock catalog is not in the manifest's current list, so it folds + // under the legacy section like an old Codex model would. + expect(snapshot.models.every((model) => model.isLegacy === true)).toBe(true); + expect(snapshot.slashCommands.map((command) => command.name)).toEqual(["plan", "logout"]); + expect(snapshot.supportsTextGeneration).toBe(true); + h.controls.selected = h.second; + yield* h.refresh(); + + expect(h.acquisitions).toEqual([ + { binaryPath: "", path: h.instancePath }, + { binaryPath: "", path: h.instancePath }, + ]); + const nativeLaunches = h.launches.filter((launch) => launch.harnessPath !== undefined); + expect(nativeLaunches.map((launch) => launch.command)).toEqual([ + h.first.executablePath, + h.second.executablePath, + ]); + expect(nativeLaunches.map((launch) => launch.harnessPath)).toEqual([ + h.first.harnessPath, + h.second.harnessPath, + ]); + for (const launch of nativeLaunches) { + expect(launch.args).toEqual(hostPlatform === "linux" ? ["--uid="] : []); + expect(launch.profileDirectory).toBe(h.profileDirectory); + expect(launch.forceFileStorage).toBe("1"); + expect(launch.extendEnv).toBe(false); + } + for (const launch of h.launches) expect(launch.credentialKeys).toEqual([]); + expect(h.releases).toEqual([h.first.version, h.second.version]); + const requests = yield* h.readRequests; + expect(requests.map((request) => request.method)).toEqual([ + "initialize", + "authenticate", + "session/new", + "initialize", + "authenticate", + "session/new", + ]); + expect( + requests + .filter((request) => request.method === "authenticate") + .map((request) => request.params?.methodId), + ).toEqual(["oauth-personal", "oauth-personal"]); + expect( + requests + .filter((request) => request.method === "session/new") + .map((request) => request.params?.mcpServers), + ).toEqual([[], []]); + yield* h.assertClosed; + }).pipe(Effect.scoped), + ); + + it.effect.skipIf(windowsHost)( + "authenticates with the configured API key method and labels the account by method", + () => + Effect.gen(function* () { + const h = yield* makeHarness({ + config: { authMethod: "gemini-api-key", apiKey: "fixture-gemini-key" }, + }); + yield* h.refresh(); + const snapshot = yield* h.instance.snapshot.getSnapshot; + expect(snapshot.auth).toMatchObject({ + status: "authenticated", + type: "gemini-api-key", + label: "Gemini API key", + }); + expect(snapshot.models.length).toBeGreaterThan(0); + const nativeLaunches = h.launches.filter((launch) => launch.harnessPath !== undefined); + expect(nativeLaunches.map((launch) => launch.geminiApiKey)).toEqual(["fixture-gemini-key"]); + const requests = yield* h.readRequests; + expect( + requests + .filter((request) => request.method === "authenticate") + .map((request) => request.params?.methodId), + ).toEqual(["gemini-api-key"]); + yield* h.assertClosed; + }).pipe(Effect.scoped), + ); + + it.effect.skipIf(windowsHost)("reports the missing credential before launching a process", () => + Effect.gen(function* () { + const h = yield* makeHarness({ config: { authMethod: "gemini-api-key" } }); + const error = yield* h.refresh().pipe(Effect.flip); + expect(error.detail).toContain("API key"); + expect(h.launches.filter((launch) => launch.harnessPath !== undefined)).toEqual([]); + }).pipe(Effect.scoped), + ); + + it.effect.skipIf(windowsHost)( + "closes refresh processes and clears account metadata when Google sign-in is required", + () => + Effect.gen(function* () { + const h = yield* makeHarness(); + yield* h.refresh(); + h.controls.selected = h.signedOut; + const error = yield* h.refresh().pipe(Effect.flip); + expect(error.detail).toContain("Sign in to Antigravity"); + const snapshot = yield* h.instance.snapshot.getSnapshot; + expect(snapshot.auth.status).toBe("unauthenticated"); + expect(snapshot.models).toEqual([]); + expect(snapshot.slashCommands).toEqual([]); + expect(snapshot.supportsTextGeneration).toBe(false); + expect(h.acquisitions).toHaveLength(2); + expect(h.releases).toEqual([h.first.version, h.signedOut.version]); + yield* h.assertClosed; + }).pipe(Effect.scoped), + ); + + it.effect.skipIf(windowsHost)( + "clears account metadata when a text helper needs Google sign-in", + () => + Effect.gen(function* () { + const h = yield* makeHarness(); + yield* h.refresh(); + h.controls.selected = h.signedOut; + const error = yield* h.instance.textGeneration + .generateThreadTitle({ + cwd: h.profileDirectory, + message: "Repair Google login", + modelSelection: { instanceId: h.instance.instanceId, model: "gemini-test-low" }, + }) + .pipe(Effect.flip); + expect(error._tag).toBe("TextGenerationError"); + const snapshot = yield* h.instance.snapshot.getSnapshot; + expect(snapshot.auth.status).toBe("unauthenticated"); + expect(snapshot.models).toEqual([]); + expect(snapshot.supportsTextGeneration).toBe(false); + expect(h.releases).toEqual([h.first.version, h.signedOut.version]); + yield* h.assertClosed; + }).pipe(Effect.scoped), + ); + + it.effect.skipIf(windowsHost)("keeps the previous catalog when executable resolution fails", () => + Effect.gen(function* () { + const h = yield* makeHarness(); + yield* h.refresh(); + const before = yield* h.instance.snapshot.getSnapshot; + h.controls.failResolution = true; + const error = yield* h.refresh().pipe(Effect.flip); + expect(error.detail).toContain("previous model list is unchanged"); + const after = yield* h.instance.snapshot.getSnapshot; + expect(after.models).toEqual(before.models); + expect(after.auth).toEqual(before.auth); + expect(h.acquisitions).toHaveLength(2); + expect(h.releases).toEqual([h.first.version]); + yield* h.assertClosed; + }).pipe(Effect.scoped), + ); +}); diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.ts b/apps/server/src/provider/Drivers/AntigravityDriver.ts new file mode 100644 index 000000000000..80d8586a91b7 --- /dev/null +++ b/apps/server/src/provider/Drivers/AntigravityDriver.ts @@ -0,0 +1,399 @@ +import { AntigravitySettings, ProviderDriverKind, ProviderSetupError } from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +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 Stream from "effect/Stream"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import type { AcpError } from "effect-acp/errors"; + +import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; +import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { + isAntigravityTextGenerationAvailable, + makeAntigravityTextGeneration, +} from "../../textGeneration/AntigravityTextGeneration.ts"; +import { makeAntigravityAuth, type AntigravityAuth } from "../AntigravityAuth.ts"; +import { AntigravityInstallation } from "../AntigravityInstallation.ts"; +import { + antigravityAuthConfigIssue, + antigravityAuthLabel, + antigravityAuthUsesBrowser, + buildAntigravityAcpSpawnInput, + isAntigravitySignInRequiredError, + prepareAntigravityProfile, + resolveAntigravityProfileDirectory, + type AntigravityAuthConfig, +} from "../antigravityAuthSupport.ts"; +import { + makeAntigravityAcpRuntime, + type AntigravityAcpRuntimeInput, +} from "../acp/AntigravityAcpSupport.ts"; +import type { AcpSessionRuntime, AcpSessionRuntimeStartResult } from "../acp/AcpSessionRuntime.ts"; +import type { ServerProviderDraft } from "../providerSnapshot.ts"; +import { removeAntigravitySessionFiles } from "../acp/AntigravitySessionFiles.ts"; +import { ProviderDriverError } from "../Errors.ts"; +import { makeAntigravityAdapter } from "../Layers/AntigravityAdapter.ts"; +import { makeAntigravityProvider } from "../Layers/AntigravityProvider.ts"; +import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; +import * as ModelManifest from "../ModelManifest.ts"; +import { + defaultProviderContinuationIdentity, + type ProviderDriver, + type ProviderInstance, +} from "../ProviderDriver.ts"; +import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { withInstanceIdentity } from "./instanceIdentity.ts"; +import { discoverAntigravitySkills } from "./AntigravitySkills.ts"; + +const DRIVER = ProviderDriverKind.make("antigravity"); +const decodeSettings = Schema.decodeSync(AntigravitySettings); + +export type AntigravityDriverEnv = + | AntigravityInstallation + | BackgroundPolicy.BackgroundPolicy + | ChildProcessSpawner.ChildProcessSpawner + | Crypto.Crypto + | FileSystem.FileSystem + | ModelManifest.ModelManifest + | Path.Path + | ProviderEventLoggers + | ServerConfig + | ServerSettingsService; + +/** Each instance owns its Google profile. Executable releases are shared by the environment. */ +export const AntigravityDriver: ProviderDriver = { + driverKind: DRIVER, + metadata: { displayName: "Antigravity", supportsMultipleInstances: true }, + configSchema: AntigravitySettings, + defaultConfig: () => decodeSettings({}), + create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => + Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const serverConfig = yield* ServerConfig; + const installation = yield* AntigravityInstallation; + const loggers = yield* ProviderEventLoggers; + const modelManifest = yield* ModelManifest.ModelManifest; + const settings = { ...config, enabled } satisfies AntigravitySettings; + const auth: AntigravityAuthConfig = { + authMethod: settings.authMethod, + apiKey: settings.apiKey, + gcpProject: settings.gcpProject, + gcpLocation: settings.gcpLocation, + }; + const authConfigIssue = antigravityAuthConfigIssue(auth); + const processEnvironment = mergeProviderInstanceEnvironment(environment); + const profileDirectory = resolveAntigravityProfileDirectory( + serverConfig.stateDir, + instanceId, + ); + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER, + instanceId, + }); + const stampIdentity = withInstanceIdentity({ + instanceId, + driverKind: DRIVER, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + // Google returns every model the account can use, including older + // Gemini generations. The manifest names the current ones so the picker + // folds the rest under its legacy section, as it does for Codex. + const classifyModels = (draft: ServerProviderDraft) => + modelManifest.current.pipe( + Effect.map((manifest) => + stampIdentity(ModelManifest.applyModelManifest(draft, manifest, DRIVER)), + ), + ); + + const makeRuntime = Effect.fn("AntigravityDriver.makeRuntime")(function* ( + input: Omit, + ): Effect.fn.Return< + AcpSessionRuntime["Service"], + AcpError | ProviderSetupError, + Scope.Scope + > { + if (authConfigIssue !== null) { + return yield* new ProviderSetupError({ + instanceId, + operation: "configure", + detail: authConfigIssue, + }); + } + const executable = yield* installation + .acquire(settings.binaryPath, processEnvironment) + .pipe( + Effect.mapError( + (cause) => + new ProviderSetupError({ + instanceId, + operation: "resolve", + detail: cause.detail, + }), + ), + ); + const profile = yield* prepareAntigravityProfile({ + profileDirectory, + baseEnv: processEnvironment, + auth, + }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + const runtime = yield* makeAntigravityAcpRuntime({ + ...input, + authMethod: auth.authMethod, + childProcessSpawner: spawner, + spawn: buildAntigravityAcpSpawnInput({ + installation: executable, + profile, + cwd: input.cwd, + baseEnv: processEnvironment, + auth, + }), + }).pipe(Effect.provideService(Crypto.Crypto, crypto)); + return { + ...runtime, + start: () => + runtime + .start() + .pipe( + Effect.tapError((cause): Effect.Effect => + input.onAuthorizationUrl === undefined && isAntigravitySignInRequiredError(cause) + ? provider.onAuthRequired + : Effect.void, + ), + ), + }; + }); + + const makeDisposableRuntime = Effect.fn("AntigravityDriver.makeDisposableRuntime")(function* ( + input: Pick, + ) { + const cwd = yield* fileSystem + .makeTempDirectoryScoped({ prefix: "t3-antigravity-setup-" }) + .pipe( + Effect.mapError( + () => + new ProviderSetupError({ + instanceId, + operation: "start", + detail: "Could not create an Antigravity setup workspace.", + }), + ), + ); + let sessionId: string | undefined; + yield* Effect.addFinalizer(() => + removeAntigravitySessionFiles({ + profileDirectory, + sessionId, + cwd, + }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ), + ); + const runtime = yield* makeRuntime({ + cwd, + clientInfo: { name: "t3-code-provider-setup", version: "0.0.0" }, + mcpServers: [], + ...(input.onAuthorizationUrl ? { onAuthorizationUrl: input.onAuthorizationUrl } : {}), + }); + return { + ...runtime, + start: () => + runtime.start().pipe( + Effect.tap((started) => + Effect.sync(() => { + sessionId = started.sessionId; + }), + ), + ), + }; + }); + + const publishCatalog = ( + started: AcpSessionRuntimeStartResult, + runtime: Pick, + ): Effect.Effect => + Effect.gen(function* () { + yield* provider.onSessionStarted(started); + yield* Stream.runForEach(runtime.getEvents(), (event) => { + if (event._tag === "EventStreamBarrier") { + return Deferred.succeed(event.acknowledge, undefined).pipe(Effect.asVoid); + } + if (event._tag === "ConfigOptionsUpdated") { + return provider.onConfigOptionsUpdated(event.configOptions); + } + return event._tag === "AvailableCommandsUpdated" + ? provider.onAvailableCommands(event.availableCommands) + : Effect.void; + }).pipe(Effect.forkScoped); + yield* runtime.drainEvents; + }).pipe(Effect.scoped); + + const authFlow: AntigravityAuth = yield* makeAntigravityAuth({ + instanceId, + makeRuntime: makeDisposableRuntime, + onAuthenticated: publishCatalog, + onSignedOut: Effect.suspend(() => provider.onSignedOut), + usesBrowser: antigravityAuthUsesBrowser(auth.authMethod), + }); + + // Kick the TTL-gated manifest refresh alongside the health check, as + // Codex and Claude do. Without it an environment that only runs + // Antigravity would keep classifying against a stale disk cache. + const probe = Effect.gen(function* () { + yield* modelManifest.refreshInBackground; + const processScope = yield* Scope.make(); + yield* Effect.addFinalizer((exit) => Scope.close(processScope, exit)); + return yield* authFlow + .withProcess( + Scope.close(processScope, Exit.void), + Effect.gen(function* () { + const runtime = yield* makeRuntime({ + cwd: serverConfig.stateDir, + clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" }, + mcpServers: [], + }); + return yield* runtime.initialize(); + }), + ) + .pipe(Effect.provideService(Scope.Scope, processScope)); + }).pipe(Effect.scoped); + + const provider = yield* makeAntigravityProvider(settings, { + stampIdentity: classifyModels, + probe, + auth: { type: auth.authMethod, label: antigravityAuthLabel(auth.authMethod) }, + supportsTextGeneration: isAntigravityTextGenerationAvailable(profileDirectory).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.orElseSucceed(() => false), + ), + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER, + instanceId, + detail: "Could not prepare the Antigravity provider status.", + cause, + }), + ), + ); + const defaultModel = modelManifest.current.pipe( + Effect.map((manifest) => ModelManifest.manifestDefaultModel(manifest, DRIVER)), + ); + const adapter = yield* makeAntigravityAdapter(settings, { + instanceId, + makeRuntime, + withProcess: authFlow.withProcess, + defaultModel, + onSessionStarted: provider.onSessionStarted, + onConfigOptionsUpdated: provider.onConfigOptionsUpdated, + onAvailableCommands: provider.onAvailableCommands, + onAuthRequired: provider.onAuthRequired, + ...(loggers.native ? { nativeEventLogger: loggers.native } : {}), + }); + const textGeneration = yield* makeAntigravityTextGeneration({ + profileDirectory, + defaultModel, + withProcess: authFlow.withProcess, + makeRuntime: (cwd) => + makeRuntime({ + cwd, + clientInfo: { name: "t3-code-text", version: "0.0.0" }, + mcpServers: [], + }), + }); + + const refreshModels = Effect.fn("AntigravityDriver.refreshModels")( + function* () { + const processScope = yield* Scope.make(); + yield* Effect.addFinalizer((exit) => Scope.close(processScope, exit)); + yield* authFlow + .withProcess( + Scope.close(processScope, Exit.void), + Effect.gen(function* () { + const runtime = yield* makeDisposableRuntime({}); + const started = yield* runtime.start(); + yield* publishCatalog(started, runtime); + }), + ) + .pipe(Effect.provideService(Scope.Scope, processScope)); + }, + Effect.scoped, + Effect.timeoutOrElse({ + duration: "90 seconds", + orElse: () => + Effect.fail( + new ProviderDriverError({ + driver: DRIVER, + instanceId, + detail: "Antigravity model refresh timed out. Try again or check Google sign-in.", + }), + ), + }), + Effect.tapError((cause) => + isAntigravitySignInRequiredError(cause) ? provider.onAuthRequired : Effect.void, + ), + Effect.mapError((cause) => + cause._tag === "ProviderDriverError" + ? cause + : new ProviderDriverError({ + driver: DRIVER, + instanceId, + detail: isAntigravitySignInRequiredError(cause) + ? "Sign in to Antigravity in provider settings before refreshing models." + : cause._tag === "ProviderSetupError" && cause.operation === "configure" + ? cause.detail + : "Could not refresh Antigravity models. The previous model list is unchanged.", + cause, + }), + ), + ); + + return { + instanceId, + driverKind: DRIVER, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot: provider.snapshot, + snapshotForCwd: (cwd) => + !enabled + ? provider.snapshot.getSnapshot + : discoverAntigravitySkills({ cwd, profileDirectory }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.flatMap((skills) => provider.snapshotForCwd(cwd, skills)), + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER, + instanceId, + detail: "Could not read Antigravity workspace skills.", + cause, + }), + ), + ), + adapter, + textGeneration, + auth: authFlow.controller, + refreshModels, + } satisfies ProviderInstance; + }), +}; diff --git a/apps/server/src/provider/Drivers/AntigravitySkills.test.ts b/apps/server/src/provider/Drivers/AntigravitySkills.test.ts new file mode 100644 index 000000000000..c5e1faacf237 --- /dev/null +++ b/apps/server/src/provider/Drivers/AntigravitySkills.test.ts @@ -0,0 +1,303 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +import { discoverAntigravitySkills } from "./AntigravitySkills.ts"; + +const writeSkill = Effect.fn("writeSkill")(function* (directory: string, contents: string) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fileSystem.makeDirectory(directory, { recursive: true }); + const skillPath = path.join(directory, "SKILL.md"); + yield* fileSystem.writeFileString(skillPath, contents); + return skillPath; +}); + +const makeWorkspace = Effect.fn("makeWorkspace")(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const temporaryDirectory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-antigravity-skills-", + }); + return { + cwd: path.join(temporaryDirectory, "workspace"), + profileDirectory: path.join(temporaryDirectory, "profile"), + }; +}); + +it.layer(NodeServices.layer)("discoverAntigravitySkills", (it) => { + 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(); + const roots = [ + { directory: path.join(input.profileDirectory, "config", "skills"), scope: "user" }, + { directory: path.join(input.cwd, ".gemini", "skills"), scope: "project" }, + { + directory: path.join(input.profileDirectory, "antigravity-cli", "skills"), + scope: "user", + }, + { directory: path.join(input.cwd, ".agents", "skills"), scope: "project" }, + ]; + const expected = []; + for (const [index, root] of roots.entries()) { + const name = `review-${index}`; + const description = `Review changes in root ${index}.`; + const skillPath = yield* writeSkill( + path.join(root.directory, name), + `---\nname: ${name}\ndescription: ${description}\n---\n# Review\n`, + ); + expected.push({ name, description, path: skillPath, scope: root.scope, enabled: true }); + } + + assert.deepEqual(yield* discoverAntigravitySkills(input), expected); + }), + ); + + it.effect("returns no skills when the native roots are missing", () => + Effect.gen(function* () { + const input = yield* makeWorkspace(); + assert.deepEqual(yield* discoverAntigravitySkills(input), []); + }), + ); + + 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; + const path = yield* Path.Path; + const input = yield* makeWorkspace(); + const roots = [ + path.join(input.profileDirectory, "config", "skills"), + 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( + path.join(root, `copy-${index}`), + `---\nname: review\ndescription: Copy ${index}.\n---\n`, + ); + } + + for (const [index, root] of roots.entries()) { + const skills = yield* discoverAntigravitySkills(input); + assert.equal(skills.length, 1); + assert.equal(skills[0]?.path, path.join(root, `copy-${index}`, "SKILL.md")); + yield* fileSystem.remove(root, { recursive: true }); + } + }), + ); + + it.effect("loads a root skill without scanning its children", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const input = yield* makeWorkspace(); + const root = path.join(input.cwd, ".agents", "skills"); + const skillPath = yield* writeSkill(root, "---\nname: root-skill\n---\n"); + yield* writeSkill(path.join(root, "child"), "---\nname: child-skill\n---\n"); + + assert.deepEqual(yield* discoverAntigravitySkills(input), [ + { name: "root-skill", path: skillPath, scope: "project", enabled: true }, + ]); + + yield* fileSystem.writeFileString(skillPath, "---\nname: [invalid\n---\n"); + assert.deepEqual(yield* discoverAntigravitySkills(input), []); + }), + ); + + it.effect("uses the filename when valid frontmatter has no name", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const input = yield* makeWorkspace(); + const skillPath = yield* writeSkill( + path.join(input.cwd, ".agents", "skills", "not-the-name"), + "---\n---\n# Skill body\n", + ); + + assert.deepEqual(yield* discoverAntigravitySkills(input), [ + { name: "SKILL", path: skillPath, scope: "project", enabled: true }, + ]); + + const lowerCasePath = path.join(path.dirname(skillPath), "skill.md"); + yield* fileSystem.rename(skillPath, lowerCasePath); + assert.deepEqual(yield* discoverAntigravitySkills(input), [ + { name: "skill", path: lowerCasePath, scope: "project", enabled: true }, + ]); + + yield* fileSystem.rename(lowerCasePath, path.join(path.dirname(skillPath), "SKILL.MD")); + assert.deepEqual(yield* discoverAntigravitySkills(input), []); + }), + ); + + it.effect("accepts native metadata delimiters after leading text", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const input = yield* makeWorkspace(); + const skillPath = yield* writeSkill( + path.join(input.cwd, ".agents", "skills", "review"), + "Leading text.---\nname: review\ndescription: null\n---Skill body.", + ); + + assert.deepEqual(yield* discoverAntigravitySkills(input), [ + { name: "review", path: skillPath, scope: "project", enabled: true }, + ]); + }), + ); + + it.effect("ignores invalid files and deeper directories but keeps native hidden skills", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const input = yield* makeWorkspace(); + const root = path.join(input.cwd, ".agents", "skills"); + const invalidSkills = [ + ["plain", "# Missing frontmatter\n"], + ["broken", "---\nname: [unclosed\n---\n"], + ["scalar", "---\n42\n---\n"], + ["wrong-type", "---\nname: invalid\ndescription: {}\n---\n"], + ["blank-name", '---\nname: " "\n---\n'], + ] as const; + for (const [name, contents] of invalidSkills) { + yield* writeSkill(path.join(root, name), contents); + } + yield* writeSkill(path.join(root, "nested", "deep"), "---\nname: too-deep\n---\n"); + yield* writeSkill( + path.join(input.cwd, ".claude", "skills", "wrong-provider"), + "---\nname: wrong-provider\n---\n", + ); + yield* fileSystem.makeDirectory(path.join(root, ".not-a-skill")); + yield* fileSystem.writeFileString(path.join(root, "README.md"), "Not a skill."); + const skillPath = yield* writeSkill( + path.join(root, ".native-hidden-skill"), + "---\nname: native-name\ndescription: >\n Review the code\n and run tests.\n---\n", + ); + + assert.deepEqual(yield* discoverAntigravitySkills(input), [ + { + name: "native-name", + description: "Review the code and run tests.", + path: skillPath, + scope: "project", + enabled: true, + }, + ]); + }), + ); + + it.effect("uses native URI order within a root and skips an invalid higher root", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const input = yield* makeWorkspace(); + const root = path.join(input.cwd, ".agents", "skills"); + yield* writeSkill( + path.join(input.profileDirectory, "config", "skills", "review"), + "---\nname: [invalid\n---\n", + ); + const nativeOrder = [" space-copy", "!-copy", "ø-copy", "a-copy"]; + for (const name of nativeOrder) { + yield* writeSkill(path.join(root, name), "---\nname: review\n---\n"); + } + + for (const name of nativeOrder) { + assert.deepEqual(yield* discoverAntigravitySkills(input), [ + { + name: "review", + path: path.join(root, name, "SKILL.md"), + scope: "project", + enabled: true, + }, + ]); + yield* fileSystem.remove(path.join(root, name), { recursive: true }); + } + }), + ); + + it.effect("follows directory symlinks used to install shared skills", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const input = yield* makeWorkspace(); + const sourceDirectory = path.join(input.profileDirectory, "shared-review"); + yield* writeSkill(sourceDirectory, "---\nname: review\n---\n"); + const root = path.join(input.cwd, ".agents", "skills"); + const linkedDirectory = path.join(root, "review"); + yield* fileSystem.makeDirectory(root, { recursive: true }); + yield* fileSystem.symlink(sourceDirectory, linkedDirectory); + + assert.deepEqual(yield* discoverAntigravitySkills(input), [ + { + name: "review", + path: path.join(linkedDirectory, "SKILL.md"), + scope: "project", + enabled: true, + }, + ]); + }), + ); + + it.effect("rejects an oversized skill instead of returning an incomplete catalog", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const input = yield* makeWorkspace(); + const skillPath = yield* writeSkill( + path.join(input.cwd, ".agents", "skills", "oversized"), + `---\nname: oversized\ndescription: Read a large skill.\n---\n${"x".repeat(1_000_000)}`, + ); + + const result = yield* discoverAntigravitySkills(input).pipe(Effect.result); + assert.equal(result._tag, "Failure"); + if (result._tag === "Failure") { + assert.equal(result.failure.reason, "scan-budget-exhausted"); + assert.equal(result.failure.path, skillPath); + } + }), + ); + + it.effect("bounds the total read size across skills", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const input = yield* makeWorkspace(); + const root = path.join(input.cwd, ".agents", "skills"); + for (let index = 0; index < 9; index += 1) { + yield* writeSkill( + path.join(root, `large-${index}`), + `---\nname: large-${index}\n---\n${"x".repeat(900_000)}`, + ); + } + + const result = yield* discoverAntigravitySkills(input).pipe(Effect.result); + assert.equal(result._tag, "Failure"); + if (result._tag === "Failure") { + assert.equal(result.failure.reason, "scan-budget-exhausted"); + assert.equal(result.failure.path, path.join(root, "large-8", "SKILL.md")); + } + }), + ); +}); diff --git a/apps/server/src/provider/Drivers/AntigravitySkills.ts b/apps/server/src/provider/Drivers/AntigravitySkills.ts new file mode 100644 index 000000000000..a8b206a89279 --- /dev/null +++ b/apps/server/src/provider/Drivers/AntigravitySkills.ts @@ -0,0 +1,195 @@ +import type { ServerProviderSkill } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import type * as PlatformError from "effect/PlatformError"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { parse as parseYamlDocument } from "yaml"; + +const MAX_SKILL_BYTES = 1_000_000; +const MAX_SCAN_BYTES = 8_000_000; +const MAX_SCAN_ENTRIES = 10_000; + +const SkillFrontmatter = Schema.Struct({ + name: Schema.optional(Schema.NullOr(Schema.String)), + description: Schema.optional(Schema.NullOr(Schema.String)), +}); +const decodeSkillFrontmatter = Schema.decodeUnknownSync(SkillFrontmatter); + +export class AntigravitySkillsProbeError extends Schema.TaggedErrorClass()( + "AntigravitySkillsProbeError", + { + reason: Schema.Literals(["scan-budget-exhausted", "filesystem-error"]), + path: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return this.reason === "scan-budget-exhausted" + ? `Antigravity skill discovery exceeded its scan limit at '${this.path}'.` + : `Antigravity could not read skills at '${this.path}'.`; + } +} + +interface ScanBudget { + remainingBytes: number; + remainingEntries: number; +} + +const readIfPresent = ( + effect: Effect.Effect, + path: string, +) => + effect.pipe( + Effect.catchTags({ + PlatformError: (cause) => + cause.reason._tag === "NotFound" + ? Effect.succeed(undefined) + : Effect.fail( + new AntigravitySkillsProbeError({ reason: "filesystem-error", path, cause }), + ), + }), + ); + +function parseSkillFrontmatter(contents: string, fileName: string) { + const start = contents.indexOf("---"); + if (start === -1) return undefined; + const end = contents.indexOf("---", start + 3); + if (end === -1) return undefined; + try { + const frontmatter = decodeSkillFrontmatter( + parseYamlDocument(contents.slice(start + 3, end).trim()) ?? {}, + ); + const name = frontmatter.name || fileName.slice(0, -3); + const description = frontmatter.description?.trim(); + // Native names are not trimmed. Do not rename one to fit the picker contract. + if (!name || name !== name.trim()) return undefined; + return { name, ...(description ? { description } : {}) }; + } catch { + return undefined; + } +} + +/** The native loader orders child paths with Go's URL.EscapedPath encoding. */ +function skillPathSortKey(entry: string) { + return encodeURI(entry).replace( + /[!'()*?#]/g, + (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`, + ); +} + +/** Read only regular skill files, with a byte limit that applies during the read. */ +const readSkill = Effect.fn("readAntigravitySkill")(function* ( + skillPath: string, + budget: ScanBudget, +) { + const fileSystem = yield* FileSystem.FileSystem; + const info = yield* readIfPresent(fileSystem.stat(skillPath), skillPath); + if (info?.type !== "File") return undefined; + + const byteLimit = Math.min(MAX_SKILL_BYTES, budget.remainingBytes); + if (info.size > BigInt(byteLimit)) { + return yield* new AntigravitySkillsProbeError({ + reason: "scan-budget-exhausted", + path: skillPath, + }); + } + const chunks = yield* readIfPresent( + fileSystem.stream(skillPath, { bytesToRead: byteLimit + 1 }).pipe(Stream.runCollect), + skillPath, + ); + if (chunks === undefined) return undefined; + const bytes = Buffer.concat(chunks); + if (bytes.byteLength > byteLimit) { + return yield* new AntigravitySkillsProbeError({ + reason: "scan-budget-exhausted", + path: skillPath, + }); + } + budget.remainingBytes -= bytes.byteLength; + return bytes.toString("utf8"); +}); + +/** + * Match the official ACP's explicit skill roots. The first valid same-name skill + * wins. Each root loads its own SKILL.md or those in its immediate subdirectories. + * Read failures remain typed so workspace snapshots do not cache partial results. + */ +export const discoverAntigravitySkills = Effect.fn("discoverAntigravitySkills")(function* (input: { + readonly cwd: string; + readonly profileDirectory: string; +}): Effect.fn.Return< + ReadonlyArray, + AntigravitySkillsProbeError, + FileSystem.FileSystem | Path.Path +> { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const roots = [ + { directory: path.resolve(input.profileDirectory, "config", "skills"), scope: "user" }, + { directory: path.resolve(input.cwd, ".gemini", "skills"), scope: "project" }, + { + directory: path.resolve(input.profileDirectory, "antigravity-cli", "skills"), + 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, + remainingEntries: MAX_SCAN_ENTRIES, + }; + const skillsByName = new Map(); + + const scanDirectory = Effect.fn("scanAntigravitySkillDirectory")(function* ( + directory: string, + scope: string, + scanChildren: boolean, + ): Effect.fn.Return { + const info = yield* readIfPresent(fileSystem.stat(directory), directory); + if (info?.type !== "Directory") return; + const entries = yield* readIfPresent(fileSystem.readDirectory(directory), directory); + if (entries === undefined) return; + if (entries.length > budget.remainingEntries) { + return yield* new AntigravitySkillsProbeError({ + reason: "scan-budget-exhausted", + path: directory, + }); + } + budget.remainingEntries -= entries.length; + + const sortedEntries = entries.toSorted(); + const skillFileName = sortedEntries.find((entry) => entry.toLowerCase() === "skill.md"); + if (skillFileName !== undefined) { + if (!skillFileName.endsWith(".md")) return; + const skillPath = path.join(directory, skillFileName); + const contents = yield* readSkill(skillPath, budget); + if (contents === undefined) return; + const skill = parseSkillFrontmatter(contents, skillFileName); + if (!skill || skillsByName.has(skill.name)) return; + skillsByName.set(skill.name, { + ...skill, + path: skillPath, + scope, + enabled: true, + }); + return; + } + if (scanChildren) { + const children = sortedEntries + .map((entry) => ({ entry, sortKey: skillPathSortKey(entry) })) + .sort((left, right) => + left.sortKey < right.sortKey ? -1 : left.sortKey > right.sortKey ? 1 : 0, + ); + for (const { entry } of children) { + yield* scanDirectory(path.join(directory, entry), scope, false); + } + } + }); + + for (const root of roots) { + yield* scanDirectory(root.directory, root.scope, true); + } + return [...skillsByName.values()].sort((left, right) => left.name.localeCompare(right.name)); +}); diff --git a/apps/server/src/provider/Layers/AntigravityAdapter.test.ts b/apps/server/src/provider/Layers/AntigravityAdapter.test.ts new file mode 100644 index 000000000000..21f16d40a626 --- /dev/null +++ b/apps/server/src/provider/Layers/AntigravityAdapter.test.ts @@ -0,0 +1,1317 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import { + AntigravitySettings, + ApprovalRequestId, + ProviderInstanceId, + ThreadId, + type ProviderRuntimeEvent, +} from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Queue from "effect/Queue"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import * as AcpErrors from "effect-acp/errors"; +import type * as AcpSchema from "effect-acp/schema"; + +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"); +const threadId = ThreadId.make("antigravity-thread"); +const nativeSessionId = "b75db7e9-cd99-40e5-aa63-ac2b4674a6a9"; +const nativeDefault = "gemini-test-low"; +const nativeAlternative = "gemini-test-high"; +const decodeSettings = Schema.decodeSync(AntigravitySettings); +const decodeRequestLog = Schema.decodeEffect( + Schema.Array( + Schema.fromJsonString( + Schema.Struct({ method: Schema.String, params: Schema.optional(Schema.Unknown) }), + ), + ), +); + +interface NativePrompt { + readonly index: number; + readonly result: Deferred.Deferred; +} + +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; + readonly holdClose?: boolean; + readonly holdDispatch?: boolean; +}) { + const runtimeEvents = yield* Queue.unbounded(); + const canonicalEvents = yield* Queue.unbounded(); + const prompts = yield* Queue.unbounded(); + const cancellations = yield* Queue.unbounded(); + const cancelRelease = yield* Deferred.make(); + const closeStarted = yield* Deferred.make(); + const closeRelease = yield* Deferred.make(); + const dispatchStarted = yield* Deferred.make(); + const dispatchRelease = yield* Deferred.make(); + const seen: ProviderRuntimeEvent[] = []; + const calls: string[] = []; + const launches: Array[0]> = []; + const stops: Array> = []; + const controls = { failModel: false, failAuth: false, authInvalidations: 0, closed: 0 }; + let currentModel = nativeDefault; + let promptIndex = 0; + let active: NativePrompt | undefined; + const fileHandlers: { + read?: Parameters[0]; + write?: Parameters[0]; + } = {}; + let permissionHandler: + | (( + request: AcpSchema.RequestPermissionRequest, + ) => Effect.Effect) + | undefined; + + const configOptions = (): ReadonlyArray => [ + { + id: "model", + name: "Model", + type: "select", + category: "model", + currentValue: currentModel, + options: [ + { value: nativeDefault, name: "Gemini test low" }, + { value: nativeAlternative, name: "Gemini test high" }, + ], + }, + ]; + const drainEvents = Effect.gen(function* () { + const acknowledge = yield* Deferred.make(); + yield* Queue.offer(runtimeEvents, { _tag: "EventStreamBarrier", acknowledge }); + yield* Deferred.await(acknowledge); + }); + const emitNative = (event: AcpSessionRuntimeEvent) => + Queue.offer(runtimeEvents, event).pipe(Effect.asVoid); + const runtime: Effect.Success> = { + handleRequestPermission: (handler) => + Effect.sync(() => { + permissionHandler = handler; + }), + handleReadTextFile: (handler) => + Effect.sync(() => { + fileHandlers.read = handler; + }), + handleWriteTextFile: (handler) => + Effect.sync(() => { + fileHandlers.write = handler; + }), + start: () => + Effect.gen(function* () { + if (controls.failAuth) { + return yield* new AcpErrors.AcpTransportError({ + detail: ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE, + cause: undefined, + }); + } + currentModel = nativeDefault; + calls.push("start"); + yield* emitNative({ + _tag: "AvailableCommandsUpdated", + availableCommands: [ + { name: "plan", description: "Create a plan" }, + { name: "logout", description: "Sign out" }, + ], + rawPayload: {}, + }); + return { + sessionId: nativeSessionId, + initializeResult: { + protocolVersion: 1, + agentCapabilities: { sessionCapabilities: { resume: {} } }, + }, + sessionSetupResult: { sessionId: nativeSessionId, configOptions: configOptions() }, + modelConfigId: "model", + }; + }), + getConfigOptions: Effect.sync(configOptions), + setModel: (model) => + Effect.gen(function* () { + calls.push(`model:${model}`); + if (controls.failModel) { + controls.failModel = false; + return yield* AcpErrors.AcpRequestError.invalidParams("Native model selection failed."); + } + currentModel = model; + }), + setMode: (mode) => + Effect.sync(() => { + calls.push(`mode:${mode}`); + return {}; + }), + getEvents: () => Stream.fromQueue(runtimeEvents), + drainEvents, + prompt: (_payload, promptOptions) => + Effect.gen(function* () { + yield* Deferred.succeed(dispatchStarted, undefined); + if (options?.holdDispatch) yield* Deferred.await(dispatchRelease); + const prompt: NativePrompt = { + index: ++promptIndex, + result: yield* Deferred.make(), + }; + active = prompt; + calls.push(`prompt:${prompt.index}`); + if (promptOptions?.dispatched) yield* Deferred.succeed(promptOptions.dispatched, undefined); + yield* Queue.offer(prompts, prompt); + return yield* Deferred.await(prompt.result).pipe( + Effect.ensuring( + Effect.sync(() => { + if (active === prompt) active = undefined; + }), + ), + ); + }), + cancel: Effect.gen(function* () { + const prompt = active; + if (!prompt) return; + calls.push(`cancel:${prompt.index}`); + yield* Queue.offer(cancellations, prompt.index); + if (options?.holdCancel) yield* Deferred.await(cancelRelease); + yield* Deferred.succeed(prompt.result, { stopReason: "cancelled" }); + yield* Deferred.await(prompt.result); + yield* drainEvents; + calls.push(`drained:${prompt.index}`); + }), + }; + const commandUpdates: Array> = []; + const adapter = yield* makeAntigravityAdapter( + decodeSettings({ enabled: options?.enabled ?? true }), + { + instanceId, + makeRuntime: (input) => + Effect.gen(function* () { + launches.push(input); + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + yield* Deferred.succeed(closeStarted, undefined); + if (options?.holdClose) yield* Deferred.await(closeRelease); + controls.closed += 1; + }), + ); + return runtime; + }), + withProcess: (stop, task) => + Effect.suspend(() => { + stops.push(stop); + return task; + }), + onAvailableCommands: (commands) => + Effect.sync(() => { + commandUpdates.push(commands); + }), + onAuthRequired: Effect.sync(() => { + controls.authInvalidations += 1; + }), + }, + ); + yield* adapter.streamEvents.pipe( + Stream.runForEach((event) => + Effect.sync(() => { + seen.push(event); + }).pipe(Effect.andThen(Queue.offer(canonicalEvents, event))), + ), + Effect.forkScoped({ startImmediately: true }), + ); + yield* Effect.addFinalizer(() => + Effect.all([ + Deferred.succeed(cancelRelease, undefined), + Deferred.succeed(closeRelease, undefined), + Deferred.succeed(dispatchRelease, undefined), + ]).pipe(Effect.asVoid), + ); + const waitForEvent = Effect.fn("AntigravityAdapterTest.waitForEvent")(function* < + T extends ProviderRuntimeEvent, + >(predicate: (event: ProviderRuntimeEvent) => event is T) { + while (true) { + const event = yield* Queue.take(canonicalEvents); + if (predicate(event)) return event; + } + }); + const invokePermission = (request: AcpSchema.RequestPermissionRequest) => + Effect.suspend(() => + permissionHandler + ? permissionHandler(request) + : Effect.die("Missing native permission handler"), + ); + return { + fileHandlers, + adapter, + calls, + launches, + commandUpdates, + controls, + seen, + stops, + waitForEvent, + emitNative, + invokePermission, + closeStarted, + closeRelease, + cancelRelease, + dispatchStarted, + dispatchRelease, + nextPrompt: Queue.take(prompts), + nextCancellation: Queue.take(cancellations), + drainEvents, + hasActivePrompt: () => active !== undefined, + }; +}); + +const layer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-antigravity-adapter-test-", +}).pipe(Layer.provideMerge(NodeServices.layer)); + +it.layer(layer)("AntigravityAdapter", (it) => { + it.effect( + "runs native auth, resume, models, commands, and streaming through the ACP transport", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const crypto = yield* Crypto.Crypto; + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-antigravity-transport-", + }); + const mockAgentPath = yield* path.fromFileUrl( + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), + ); + 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 }), { + instanceId, + withProcess: (_stop, task) => task, + makeRuntime: (input) => + makeAntigravityAcpRuntime({ + ...input, + childProcessSpawner, + spawn: { + command: process.execPath, + args: [mockAgentPath], + cwd: input.cwd, + env: { + ...process.env, + T3_ACP_ANTIGRAVITY: "1", + T3_ACP_REQUEST_LOG_PATH: requestLog, + }, + extendEnv: false, + }, + }).pipe(Effect.provideService(Crypto.Crypto, crypto)), + onAvailableCommands: (available) => + 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) => + Effect.gen(function* () { + observed.push(event); + if (event.type === "turn.completed") yield* Deferred.succeed(completed, undefined); + }), + ), + Effect.forkScoped({ startImmediately: true }), + ); + const original = yield* adapter.startSession({ + threadId, + cwd, + runtimeMode: "auto-accept-edits", + modelSelection: { instanceId, model: nativeAlternative }, + }); + yield* adapter.stopSession(threadId); + const resumed = yield* adapter.startSession({ + threadId, + cwd, + runtimeMode: "auto-accept-edits", + modelSelection: { instanceId, model: nativeAlternative }, + resumeCursor: original.resumeCursor, + }); + expect(resumed.model).toBe(nativeAlternative); + 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") + .map((event) => event.payload.delta) + .join(""), + ).toBe("hello from mock"); + const lines = (yield* fileSystem.readFileString(requestLog)).trim().split("\n"); + const requests = yield* decodeRequestLog(lines); + expect( + requests + .filter((request) => request.method === "authenticate") + .map((request) => request.params), + ).toEqual([{ methodId: "oauth-personal" }, { methodId: "oauth-personal" }]); + expect(requests.some((request) => request.method === "session/resume")).toBe(true); + expect(requests.some((request) => request.method === "session/load")).toBe(false); + expect( + requests + .filter((request) => request.method === "session/set_config_option") + .map((request) => request.params), + ).toContainEqual({ sessionId: "mock-session-1", configId: "mode", value: "auto_edit" }); + }), + ); + + it.effect("reapplies the exact saved model and mode after a native resume", () => + Effect.gen(function* () { + const h = yield* makeHarness(); + const first = yield* h.adapter.startSession({ + threadId, + cwd: process.cwd(), + runtimeMode: "auto-accept-edits", + modelSelection: { instanceId, model: nativeAlternative }, + }); + expect(first.model).toBe(nativeAlternative); + yield* h.adapter.stopSession(threadId); + const second = yield* h.adapter.startSession({ + threadId, + cwd: "/tmp", + runtimeMode: "auto-accept-edits", + resumeCursor: first.resumeCursor, + modelSelection: { instanceId, model: nativeAlternative }, + }); + expect(second.model).toBe(nativeAlternative); + expect(second.cwd).toBe("/tmp"); + expect(h.launches[1]?.resumeSessionId).toBe(nativeSessionId); + expect(h.calls).toEqual([ + "start", + `model:${nativeAlternative}`, + "mode:auto_edit", + "start", + `model:${nativeAlternative}`, + "mode:auto_edit", + ]); + expect(h.commandUpdates.at(-1)?.map((command) => command.name)).toEqual(["plan", "logout"]); + expect(h.adapter.capabilities.supportsConversationRollback).toBe(false); + const rollback = yield* h.adapter.rollbackThread(threadId, 1).pipe(Effect.exit); + expect(Exit.isFailure(rollback)).toBe(true); + }), + ); + + it.effect("keeps thoughts, native command results, and replies on the active turn", () => + 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: "Read the file" }) + .pipe(Effect.forkChild); + const prompt = yield* h.nextPrompt; + yield* h.emitNative({ _tag: "ThoughtDelta", text: "I will read it.", rawPayload: {} }); + yield* h.emitNative({ + _tag: "ToolCallUpdated", + toolCall: { + toolCallId: "command-1", + kind: "execute", + status: "completed", + data: { + rawInput: { CommandLine: "cat probe.txt", Cwd: "/tmp" }, + rawOutput: { combinedOutput: "after\n", exitCode: 0 }, + }, + }, + rawPayload: {}, + }); + yield* h.emitNative({ _tag: "ContentDelta", text: "The file says after.", rawPayload: {} }); + yield* Deferred.succeed(prompt.result, { stopReason: "end_turn" }); + const result = yield* Fiber.join(sending); + yield* h.waitForEvent((event) => event.type === "turn.completed"); + const deltas = h.seen.filter((event) => event.type === "content.delta"); + expect(deltas.map((event) => event.payload.streamKind)).toEqual([ + "reasoning_text", + "assistant_text", + ]); + expect(deltas.every((event) => event.turnId === result.turnId)).toBe(true); + const tool = h.seen.find( + (event) => + event.type === "item.completed" && event.payload.itemType === "command_execution", + ); + expect(tool?.type === "item.completed" ? tool.payload.data : undefined).toMatchObject({ + command: "cat probe.txt", + cwd: "/tmp", + item: { aggregatedOutput: "after\n", exitCode: 0 }, + }); + }), + ); + + it.effect("does not auto-approve a remaining native request in full access", () => + Effect.gen(function* () { + const h = yield* makeHarness(); + yield* h.adapter.startSession({ threadId, cwd: process.cwd(), runtimeMode: "full-access" }); + const permission = yield* h + .invokePermission({ + sessionId: nativeSessionId, + toolCall: { toolCallId: "write-1", kind: "edit", title: "Write probe.txt" }, + options: [ + { optionId: "native:allow", name: "Allow", kind: "allow_once" }, + { optionId: "native:deny", name: "Deny", kind: "reject_once" }, + ], + }) + .pipe(Effect.forkChild); + const opened = yield* h.waitForEvent((event) => event.type === "request.opened"); + expect(h.calls).toContain("mode:yolo"); + expect(opened.payload.options).toEqual([ + { decision: "accept", label: "Allow once" }, + { decision: "decline", label: "Deny" }, + { decision: "cancel", label: "Cancel" }, + ]); + expect(permission.pollUnsafe()).toBeUndefined(); + const always = yield* h.adapter + .respondToRequest(threadId, ApprovalRequestId.make(opened.requestId!), "acceptAlways") + .pipe(Effect.exit); + expect(Exit.isFailure(always)).toBe(true); + yield* h.adapter.respondToRequest( + threadId, + ApprovalRequestId.make(opened.requestId!), + "decline", + ); + expect(yield* Fiber.join(permission)).toEqual({ + outcome: { outcome: "selected", optionId: "native:deny" }, + }); + }), + ); + + it.effect("returns opaque native question choices and rejects ambiguous labels", () => + Effect.gen(function* () { + const h = yield* makeHarness(); + yield* h.adapter.startSession({ threadId, cwd: process.cwd(), runtimeMode: "full-access" }); + const question = yield* h + .invokePermission({ + sessionId: nativeSessionId, + toolCall: { toolCallId: "interaction_opaque", title: "Which target?" }, + options: [ + { optionId: "choice:a", name: "Same label", kind: "allow_once" }, + { optionId: "choice:b", name: "Same label", kind: "allow_once" }, + ], + }) + .pipe(Effect.forkChild); + const opened = yield* h.waitForEvent((event) => event.type === "user-input.requested"); + expect(opened.payload.questions[0]?.allowCustomAnswer).toBe(false); + expect(opened.payload.questions[0]?.options.map((option) => option.value)).toEqual([ + "choice:a", + "choice:b", + ]); + const invalid = yield* h.adapter + .respondToUserInput(threadId, ApprovalRequestId.make(opened.requestId!), { + interaction_opaque: "Same label", + }) + .pipe(Effect.exit); + expect(Exit.isFailure(invalid)).toBe(true); + expect(question.pollUnsafe()).toBeUndefined(); + yield* h.adapter.respondToUserInput(threadId, ApprovalRequestId.make(opened.requestId!), { + interaction_opaque: "choice:b", + }); + expect(yield* Fiber.join(question)).toEqual({ + outcome: { outcome: "selected", optionId: "choice:b" }, + }); + }), + ); + + it.effect("cancels native questions before waiting for the prompt to end", () => + 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: "Ask a question" }) + .pipe(Effect.forkChild); + yield* h.nextPrompt; + const question = yield* h + .invokePermission({ + sessionId: nativeSessionId, + toolCall: { toolCallId: "interaction_cancel", title: "Continue?" }, + options: [{ optionId: "yes", name: "Yes", kind: "allow_once" }], + }) + .pipe(Effect.forkChild); + yield* h.waitForEvent((event) => event.type === "user-input.requested"); + yield* h.adapter.interruptTurn(threadId); + expect(yield* Fiber.join(question)).toEqual({ outcome: { outcome: "cancelled" } }); + yield* Fiber.join(sending); + const ended = yield* h.waitForEvent((event) => event.type === "turn.completed"); + expect(ended.payload.state).toBe("cancelled"); + expect(h.seen.some((event) => event.type === "user-input.resolved")).toBe(true); + }), + ); + + it.effect("waits for native cancellation before a steer changes the model", () => + Effect.gen(function* () { + const h = yield* makeHarness({ holdCancel: true }); + yield* h.adapter.startSession({ + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + const first = yield* h.adapter + .sendTurn({ threadId, input: "First prompt" }) + .pipe(Effect.forkChild); + yield* h.nextPrompt; + const marker = h.calls.length; + const second = yield* h.adapter + .sendTurn({ + threadId, + input: "Steer the turn", + modelSelection: { instanceId, model: nativeAlternative }, + }) + .pipe(Effect.forkChild); + expect(yield* h.nextCancellation).toBe(1); + expect(h.calls.slice(marker)).toEqual(["cancel:1"]); + yield* h.emitNative({ + _tag: "ContentDelta", + text: "The first prompt stopped.", + rawPayload: {}, + }); + yield* Deferred.succeed(h.cancelRelease, undefined); + const replacement = yield* h.nextPrompt; + expect(h.calls.slice(marker)).toEqual([ + "cancel:1", + "drained:1", + `model:${nativeAlternative}`, + "mode:default", + "prompt:2", + ]); + yield* Deferred.succeed(replacement.result, { stopReason: "end_turn" }); + const [oldResult, newResult] = yield* Effect.all([Fiber.join(first), Fiber.join(second)]); + expect(oldResult.turnId).toBe(newResult.turnId); + yield* h.waitForEvent((event) => event.type === "turn.completed"); + expect(h.seen.filter((event) => event.type === "turn.completed")).toHaveLength(1); + expect((yield* h.adapter.listSessions())[0]).toMatchObject({ + status: "ready", + activeTurnId: undefined, + model: nativeAlternative, + }); + }), + ); + + it.effect("rejects an unavailable steer model without cancelling current work", () => + 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: "Keep working" }) + .pipe(Effect.forkChild); + const prompt = yield* h.nextPrompt; + const invalid = yield* h.adapter + .sendTurn({ + threadId, + input: "Change model", + modelSelection: { instanceId, model: "not-in-this-account" }, + }) + .pipe(Effect.exit); + expect(Exit.isFailure(invalid)).toBe(true); + expect(h.calls.some((call) => call.startsWith("cancel:"))).toBe(false); + expect(h.hasActivePrompt()).toBe(true); + yield* Deferred.succeed(prompt.result, { stopReason: "end_turn" }); + yield* Fiber.join(first); + }), + ); + + it.effect("settles a failed steer configuration and allows a later turn", () => + 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: "First" }).pipe(Effect.forkChild); + yield* h.nextPrompt; + h.controls.failModel = true; + const failed = yield* h.adapter + .sendTurn({ + threadId, + input: "Replacement", + modelSelection: { instanceId, model: nativeAlternative }, + }) + .pipe(Effect.exit); + expect(Exit.isFailure(failed)).toBe(true); + yield* Fiber.join(first); + const ended = yield* h.waitForEvent((event) => event.type === "turn.completed"); + expect(ended.payload.state).toBe("failed"); + expect((yield* h.adapter.listSessions())[0]).toMatchObject({ + status: "error", + activeTurnId: undefined, + }); + const later = yield* h.adapter + .sendTurn({ threadId, input: "Try again" }) + .pipe(Effect.forkChild); + const prompt = yield* h.nextPrompt; + yield* Deferred.succeed(prompt.result, { stopReason: "end_turn" }); + const recovered = yield* Fiber.join(later); + expect(recovered.turnId).not.toBe(ended.turnId); + expect((yield* h.adapter.listSessions())[0]?.status).toBe("ready"); + }), + ); + + it.effect("cancels the native prompt if its send caller is interrupted", () => + 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: "Keep working" }) + .pipe(Effect.forkChild); + yield* h.nextPrompt; + yield* Fiber.interrupt(sending); + const ended = yield* h.waitForEvent((event) => event.type === "turn.completed"); + expect(ended.payload.state).toBe("cancelled"); + expect(h.hasActivePrompt()).toBe(false); + expect((yield* h.adapter.listSessions())[0]).toMatchObject({ + status: "ready", + activeTurnId: undefined, + }); + }), + ); + + it.effect("tracks native commands that survive a turn and clears terminal tasks", () => + 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 watcher" }) + .pipe(Effect.forkChild); + const prompt = yield* h.nextPrompt; + yield* h.emitNative({ + _tag: "ToolCallUpdated", + toolCall: { + toolCallId: "watcher-1", + kind: "execute", + status: "inProgress", + command: "watch files", + data: {}, + }, + rawPayload: {}, + }); + yield* Deferred.succeed(prompt.result, { stopReason: "end_turn" }); + const turn = yield* Fiber.join(sending); + const started = yield* h.waitForEvent((event) => event.type === "task.started"); + expect(started.payload.taskType).toBe("local_bash"); + expect(started.turnId).toBe(turn.turnId); + yield* h.emitNative({ + _tag: "ToolCallUpdated", + toolCall: { toolCallId: "watcher-1", kind: "execute", status: "completed", data: {} }, + rawPayload: {}, + }); + const ended = yield* h.waitForEvent((event) => event.type === "task.completed"); + expect(ended.payload.taskId).toBe(started.payload.taskId); + expect(ended.payload.status).toBe("completed"); + }), + ); + + 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 }); + yield* h.adapter.startSession({ + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + const sending = yield* h.adapter + .sendTurn({ threadId, input: "Do not dispatch this prompt" }) + .pipe(Effect.forkChild); + yield* Deferred.await(h.dispatchStarted); + yield* Fiber.interrupt(sending); + yield* Deferred.succeed(h.dispatchRelease, undefined); + const cancelled = yield* h.waitForEvent((event) => event.type === "turn.completed"); + expect(cancelled.payload.state).toBe("cancelled"); + expect(h.calls.some((call) => call.startsWith("prompt:"))).toBe(false); + expect(h.hasActivePrompt()).toBe(false); + const later = yield* h.adapter + .sendTurn({ threadId, input: "This prompt can run" }) + .pipe(Effect.forkChild); + const prompt = yield* h.nextPrompt; + yield* Deferred.succeed(prompt.result, { stopReason: "end_turn" }); + const result = yield* Fiber.join(later); + expect(result.turnId).not.toBe(cancelled.turnId); + expect(h.calls.filter((call) => call.startsWith("prompt:"))).toEqual(["prompt:1"]); + }), + ); + + it.effect("awaits full process cleanup for concurrent stop requests", () => + Effect.gen(function* () { + const h = yield* makeHarness({ holdClose: true }); + yield* h.adapter.startSession({ + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + const stopping = yield* h.adapter.stopSession(threadId).pipe(Effect.forkChild); + yield* Deferred.await(h.closeStarted); + const registeredStop = h.stops[0]; + if (!registeredStop) return yield* Effect.die("Missing process cleanup registration"); + const signOutStop = yield* registeredStop.pipe(Effect.forkChild({ startImmediately: true })); + expect(signOutStop.pollUnsafe()).toBeUndefined(); + yield* Deferred.succeed(h.closeRelease, undefined); + yield* Effect.all([Fiber.join(stopping), Fiber.join(signOutStop)]); + yield* h.waitForEvent((event) => event.type === "session.exited"); + expect(h.controls.closed).toBe(1); + expect(h.seen.filter((event) => event.type === "session.exited")).toHaveLength(1); + expect(yield* h.adapter.hasSession(threadId)).toBe(false); + }), + ); + + it.effect("stops a session while its prompt is waiting to dispatch", () => + Effect.gen(function* () { + const h = yield* makeHarness({ holdDispatch: true }); + yield* h.adapter.startSession({ + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + const sending = yield* h.adapter + .sendTurn({ threadId, input: "Do not dispatch after stop" }) + .pipe(Effect.forkChild); + yield* Deferred.await(h.dispatchStarted); + yield* h.adapter.stopSession(threadId); + yield* Fiber.await(sending); + yield* Deferred.succeed(h.dispatchRelease, undefined); + expect(h.calls.some((call) => call.startsWith("prompt:"))).toBe(false); + expect(h.controls.closed).toBe(1); + expect(yield* h.adapter.hasSession(threadId)).toBe(false); + }), + ); + + it.effect("propagates idle process exits and rejects stale session use", () => + Effect.gen(function* () { + const h = yield* makeHarness(); + yield* h.adapter.startSession({ + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + yield* h.emitNative({ + _tag: "ConnectionTerminated", + error: new AcpErrors.AcpTransportError({ detail: "Process exited.", cause: undefined }), + }); + const exited = yield* h.waitForEvent((event) => event.type === "session.exited"); + expect(exited.payload.exitKind).toBe("error"); + expect(yield* h.adapter.hasSession(threadId)).toBe(false); + expect( + Exit.isFailure(yield* h.adapter.sendTurn({ threadId, input: "Hello" }).pipe(Effect.exit)), + ).toBe(true); + }), + ); + + it.effect("reports hidden login requests as sign-in required and clears account metadata", () => + Effect.gen(function* () { + const h = yield* makeHarness(); + h.controls.failAuth = true; + const started = yield* h.adapter + .startSession({ threadId, cwd: process.cwd(), runtimeMode: "approval-required" }) + .pipe(Effect.exit); + expect(Exit.isFailure(started)).toBe(true); + expect(h.controls.authInvalidations).toBe(1); + expect(h.controls.closed).toBe(1); + expect(yield* h.adapter.hasSession(threadId)).toBe(false); + }), + ); + + it.effect("serves client file reads and writes only inside the session roots", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const h = yield* makeHarness(); + const { attachmentsDir } = yield* ServerConfig; + const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-agy-fs-" }); + const outside = yield* fs.makeTempDirectoryScoped({ prefix: "t3-agy-outside-" }); + yield* fs.writeFileString(path.join(cwd, "notes.txt"), "one\ntwo\nthree\n"); + yield* h.adapter.startSession({ threadId, cwd, runtimeMode: "approval-required" }); + expect(h.launches[0]?.clientFileSystem).toBe(true); + expect(h.launches[0]?.additionalDirectories).toEqual([attachmentsDir]); + const read = h.fileHandlers.read; + const write = h.fileHandlers.write; + if (!read || !write) return yield* Effect.die("File handlers were not registered."); + + const full = yield* read({ sessionId: nativeSessionId, path: path.join(cwd, "notes.txt") }); + expect(full.content).toBe("one\ntwo\nthree\n"); + const window = yield* read({ + sessionId: nativeSessionId, + path: path.join(cwd, "notes.txt"), + line: 2, + limit: 1, + }); + expect(window.content).toBe("two"); + + yield* write({ + sessionId: nativeSessionId, + path: path.join(cwd, "nested", "new.txt"), + content: "created", + }); + expect(yield* fs.readFileString(path.join(cwd, "nested", "new.txt"))).toBe("created"); + + const escape = yield* write({ + sessionId: nativeSessionId, + path: path.join(outside, "escape.txt"), + content: "nope", + }).pipe(Effect.flip); + expect(escape._tag).toBe("AcpRequestError"); + expect(yield* fs.exists(path.join(outside, "escape.txt"))).toBe(false); + const missing = yield* read({ + sessionId: nativeSessionId, + path: path.join(cwd, "missing.txt"), + }).pipe(Effect.flip); + expect(missing._tag).toBe("AcpRequestError"); + }).pipe(Effect.scoped), + ); + + it.effect("does not launch a process for a disabled instance or invalid resume cursor", () => + Effect.gen(function* () { + const disabled = yield* makeHarness({ enabled: false }); + const rejected = yield* disabled.adapter + .startSession({ threadId, cwd: process.cwd(), runtimeMode: "approval-required" }) + .pipe(Effect.exit); + expect(Exit.isFailure(rejected)).toBe(true); + expect(disabled.launches).toHaveLength(0); + const active = yield* makeHarness(); + const stale = yield* active.adapter + .startSession({ + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + resumeCursor: { sessionId: nativeSessionId }, + }) + .pipe(Effect.exit); + expect(Exit.isFailure(stale)).toBe(true); + expect(active.launches).toHaveLength(0); + }), + ); +}); diff --git a/apps/server/src/provider/Layers/AntigravityAdapter.ts b/apps/server/src/provider/Layers/AntigravityAdapter.ts new file mode 100644 index 000000000000..2541d3ee1670 --- /dev/null +++ b/apps/server/src/provider/Layers/AntigravityAdapter.ts @@ -0,0 +1,1240 @@ +import { + ApprovalRequestId, + EventId, + ProviderDriverKind, + ProviderInstanceId, + RuntimeRequestId, + RuntimeTaskId, + TurnId, + type AntigravitySettings, + type ProviderApprovalDecision, + type ProviderRuntimeEvent, + type ProviderSession, + type ProviderSetupError, + type ProviderUserInputAnswers, + type RuntimeTaskStatus, + type ThreadId, + type TurnCompletedPayload, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as PubSub from "effect/PubSub"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import * as SynchronizedRef from "effect/SynchronizedRef"; +import * as EffectAcpErrors from "effect-acp/errors"; +import type * as EffectAcpSchema from "effect-acp/schema"; + +import { ServerConfig } from "../../config.ts"; +import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; +import type { AntigravityAuth } from "../AntigravityAuth.ts"; +import { + ProviderAdapterRequestError, + ProviderAdapterSessionClosedError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, + type ProviderAdapterError, +} from "../Errors.ts"; +import { + ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE, + isAntigravitySignInRequiredError, +} from "../antigravityAuthSupport.ts"; +import { mapAcpToAdapterError } from "../acp/AcpAdapterSupport.ts"; +import { + makeAcpAssistantItemEvent, + makeAcpContentDeltaEvent, + makeAcpPlanUpdatedEvent, + makeAcpRequestOpenedEvent, + makeAcpRequestResolvedEvent, + makeAcpToolCallEvent, +} from "../acp/AcpCoreRuntimeEvents.ts"; +import { makeAcpNativeLoggerFactory } from "../acp/AcpNativeLogging.ts"; +import { parsePermissionRequest, type AcpToolCallState } from "../acp/AcpRuntimeModel.ts"; +import type * as AcpSessionRuntime from "../acp/AcpSessionRuntime.ts"; +import { + antigravityPermissionMode, + antigravityModelOptions, + applyAntigravityAcpModelSelection, + buildAntigravityPrompt, + type AntigravityAcpRuntimeInput, + resolveAntigravityModel, +} from "../acp/AntigravityAcpSupport.ts"; +import { + antigravityApprovalOptions, + antigravitySubagentResult, + classifyAntigravitySubagentToolCall, + extractAntigravityUserInputQuestion, + isAntigravityOpenCommand, + isAntigravitySubagentReplayStart, + isAntigravityUserInputRequest, + makeAntigravityUserInputResponse, + normalizeAntigravityToolCall, + sanitizeAntigravityToolPayload, + selectAntigravityPermissionOptionId, +} from "../acp/AntigravityProtocol.ts"; +import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; +import type { EventNdjsonLogger } from "./EventNdjsonLogger.ts"; + +const PROVIDER = ProviderDriverKind.make("antigravity"); +const ResumeCursor = Schema.Struct({ + schemaVersion: Schema.Literal(1), + sessionId: Schema.NonEmptyString, +}); +const decodeResumeCursor = Schema.decodeUnknownOption(ResumeCursor); +const isAcpError = Schema.is(EffectAcpErrors.AcpError); + +type Adapter = ProviderAdapterShape; +type Runtime = Pick< + AcpSessionRuntime.AcpSessionRuntime["Service"], + | "handleRequestPermission" + | "handleReadTextFile" + | "handleWriteTextFile" + | "start" + | "setMode" + | "setModel" + | "getConfigOptions" + | "getEvents" + | "drainEvents" + | "prompt" + | "cancel" +>; +type NativePermission = EffectAcpSchema.RequestPermissionRequest; +type NativePermissionResponse = EffectAcpSchema.RequestPermissionResponse; + +function mapAntigravityError(threadId: ThreadId, method: string, cause: EffectAcpErrors.AcpError) { + return isAntigravitySignInRequiredError(cause) + ? new ProviderAdapterRequestError({ + provider: PROVIDER, + method, + detail: ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE, + cause, + }) + : mapAcpToAdapterError(PROVIDER, threadId, method, cause); +} + +export interface AntigravityAdapterOptions { + readonly instanceId: ProviderInstanceId; + readonly makeRuntime: ( + input: Omit, + ) => Effect.Effect; + readonly withProcess: AntigravityAuth["withProcess"]; + readonly onSessionStarted?: ( + started: AcpSessionRuntime.AcpSessionRuntimeStartResult, + cwd: string, + ) => Effect.Effect; + readonly onAvailableCommands?: ( + 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; + readonly nativeEventLogger?: EventNdjsonLogger; +} + +interface PendingApproval { + readonly request: NativePermission; + readonly response: Deferred.Deferred<{ + readonly decision: ProviderApprovalDecision; + readonly result: NativePermissionResponse; + }>; +} + +interface PendingQuestion { + readonly request: NativePermission; + readonly response: Deferred.Deferred<{ + readonly answers: ProviderUserInputAnswers; + readonly result: NativePermissionResponse; + }>; +} + +interface OpenCommand { + readonly toolCall: AcpToolCallState; + readonly turnId: TurnId | undefined; + 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; + settled: boolean; +} + +interface SessionContext { + readonly threadId: ThreadId; + readonly cwd: string; + readonly nativeSessionId: string; + readonly scope: Scope.Closeable; + readonly runtime: Runtime; + readonly promptLock: Semaphore.Semaphore; + readonly stopLock: Semaphore.Semaphore; + readonly commandLock: Semaphore.Semaphore; + 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; + promptFiber: Fiber.Fiber | undefined; + generation: number; + stopped: boolean; + closed: boolean; + disconnected: boolean; +} + +const CLIENT_FILE_MAX_BYTES = 8 * 1024 * 1024; + +function isInsideRoot(path: Path.Path, root: string, candidate: string): boolean { + const relative = path.relative(root, candidate); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +} + +/** Resolves an agent-supplied path and rejects anything outside the session roots. */ +const resolveClientFilePath = Effect.fn("AntigravityAdapter.resolveClientFilePath")( + function* (input: { + readonly fileSystem: FileSystem.FileSystem; + readonly path: Path.Path; + readonly allowedRoots: ReadonlyArray; + readonly requestPath: string; + }) { + const { path } = input; + const resolved = path.resolve(input.requestPath); + // Follow symlinks on the parent so a link out of the workspace cannot escape it. + const parent = yield* input.fileSystem + .realPath(path.dirname(resolved)) + .pipe(Effect.orElseSucceed(() => path.dirname(resolved))); + const real = path.join(parent, path.basename(resolved)); + const roots = yield* Effect.forEach(input.allowedRoots, (root) => + input.fileSystem.realPath(root).pipe(Effect.orElseSucceed(() => root)), + ); + if (!roots.some((root) => isInsideRoot(path, root, real))) { + return yield* EffectAcpErrors.AcpRequestError.invalidParams( + `Path '${input.requestPath}' is outside the session workspace.`, + ); + } + return real; + }, +); + +const readClientTextFile = Effect.fn("AntigravityAdapter.readClientTextFile")(function* (input: { + readonly fileSystem: FileSystem.FileSystem; + readonly path: Path.Path; + readonly allowedRoots: ReadonlyArray; + readonly request: EffectAcpSchema.ReadTextFileRequest; +}): Effect.fn.Return { + const filePath = yield* resolveClientFilePath({ ...input, requestPath: input.request.path }); + const info = yield* input.fileSystem + .stat(filePath) + .pipe( + Effect.mapError(() => + EffectAcpErrors.AcpRequestError.resourceNotFound(`File '${input.request.path}' not found.`), + ), + ); + if (info.type !== "File" || Number(info.size) > CLIENT_FILE_MAX_BYTES) { + return yield* EffectAcpErrors.AcpRequestError.invalidParams( + `File '${input.request.path}' is not a readable text file under ${CLIENT_FILE_MAX_BYTES} bytes.`, + ); + } + const text = yield* input.fileSystem + .readFileString(filePath) + .pipe( + Effect.mapError(() => + EffectAcpErrors.AcpRequestError.internalError(`Could not read '${input.request.path}'.`), + ), + ); + const line = input.request.line ?? undefined; + const limit = input.request.limit ?? undefined; + if (line === undefined && limit === undefined) { + return { content: text }; + } + // ACP lines are 1-indexed. `limit` is a line count. + const lines = text.split("\n"); + const start = Math.max(0, (line ?? 1) - 1); + const end = limit === undefined ? lines.length : Math.min(lines.length, start + limit); + return { content: lines.slice(start, end).join("\n") }; +}); + +const writeClientTextFile = Effect.fn("AntigravityAdapter.writeClientTextFile")(function* (input: { + readonly fileSystem: FileSystem.FileSystem; + readonly path: Path.Path; + readonly allowedRoots: ReadonlyArray; + readonly request: EffectAcpSchema.WriteTextFileRequest; +}): Effect.fn.Return { + const filePath = yield* resolveClientFilePath({ ...input, requestPath: input.request.path }); + yield* input.fileSystem.makeDirectory(input.path.dirname(filePath), { recursive: true }).pipe( + Effect.andThen(input.fileSystem.writeFileString(filePath, input.request.content)), + Effect.mapError(() => + EffectAcpErrors.AcpRequestError.internalError(`Could not write '${input.request.path}'.`), + ), + ); + return {}; +}); + +/** Keeps one official ACP process per thread and drains a cancelled prompt before steering. */ +export const makeAntigravityAdapter = Effect.fn("makeAntigravityAdapter")(function* ( + settings: AntigravitySettings, + options: AntigravityAdapterOptions, +) { + const crypto = yield* Crypto.Crypto; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const serverConfig = yield* ServerConfig; + const ownerScope = yield* Effect.scope; + const makeNativeLoggers = yield* makeAcpNativeLoggerFactory(); + const sessions = new Map(); + const locks = yield* SynchronizedRef.make(new Map()); + const events = yield* PubSub.unbounded(); + const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + const randomId = crypto.randomUUIDv4.pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "crypto/randomUUIDv4", + detail: "Could not create an Antigravity event ID.", + cause, + }), + ), + ); + const stamp = Effect.all({ + eventId: Effect.map(randomId, EventId.make), + createdAt: nowIso, + }); + const emit = (event: ProviderRuntimeEvent) => PubSub.publish(events, event).pipe(Effect.asVoid); + + const withThreadLock = (threadId: ThreadId, task: Effect.Effect) => + SynchronizedRef.modifyEffect(locks, (current) => { + const existing = current.get(threadId); + if (existing) return Effect.succeed([existing, current] as const); + return Semaphore.make(1).pipe( + Effect.map((lock) => [lock, new Map(current).set(threadId, lock)] as const), + ); + }).pipe(Effect.flatMap((lock) => lock.withPermit(task))); + + const requireSession = (threadId: ThreadId) => { + const context = sessions.get(threadId); + return context && !context.stopped + ? Effect.succeed(context) + : Effect.fail(new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId })); + }; + + const cancelRequests = Effect.fn("AntigravityAdapter.cancelRequests")(function* ( + context: SessionContext, + ) { + for (const pending of context.approvals.values()) { + yield* Deferred.succeed(pending.response, { + decision: "cancel", + result: { outcome: { outcome: "cancelled" } }, + }); + } + for (const pending of context.questions.values()) { + yield* Deferred.succeed(pending.response, { + answers: {}, + result: { outcome: { outcome: "cancelled" } }, + }); + } + }); + + const finishBackgroundCommands = (context: SessionContext) => + context.commandLock.withPermit( + Effect.gen(function* () { + for (const [id, command] of context.commands) { + if (!command.promoted) continue; + yield* emit({ + type: "task.completed", + ...(yield* stamp), + provider: PROVIDER, + threadId: context.threadId, + turnId: command.turnId, + payload: { + taskId: RuntimeTaskId.make(id), + taskType: "local_bash", + toolUseId: id, + status: "stopped", + }, + }); + } + context.commands.clear(); + }), + ); + + 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( + Effect.gen(function* () { + if (context.closed) return; + context.stopped = true; + yield* Effect.gen(function* () { + yield* cancelRequests(context); + if (context.promptFiber && !context.disconnected) { + yield* Effect.ignore(context.runtime.cancel); + } + }).pipe(Effect.ensuring(Scope.close(context.scope, Exit.void))); + 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), + provider: PROVIDER, + threadId: context.threadId, + payload: { + exitKind: context.disconnected ? "error" : "graceful", + ...(context.disconnected ? { reason: "Antigravity process stopped." } : {}), + }, + }); + }), + ) + .pipe(Effect.uninterruptible); + + const handlePermission = Effect.fn("AntigravityAdapter.handlePermission")(function* ( + context: SessionContext, + request: NativePermission, + ): Effect.fn.Return { + if (context.stopped || request.sessionId !== context.nativeSessionId) { + return { outcome: { outcome: "cancelled" } }; + } + const requestId = ApprovalRequestId.make(yield* randomId); + const runtimeRequestId = RuntimeRequestId.make(requestId); + const turnId = context.activeTurnId; + const rawPayload = sanitizeAntigravityToolPayload(request); + + if (isAntigravityUserInputRequest(request)) { + const question = extractAntigravityUserInputQuestion(request); + if (!question) return { outcome: { outcome: "cancelled" } }; + const response = yield* Deferred.make<{ + answers: ProviderUserInputAnswers; + result: NativePermissionResponse; + }>(); + context.questions.set(requestId, { request, response }); + return yield* Effect.gen(function* () { + yield* emit({ + type: "user-input.requested", + ...(yield* stamp), + provider: PROVIDER, + threadId: context.threadId, + turnId, + requestId: runtimeRequestId, + payload: { questions: [question] }, + raw: { source: "acp.jsonrpc", method: "session/request_permission", payload: rawPayload }, + }); + const answer = yield* Deferred.await(response); + yield* emit({ + type: "user-input.resolved", + ...(yield* stamp), + provider: PROVIDER, + threadId: context.threadId, + turnId, + requestId: runtimeRequestId, + payload: { answers: answer.answers }, + }); + return answer.result; + }).pipe(Effect.ensuring(Effect.sync(() => context.questions.delete(requestId)))); + } + + const response = yield* Deferred.make<{ + decision: ProviderApprovalDecision; + result: NativePermissionResponse; + }>(); + context.approvals.set(requestId, { request, response }); + const parsed = parsePermissionRequest(request); + const toolCall = parsed.toolCall ? normalizeAntigravityToolCall(parsed.toolCall) : undefined; + const permissionRequest = { + ...parsed, + ...(toolCall ? { toolCall } : {}), + detail: + toolCall?.command ?? + toolCall?.detail ?? + toolCall?.title ?? + "Antigravity requests permission.", + }; + return yield* Effect.gen(function* () { + yield* emit( + makeAcpRequestOpenedEvent({ + stamp: yield* stamp, + provider: PROVIDER, + threadId: context.threadId, + turnId, + requestId: runtimeRequestId, + permissionRequest, + approvalOptions: antigravityApprovalOptions(request), + detail: permissionRequest.detail ?? "Antigravity requests permission.", + args: rawPayload, + source: "acp.jsonrpc", + method: "session/request_permission", + rawPayload, + }), + ); + const answer = yield* Deferred.await(response); + yield* emit( + makeAcpRequestResolvedEvent({ + stamp: yield* stamp, + provider: PROVIDER, + threadId: context.threadId, + turnId, + requestId: runtimeRequestId, + permissionRequest, + decision: answer.decision, + }), + ); + return answer.result; + }).pipe(Effect.ensuring(Effect.sync(() => context.approvals.delete(requestId)))); + }); + + const handleEvent = Effect.fn("AntigravityAdapter.handleEvent")(function* ( + context: SessionContext, + event: AcpSessionRuntime.AcpSessionRuntimeEvent, + ) { + if (event._tag === "EventStreamBarrier") { + yield* Deferred.succeed(event.acknowledge, undefined); + return; + } + if (context.stopped) return; + switch (event._tag) { + case "ModeChanged": + return; + 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; + yield* stopContext(context).pipe(Effect.forkIn(ownerScope)); + return; + case "AssistantItemStarted": + case "AssistantItemCompleted": + yield* emit( + makeAcpAssistantItemEvent({ + stamp: yield* stamp, + provider: PROVIDER, + threadId: context.threadId, + turnId: context.activeTurnId, + itemId: event.itemId, + lifecycle: event._tag === "AssistantItemStarted" ? "item.started" : "item.completed", + }), + ); + return; + case "ThoughtDelta": + case "ContentDelta": + yield* emit( + makeAcpContentDeltaEvent({ + stamp: yield* stamp, + provider: PROVIDER, + threadId: context.threadId, + turnId: context.activeTurnId, + ...(event._tag === "ContentDelta" && event.itemId ? { itemId: event.itemId } : {}), + ...(event._tag === "ThoughtDelta" ? { streamKind: "reasoning_text" } : {}), + text: event.text, + rawPayload: sanitizeAntigravityToolPayload(event.rawPayload), + }), + ); + return; + case "PlanUpdated": + yield* emit( + makeAcpPlanUpdatedEvent({ + stamp: yield* stamp, + provider: PROVIDER, + threadId: context.threadId, + turnId: context.activeTurnId, + payload: event.payload, + source: "acp.jsonrpc", + method: "session/update", + rawPayload: sanitizeAntigravityToolPayload(event.rawPayload), + }), + ); + return; + case "ToolCallUpdated": + 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({ + stamp: yield* stamp, + provider: PROVIDER, + threadId: context.threadId, + turnId: existing?.turnId ?? context.activeTurnId, + toolCall, + rawPayload: sanitizeAntigravityToolPayload(event.rawPayload), + }), + ); + if (isAntigravityOpenCommand(toolCall)) { + context.commands.set(toolCall.toolCallId, { + toolCall, + turnId: existing?.turnId ?? context.activeTurnId, + promoted: existing?.promoted ?? false, + }); + } else if (toolCall.status === "completed" || toolCall.status === "failed") { + context.commands.delete(toolCall.toolCallId); + if (existing?.promoted) { + yield* emit({ + type: "task.completed", + ...(yield* stamp), + provider: PROVIDER, + threadId: context.threadId, + turnId: existing.turnId, + payload: { + taskId: RuntimeTaskId.make(toolCall.toolCallId), + taskType: "local_bash", + toolUseId: toolCall.toolCallId, + status: toolCall.status === "failed" ? "failed" : "completed", + }, + }); + } + } + }), + ); + return; + } + }); + + const startSession: Adapter["startSession"] = (input) => + withThreadLock( + input.threadId, + Effect.gen(function* () { + if (!settings.enabled) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: "Enable Antigravity in provider settings before starting a thread.", + }); + } + if ( + (input.provider !== undefined && input.provider !== PROVIDER) || + (input.providerInstanceId !== undefined && + input.providerInstanceId !== options.instanceId) || + (input.modelSelection !== undefined && + input.modelSelection.instanceId !== options.instanceId) + ) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: "The Antigravity provider instance does not match the requested session.", + }); + } + if (!input.cwd?.trim()) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: "The session requires a workspace directory.", + }); + } + const cursor = decodeResumeCursor(input.resumeCursor); + if (input.resumeCursor !== undefined && Option.isNone(cursor)) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: "The saved Antigravity session is invalid. Start a new thread.", + }); + } + const previous = sessions.get(input.threadId); + if (previous) yield* stopContext(previous); + const cwd = path.resolve(input.cwd); + const sessionScope = yield* Scope.make("sequential"); + let transferred = false; + let context: SessionContext | undefined; + yield* Effect.addFinalizer(() => { + if (transferred) return Effect.void; + sessions.delete(input.threadId); + return Scope.close(sessionScope, Exit.void); + }); + const stopOwned = Effect.suspend(() => + context ? stopContext(context).pipe(Effect.ignore) : Scope.close(sessionScope, Exit.void), + ); + + return yield* options + .withProcess( + stopOwned, + Effect.gen(function* () { + const mcp = McpProviderSession.readMcpProviderSession(input.threadId); + // The attachments dir grant lets the agent read pasted files at + // the paths ProviderService injects into the turn text. It is a + // leaf directory holding only uploads. + const runtime = yield* options.makeRuntime({ + cwd, + clientInfo: { name: "t3-code", version: "0.0.0" }, + clientFileSystem: true, + additionalDirectories: [serverConfig.attachmentsDir], + ...(Option.isSome(cursor) ? { resumeSessionId: cursor.value.sessionId } : {}), + mcpServers: mcp + ? [ + { + type: "http", + name: "t3-code", + url: mcp.endpoint, + headers: [{ name: "Authorization", value: mcp.authorizationHeader }], + }, + ] + : [], + ...makeNativeLoggers({ + nativeEventLogger: options.nativeEventLogger, + provider: PROVIDER, + threadId: input.threadId, + }), + }); + // Workspace file access requested through the client fs + // capability. The agent gates each write behind + // `session/request_permission`, so only path containment is + // checked here. + const allowedRoots = [cwd, serverConfig.attachmentsDir]; + yield* runtime.handleReadTextFile((request) => + readClientTextFile({ fileSystem, path, allowedRoots, request }), + ); + yield* runtime.handleWriteTextFile((request) => + writeClientTextFile({ fileSystem, path, allowedRoots, request }), + ); + yield* runtime.handleRequestPermission((request) => + context + ? handlePermission(context, request).pipe( + Effect.mapError((cause) => + EffectAcpErrors.AcpRequestError.internalError( + "Could not process an Antigravity permission request.", + undefined, + { cause }, + ), + ), + ) + : Effect.succeed({ + outcome: { outcome: "cancelled" }, + } satisfies NativePermissionResponse), + ); + const started = yield* runtime.start(); + const model = yield* applyAntigravityAcpModelSelection({ + runtime, + model: input.modelSelection?.model, + defaultModel: yield* options.defaultModel ?? Effect.succeed(undefined), + mapError: (cause) => cause, + }); + yield* runtime.setMode(antigravityPermissionMode(input.runtimeMode)); + yield* options.onSessionStarted?.(started, cwd) ?? Effect.void; + const createdAt = yield* nowIso; + const session: ProviderSession = { + provider: PROVIDER, + providerInstanceId: options.instanceId, + threadId: input.threadId, + cwd, + status: "ready", + runtimeMode: input.runtimeMode, + ...(model ? { model } : {}), + resumeCursor: { schemaVersion: 1, sessionId: started.sessionId }, + createdAt, + updatedAt: createdAt, + }; + context = { + threadId: input.threadId, + cwd, + nativeSessionId: started.sessionId, + scope: sessionScope, + runtime, + promptLock: yield* Semaphore.make(1), + stopLock: yield* Semaphore.make(1), + commandLock: yield* Semaphore.make(1), + approvals: new Map(), + questions: new Map(), + commands: new Map(), + subagents: new Map(), + turns: [], + session, + activeTurnId: undefined, + promptFiber: undefined, + generation: 0, + stopped: false, + closed: false, + disconnected: false, + }; + const running = context; + sessions.set(input.threadId, running); + yield* Stream.runForEach(runtime.getEvents(), (event) => + handleEvent(running, event), + ).pipe( + Effect.catchCause(() => + Effect.logError("Could not process an Antigravity runtime event."), + ), + Effect.forkIn(sessionScope), + ); + yield* emit({ + type: "session.started", + ...(yield* stamp), + provider: PROVIDER, + threadId: input.threadId, + payload: { resume: started.initializeResult }, + }); + yield* emit({ + type: "session.state.changed", + ...(yield* stamp), + provider: PROVIDER, + threadId: input.threadId, + payload: { state: "ready", reason: "Antigravity ACP session ready" }, + }); + yield* emit({ + type: "thread.started", + ...(yield* stamp), + provider: PROVIDER, + threadId: input.threadId, + payload: { providerThreadId: started.sessionId }, + }); + yield* runtime.drainEvents; + if (running.stopped) { + return yield* new ProviderAdapterSessionClosedError({ + provider: PROVIDER, + threadId: input.threadId, + }); + } + transferred = true; + return session; + }), + ) + .pipe( + Effect.provideService(Scope.Scope, sessionScope), + Effect.tapError((cause) => + isAntigravitySignInRequiredError(cause) + ? (options.onAuthRequired ?? Effect.void) + : Effect.void, + ), + Effect.mapError((cause) => + isAcpError(cause) + ? mapAntigravityError(input.threadId, "session/start", cause) + : new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/start", + detail: "Could not start Antigravity. Check the provider setup status.", + cause, + }), + ), + ); + }).pipe(Effect.scoped), + ); + + const promoteBackgroundCommands = (context: SessionContext) => + context.commandLock.withPermit( + Effect.gen(function* () { + for (const [id, command] of context.commands) { + if (command.promoted) continue; + yield* emit({ + type: "task.started", + ...(yield* stamp), + provider: PROVIDER, + threadId: context.threadId, + turnId: command.turnId, + payload: { + taskId: RuntimeTaskId.make(id), + taskType: "local_bash", + toolUseId: id, + description: + command.toolCall.command ?? command.toolCall.title ?? "Antigravity command", + }, + }); + context.commands.set(id, { ...command, promoted: true }); + } + }), + ); + + const sendTurn: Adapter["sendTurn"] = Effect.fn("AntigravityAdapter.sendTurn")(function* (input) { + const context = yield* requireSession(input.threadId); + if (input.modelSelection && input.modelSelection.instanceId !== options.instanceId) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "The selected model belongs to another provider instance.", + }); + } + const prompt = yield* buildAntigravityPrompt({ + input: input.input, + attachments: input.attachments, + attachmentsDir: serverConfig.attachmentsDir, + }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.mapError((cause) => mapAntigravityError(input.threadId, "session/prompt", cause)), + ); + let intent: TurnIntent | undefined; + // The caller holds promptLock while it changes or settles the active turn. + const finishTurn = (turn: TurnIntent, payload: TurnCompletedPayload) => + Effect.gen(function* () { + 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 = { + ...context.session, + status: payload.state === "failed" ? "error" : "ready", + activeTurnId: undefined, + updatedAt: yield* nowIso, + ...(payload.errorMessage + ? { lastError: payload.errorMessage } + : { lastError: undefined }), + }; + yield* emit({ + type: "turn.completed", + ...(yield* stamp), + provider: PROVIDER, + threadId: input.threadId, + turnId: turn.turnId, + payload, + }); + }).pipe(Effect.uninterruptible); + + return yield* Effect.gen(function* () { + const launch = yield* context.promptLock.withPermit( + Effect.gen(function* () { + yield* requireSession(input.threadId); + const requestedModel = input.modelSelection?.model ?? context.session.model; + const configOptions = yield* context.runtime.getConfigOptions; + const model = resolveAntigravityModel({ + configOptions, + model: requestedModel, + defaultModel: yield* options.defaultModel ?? Effect.succeed(undefined), + }); + const availableModels = antigravityModelOptions(configOptions); + if (model && !availableModels.some((option) => option.value === model)) { + return yield* EffectAcpErrors.AcpRequestError.invalidParams( + `Antigravity model '${model}' is unavailable for this Google account. Select an available model.`, + ); + } + const turnId = context.activeTurnId ?? TurnId.make(yield* randomId); + const steering = context.activeTurnId !== undefined; + const turn: TurnIntent = { turnId, generation: ++context.generation, settled: false }; + intent = turn; + context.activeTurnId = turnId; + if (!steering) { + yield* emit({ + type: "turn.started", + ...(yield* stamp), + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: model ? { model } : {}, + }); + } + if (context.promptFiber) { + yield* cancelRequests(context); + yield* context.runtime.cancel; + yield* Fiber.await(context.promptFiber); + yield* finishSubagents(context, "cancelled"); + } + yield* applyAntigravityAcpModelSelection({ + runtime: context.runtime, + model, + mapError: (cause) => cause, + }); + yield* context.runtime.setMode(antigravityPermissionMode(context.session.runtimeMode)); + context.session = { + ...context.session, + status: "running", + activeTurnId: turnId, + ...(model ? { model } : {}), + updatedAt: yield* nowIso, + }; + const dispatched = yield* Deferred.make(); + const fiber = yield* context.runtime + .prompt({ prompt }, { dispatched }) + .pipe(Effect.forkIn(context.scope)); + context.promptFiber = fiber; + // Fiber.join can skip a scope-close waiter when the child is interrupted. + // Unwrap the Exit after Fiber.await returns. + yield* Effect.raceFirst( + Deferred.await(dispatched), + Fiber.await(fiber).pipe( + Effect.flatMap((exit) => exit), + Effect.asVoid, + ), + ); + return { turn, fiber }; + }), + ); + const result = yield* Fiber.await(launch.fiber).pipe(Effect.flatMap((exit) => exit)); + yield* context.runtime.drainEvents; + if (context.stopped) { + return yield* new ProviderAdapterSessionClosedError({ + provider: PROVIDER, + threadId: input.threadId, + }); + } + const record = context.turns.find((turn) => turn.id === launch.turn.turnId); + if (record) record.items.push(result); + else context.turns.push({ id: launch.turn.turnId, items: [result] }); + yield* context.promptLock.withPermit( + finishTurn(launch.turn, { + state: result.stopReason === "cancelled" ? "cancelled" : "completed", + stopReason: result.stopReason, + }), + ); + return { + threadId: input.threadId, + turnId: launch.turn.turnId, + resumeCursor: context.session.resumeCursor, + }; + }).pipe( + Effect.tapError((cause) => + isAntigravitySignInRequiredError(cause) + ? (options.onAuthRequired ?? Effect.void) + : Effect.void, + ), + Effect.mapError((cause) => + isAcpError(cause) ? mapAntigravityError(input.threadId, "session/prompt", cause) : cause, + ), + Effect.tapError((cause) => + Effect.suspend(() => + intent + ? context.promptLock.withPermit( + finishTurn(intent, { state: "failed", errorMessage: cause.message }), + ) + : Effect.void, + ), + ), + Effect.onInterrupt(() => + context.promptLock.withPermit( + Effect.gen(function* () { + const turn = intent; + if (!turn || turn.settled || context.stopped || context.generation !== turn.generation) + return; + const promptFiber = context.promptFiber; + yield* cancelRequests(context); + yield* Effect.ignore(context.runtime.cancel); + if (promptFiber) yield* Fiber.interrupt(promptFiber); + yield* finishTurn(turn, { state: "cancelled", stopReason: "cancelled" }); + }), + ), + ), + ); + }); + + const interruptTurn: Adapter["interruptTurn"] = (threadId) => + Effect.gen(function* () { + const context = yield* requireSession(threadId); + yield* context.promptLock + .withPermit( + Effect.gen(function* () { + yield* cancelRequests(context); + yield* context.runtime.cancel; + }), + ) + .pipe(Effect.mapError((cause) => mapAntigravityError(threadId, "session/cancel", cause))); + }); + + const respondToRequest: Adapter["respondToRequest"] = (threadId, requestId, decision) => + Effect.gen(function* () { + const context = yield* requireSession(threadId); + const pending = context.approvals.get(requestId); + if (!pending) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/request_permission", + detail: "This approval request is no longer pending.", + }); + } + const optionId = + decision === "cancel" + ? undefined + : selectAntigravityPermissionOptionId(pending.request, decision); + if (decision !== "cancel" && optionId === undefined) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "respondToRequest", + issue: + "Antigravity did not offer this permission choice. Select one of the available choices.", + }); + } + yield* Deferred.succeed(pending.response, { + decision, + result: { + outcome: + optionId === undefined ? { outcome: "cancelled" } : { outcome: "selected", optionId }, + }, + }); + }); + + const respondToUserInput: Adapter["respondToUserInput"] = (threadId, requestId, answers) => + Effect.gen(function* () { + const context = yield* requireSession(threadId); + const pending = context.questions.get(requestId); + if (!pending) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/request_permission", + detail: "This question is no longer pending.", + }); + } + const result = makeAntigravityUserInputResponse(pending.request, answers); + if (!result) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "respondToUserInput", + issue: + "Select one of Antigravity's offered answers. Custom answers are not supported for this question.", + }); + } + yield* Deferred.succeed(pending.response, { answers, result }); + }); + + const stopSession: Adapter["stopSession"] = (threadId) => + withThreadLock(threadId, Effect.flatMap(requireSession(threadId), stopContext)); + const stopAll: Adapter["stopAll"] = () => + Effect.forEach([...sessions.values()], stopContext, { discard: true }); + yield* Effect.addFinalizer(() => + stopAll().pipe( + Effect.catchCause((cause) => + Cause.hasInterrupts(cause) + ? Effect.void + : Effect.logError("Could not stop an Antigravity session."), + ), + Effect.ensuring(PubSub.shutdown(events)), + ), + ); + + return { + provider: PROVIDER, + capabilities: { sessionModelSwitch: "in-session", supportsConversationRollback: false }, + startSession, + sendTurn, + interruptTurn, + respondToRequest, + respondToUserInput, + stopSession, + stopAll, + listSessions: () => + Effect.sync(() => + [...sessions.values()] + .filter((context) => !context.stopped) + .map((context) => ({ ...context.session })), + ), + hasSession: (threadId) => + Effect.sync(() => sessions.has(threadId) && !sessions.get(threadId)?.stopped), + readThread: (threadId) => + Effect.map(requireSession(threadId), (context) => ({ threadId, turns: context.turns })), + rollbackThread: (_threadId: ThreadId, _numTurns: number) => + Effect.fail( + new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "rollbackThread", + issue: "Antigravity does not support conversation rewind. Start a new thread instead.", + }), + ), + streamEvents: Stream.fromPubSub(events), + } satisfies Adapter; +}); diff --git a/apps/server/src/provider/Layers/AntigravityProvider.test.ts b/apps/server/src/provider/Layers/AntigravityProvider.test.ts new file mode 100644 index 000000000000..363afbee1106 --- /dev/null +++ b/apps/server/src/provider/Layers/AntigravityProvider.test.ts @@ -0,0 +1,627 @@ +import { describe, expect, it } from "@effect/vitest"; +import { + ANTIGRAVITY_DEFAULT_MODEL, + AntigravitySettings, + ProviderDriverKind, + ProviderInstanceId, + ProviderSetupError, +} from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +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"; + +import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import type { AcpSessionRuntimeStartResult } from "../acp/AcpSessionRuntime.ts"; +import { + buildAntigravityModelsFromSession, + makeAntigravityProvider, +} from "./AntigravityProvider.ts"; + +const decodeSettings = Schema.decodeSync(AntigravitySettings); +const instanceId = ProviderInstanceId.make("antigravity-test"); +const driver = ProviderDriverKind.make("antigravity"); + +const initializeResult = { + protocolVersion: 1, + agentCapabilities: { + loadSession: true, + promptCapabilities: { image: true, audio: true, embeddedContext: true }, + sessionCapabilities: { list: {}, resume: {} }, + }, + authMethods: [{ id: "oauth-personal", name: "Log in with Google" }], + agentInfo: { + name: "antigravity-acp", + title: "Google Antigravity", + version: "agy_acp_server_20260818_01_RC01", + }, +} satisfies EffectAcpSchema.InitializeResponse; + +const modelOptions = [ + { value: "gemini-3.8-flash-high", name: "Gemini 3.8 Flash (High)" }, + { value: "gemini-3.8-flash-medium", name: "Gemini 3.8 Flash (Medium)" }, + { value: "gemini-3.8-flash-low", name: "Gemini 3.8 Flash (Low)" }, + { value: "gemini-3.7-flash-high", name: "Gemini 3.7 Flash (High)" }, + { value: "gemini-3.7-flash-medium", name: "Gemini 3.7 Flash (Medium)" }, + { value: "gemini-3.7-flash-low", name: "Gemini 3.7 Flash (Low)" }, + { value: "gemini-3.6-flash-high", name: "Gemini 3.6 Flash (High)" }, + { value: "gemini-3.6-flash-medium", name: "Gemini 3.6 Flash (Medium)" }, + { value: "gemini-3.6-flash-low", name: "Gemini 3.6 Flash (Low)" }, + { value: "gemini-pro-agent", name: "Gemini 3.1 Pro (High)" }, + { value: "gemini-3.1-pro-low", name: "Gemini 3.1 Pro (Low)" }, +]; + +const modelConfig = { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: "gemini-3.7-flash-high", + options: modelOptions, +} satisfies EffectAcpSchema.SessionConfigOption; + +const sessionSetupResult = { + sessionId: "session-1", + models: { + currentModelId: "gemini-3.7-flash-high", + availableModels: modelOptions.map((option) => ({ modelId: option.value, name: option.name })), + }, + configOptions: [ + modelConfig, + { + id: "mode", + name: "Session Mode", + category: "mode", + type: "select", + currentValue: "default", + options: [ + { value: "default", name: "Default" }, + { value: "yolo", name: "YOLO" }, + ], + }, + ], +} satisfies EffectAcpSchema.NewSessionResponse; + +const started = { + sessionId: "session-1", + initializeResult, + sessionSetupResult, + modelConfigId: "model", +} satisfies AcpSessionRuntimeStartResult; + +const commands = [ + { name: "plan", description: "Create a plan", input: { hint: "What to plan" } }, + { name: "logout", description: "Sign out of Google" }, +] satisfies ReadonlyArray; + +const testLayer = Layer.merge( + Layer.mock(BackgroundPolicy.BackgroundPolicy)({ + shouldRunScopeWork: () => Effect.succeed(false), + }), + ServerSettingsService.layerTest(), +); + +type ProbeError = EffectAcpErrors.AcpError | ProviderSetupError; + +const makeHarness = Effect.fn("makeAntigravityProviderHarness")(function* ( + options: { readonly enabled?: boolean; readonly safe?: boolean } = {}, +) { + const initialProbe = yield* Deferred.make(); + const probeCalls = yield* Ref.make(0); + const safetyCalls = yield* Ref.make(0); + const probe = yield* Ref.make>( + Deferred.await(initialProbe), + ); + const safety = yield* Ref.make>(Effect.succeed(options.safe ?? true)); + const provider = yield* makeAntigravityProvider( + decodeSettings({ enabled: options.enabled ?? true, customModels: ["do-not-seed-me"] }), + { + stampIdentity: (snapshot) => Effect.succeed({ ...snapshot, instanceId, driver }), + probe: Ref.update(probeCalls, (count) => count + 1).pipe( + Effect.andThen(Ref.get(probe)), + Effect.flatten, + ), + supportsTextGeneration: Ref.update(safetyCalls, (count) => count + 1).pipe( + Effect.andThen(Ref.get(safety)), + Effect.flatten, + ), + }, + ); + const initialUpdate = yield* Stream.toPull( + provider.snapshot.streamChanges.pipe( + Stream.filter((snapshot) => snapshot.installed || snapshot.status === "error"), + ), + ); + const initialize = Deferred.succeed(initialProbe, initializeResult).pipe( + Effect.andThen(initialUpdate), + Effect.asVoid, + ); + return { + provider, + probe, + probeCalls, + safety, + safetyCalls, + initialProbe, + initialUpdate, + initialize, + }; +}); + +describe("Antigravity model catalog", () => { + it("keeps the captured personal catalog's IDs, labels, order, and selected default", () => { + const models = buildAntigravityModelsFromSession(sessionSetupResult); + expect(models.map((model) => [model.slug, model.name])).toEqual( + modelOptions.map((option) => [option.value, option.name]), + ); + expect(models.filter((model) => model.isDefault).map((model) => model.slug)).toEqual([ + "gemini-3.7-flash-high", + ]); + expect( + models + .filter((model) => model.aliases?.includes(ANTIGRAVITY_DEFAULT_MODEL)) + .map((model) => model.slug), + ).toEqual(["gemini-3.7-flash-high"]); + expect(models.every((model) => model.capabilities?.optionDescriptors?.length === 0)).toBe(true); + expect(models.every((model) => !model.isCustom)).toBe(true); + }); + + it("uses legacy session models only when model config is absent", () => { + const fromLegacy = buildAntigravityModelsFromSession({ + models: sessionSetupResult.models, + }); + expect(fromLegacy).toEqual(buildAntigravityModelsFromSession(sessionSetupResult)); + expect( + buildAntigravityModelsFromSession({ + ...sessionSetupResult, + configOptions: [{ ...modelConfig, options: [] }], + }), + ).toEqual([]); + }); + + it("flattens native option groups without combining distinct model IDs", () => { + const models = buildAntigravityModelsFromSession({ + configOptions: [ + { + ...modelConfig, + currentValue: "gemini-pro-agent", + options: [ + { group: "Flash", name: "Flash", options: [modelOptions[3]!, modelOptions[4]!] }, + { group: "Pro", name: "Pro", options: [modelOptions[9]!, modelOptions[3]!] }, + ], + }, + ], + }); + expect(models.map((model) => model.slug)).toEqual([ + "gemini-3.7-flash-high", + "gemini-3.7-flash-medium", + "gemini-pro-agent", + ]); + expect(models.find((model) => model.isDefault)?.slug).toBe("gemini-pro-agent"); + expect(models.find((model) => model.aliases?.includes(ANTIGRAVITY_DEFAULT_MODEL))?.slug).toBe( + "gemini-pro-agent", + ); + }); +}); + +it.layer(testLayer)("Antigravity provider snapshots", (it) => { + it.effect("does not probe or run helper safety checks while disabled", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeHarness({ enabled: false }); + const snapshot = yield* harness.provider.snapshot.refresh; + expect(snapshot).toMatchObject({ + enabled: false, + installed: false, + status: "disabled", + auth: { status: "unknown" }, + models: [], + setup: { canAuthenticate: true, canInstall: true }, + showInteractionModeToggle: false, + supportsConversationRollback: false, + supportsTextGeneration: false, + }); + expect(yield* Ref.get(harness.probeCalls)).toBe(0); + expect(yield* Ref.get(harness.safetyCalls)).toBe(0); + }), + ), + ); + + it.effect("records explicit sign-in while disabled without starting a health probe", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeHarness({ enabled: false }); + const signedIn = yield* Stream.toPull( + harness.provider.snapshot.streamChanges.pipe( + Stream.filter( + (snapshot) => + snapshot.auth.status === "authenticated" && snapshot.slashCommands.length > 0, + ), + ), + ); + yield* harness.provider.onSessionStarted(started); + yield* harness.provider.onAvailableCommands(commands); + const [snapshot] = yield* signedIn; + expect(snapshot).toMatchObject({ + enabled: false, + installed: true, + status: "disabled", + auth: { status: "authenticated", type: "oauth-personal" }, + workspaceSnapshots: [], + }); + expect(snapshot.models).toEqual(buildAntigravityModelsFromSession(sessionSetupResult)); + expect(snapshot.slashCommands).toEqual(commands); + expect((yield* harness.provider.snapshot.refresh).models).toEqual(snapshot.models); + expect(yield* Ref.get(harness.probeCalls)).toBe(0); + }), + ), + ); + + it.effect("treats initialize as installation proof, not account or model discovery", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeHarness(); + expect(yield* harness.provider.snapshot.getSnapshot).toMatchObject({ + installed: false, + status: "warning", + auth: { status: "unknown" }, + models: [], + }); + yield* harness.initialize; + const snapshot = yield* harness.provider.snapshot.getSnapshot; + expect(snapshot).toMatchObject({ + installed: true, + status: "warning", + version: "agy_acp_server_20260818_01_RC01", + auth: { status: "unknown" }, + models: [], + }); + expect(yield* Ref.get(harness.probeCalls)).toBe(1); + }), + ), + ); + + it.effect("publishes session metadata and native commands without another health probe", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* harness.initialize; + const nextReady = yield* Stream.toPull( + harness.provider.snapshot.streamChanges.pipe( + Stream.filter( + (snapshot) => + snapshot.auth.status === "authenticated" && snapshot.slashCommands.length > 0, + ), + ), + ); + yield* harness.provider.onSessionStarted(started, "/workspace"); + yield* harness.provider.onAvailableCommands(commands, "/workspace"); + const [snapshot] = yield* nextReady; + expect(snapshot).toMatchObject({ + status: "ready", + auth: { status: "authenticated", type: "oauth-personal" }, + supportsTextGeneration: true, + }); + expect(snapshot.models).toEqual(buildAntigravityModelsFromSession(sessionSetupResult)); + expect(snapshot.slashCommands).toEqual(commands); + expect((yield* harness.provider.snapshotForCwd("/workspace")).slashCommands).toEqual( + commands, + ); + expect(yield* Ref.get(harness.probeCalls)).toBe(1); + }), + ), + ); + + it.effect("does not retain a disposable sign-in workspace", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* harness.initialize; + yield* harness.provider.onSessionStarted(started); + yield* harness.provider.onAvailableCommands(commands); + const snapshot = yield* harness.provider.snapshot.getSnapshot; + expect(snapshot.models).toHaveLength(11); + expect(snapshot.slashCommands).toEqual(commands); + expect(snapshot.workspaceSnapshots).toEqual([]); + }), + ), + ); + + it.effect("clears all account metadata on sign-out and authentication failure", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* harness.initialize; + for (const clear of [harness.provider.onSignedOut, harness.provider.onAuthRequired]) { + yield* harness.provider.onSessionStarted(started, "/workspace"); + yield* harness.provider.onAvailableCommands(commands, "/workspace"); + yield* clear; + expect(yield* harness.provider.snapshot.getSnapshot).toMatchObject({ + installed: true, + status: "warning", + auth: { status: "unauthenticated" }, + models: [], + slashCommands: [], + skills: [], + workspaceSnapshots: [], + 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"); + expect(refreshed.supportsTextGeneration).toBe(false); + }), + ), + ); + + 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* () { + const harness = yield* makeHarness(); + yield* harness.initialize; + yield* harness.provider.onSessionStarted(started); + yield* harness.provider.onSignedOut; + yield* harness.provider.onSessionStarted({ + ...started, + sessionSetupResult: { + sessionId: "new-account-session", + configOptions: [ + { ...modelConfig, currentValue: "gemini-pro-agent", options: [modelOptions[9]!] }, + ], + }, + }); + expect( + (yield* harness.provider.snapshot.getSnapshot).models.map((model) => model.slug), + ).toEqual(["gemini-pro-agent"]); + }), + ), + ); + + it.effect("retains known account metadata when a local health check fails", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* harness.initialize; + yield* harness.provider.onSessionStarted(started, "/workspace"); + yield* harness.provider.onAvailableCommands(commands, "/workspace"); + yield* Ref.set( + harness.probe, + Effect.fail(EffectAcpErrors.AcpRequestError.internalError("probe failed")), + ); + const snapshot = yield* harness.provider.snapshot.refresh; + expect(snapshot).toMatchObject({ + installed: true, + status: "error", + auth: { status: "authenticated" }, + }); + expect(snapshot.models).toEqual(buildAntigravityModelsFromSession(sessionSetupResult)); + expect(snapshot.slashCommands).toEqual(commands); + expect(snapshot.workspaceSnapshots?.[0]?.cwd).toBe("/workspace"); + }), + ), + ); + + 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* () { + const harness = yield* makeHarness(); + yield* harness.initialize; + const failures = [ + { + error: new EffectAcpErrors.AcpSpawnError({ cause: { code: "ENOENT" } }), + installed: false, + }, + { + error: new EffectAcpErrors.AcpSpawnError({ cause: { code: "EACCES" } }), + installed: true, + }, + { + error: new ProviderSetupError({ + instanceId, + operation: "resolve", + detail: "Antigravity is not installed.", + }), + installed: false, + }, + ]; + for (const { error, installed } of failures) { + yield* harness.provider.onSessionStarted(started, "/workspace"); + yield* harness.provider.onAvailableCommands(commands, "/workspace"); + yield* Ref.set(harness.probe, Effect.fail(error)); + const snapshot = yield* harness.provider.snapshot.refresh; + expect(snapshot).toMatchObject({ + installed, + status: "error", + auth: { status: "authenticated" }, + }); + expect(snapshot.models).toHaveLength(installed ? 11 : 0); + expect(snapshot.slashCommands).toHaveLength(installed ? 2 : 0); + expect(snapshot.workspaceSnapshots).toHaveLength(installed ? 1 : 0); + expect(snapshot.supportsTextGeneration).toBe(installed); + } + }), + ), + ); + + it.effect("does not let an old health result restore a signed-out account", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* harness.initialize; + yield* harness.provider.onSessionStarted(started); + const releaseProbe = yield* Deferred.make(); + const probeEntered = yield* Deferred.make(); + yield* Ref.set( + harness.probe, + Deferred.succeed(probeEntered, undefined).pipe( + Effect.andThen(Deferred.await(releaseProbe)), + ), + ); + const refresh = yield* harness.provider.snapshot.refresh.pipe(Effect.forkChild); + yield* Deferred.await(probeEntered); + yield* harness.provider.onSignedOut; + yield* Deferred.succeed(releaseProbe, initializeResult); + const snapshot = yield* Fiber.join(refresh); + expect(snapshot).toMatchObject({ + auth: { status: "unauthenticated" }, + models: [], + supportsTextGeneration: false, + }); + }), + ), + ); + + it.effect("exposes helper support only when the supplied safety check allows it", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeHarness({ safe: false }); + yield* harness.initialize; + yield* harness.provider.onSessionStarted(started); + expect((yield* harness.provider.snapshot.getSnapshot).supportsTextGeneration).toBe(false); + yield* Ref.set(harness.safety, Effect.succeed(true)); + yield* harness.provider.snapshot.refresh; + expect((yield* harness.provider.snapshot.getSnapshot).supportsTextGeneration).toBe(true); + }), + ), + ); + + it.effect("keeps discovered workspace skills through session and command updates", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* harness.initialize; + const skills = [ + { + name: "deploy", + description: "Ship it", + path: "/workspace/.agent/skills/deploy", + enabled: true, + }, + ]; + const discovered = yield* harness.provider.snapshotForCwd("/workspace", skills); + expect(discovered.skills).toEqual(skills); + yield* harness.provider.onSessionStarted(started, "/workspace"); + yield* harness.provider.onAvailableCommands(commands, "/workspace"); + const after = yield* harness.provider.snapshot.getSnapshot; + expect( + after.workspaceSnapshots?.find((entry) => entry.cwd === "/workspace")?.skills, + ).toEqual(skills); + expect((yield* harness.provider.snapshotForCwd("/workspace")).skills).toEqual(skills); + }), + ), + ); + + it.effect("bounds workspace metadata without starting sessions for workspace lookup", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* harness.initialize; + yield* harness.provider.onSessionStarted(started); + for (let index = 0; index < 35; index++) { + yield* harness.provider.onAvailableCommands(commands, `/workspace-${index}`); + } + const snapshot = yield* harness.provider.snapshotForCwd("/workspace-34"); + expect(snapshot.workspaceSnapshots).toHaveLength(32); + expect(snapshot.workspaceSnapshots?.[0]?.cwd).toBe("/workspace-3"); + expect(snapshot.slashCommands).toEqual(commands); + expect(yield* Ref.get(harness.probeCalls)).toBe(1); + }), + ), + ); +}); diff --git a/apps/server/src/provider/Layers/AntigravityProvider.ts b/apps/server/src/provider/Layers/AntigravityProvider.ts new file mode 100644 index 000000000000..62bd224cd4da --- /dev/null +++ b/apps/server/src/provider/Layers/AntigravityProvider.ts @@ -0,0 +1,395 @@ +import { + ANTIGRAVITY_DEFAULT_MODEL, + ProviderDriverKind, + type AntigravitySettings, + type ProviderSetupError, + type ServerProvider, + type ServerProviderModel, + type ServerProviderSlashCommand, +} from "@t3tools/contracts"; +import { createModelCapabilities } from "@t3tools/shared/model"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Predicate from "effect/Predicate"; +import * as Result from "effect/Result"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import type * as EffectAcpErrors from "effect-acp/errors"; +import type * as EffectAcpSchema from "effect-acp/schema"; + +import type { AcpSessionRuntimeStartResult } from "../acp/AcpSessionRuntime.ts"; +import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { + makeManualOnlyProviderMaintenanceCapabilities, + type ProviderMaintenanceCapabilities, +} from "../providerMaintenance.ts"; +import { + buildServerProvider, + isCommandMissingCause, + type ServerProviderDraft, +} from "../providerSnapshot.ts"; + +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 = Pick< + AcpSessionRuntimeStartResult["sessionSetupResult"], + "configOptions" | "models" +>; + +/** Keep the native model IDs, including model-specific thinking levels. */ +export function buildAntigravityModelsFromSession( + setup: SessionSetupResult, +): ReadonlyArray { + const config = setup.configOptions?.find( + (option) => option.id === "model" || option.category === "model", + ); + const currentValue = + config?.type === "select" ? config.currentValue : setup.models?.currentModelId; + const entries = + config?.type === "select" + ? config.options.flatMap((entry) => ("value" in entry ? [entry] : entry.options)) + : config === undefined + ? (setup.models?.availableModels.map((model) => ({ + value: model.modelId, + name: model.name, + })) ?? []) + : []; + const seen = new Set(); + return entries.flatMap((entry): ServerProviderModel[] => { + if (!entry.value.trim() || seen.has(entry.value)) return []; + seen.add(entry.value); + return [ + { + slug: entry.value, + name: entry.name.trim() ? entry.name : entry.value, + isCustom: false, + ...(entry.value === currentValue + ? { isDefault: true, aliases: [ANTIGRAVITY_DEFAULT_MODEL] } + : {}), + capabilities: EMPTY_MODEL_CAPABILITIES, + }, + ]; + }); +} + +function nativeCommands( + commands: ReadonlyArray, +): ReadonlyArray { + const seen = new Set(); + return commands.flatMap((command): ServerProviderSlashCommand[] => { + if (!command.name.trim() || seen.has(command.name)) return []; + seen.add(command.name); + const description = command.description.trim(); + const hint = command.input?.hint.trim(); + return [ + { + name: command.name, + ...(description ? { description } : {}), + ...(hint ? { input: { hint } } : {}), + }, + ]; + }); +} + +function isMissingInstallation(error: EffectAcpErrors.AcpError | ProviderSetupError): boolean { + if (error._tag === "AcpSpawnError") { + return ( + isCommandMissingCause(error.cause) || + (Predicate.isObject(error.cause) && error.cause.code === "ENOENT") + ); + } + return ( + error._tag === "ProviderSetupError" && + error.operation === "resolve" && + /not installed|missing|incomplete|does not publish/i.test(error.detail) + ); +} + +interface AntigravityProviderState { + readonly draft: ServerProviderDraft; + readonly authRevision: number; +} + +interface AntigravityProviderOptions { + readonly stampIdentity: (snapshot: ServerProviderDraft) => Effect.Effect; + readonly probe: Effect.Effect< + EffectAcpSchema.InitializeResponse, + EffectAcpErrors.AcpError | ProviderSetupError + >; + readonly supportsTextGeneration: Effect.Effect; + readonly maintenanceCapabilities?: ProviderMaintenanceCapabilities; + /** Auth type and label published once a session authenticates. */ + readonly auth?: { readonly type: string; readonly label: string }; +} + +/** Health uses initialize only. Session callbacks supply account-specific metadata. */ +export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(function* ( + settings: AntigravitySettings, + options: AntigravityProviderOptions, +) { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const initialDraft = { + ...buildServerProvider({ + presentation: { displayName: "Antigravity", showInteractionModeToggle: false }, + enabled: settings.enabled, + checkedAt, + models: [], + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: settings.enabled + ? "Checking Antigravity availability." + : "Antigravity is disabled in T3 Code settings.", + }, + }), + setup: { canAuthenticate: true, canInstall: true }, + supportsConversationRollback: false, + supportsTextGeneration: false, + workspaceSnapshots: [], + } satisfies ServerProviderDraft; + const metadata = yield* SubscriptionRef.make({ + draft: initialDraft, + authRevision: 0, + }); + // Skills the driver discovered on disk per workspace. Session callbacks + // rewrite the workspace entry with native commands and must keep these, or + // the registry drops the suggestions and never re-reads the workspace. + const discoveredSkills = new Map(); + const getSnapshot = SubscriptionRef.get(metadata).pipe( + Effect.flatMap((state) => options.stampIdentity(state.draft)), + ); + + 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(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; + const missingInstallation = failure !== undefined && isMissingInstallation(failure); + const errorMessage = + initialized !== undefined + ? undefined + : failure?._tag === "ProviderSetupError" + ? failure.detail.trim() || "Antigravity could not complete its local health check." + : missingInstallation + ? "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 ${HEALTH_CHECK_TIMEOUT}.`; + const supportsTextGeneration = + initialized !== undefined ? yield* options.supportsTextGeneration : false; + const updatedAt = DateTime.formatIso(yield* DateTime.now); + const next = yield* SubscriptionRef.updateAndGet(metadata, (state) => { + if (state.authRevision !== before.authRevision) return state; + const { message: _previousMessage, ...draft } = state.draft; + const authenticated = draft.auth.status === "authenticated"; + const message = + errorMessage ?? + (authenticated + ? undefined + : draft.auth.status === "unauthenticated" + ? SIGN_IN_MESSAGE + : AUTH_UNCHECKED_MESSAGE); + return { + ...state, + draft: { + ...draft, + installed: !missingInstallation, + version: initialized?.agentInfo?.version || draft.version, + status: errorMessage ? "error" : authenticated ? "ready" : "warning", + checkedAt: updatedAt, + ...(missingInstallation + ? { + models: [], + slashCommands: [], + skills: [], + workspaceSnapshots: [], + supportsTextGeneration: false, + } + : {}), + ...(initialized !== undefined + ? { + supportsTextGeneration: + supportsTextGeneration && draft.auth.status !== "unauthenticated", + } + : {}), + ...(message ? { message } : {}), + }, + } satisfies AntigravityProviderState; + }); + return yield* options.stampIdentity(next.draft); + }); + + const managed = yield* makeManagedServerProvider({ + maintenanceCapabilities: + options.maintenanceCapabilities ?? + makeManualOnlyProviderMaintenanceCapabilities({ + provider: ProviderDriverKind.make("antigravity"), + packageName: null, + }), + getSettings: Effect.succeed(settings), + streamSettings: Stream.empty, + haveSettingsChanged: () => false, + initialSnapshot: () => getSnapshot, + checkProvider: checkProvider(), + enrichSnapshot: ({ publishSnapshot }) => + SubscriptionRef.changes(metadata).pipe( + Stream.runForEach((state) => + options.stampIdentity(state.draft).pipe(Effect.flatMap(publishSnapshot)), + ), + ), + }); + + const onSessionStarted = Effect.fn("AntigravityProvider.onSessionStarted")(function* ( + started: AcpSessionRuntimeStartResult, + cwd?: string, + ) { + const before = yield* SubscriptionRef.get(metadata); + const supportsTextGeneration = yield* options.supportsTextGeneration; + const updatedAt = DateTime.formatIso(yield* DateTime.now); + yield* SubscriptionRef.update(metadata, (state) => { + if ( + state.authRevision !== before.authRevision && + state.draft.auth.status === "unauthenticated" + ) { + return state; + } + const { message: _previousMessage, ...draft } = state.draft; + const workspaces = draft.workspaceSnapshots ?? []; + const workspace = cwd ? workspaces.find((entry) => entry.cwd === cwd) : undefined; + return { + authRevision: state.authRevision + 1, + draft: { + ...draft, + installed: true, + status: settings.enabled ? "ready" : "disabled", + version: started.initializeResult.agentInfo?.version || draft.version, + auth: { + status: "authenticated", + type: options.auth?.type ?? "oauth-personal", + label: options.auth?.label ?? "Google account", + }, + checkedAt: updatedAt, + models: buildAntigravityModelsFromSession(started.sessionSetupResult), + supportsTextGeneration, + ...(cwd + ? { + workspaceSnapshots: [ + ...workspaces.filter((entry) => entry.cwd !== cwd), + { + cwd, + checkedAt: updatedAt, + slashCommands: workspace?.slashCommands ?? draft.slashCommands, + skills: workspace?.skills ?? discoveredSkills.get(cwd) ?? [], + }, + ].slice(-MAX_WORKSPACE_SNAPSHOTS), + } + : {}), + }, + } satisfies AntigravityProviderState; + }); + }); + + 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, + ) { + const slashCommands = nativeCommands(commands); + const updatedAt = DateTime.formatIso(yield* DateTime.now); + yield* SubscriptionRef.update(metadata, (state) => { + if (state.draft.auth.status === "unauthenticated") return state; + return { + ...state, + draft: { + ...state.draft, + slashCommands, + ...(cwd + ? { + workspaceSnapshots: [ + ...(state.draft.workspaceSnapshots ?? []).filter((entry) => entry.cwd !== cwd), + { + cwd, + checkedAt: updatedAt, + slashCommands, + skills: + state.draft.workspaceSnapshots?.find((entry) => entry.cwd === cwd)?.skills ?? + discoveredSkills.get(cwd) ?? + [], + }, + ].slice(-MAX_WORKSPACE_SNAPSHOTS), + } + : {}), + }, + }; + }); + }); + + const clearAccountMetadata = Effect.fn("AntigravityProvider.clearAccountMetadata")(function* () { + const updatedAt = DateTime.formatIso(yield* DateTime.now); + yield* SubscriptionRef.update( + metadata, + (state) => + ({ + authRevision: state.authRevision + 1, + draft: { + ...state.draft, + auth: { status: "unauthenticated" }, + status: settings.enabled ? "warning" : "disabled", + message: SIGN_IN_MESSAGE, + checkedAt: updatedAt, + models: [], + slashCommands: [], + skills: [], + workspaceSnapshots: [], + supportsTextGeneration: false, + }, + }) satisfies AntigravityProviderState, + ); + discoveredSkills.clear(); + }); + + const snapshotForCwd = Effect.fn("AntigravityProvider.snapshotForCwd")(function* ( + cwd: string, + skills?: ServerProvider["skills"], + ) { + if (skills) discoveredSkills.set(cwd, skills); + const snapshot = yield* getSnapshot; + const workspace = snapshot.workspaceSnapshots?.find((entry) => entry.cwd === cwd); + const resolvedSkills = skills ?? workspace?.skills ?? discoveredSkills.get(cwd) ?? []; + return workspace + ? { ...snapshot, slashCommands: workspace.slashCommands, skills: resolvedSkills } + : { ...snapshot, skills: resolvedSkills }; + }); + + return { + snapshot: { ...managed, getSnapshot }, + onSessionStarted, + onConfigOptionsUpdated, + onAvailableCommands, + onSignedOut: clearAccountMetadata(), + onAuthRequired: clearAccountMetadata(), + snapshotForCwd, + }; +}); 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/ProviderAuthService.test.ts b/apps/server/src/provider/Layers/ProviderAuthService.test.ts new file mode 100644 index 000000000000..fb961b710486 --- /dev/null +++ b/apps/server/src/provider/Layers/ProviderAuthService.test.ts @@ -0,0 +1,608 @@ +import { assert, describe, it } from "@effect/vitest"; +import { + ProviderDriverKind, + ProviderInstanceId, + ProviderSetupError, + ThreadId, + type ProviderAuthState, + type ProviderSession, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Deferred from "effect/Deferred"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as PubSub from "effect/PubSub"; +import * as Queue from "effect/Queue"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; + +import { + ProviderSessionDirectoryPersistenceError, + ProviderValidationError, + type ProviderServiceError, +} from "../Errors.ts"; +import type { ProviderInstance } from "../ProviderDriver.ts"; +import type { ProviderAuthController } from "../Services/ProviderAuthService.ts"; +import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; +import { ProviderService } from "../Services/ProviderService.ts"; +import { + ProviderSessionDirectory, + type ProviderRuntimeBindingWithMetadata, +} from "../Services/ProviderSessionDirectory.ts"; +import { makeProviderAuthService } from "./ProviderAuthService.ts"; + +const instanceId = ProviderInstanceId.make("antigravity-personal"); +const otherInstanceId = ProviderInstanceId.make("antigravity-work"); +const unsupportedInstanceId = ProviderInstanceId.make("codex"); +const driverKind = ProviderDriverKind.make("antigravity"); +const owner = "paired-client-owner"; +const otherOwner = "paired-client-other"; +const flowId = "test-sign-in-flow"; +const callbackUrl = "http://127.0.0.1:48123/?state=test-state&code=test-code"; +const now = "2026-09-02T00:00:00.000Z"; +const idleAuthState: ProviderAuthState = { + instanceId, + phase: "idle", + flowId: null, + authorizationUrl: null, + expiresAt: null, + message: null, +}; +const waitingAuthState: ProviderAuthState = { + ...idleAuthState, + phase: "waiting", + flowId, + authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth?state=test-state", + expiresAt: "2026-09-02T00:05:00.000Z", +}; + +function makeInstance(input: { + instanceId: ProviderInstanceId; + enabled: boolean; + auth?: ProviderAuthController; +}): ProviderInstance { + return { + ...input, + driverKind, + displayName: undefined, + continuationIdentity: { driverKind, continuationKey: input.instanceId }, + get snapshot(): never { + throw new Error("Auth routing must not refresh the provider snapshot."); + }, + get adapter(): never { + throw new Error("Auth routing must not start an adapter session."); + }, + get textGeneration(): never { + throw new Error("Auth routing must not generate text."); + }, + }; +} + +function makeBinding( + thread: string, + status: NonNullable, + providerInstanceId = instanceId, +): ProviderRuntimeBindingWithMetadata { + return { + threadId: ThreadId.make(thread), + provider: driverKind, + providerInstanceId, + status, + lastSeenAt: now, + }; +} + +function makeSession(thread: string, providerInstanceId = instanceId): ProviderSession { + return { + threadId: ThreadId.make(thread), + provider: driverKind, + providerInstanceId, + status: "ready", + runtimeMode: "approval-required", + createdAt: now, + updatedAt: now, + }; +} + +const makeHarness = Effect.fn("ProviderAuthService.test.makeHarness")(function* ( + input: { + enabled?: boolean; + bindings?: ReadonlyArray; + sessions?: ReadonlyArray; + directoryError?: ProviderSessionDirectoryPersistenceError; + stopError?: ProviderServiceError; + logoutError?: ProviderSetupError; + } = {}, +) { + const actions: string[] = []; + const registryChanges = yield* PubSub.unbounded(); + const sessions = new Map(input.sessions?.map((session) => [session.threadId, session])); + const bindings = new Map(input.bindings?.map((binding) => [binding.threadId, binding])); + const idle = idleAuthState; + let state = idle; + let flowOwner: string | undefined; + let gateClosed = false; + + const checkOwner = Effect.fn("ProviderAuthService.test.checkOwner")(function* ( + ownerSessionId: string, + requestedFlowId: string, + operation: string, + ) { + if (ownerSessionId !== flowOwner || requestedFlowId !== state.flowId) { + return yield* new ProviderSetupError({ + instanceId, + operation, + detail: "This sign-in belongs to another client or has expired.", + }); + } + }); + + const auth: ProviderAuthController = { + start: Effect.fn(function* (ownerSessionId, stopSessions) { + gateClosed = true; + actions.push("close-gate"); + yield* stopSessions ?? Effect.void; + flowOwner = ownerSessionId; + state = waitingAuthState; + actions.push("start-sign-in"); + return state; + }), + complete: Effect.fn(function* (ownerSessionId, request) { + yield* checkOwner(ownerSessionId, request.flowId, "complete"); + if (request.callbackUrl !== callbackUrl) { + return yield* new ProviderSetupError({ + instanceId, + operation: "complete", + detail: "The redirect URL does not match this sign-in.", + }); + } + state = { ...idle, flowId, phase: "succeeded" }; + return state; + }), + cancel: Effect.fn(function* (ownerSessionId, requestedFlowId) { + yield* checkOwner(ownerSessionId, requestedFlowId, "cancel"); + state = { ...idle, flowId, phase: "cancelled" }; + return state; + }), + logout: Effect.fn(function* (stopSessions) { + gateClosed = true; + actions.push("close-gate"); + yield* stopSessions; + if (input.logoutError) return yield* input.logoutError; + actions.push("native-logout"); + state = idle; + flowOwner = undefined; + return state; + }), + subscribe: (ownerSessionId) => + Stream.fromEffect(Effect.sync(() => (ownerSessionId === flowOwner ? state : idle))), + isLogoutPrompt: (text, hasAttachments) => !hasAttachments && text.trim() === "/logout", + }; + const instances = [ + makeInstance({ instanceId, enabled: input.enabled ?? true, auth }), + makeInstance({ instanceId: unsupportedInstanceId, enabled: true }), + ]; + const service = yield* makeProviderAuthService.pipe( + Effect.provide( + Layer.mergeAll( + Layer.mock(ProviderInstanceRegistry)({ + getInstance: (id) => + Effect.succeed(instances.find((instance) => instance.instanceId === id)), + subscribeChanges: PubSub.subscribe(registryChanges), + }), + Layer.mock(ProviderSessionDirectory)({ + listBindings: () => + Effect.suspend(() => { + assert.isTrue(gateClosed); + actions.push("list-bindings"); + return input.directoryError + ? Effect.fail(input.directoryError) + : Effect.succeed([...bindings.values()]); + }), + }), + Layer.mock(ProviderService)({ + listSessions: () => + Effect.sync(() => { + assert.isTrue(gateClosed); + actions.push("list-sessions"); + return [...sessions.values()]; + }), + stopSession: ({ threadId }) => + Effect.suspend(() => { + assert.isTrue(gateClosed); + actions.push(`stop:${threadId}`); + if (input.stopError) return Effect.fail(input.stopError); + sessions.delete(threadId); + const binding = bindings.get(threadId); + if (binding) bindings.set(threadId, { ...binding, status: "stopped" }); + return Effect.void; + }), + }), + ), + ), + ); + return { service, actions, sessions, bindings }; +}); + +const makeStreamingController = Effect.fn("ProviderAuthService.test.makeStreamingController")( + function* (flowOwner: string) { + const state = yield* SubscriptionRef.make(idleAuthState); + const close = yield* Deferred.make(); + const closedSubscriptions = yield* Queue.unbounded(); + const unused = () => Effect.die("Unexpected auth operation in a subscription test."); + const auth: ProviderAuthController = { + start: unused, + complete: unused, + cancel: unused, + logout: unused, + subscribe: (ownerSessionId) => + SubscriptionRef.changes(state).pipe( + Stream.map((current) => (ownerSessionId === flowOwner ? current : idleAuthState)), + Stream.interruptWhen(Deferred.await(close)), + Stream.ensuring(Queue.offer(closedSubscriptions, ownerSessionId)), + ), + }; + return { + auth, + state, + closedSubscriptions, + close: Deferred.succeed(close, undefined), + }; + }, +); + +const makeSubscriptionHarness = Effect.fn("ProviderAuthService.test.makeSubscriptionHarness")( + function* (initial: ProviderInstance, replaceDuringFirstLookup?: ProviderInstance) { + const changes = yield* PubSub.unbounded(); + let current: ProviderInstance | undefined = initial; + let pendingReplacement = replaceDuringFirstLookup; + let subscribed = false; + const service = yield* makeProviderAuthService.pipe( + Effect.provide( + Layer.mergeAll( + Layer.mock(ProviderInstanceRegistry)({ + subscribeChanges: Effect.gen(function* () { + const subscription = yield* PubSub.subscribe(changes); + subscribed = true; + return subscription; + }), + getInstance: () => + Effect.gen(function* () { + const instance = current; + if (pendingReplacement) { + assert.isTrue(subscribed, "Registry changes must be subscribed before lookup."); + current = pendingReplacement; + pendingReplacement = undefined; + yield* PubSub.publish(changes, undefined); + } + return instance; + }), + }), + Layer.mock(ProviderService)({}), + Layer.mock(ProviderSessionDirectory)({}), + ), + ), + ); + return { + service, + replace: Effect.fn(function* (replacement: ProviderInstance | undefined) { + current = replacement; + yield* PubSub.publish(changes, undefined); + }), + }; + }, +); + +const observeAuth = Effect.fn("ProviderAuthService.test.observeAuth")(function* ( + stream: Stream.Stream, +) { + const states = yield* Queue.unbounded(); + const fiber = yield* stream.pipe( + Stream.runForEach((state) => Queue.offer(states, state)), + Effect.forkScoped, + ); + return { states, fiber }; +}); + +describe("ProviderAuthService", () => { + it.effect("stops routed sessions before sign-in, including for a disabled instance", () => + Effect.gen(function* () { + const { service, actions, sessions } = yield* makeHarness({ + enabled: false, + sessions: [makeSession("active")], + }); + const state = yield* service.start({ instanceId }, owner); + + assert.strictEqual(state.instanceId, instanceId); + assert.strictEqual(state.phase, "waiting"); + assert.strictEqual(sessions.size, 0); + assert.deepStrictEqual(actions, [ + "close-gate", + "list-bindings", + "list-sessions", + "stop:active", + "start-sign-in", + ]); + }), + ); + + it.effect("keeps sign-in state private and accepts the owner's redirect URL", () => + Effect.gen(function* () { + const { service } = yield* makeHarness(); + const waiting = yield* service.start({ instanceId }, owner); + const ownerStates = yield* service + .subscribe({ instanceId }, owner) + .pipe(Stream.take(1), Stream.runCollect); + const otherStates = yield* service + .subscribe({ instanceId }, otherOwner) + .pipe(Stream.take(1), Stream.runCollect); + + assert.deepStrictEqual(ownerStates, [waiting]); + assert.strictEqual(otherStates[0]?.authorizationUrl, null); + assert.strictEqual(otherStates[0]?.flowId, null); + + const otherError = yield* Effect.flip( + service.complete({ instanceId, flowId, callbackUrl }, otherOwner), + ); + assert.strictEqual(otherError.operation, "complete"); + const complete = yield* service.complete({ instanceId, flowId, callbackUrl }, owner); + assert.strictEqual(complete.phase, "succeeded"); + assert.strictEqual(complete.authorizationUrl, null); + }), + ); + + it.effect("lets only the flow owner cancel sign-in", () => + Effect.gen(function* () { + const { service } = yield* makeHarness(); + yield* service.start({ instanceId }, owner); + const error = yield* Effect.flip(service.cancel({ instanceId, flowId }, otherOwner)); + assert.strictEqual(error.operation, "cancel"); + + const cancelled = yield* service.cancel({ instanceId, flowId }, owner); + assert.strictEqual(cancelled.phase, "cancelled"); + assert.strictEqual(cancelled.authorizationUrl, null); + }), + ); + + it.effect.each([ + { change: "enable", initialEnabled: false, closeBeforeReplacement: true }, + { change: "config", initialEnabled: true, closeBeforeReplacement: false }, + ])( + "keeps private auth subscriptions current after an instance $change change", + ({ initialEnabled, closeBeforeReplacement }) => + Effect.gen(function* () { + const first = yield* makeStreamingController(owner); + const replacement = yield* makeStreamingController(otherOwner); + yield* SubscriptionRef.set(first.state, waitingAuthState); + const { service, replace } = yield* makeSubscriptionHarness( + makeInstance({ instanceId, enabled: initialEnabled, auth: first.auth }), + ); + const firstClient = yield* observeAuth(service.subscribe({ instanceId }, owner)); + const secondClient = yield* observeAuth(service.subscribe({ instanceId }, otherOwner)); + assert.deepStrictEqual(yield* Queue.take(firstClient.states), waitingAuthState); + assert.deepStrictEqual(yield* Queue.take(secondClient.states), idleAuthState); + + if (closeBeforeReplacement) { + yield* first.close; + const closed = yield* Queue.takeN(first.closedSubscriptions, 2); + assert.deepStrictEqual(new Set(closed), new Set([owner, otherOwner])); + } + yield* replace(makeInstance({ instanceId, enabled: true, auth: replacement.auth })); + assert.deepStrictEqual(yield* Queue.take(firstClient.states), idleAuthState); + assert.deepStrictEqual(yield* Queue.take(secondClient.states), idleAuthState); + if (!closeBeforeReplacement) { + const closed = yield* Queue.takeN(first.closedSubscriptions, 2); + assert.deepStrictEqual(new Set(closed), new Set([owner, otherOwner])); + } + + const staleState: ProviderAuthState = { ...idleAuthState, phase: "cancelled" }; + yield* SubscriptionRef.set(first.state, staleState); + const newWaiting: ProviderAuthState = { + ...waitingAuthState, + flowId: "replacement-flow", + authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth?state=replacement-state", + }; + yield* SubscriptionRef.set(replacement.state, newWaiting); + assert.deepStrictEqual(yield* Queue.take(firstClient.states), idleAuthState); + assert.deepStrictEqual(yield* Queue.take(secondClient.states), newWaiting); + }), + ); + + it.effect("does not miss a replacement during the first instance lookup", () => + Effect.gen(function* () { + const first = yield* makeStreamingController(owner); + const replacement = yield* makeStreamingController(owner); + yield* SubscriptionRef.set(replacement.state, waitingAuthState); + const { service } = yield* makeSubscriptionHarness( + makeInstance({ instanceId, enabled: false, auth: first.auth }), + makeInstance({ instanceId, enabled: true, auth: replacement.auth }), + ); + + const states = yield* service.subscribe({ instanceId }, owner).pipe( + Stream.filter((state) => state.phase === "waiting"), + Stream.take(1), + Stream.runCollect, + ); + + assert.deepStrictEqual(states, [waitingAuthState]); + }), + ); + + it.effect("ends the subscription with a setup error when the instance is removed", () => + Effect.gen(function* () { + const controller = yield* makeStreamingController(owner); + const { service, replace } = yield* makeSubscriptionHarness( + makeInstance({ instanceId, enabled: true, auth: controller.auth }), + ); + const { states, fiber } = yield* observeAuth(service.subscribe({ instanceId }, owner)); + assert.deepStrictEqual(yield* Queue.take(states), idleAuthState); + + yield* replace(undefined); + const error = yield* Effect.flip(Fiber.join(fiber)); + + assert.instanceOf(error, ProviderSetupError); + assert.strictEqual(error.instanceId, instanceId); + assert.strictEqual(error.operation, "subscribe"); + assert.include(error.detail, "no longer available"); + assert.strictEqual(yield* Queue.take(controller.closedSubscriptions), owner); + }), + ); + + it.effect.each([ + { id: ProviderInstanceId.make("missing"), detail: "no longer available" }, + { id: unsupportedInstanceId, detail: "does not support sign-in" }, + ])("rejects setup for unavailable or unsupported instance $id", ({ id, detail }) => + Effect.gen(function* () { + const { service, actions } = yield* makeHarness(); + const operations: Effect.Effect< + ProviderAuthState | ReadonlyArray, + ProviderSetupError + >[] = [ + service.start({ instanceId: id }, owner), + service.complete({ instanceId: id, flowId, callbackUrl }, owner), + service.cancel({ instanceId: id, flowId }, owner), + service.logout({ instanceId: id }), + Stream.runCollect(service.subscribe({ instanceId: id }, owner)), + ]; + for (const operation of operations) { + const error = yield* Effect.flip(operation); + assert.instanceOf(error, ProviderSetupError); + assert.strictEqual(error.instanceId, id); + assert.include(error.detail, detail); + } + assert.deepStrictEqual(actions, []); + }), + ); + + it.effect("stops active and persisted sessions once before native logout", () => + Effect.gen(function* () { + const { service, actions, sessions, bindings } = yield* makeHarness({ + bindings: [ + makeBinding("shared", "running"), + makeBinding("starting", "starting"), + makeBinding("persisted", "running"), + makeBinding("already-stopped", "stopped"), + makeBinding("other-instance", "running", otherInstanceId), + ], + sessions: [ + makeSession("shared"), + makeSession("runtime-only"), + makeSession("other-instance", otherInstanceId), + ], + }); + const state = yield* service.logout({ instanceId }); + + assert.strictEqual(state.phase, "idle"); + assert.deepStrictEqual(actions, [ + "close-gate", + "list-bindings", + "list-sessions", + "stop:shared", + "stop:starting", + "stop:persisted", + "stop:runtime-only", + "native-logout", + ]); + assert.deepStrictEqual([...sessions.keys()], [ThreadId.make("other-instance")]); + assert.strictEqual(bindings.get(ThreadId.make("starting"))?.status, "stopped"); + assert.strictEqual(bindings.get(ThreadId.make("persisted"))?.status, "stopped"); + assert.strictEqual(bindings.get(ThreadId.make("other-instance"))?.status, "running"); + }), + ); + + it.effect.each([ + { text: "/logout", hasAttachments: false, handled: true }, + { text: " \n/logout\t", hasAttachments: false, handled: true }, + { text: "/logout", hasAttachments: true, handled: false }, + { text: "/logout please", hasAttachments: false, handled: false }, + { text: "Explain /logout", hasAttachments: false, handled: false }, + { text: "/Logout", hasAttachments: false, handled: false }, + ])("handles only a standalone logout command %#", ({ text, hasAttachments, handled }) => + Effect.gen(function* () { + const { service, actions } = yield* makeHarness(); + const result = yield* service.tryHandlePromptCommand({ instanceId, text, hasAttachments }); + + assert.strictEqual(result, handled); + assert.deepStrictEqual( + actions, + handled ? ["close-gate", "list-bindings", "list-sessions", "native-logout"] : [], + ); + }), + ); + + it.effect("does not intercept commands for providers without an auth controller", () => + Effect.gen(function* () { + const { service, actions } = yield* makeHarness(); + for (const id of [unsupportedInstanceId, ProviderInstanceId.make("missing")]) { + assert.isFalse( + yield* service.tryHandlePromptCommand({ + instanceId: id, + text: "/logout", + hasAttachments: false, + }), + ); + } + assert.deepStrictEqual(actions, []); + }), + ); + + it.effect("does not log out when the session directory cannot be read", () => + Effect.gen(function* () { + const { service, actions } = yield* makeHarness({ + directoryError: new ProviderSessionDirectoryPersistenceError({ + operation: "listBindings", + detail: "private database diagnostics", + }), + }); + const error = yield* Effect.flip(service.logout({ instanceId })); + + assert.instanceOf(error, ProviderSetupError); + assert.strictEqual(error.instanceId, instanceId); + assert.strictEqual(error.operation, "stopSessions"); + assert.notInclude(error.detail, "private database diagnostics"); + assert.deepStrictEqual(actions, ["close-gate", "list-bindings"]); + }), + ); + + it.effect("does not log out or consume the command when stopping a session fails", () => + Effect.gen(function* () { + const { service, actions, sessions } = yield* makeHarness({ + sessions: [makeSession("active")], + stopError: new ProviderValidationError({ + operation: "stopSession", + issue: "private process diagnostics", + }), + }); + const error = yield* Effect.flip( + service.tryHandlePromptCommand({ instanceId, text: "/logout", hasAttachments: false }), + ); + + assert.instanceOf(error, ProviderSetupError); + assert.strictEqual(error.operation, "stopSessions"); + assert.notInclude(error.detail, "private process diagnostics"); + assert.strictEqual(sessions.size, 1); + assert.deepStrictEqual(actions, [ + "close-gate", + "list-bindings", + "list-sessions", + "stop:active", + ]); + }), + ); + + it.effect("returns native logout failures to the command caller", () => + Effect.gen(function* () { + const logoutError = new ProviderSetupError({ + instanceId, + operation: "logout", + detail: "Native sign-out failed. Try again.", + }); + const { service } = yield* makeHarness({ logoutError }); + const error = yield* Effect.flip( + service.tryHandlePromptCommand({ instanceId, text: "/logout", hasAttachments: false }), + ); + + assert.strictEqual(error, logoutError); + }), + ); +}); diff --git a/apps/server/src/provider/Layers/ProviderAuthService.ts b/apps/server/src/provider/Layers/ProviderAuthService.ts new file mode 100644 index 000000000000..3b69c5082528 --- /dev/null +++ b/apps/server/src/provider/Layers/ProviderAuthService.ts @@ -0,0 +1,120 @@ +import { ProviderSetupError, type ProviderInstanceId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; + +import { ProviderAuthService } from "../Services/ProviderAuthService.ts"; +import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; +import { ProviderService } from "../Services/ProviderService.ts"; +import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; + +export const makeProviderAuthService = Effect.gen(function* () { + const registry = yield* ProviderInstanceRegistry; + const providers = yield* ProviderService; + const directory = yield* ProviderSessionDirectory; + + const getController = Effect.fn("ProviderAuthService.getController")(function* ( + instanceId: ProviderInstanceId, + operation: string, + ) { + const instance = yield* registry.getInstance(instanceId); + if (!instance?.auth) { + return yield* new ProviderSetupError({ + instanceId, + operation, + detail: instance + ? "This provider does not support sign-in in T3 Code." + : "This provider instance is no longer available.", + }); + } + return instance.auth; + }); + + const stopSessions = Effect.fn("ProviderAuthService.stopSessions")(function* ( + instanceId: ProviderInstanceId, + ) { + const bindings = yield* directory.listBindings().pipe( + Effect.mapError( + () => + new ProviderSetupError({ + instanceId, + operation: "stopSessions", + detail: "Could not read the provider's active sessions. Try again.", + }), + ), + ); + const sessions = yield* providers.listSessions(); + const threadIds = new Set( + bindings + .filter( + (binding) => binding.providerInstanceId === instanceId && binding.status !== "stopped", + ) + .map((binding) => binding.threadId), + ); + for (const session of sessions) { + if (session.providerInstanceId === instanceId) { + threadIds.add(session.threadId); + } + } + yield* Effect.forEach( + threadIds, + (threadId) => + providers.stopSession({ threadId }).pipe( + Effect.mapError( + () => + new ProviderSetupError({ + instanceId, + operation: "stopSessions", + detail: "Could not stop all sessions for this provider. Try again.", + }), + ), + ), + { discard: true }, + ); + }); + + return ProviderAuthService.of({ + start: Effect.fn("ProviderAuthService.start")(function* (input, ownerSessionId) { + const auth = yield* getController(input.instanceId, "start"); + return yield* auth.start(ownerSessionId, stopSessions(input.instanceId)); + }), + complete: Effect.fn("ProviderAuthService.complete")(function* (input, ownerSessionId) { + const auth = yield* getController(input.instanceId, "complete"); + return yield* auth.complete(ownerSessionId, input); + }), + cancel: Effect.fn("ProviderAuthService.cancel")(function* (input, ownerSessionId) { + const auth = yield* getController(input.instanceId, "cancel"); + return yield* auth.cancel(ownerSessionId, input.flowId); + }), + logout: Effect.fn("ProviderAuthService.logout")(function* (input) { + const auth = yield* getController(input.instanceId, "logout"); + return yield* auth.logout(stopSessions(input.instanceId)); + }), + subscribe: (input, ownerSessionId) => + Effect.gen(function* () { + const changes = yield* registry.subscribeChanges; + const initial = yield* getController(input.instanceId, "subscribe"); + return Stream.concat( + Stream.succeed(initial), + Stream.fromSubscription(changes).pipe( + Stream.mapEffect(() => getController(input.instanceId, "subscribe")), + ), + ).pipe( + Stream.changesWith((previous, next) => previous === next), + Stream.switchMap((auth) => auth.subscribe(ownerSessionId)), + ); + }).pipe(Stream.unwrap), + tryHandlePromptCommand: Effect.fn("ProviderAuthService.tryHandlePromptCommand")( + function* (input) { + const instance = yield* registry.getInstance(input.instanceId); + if (!instance?.auth?.isLogoutPrompt?.(input.text, input.hasAttachments)) { + return false; + } + yield* instance.auth.logout(stopSessions(input.instanceId)); + return true; + }, + ), + }); +}); + +export const ProviderAuthServiceLive = Layer.effect(ProviderAuthService, makeProviderAuthService); diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts index 5d3b6f816365..0e6f84b931bb 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts @@ -42,6 +42,7 @@ import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; import type { BuiltInDriversEnv } from "../builtInDrivers.ts"; +import { AntigravityInstallation } from "../AntigravityInstallation.ts"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { ClaudeDriver } from "../Drivers/ClaudeDriver.ts"; @@ -308,9 +309,12 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { // surfaced; that merged layer then provides `ServerConfig.layerTest`'s // `FileSystem` dep while keeping everything else surfaced to the test. const infraLayer = OpenCodeRuntimeLive.pipe(Layer.provideMerge(NodeServices.layer)); - const testLayer = ServerConfig.layerTest(process.cwd(), { - prefix: "provider-instance-registry-all-drivers-test", - }).pipe( + const testLayer = AntigravityInstallation.layer.pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "provider-instance-registry-all-drivers-test", + }), + ), Layer.provideMerge(infraLayer), Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), Layer.provideMerge(ServerSettingsService.layerTest()), diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 6c47def1e86c..5d0442d2679f 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -35,6 +35,7 @@ import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; import { checkCodexProviderStatus, type CodexAppServerProviderSnapshot } from "./CodexProvider.ts"; import { checkClaudeProviderStatus } from "./ClaudeProvider.ts"; import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; +import { AntigravityInstallation } from "../AntigravityInstallation.ts"; import * as ModelManifest from "../ModelManifest.ts"; import * as OpenCodeRuntime from "../opencodeRuntime.ts"; import * as ProviderEventLoggers from "./ProviderEventLoggers.ts"; @@ -906,6 +907,119 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te assert.deepStrictEqual(afterFailure.models, [authoritativeProvider.models[0]!]); }); + describe("Antigravity model inventories", () => { + const previousProvider = { + instanceId: ProviderInstanceId.make("antigravity-personal"), + driver: ProviderDriverKind.make("antigravity"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-09-02T00:00:00.000Z", + version: "0.1.3", + models: [ + { + slug: "gemini-3.1-pro-high", + name: "Gemini 3.1 Pro High", + isCustom: false, + capabilities: null, + }, + { + slug: "gemini-3-flash", + name: "Gemini 3 Flash", + isCustom: false, + capabilities: null, + }, + ], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + + it("removes unavailable models after a successful refresh", () => { + for (const status of ["ready", "warning"] as const) { + const refreshedProvider = { + ...previousProvider, + status, + checkedAt: "2026-09-02T00:01:00.000Z", + models: [previousProvider.models[1]], + } satisfies ServerProvider; + const afterRefresh = mergeProviderSnapshot(previousProvider, refreshedProvider); + + assert.deepStrictEqual(afterRefresh.models, refreshedProvider.models); + + const afterFailure = mergeProviderSnapshot(afterRefresh, { + ...refreshedProvider, + status: "error", + auth: { status: "unknown" }, + models: [], + }); + assert.deepStrictEqual(afterFailure.models, refreshedProvider.models); + } + }); + + it("keeps cached models during health checks and temporary failures", () => { + for (const installed of [false, true]) { + const pendingProvider = { + ...previousProvider, + status: "warning", + installed, + auth: { status: "unknown" }, + checkedAt: "2026-09-02T00:01:00.000Z", + version: installed ? previousProvider.version : null, + models: [], + } satisfies ServerProvider; + + assert.deepStrictEqual( + mergeProviderSnapshot(previousProvider, pendingProvider).models, + previousProvider.models, + ); + } + + for (const authStatus of ["unknown", "authenticated"] as const) { + const failedProvider = { + ...previousProvider, + status: "error", + auth: { status: authStatus }, + checkedAt: "2026-09-02T00:02:00.000Z", + models: [], + } satisfies ServerProvider; + + assert.deepStrictEqual( + mergeProviderSnapshot(previousProvider, failedProvider).models, + previousProvider.models, + ); + } + }); + + it("clears models after sign-out, disable, uninstall, or an empty successful refresh", () => { + const emptyProvider = { + ...previousProvider, + checkedAt: "2026-09-02T00:01:00.000Z", + models: [], + } satisfies ServerProvider; + const clearedProviders = [ + { ...emptyProvider, status: "warning", auth: { status: "unauthenticated" } }, + { ...emptyProvider, status: "error", auth: { status: "unauthenticated" } }, + { ...emptyProvider, status: "disabled", enabled: false }, + { ...emptyProvider, status: "error", enabled: false }, + { ...emptyProvider, status: "error", installed: false, auth: { status: "unknown" } }, + emptyProvider, + ] satisfies ReadonlyArray; + + for (const provider of clearedProviders) { + const afterRemoval = mergeProviderSnapshot(previousProvider, provider); + assert.deepStrictEqual(afterRemoval.models, []); + + const afterFailure = mergeProviderSnapshot(afterRemoval, { + ...emptyProvider, + status: "error", + auth: { status: "unknown" }, + }); + assert.deepStrictEqual(afterFailure.models, []); + } + }); + }); + it("fills missing capabilities from the previous provider snapshot", () => { const previousProvider = { instanceId: ProviderInstanceId.make("cursor"), @@ -1879,6 +1993,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); const providerRegistryLayer = ProviderRegistryLive.pipe( Layer.provideMerge(ProviderInstanceRegistryHydrationLive), + Layer.provideMerge(AntigravityInstallation.layer), Layer.provideMerge( Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings), ), @@ -1943,18 +2058,14 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te }), ); - // Guards the second half of the reported bug: changing - // `providers.codex.binaryPath` in settings must tear down the live - // instance and rebuild it so a fresh probe runs with the new binary. - // This test drives the real settings stream → registry reconcile → - // aggregator sync pipeline and asserts that `getProviders` reflects - // the new background probe's outcome. - // + // A binary path change must rebuild Codex and publish its new probe result. it.effect("re-probes when settings change the codex binaryPath", () => Effect.gen(function* () { const firstMissing = `t3code_codex_first_`; const secondMissing = `t3code_codex_second_`; const spawnedCommands: Array = []; + const secondProbeStarted = yield* Deferred.make(); + const releaseSecondProbe = yield* Deferred.make(); const allowLazySettingsStream = yield* Deferred.make(); const mutableServerSettings = yield* makeMutableServerSettingsService( decodeServerSettings( @@ -1981,6 +2092,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); const providerRegistryLayer = ProviderRegistryLive.pipe( Layer.provideMerge(ProviderInstanceRegistryHydrationLive), + Layer.provideMerge(AntigravityInstallation.layer), Layer.provideMerge( Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings), ), @@ -2000,8 +2112,15 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), Layer.updateService(ChildProcessSpawner.ChildProcessSpawner, (spawner) => ChildProcessSpawner.make((command) => { - spawnedCommands.push((command as { readonly command: string }).command); - return spawner.spawn(command); + if (command._tag !== "StandardCommand") return spawner.spawn(command); + spawnedCommands.push(command.command); + const beforeSpawn = + command.command === secondMissing + ? Deferred.succeed(secondProbeStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseSecondProbe)), + ) + : Effect.void; + return beforeSpawn.pipe(Effect.andThen(spawner.spawn(command))); }), ), Layer.provideMerge(NodeServices.layer), @@ -2017,36 +2136,29 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te yield* Effect.gen(function* () { const registry = yield* ProviderRegistry.ProviderRegistry; - // Boot-time probe: the default codex instance is enabled with - // `firstMissing`, so the real spawner yields ENOENT and the - // snapshot should be `status: "error"`. - let initialProviders = yield* registry.getProviders; - for ( - let attempts = 0; - attempts < 50 && - initialProviders.find((provider) => provider.instanceId === "codex")?.status !== - "error"; - attempts += 1 - ) { - yield* TestClock.adjust("10 millis"); - yield* Effect.yieldNow; - initialProviders = yield* registry.getProviders; - } - const initialCodex = initialProviders.find( + const codexSnapshots = registry.streamChanges.pipe( + Stream.map((providers) => + providers.find((provider) => provider.instanceId === "codex"), + ), + Stream.filter((provider): provider is ServerProvider => provider !== undefined), + ); + const firstError = yield* Stream.toPull( + codexSnapshots.pipe(Stream.filter((provider) => provider.status === "error")), + ); + const currentCodex = (yield* registry.getProviders).find( (provider) => provider.instanceId === "codex", ); + const initialCodex = + currentCodex?.status === "error" ? currentCodex : (yield* firstError)[0]; assert.strictEqual(initialCodex?.status, "error"); assert.strictEqual(initialCodex?.installed, false); assert.deepStrictEqual(codexProbeCommands(), [firstMissing]); - // Drive a settings change. The Hydration layer's - // `SettingsWatcherLive` consumes this via `subscribeChanges`, - // calls `reconcile`, which rebuilds the codex instance (the - // envelope changed because `binaryPath` differs → `entryEqual` - // is false). The registry's `Stream.runForEach( - // instanceRegistry.streamChanges, () => syncLiveSources)` - // fires `syncLiveSources`, which subscribes and launches a fresh - // background refresh on the rebuilt instance. + const pendingRebuild = yield* Stream.toPull( + codexSnapshots.pipe( + Stream.filter((provider) => provider.status === "warning" && !provider.installed), + ), + ); yield* serverSettings.updateSettings({ providers: { codex: { enabled: true, binaryPath: secondMissing }, @@ -2056,34 +2168,15 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te // not subscribe before forking has already lost this update. yield* Deferred.succeed(allowLazySettingsStream, undefined); - // Poll until the injected process boundary observes the new - // executable. This verifies the public settings-to-probe behavior - // without depending on timestamps assigned by TestClock. - const refreshed = yield* Effect.gen(function* () { - for (let attempts = 0; attempts < 60; attempts += 1) { - const providers = yield* registry.getProviders; - const codex = providers.find((provider) => provider.instanceId === "codex"); - if ( - codex !== undefined && - codex.status === "error" && - spawnedCommands.includes(secondMissing) - ) { - return providers; - } - yield* TestClock.adjust("50 millis"); - yield* Effect.yieldNow; - yield* Effect.promise( - () => - new Promise((resolve) => { - // @effect-diagnostics-next-line globalTimers:off - This integration test waits for the real settings watcher fiber to observe the PubSub event. - globalThis.setTimeout(resolve, 5); - }), - ); - } - return yield* registry.getProviders; - }); - - const reprobedCodex = refreshed.find((provider) => provider.instanceId === "codex"); + // Hold the second probe until the aggregator sees the rebuilt + // instance. Its next error must come from the new executable. + yield* Deferred.await(secondProbeStarted); + yield* pendingRebuild; + const rebuiltError = yield* Stream.toPull( + codexSnapshots.pipe(Stream.filter((provider) => provider.status === "error")), + ); + yield* Deferred.succeed(releaseSecondProbe, undefined); + const [reprobedCodex] = yield* rebuiltError; assert.deepStrictEqual(codexProbeCommands(), [firstMissing, secondMissing]); assert.strictEqual(reprobedCodex?.status, "error"); assert.strictEqual(reprobedCodex?.installed, false); @@ -2118,6 +2211,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); const providerRegistryLayer = ProviderRegistryLive.pipe( Layer.provideMerge(ProviderInstanceRegistryHydrationLive), + Layer.provideMerge(AntigravityInstallation.layer), Layer.provideMerge( Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings), ), @@ -2178,6 +2272,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); const providerRegistryLayer = ProviderRegistryLive.pipe( Layer.provideMerge(ProviderInstanceRegistryHydrationLive), + Layer.provideMerge(AntigravityInstallation.layer), Layer.provideMerge( Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings), ), @@ -2236,6 +2331,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te assert.deepStrictEqual(providers.map((provider) => provider.instanceId).toSorted(), [ "amp", + "antigravity", "claudeAgent", "codex", "copilot", diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index 915539cbdb3c..4c8658e38afc 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -101,20 +101,25 @@ export function upsertProviderWorkspaceSnapshot( } const shouldRetainMissingProviderModels = (provider: ServerProvider): boolean => { - if (provider.driver !== ProviderDriverKind.make("opencode")) { + const isAntigravity = provider.driver === ProviderDriverKind.make("antigravity"); + if (!isAntigravity && provider.driver !== ProviderDriverKind.make("opencode")) { return true; } - // OpenCode's initial snapshot is deliberately non-authoritative while its - // first probe is still running. A probe error from an installed CLI/server - // is likewise partial: it could not establish the current inventory. - // Conversely, disabled and missing-CLI snapshots are authoritative removals, - // as are successful ready/warning inventories (including an empty one after - // logout or plugin removal). + if (isAntigravity && (!provider.enabled || provider.auth.status === "unauthenticated")) { + return false; + } + + // Both drivers replace their inventories after successful catalog discovery. + // Antigravity's local health check does not authenticate or discover models. + const isPendingAntigravityAuthentication = + isAntigravity && provider.status === "warning" && provider.auth.status === "unknown"; const isPendingInitialProbe = provider.enabled && !provider.installed && provider.status === "warning"; const didInstalledProviderProbeFail = provider.installed && provider.status === "error"; - return isPendingInitialProbe || didInstalledProviderProbeFail; + return ( + isPendingAntigravityAuthentication || isPendingInitialProbe || didInstalledProviderProbeFail + ); }; const shouldRetainMissingOpenCodeMetadata = (provider: ServerProvider): boolean => diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 89de71b7fca1..3f869ddf8bee 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -123,7 +123,10 @@ type LegacyProviderRuntimeEvent = { readonly [key: string]: unknown; }; -function makeFakeCodexAdapter(provider: ProviderDriverKind = CODEX_DRIVER) { +function makeFakeCodexAdapter( + provider: ProviderDriverKind = CODEX_DRIVER, + supportsConversationRollback?: boolean, +) { const sessions = new Map(); const runtimeEventPubSub = Effect.runSync(PubSub.unbounded()); @@ -246,6 +249,7 @@ function makeFakeCodexAdapter(provider: ProviderDriverKind = CODEX_DRIVER) { provider, capabilities: { sessionModelSwitch: "in-session", + ...(supportsConversationRollback !== undefined ? { supportsConversationRollback } : {}), ...(provider === CODEX_DRIVER ? { promptlessTurnContinuation: true } : {}), }, startSession, @@ -316,16 +320,20 @@ const hasMetricSnapshot = ( function makeProviderServiceLayer( input: { readonly directory?: ProviderSessionDirectory.ProviderSessionDirectory["Service"]; + readonly supportsConversationRollback?: boolean; + readonly registry?: ProviderAdapterRegistry.ProviderAdapterRegistry["Service"]; } = {}, ) { - const codex = makeFakeCodexAdapter(); + const codex = makeFakeCodexAdapter(CODEX_DRIVER, input.supportsConversationRollback); const claude = makeFakeCodexAdapter(CLAUDE_AGENT_DRIVER); const cursor = makeFakeCodexAdapter(CURSOR_DRIVER); - const registry = makeAdapterRegistryMock({ - [ProviderDriverKind.make("codex")]: codex.adapter, - [ProviderDriverKind.make("claudeAgent")]: claude.adapter, - [ProviderDriverKind.make("cursor")]: cursor.adapter, - }); + const registry = + input.registry ?? + makeAdapterRegistryMock({ + [ProviderDriverKind.make("codex")]: codex.adapter, + [ProviderDriverKind.make("claudeAgent")]: claude.adapter, + [ProviderDriverKind.make("cursor")]: cursor.adapter, + }); const providerAdapterLayer = Layer.succeed( ProviderAdapterRegistry.ProviderAdapterRegistry, @@ -639,6 +647,119 @@ it.effect("ProviderServiceLive rejects new sessions for disabled custom instance const routing = makeProviderServiceLayer(); +const antigravityDriver = ProviderDriverKind.make("antigravity"); +const replacementAntigravity = makeFakeCodexAdapter(antigravityDriver); +const originalAntigravityInstanceId = ProviderInstanceId.make("antigravity-personal"); +const replacementAntigravityInstanceId = ProviderInstanceId.make("antigravity"); +const antigravityRegistry = makeAdapterRegistryMock({ + [antigravityDriver]: replacementAntigravity.adapter, +}); +let originalAntigravityInstanceAvailable = true; +const antigravityInstanceRouting = makeProviderServiceLayer({ + registry: { + ...antigravityRegistry, + getInstanceInfo: (instanceId) => + instanceId === originalAntigravityInstanceId && originalAntigravityInstanceAvailable + ? Effect.succeed({ + instanceId, + driverKind: antigravityDriver, + displayName: undefined, + enabled: true, + continuationIdentity: { + driverKind: antigravityDriver, + continuationKey: `${antigravityDriver}:instance:${instanceId}`, + }, + }) + : antigravityRegistry.getInstanceInfo(instanceId), + }, +}); +antigravityInstanceRouting.layer("ProviderServiceLive instance-owned conversations", (it) => { + it.effect( + "does not replace a native conversation with another instance or a removed-instance fallback", + () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; + + for (const originalAvailable of [true, false]) { + originalAntigravityInstanceAvailable = originalAvailable; + for (const passCursor of [true, false]) { + const threadId = asThreadId( + `thread-antigravity-instance-${originalAvailable}-${passCursor}`, + ); + const resumeCursor = { sessionId: "native-session" }; + yield* directory.upsert({ + threadId, + provider: antigravityDriver, + providerInstanceId: originalAntigravityInstanceId, + status: "stopped", + runtimeMode: "approval-required", + ...(passCursor ? {} : { resumeCursor }), + }); + const originalBinding = yield* directory.getBinding(threadId); + replacementAntigravity.startSession.mockClear(); + + const error = yield* Effect.flip( + provider.startSession(threadId, { + providerInstanceId: replacementAntigravityInstanceId, + threadId, + runtimeMode: "approval-required", + ...(passCursor ? { resumeCursor } : {}), + }), + ); + + assert.equal( + error._tag, + originalAvailable ? "ProviderValidationError" : "ProviderUnsupportedError", + ); + assert.equal(replacementAntigravity.startSession.mock.calls.length, 0); + assert.deepEqual(yield* directory.getBinding(threadId), originalBinding); + } + } + }), + ); +}); + +const unsupportedRollback = makeProviderServiceLayer({ supportsConversationRollback: false }); +unsupportedRollback.layer("ProviderServiceLive unsupported rewind", (it) => { + it.effect("rejects rewind without starting or changing the provider conversation", () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; + + for (const active of [true, false]) { + const threadId = asThreadId(`thread-unsupported-rewind-${active}`); + yield* provider.startSession(threadId, { + providerInstanceId: codexInstanceId, + threadId, + cwd: "/tmp/project", + runtimeMode: "approval-required", + }); + if (!active) { + yield* unsupportedRollback.codex.stopSession(threadId); + } + const originalBinding = yield* directory.getBinding(threadId); + unsupportedRollback.codex.startSession.mockClear(); + unsupportedRollback.codex.rollbackThread.mockClear(); + + const preflightError = yield* Effect.flip( + provider.assertConversationRollbackSupported(threadId), + ); + const rollbackError = yield* Effect.flip( + provider.rollbackConversation({ threadId, numTurns: 1 }), + ); + + assert.instanceOf(preflightError, ProviderValidationError); + assert.include(preflightError.message, "does not support conversation rewind"); + assert.instanceOf(rollbackError, ProviderValidationError); + assert.equal(unsupportedRollback.codex.startSession.mock.calls.length, 0); + assert.equal(unsupportedRollback.codex.rollbackThread.mock.calls.length, 0); + assert.deepEqual(yield* directory.getBinding(threadId), originalBinding); + } + }), + ); +}); + it.effect( "ProviderServiceLive uploads feedback through the adapter that recovered the session", () => @@ -1279,6 +1400,9 @@ routing.layer("ProviderServiceLive routing", (it) => { routing.codex.startSession.mockClear(); routing.codex.rollbackThread.mockClear(); + yield* provider.assertConversationRollbackSupported(initial.threadId); + assert.equal(routing.codex.startSession.mock.calls.length, 0); + yield* provider.rollbackConversation({ threadId: initial.threadId, numTurns: 1, diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 552f191ac513..bbf85e9d5a11 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -623,6 +623,26 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ); } const persistedBinding = Option.getOrUndefined(yield* directory.getBinding(threadId)); + if ( + persistedBinding?.provider === resolvedProvider && + persistedBinding.providerInstanceId !== resolvedInstanceId && + (input.resumeCursor != null || persistedBinding.resumeCursor != null) + ) { + const previousInstanceId = yield* requireBindingInstanceId( + "ProviderService.startSession", + persistedBinding, + ); + const previousInfo = yield* registry.getInstanceInfo(previousInstanceId); + if ( + previousInfo.continuationIdentity.continuationKey !== + instanceInfo.continuationIdentity.continuationKey + ) { + return yield* toValidationError( + "ProviderService.startSession", + `Thread '${threadId}' cannot switch from instance '${previousInstanceId}' to '${resolvedInstanceId}' because their provider resume state is incompatible.`, + ); + } + } const effectiveResumeCursor = input.resumeCursor ?? (persistedBinding?.providerInstanceId === resolvedInstanceId @@ -1109,6 +1129,21 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( const getInstanceInfo: ProviderServiceMethod<"getInstanceInfo"> = (instanceId) => registry.getInstanceInfo(instanceId); + const assertConversationRollbackSupported: ProviderServiceMethod<"assertConversationRollbackSupported"> = + Effect.fn("assertConversationRollbackSupported")(function* (threadId) { + const routed = yield* resolveRoutableSession({ + threadId, + operation: "ProviderService.assertConversationRollbackSupported", + allowRecovery: false, + }); + if (routed.adapter.capabilities.supportsConversationRollback === false) { + return yield* toValidationError( + "ProviderService.assertConversationRollbackSupported", + `Provider '${routed.adapter.provider}' does not support conversation rewind.`, + ); + } + }); + const rollbackConversation: ProviderServiceMethod<"rollbackConversation"> = Effect.fn( "rollbackConversation", )(function* (rawInput) { @@ -1122,6 +1157,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( } let metricProvider = "unknown"; return yield* Effect.gen(function* () { + yield* assertConversationRollbackSupported(input.threadId); const routed = yield* resolveRoutableSession({ threadId: input.threadId, operation: "ProviderService.rollbackConversation", @@ -1261,6 +1297,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( listSessions, getCapabilities, getInstanceInfo, + assertConversationRollbackSupported, rollbackConversation, uploadFeedback, // Each access creates a fresh PubSub subscription so that multiple diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts index 692181531aae..15e23c79010c 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts @@ -170,6 +170,7 @@ describe("ProviderSessionReaper", () => { stopSession, listSessions: () => Effect.succeed([]), getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }), + assertConversationRollbackSupported: () => unsupported(), getInstanceInfo: (instanceId) => { const driverKind = ProviderDriverKind.make(String(instanceId)); return Effect.succeed({ diff --git a/apps/server/src/provider/ModelManifest.test.ts b/apps/server/src/provider/ModelManifest.test.ts index e46a462e438a..73049ad01c30 100644 --- a/apps/server/src/provider/ModelManifest.test.ts +++ b/apps/server/src/provider/ModelManifest.test.ts @@ -2,18 +2,23 @@ import { assert, describe, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { ProviderDriverKind, type ServerProviderModel } from "@t3tools/contracts"; 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 TestClock from "effect/testing/TestClock"; import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import * as ServerConfig from "../config.ts"; import * as ServerSettings from "../serverSettings.ts"; import { + applyManifestDefault, BUNDLED_MODEL_MANIFEST, classifyModels, make, resolveProviderCatalog, type ModelManifestData, + manifestUpdatedAtMs, + encodeManifestCache, } from "./ModelManifest.ts"; /** @@ -59,6 +64,36 @@ describe("classifyModels", () => { }); }); +describe("applyManifestDefault", () => { + it("moves the default flag and its aliases to the manifest's chat default", () => { + const driver = ProviderDriverKind.make("antigravity"); + const manifest: ModelManifestData = { + version: 1, + currentModels: {}, + providers: { + antigravity: { + defaults: { chat: "gemini-new" }, + profiles: {}, + models: [{ slug: "gemini-new", name: "New", status: "current" }], + }, + }, + }; + const models = [ + model({ slug: "gemini-old", isDefault: true, aliases: ["antigravity-default"] }), + model({ slug: "gemini-new" }), + ]; + assert.deepStrictEqual(applyManifestDefault(models, manifest, driver), [ + model({ slug: "gemini-old" }), + model({ slug: "gemini-new", isDefault: true, aliases: ["antigravity-default"] }), + ]); + // The account does not offer the manifest default: keep the runtime's choice. + assert.deepStrictEqual( + applyManifestDefault(models.slice(0, 1), manifest, driver), + models.slice(0, 1), + ); + }); +}); + describe("resolveProviderCatalog", () => { it("resolves generic model presentation through a reusable profile", () => { const manifest: ModelManifestData = { @@ -155,8 +190,12 @@ describe("resolveProviderCatalog", () => { }); }); +// Remote fixtures date after the bundle so a fetch still outranks it. +const REMOTE_UPDATED_AT = "2099-01-01T00:00:00Z"; + const REMOTE_MANIFEST: ModelManifestData = { version: 1, + updatedAt: REMOTE_UPDATED_AT, currentModels: { codex: ["remote-model"], claudeAgent: ["remote-agent-model"], @@ -165,6 +204,7 @@ const REMOTE_MANIFEST: ModelManifestData = { const REMOTE_CLAUDE_MANIFEST: ModelManifestData = { version: 1, + updatedAt: REMOTE_UPDATED_AT, currentModels: {}, providers: { claudeAgent: { @@ -337,6 +377,47 @@ describe("ModelManifest service", () => { ); }); + it.live("drops a disk cache of a manifest older than the bundled one", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const config = yield* ServerConfig.ServerConfig; + assert.isAbove(manifestUpdatedAtMs(BUNDLED_MODEL_MANIFEST), 0); + const cachePath = path.join(config.stateDir, "model-manifest.json"); + // A cache of the manifest as it was before the release edited it. The + // fetch time is irrelevant: the remote may be unreachable now, so + // `current` must already prefer the bundle. + const { updatedAt: _undated, ...undatedManifest } = REMOTE_MANIFEST; + for (const stale of [ + undatedManifest, + { ...REMOTE_MANIFEST, updatedAt: "2000-01-01T00:00:00Z" }, + ]) { + yield* fs.writeFileString( + cachePath, + yield* encodeManifestCache({ fetchedAtMs: 0, manifest: stale }), + ); + const service = yield* make; + assert.deepStrictEqual(yield* service.current, BUNDLED_MODEL_MANIFEST); + } + + // A cache of a newer edit still outranks the bundle. + yield* fs.writeFileString( + cachePath, + yield* encodeManifestCache({ fetchedAtMs: 0, manifest: REMOTE_MANIFEST }), + ); + const later = yield* make; + assert.deepStrictEqual(yield* later.current, REMOTE_MANIFEST); + }).pipe( + Effect.scoped, + Effect.provide( + serviceLayers({ + prefix: "model-manifest-newer-bundle-test", + response: () => Response.json(REMOTE_MANIFEST), + }), + ), + ), + ); + it.live("does not fetch when provider update checks are disabled", () => Effect.gen(function* () { let fetchCount = 0; diff --git a/apps/server/src/provider/ModelManifest.ts b/apps/server/src/provider/ModelManifest.ts index 2f378f835d67..c3cb36566c02 100644 --- a/apps/server/src/provider/ModelManifest.ts +++ b/apps/server/src/provider/ModelManifest.ts @@ -82,6 +82,12 @@ const ManifestProviderCatalog = Schema.Struct({ */ const ModelManifestEnvelopeSchema = Schema.Struct({ version: Schema.Literal(1), + /** + * ISO date of the last edit. A release bundles its manifest, and a disk + * cache of an older edit must not outrank it. Optional so older remote + * files still decode; they count as older than any dated bundle. + */ + updatedAt: Schema.optional(Schema.String), currentModels: Schema.Record(Schema.String, Schema.Array(Schema.String)), providers: Schema.optional(Schema.Record(Schema.String, ManifestProviderCatalog)), }); @@ -131,6 +137,13 @@ const decodeManifest = Schema.decodeUnknownEffect(ModelManifestSchema); export const BUNDLED_MODEL_MANIFEST: ModelManifestData = Schema.decodeUnknownSync(ModelManifestSchema)(bundledManifestJson); +/** Epoch millis of the manifest's `updatedAt`, or 0 when absent or unparsable. */ +export function manifestUpdatedAtMs(manifest: ModelManifestData): number { + if (manifest.updatedAt === undefined) return 0; + const parsed = Date.parse(manifest.updatedAt); + return Number.isNaN(parsed) ? 0 : parsed; +} + /** Resolve provider-neutral model presentation and capability data. */ export function resolveProviderCatalog( manifest: ModelManifestData, @@ -186,7 +199,8 @@ const decodeManifestCache = Schema.decodeUnknownEffect( ManifestCacheFile as unknown as Schema.Codec, ), ); -const encodeManifestCache = Schema.encodeEffect( +/** Exported for tests that seed the disk cache. */ +export const encodeManifestCache = Schema.encodeEffect( Schema.fromJsonString( ManifestCacheFile as unknown as Schema.Codec, ), @@ -216,7 +230,52 @@ export function applyModelManifest( manifest: ModelManifestData, driverKind: ProviderDriverKind, ): ServerProviderDraft { - return { ...draft, models: classifyModels(draft.models, manifest, driverKind) }; + return { + ...draft, + models: applyManifestDefault( + classifyModels(draft.models, manifest, driverKind), + manifest, + driverKind, + ), + }; +} + +/** The manifest's chat default for `driverKind`, when it names one. */ +export function manifestDefaultModel( + manifest: ModelManifestData, + driverKind: ProviderDriverKind, +): string | undefined { + return manifest.providers?.[driverKind]?.defaults?.chat; +} + +/** + * Moves `isDefault` to the manifest's chat default when the catalog carries + * it. Providers that learn their default from the runtime (Antigravity takes + * Google's current model) can be overridden here without a release. Aliases + * that pointed at the old default move with the flag so the shared + * "provider default" alias keeps resolving. + */ +export function applyManifestDefault( + models: ReadonlyArray, + manifest: ModelManifestData, + driverKind: ProviderDriverKind, +): ReadonlyArray { + const slug = manifestDefaultModel(manifest, driverKind); + if (slug === undefined || !models.some((model) => model.slug === slug)) return models; + const previous = models.find((model) => model.isDefault && model.slug !== slug); + if (!previous) return models; + const movedAliases = previous.aliases ?? []; + return models.map((model) => { + if (model.slug === previous.slug) { + const { isDefault: _isDefault, aliases: _aliases, ...rest } = model; + return rest; + } + if (model.slug === slug) { + const aliases = [...new Set([...(model.aliases ?? []), ...movedAliases])]; + return { ...model, isDefault: true, ...(aliases.length > 0 ? { aliases } : {}) }; + } + return model; + }); } /** Model-level half of `applyModelManifest`, exported for focused tests. */ @@ -285,7 +344,14 @@ export const make = Effect.gen(function* () { ); if (fromDisk === null) return; // The disk copy is the last-seen remote manifest, so it outranks the - // bundle even when stale: it is refreshed on the next successful fetch. + // bundle even when stale, unless the bundle's own edit date is newer + // than the cached manifest's. Then the release carries data the cache + // has not seen and the cache is dropped so the next refresh replaces + // it. Comparing edit dates, not fetch time, keeps this independent of + // when the cache was written relative to the release. + if (manifestUpdatedAtMs(BUNDLED_MODEL_MANIFEST) > manifestUpdatedAtMs(fromDisk.manifest)) { + return; + } manifest = fromDisk.manifest; fetchedAtMs = fromDisk.fetchedAtMs; }), diff --git a/apps/server/src/provider/ProviderDriver.ts b/apps/server/src/provider/ProviderDriver.ts index bdb3020ed42f..b6569d6fabaf 100644 --- a/apps/server/src/provider/ProviderDriver.ts +++ b/apps/server/src/provider/ProviderDriver.ts @@ -35,6 +35,7 @@ import type * as TextGeneration from "../textGeneration/TextGeneration.ts"; import type { ProviderAdapterError, ProviderDriverError } from "./Errors.ts"; import type { ProviderAdapterShape } from "./Services/ProviderAdapter.ts"; import type { ServerProviderShape } from "./Services/ServerProvider.ts"; +import type { ProviderAuthController } from "./Services/ProviderAuthService.ts"; /** * Static metadata advertised by a driver. Used for default presentation @@ -71,8 +72,10 @@ export interface ProviderInstance { readonly enabled: boolean; readonly snapshot: ServerProviderShape; readonly snapshotForCwd?: (cwd: string) => Effect.Effect; + readonly refreshModels?: () => Effect.Effect; readonly adapter: ProviderAdapterShape; readonly textGeneration: TextGeneration.TextGeneration["Service"]; + readonly auth?: ProviderAuthController; } export interface ProviderContinuationIdentity { diff --git a/apps/server/src/provider/Services/ProviderAdapter.ts b/apps/server/src/provider/Services/ProviderAdapter.ts index c4bbddfa440a..90521675eeca 100644 --- a/apps/server/src/provider/Services/ProviderAdapter.ts +++ b/apps/server/src/provider/Services/ProviderAdapter.ts @@ -35,6 +35,8 @@ export interface ProviderAdapterCapabilities { /** Starts a resumed turn with no synthetic user prompt. Omitted means the adapter needs an explicit continuation instruction. */ readonly promptlessTurnContinuation?: boolean; + /** False when native conversation history cannot be rewound. */ + readonly supportsConversationRollback?: boolean; } export interface ProviderThreadTurnSnapshot { diff --git a/apps/server/src/provider/Services/ProviderAuthService.ts b/apps/server/src/provider/Services/ProviderAuthService.ts new file mode 100644 index 000000000000..ea89edf78af5 --- /dev/null +++ b/apps/server/src/provider/Services/ProviderAuthService.ts @@ -0,0 +1,59 @@ +import type { ProviderAuthState, ProviderInstanceId, ProviderSetupError } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import type * as Stream from "effect/Stream"; + +export interface ProviderAuthController { + readonly start: ( + ownerSessionId: string, + stopSessions?: Effect.Effect, + ) => Effect.Effect; + readonly complete: ( + ownerSessionId: string, + input: { readonly flowId: string; readonly callbackUrl: string }, + ) => Effect.Effect; + readonly cancel: ( + ownerSessionId: string, + flowId: string, + ) => Effect.Effect; + /** The controller closes process admission before it stops routed sessions. */ + readonly logout: ( + stopSessions: Effect.Effect, + ) => Effect.Effect; + readonly subscribe: (ownerSessionId: string) => Stream.Stream; + readonly isLogoutPrompt?: (text: string, hasAttachments: boolean) => boolean; +} + +interface ProviderAuthTarget { + readonly instanceId: ProviderInstanceId; +} + +export interface ProviderAuthServiceShape { + readonly start: ( + input: ProviderAuthTarget, + ownerSessionId: string, + ) => Effect.Effect; + readonly complete: ( + input: ProviderAuthTarget & { readonly flowId: string; readonly callbackUrl: string }, + ownerSessionId: string, + ) => Effect.Effect; + readonly cancel: ( + input: ProviderAuthTarget & { readonly flowId: string }, + ownerSessionId: string, + ) => Effect.Effect; + readonly logout: ( + input: ProviderAuthTarget, + ) => Effect.Effect; + readonly subscribe: ( + input: ProviderAuthTarget, + ownerSessionId: string, + ) => Stream.Stream; + readonly tryHandlePromptCommand: ( + input: ProviderAuthTarget & { readonly text: string; readonly hasAttachments: boolean }, + ) => Effect.Effect; +} + +export class ProviderAuthService extends Context.Service< + ProviderAuthService, + ProviderAuthServiceShape +>()("t3/provider/Services/ProviderAuthService") {} diff --git a/apps/server/src/provider/Services/ProviderService.ts b/apps/server/src/provider/Services/ProviderService.ts index 545641d2e866..2f88d2a0271f 100644 --- a/apps/server/src/provider/Services/ProviderService.ts +++ b/apps/server/src/provider/Services/ProviderService.ts @@ -99,6 +99,13 @@ export interface ProviderServiceShape { instanceId: ProviderInstanceId, ) => Effect.Effect; + /** + * Reject unsupported rewind before files change, without resuming the session. + */ + readonly assertConversationRollbackSupported: ( + threadId: ThreadId, + ) => Effect.Effect; + /** * Roll back provider conversation state by a number of turns. */ diff --git a/apps/server/src/provider/acp/AcpCoreRuntimeEvents.test.ts b/apps/server/src/provider/acp/AcpCoreRuntimeEvents.test.ts index 394ada83f763..cb9433b5bf4a 100644 --- a/apps/server/src/provider/acp/AcpCoreRuntimeEvents.test.ts +++ b/apps/server/src/provider/acp/AcpCoreRuntimeEvents.test.ts @@ -27,27 +27,27 @@ describe("AcpCoreRuntimeEvents", () => { }, }; - expect( - makeAcpRequestOpenedEvent({ - stamp, - provider: ProviderDriverKind.make("cursor"), - threadId: "thread-1" as never, - turnId, - requestId: RuntimeRequestId.make("request-1"), - permissionRequest, - detail: "cat package.json", - args: { command: ["cat", "package.json"] }, - source: "acp.jsonrpc", - method: "session/request_permission", - rawPayload: { sessionId: "session-1" }, - }), - ).toMatchObject({ + const openedEvent = makeAcpRequestOpenedEvent({ + stamp, + provider: ProviderDriverKind.make("cursor"), + threadId: "thread-1" as never, + turnId, + requestId: RuntimeRequestId.make("request-1"), + permissionRequest, + detail: "cat package.json", + args: { command: ["cat", "package.json"] }, + source: "acp.jsonrpc", + method: "session/request_permission", + rawPayload: { sessionId: "session-1" }, + }); + expect(openedEvent).toMatchObject({ type: "request.opened", payload: { requestType: "exec_command_approval", detail: "cat package.json", }, }); + expect(openedEvent).not.toHaveProperty("payload.options"); expect( makeAcpRequestResolvedEvent({ @@ -68,6 +68,36 @@ describe("AcpCoreRuntimeEvents", () => { }); }); + it("preserves a native file approval without a remembered-allow choice", () => { + const event = makeAcpRequestOpenedEvent({ + stamp: { eventId: "approval-1" as never, createdAt: "2026-09-02T00:00:00.000Z" }, + provider: ProviderDriverKind.make("antigravity"), + threadId: "thread-1" as never, + turnId: TurnId.make("turn-1"), + requestId: RuntimeRequestId.make("request-1"), + permissionRequest: { kind: "edit" }, + approvalOptions: [ + { decision: "accept", label: "Allow once" }, + { decision: "decline", label: "Reject" }, + ], + detail: "Edit package.json", + args: {}, + source: "acp.jsonrpc", + method: "session/request_permission", + rawPayload: { sessionId: "session-1" }, + }); + + expect(event.payload).toEqual({ + requestType: "file_change_approval", + detail: "Edit package.json", + args: {}, + options: [ + { decision: "accept", label: "Allow once" }, + { decision: "decline", label: "Reject" }, + ], + }); + }); + it("maps generic ACP permission kinds to dynamic tool approvals", () => { const stamp = { eventId: "event-1" as never, createdAt: "2026-03-27T00:00:00.000Z" }; @@ -192,4 +222,24 @@ describe("AcpCoreRuntimeEvents", () => { }, }); }); + + it("maps thoughts to the reasoning stream", () => { + expect( + makeAcpContentDeltaEvent({ + stamp: { eventId: "thought-1" as never, createdAt: "2026-09-02T00:00:00.000Z" }, + provider: ProviderDriverKind.make("cursor"), + threadId: "thread-1" as never, + turnId: TurnId.make("turn-1"), + streamKind: "reasoning_text", + text: "Inspect the current implementation first.", + rawPayload: { sessionId: "session-1" }, + }), + ).toMatchObject({ + type: "content.delta", + payload: { + streamKind: "reasoning_text", + delta: "Inspect the current implementation first.", + }, + }); + }); }); diff --git a/apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts b/apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts index 4de5247ac160..79def1125305 100644 --- a/apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts +++ b/apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts @@ -4,6 +4,7 @@ import { type CanonicalRequestType, type EventId, type ProviderApprovalDecision, + type ProviderApprovalOption, type ProviderDriverKind, type ProviderRuntimeEvent, type RuntimeRequestId, @@ -71,6 +72,7 @@ export function makeAcpRequestOpenedEvent(input: { readonly turnId: TurnId | undefined; readonly requestId: RuntimeRequestId; readonly permissionRequest: AcpPermissionRequest; + readonly approvalOptions?: ReadonlyArray; readonly detail: string; readonly args: unknown; readonly source: AcpAdapterRawSource; @@ -88,6 +90,7 @@ export function makeAcpRequestOpenedEvent(input: { requestType: canonicalRequestTypeFromAcpKind(input.permissionRequest.kind), detail: input.detail, args: input.args, + ...(input.approvalOptions !== undefined ? { options: input.approvalOptions } : {}), }, raw: { source: input.source, @@ -207,6 +210,7 @@ export function makeAcpContentDeltaEvent(input: { readonly threadId: ThreadId; readonly turnId: TurnId | undefined; readonly itemId?: string; + readonly streamKind?: "assistant_text" | "reasoning_text"; readonly text: string; readonly rawPayload: unknown; }): ProviderRuntimeEvent { @@ -218,7 +222,7 @@ export function makeAcpContentDeltaEvent(input: { turnId: input.turnId, ...(input.itemId ? { itemId: RuntimeItemId.make(input.itemId) } : {}), payload: { - streamKind: "assistant_text", + streamKind: input.streamKind ?? "assistant_text", delta: input.text, }, raw: { diff --git a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts index 93ffc63806f6..57323e2675e0 100644 --- a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts +++ b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts @@ -6,22 +6,496 @@ import * as NodeFS from "node:fs"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as Option from "effect/Option"; +import * as Scope from "effect/Scope"; import * as TestClock from "effect/testing/TestClock"; import * as Stream from "effect/Stream"; 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"); const mockAgentCommand = "node"; const mockAgentArgs = [mockAgentPath]; +const mockRuntimeOptions = { + spawn: { command: mockAgentCommand, args: mockAgentArgs }, + cwd: process.cwd(), + clientInfo: { name: "t3-test", version: "0.0.0" }, + authMethodId: "test", +} satisfies AcpSessionRuntime.AcpSessionRuntimeOptions; describe("AcpSessionRuntime", () => { + for (const setupMethod of ["session/new", "session/resume"] as const) { + it.effect(`buffers root metadata while ${setupMethod} startup is still pending`, () => + Effect.gen(function* () { + const setupReplied = yield* Deferred.make(); + const allowStartup = yield* Deferred.make(); + const events: Array = []; + const runtime = yield* AcpSessionRuntime.make({ + ...mockRuntimeOptions, + ...(setupMethod === "session/resume" + ? { resumeSessionId: "mock-session-1", resumeMethod: "resume" as const } + : {}), + requestLogger: (event) => + event.method === setupMethod && event.status === "succeeded" + ? Deferred.succeed(setupReplied, undefined).pipe( + Effect.andThen(Deferred.await(allowStartup)), + ) + : Effect.void, + }); + yield* runtime.getEvents().pipe( + Stream.runForEach((event) => { + if (event._tag === "EventStreamBarrier") { + return Deferred.succeed(event.acknowledge, undefined); + } + events.push(event); + return Effect.void; + }), + Effect.forkChild, + ); + const startup = yield* runtime.start().pipe(Effect.forkChild); + yield* Deferred.await(setupReplied); + yield* runtime.request("_test/startup-metadata", {}); + yield* Deferred.succeed(allowStartup, undefined); + yield* Fiber.join(startup); + yield* runtime.drainEvents; + + 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" }); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + } + + 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(); + const requestMethods: Array = []; + const runtime = yield* AcpSessionRuntime.make({ + ...mockRuntimeOptions, + spawn: { + ...mockRuntimeOptions.spawn, + env: { T3_ACP_WAIT_FOR_RESUME_RELEASE: "1" }, + }, + resumeSessionId: "mock-session-1", + resumeMethod: "resume", + sessionLoadReplayIdleGap: "1 second", + requestLogger: (event) => + Effect.sync(() => { + if (event.status === "started") requestMethods.push(event.method); + }), + }); + yield* runtime.handleSessionUpdate((notification) => + notification.update.sessionUpdate === "user_message_chunk" + ? Deferred.succeed(resumeStarted, undefined).pipe(Effect.asVoid) + : Effect.void, + ); + const startup = yield* runtime.start().pipe(Effect.forkChild); + yield* Deferred.await(resumeStarted); + yield* TestClock.adjust("3 seconds"); + expect(startup.pollUnsafe()).toBeUndefined(); + yield* runtime.request("_test/release-resume", {}); + const started = yield* Fiber.join(startup); + + expect(started.sessionSetupResult._meta).toEqual({ nativeResume: true }); + expect(requestMethods).toContain("session/resume"); + expect(requestMethods).not.toContain("session/load"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("waits for native cancellation and drains final updates before another prompt", () => + Effect.gen(function* () { + const toolStarted = yield* Deferred.make(); + const cancelReceived = yield* Deferred.make(); + const events: Array = []; + let promptRequests = 0; + const runtime = yield* AcpSessionRuntime.make({ + ...mockRuntimeOptions, + spawn: { + ...mockRuntimeOptions.spawn, + env: { T3_ACP_COMPLETE_FIRST_PROMPT_ON_CANCEL: "1" }, + }, + cancelBehavior: "wait-for-prompt", + requestLogger: (event) => + Effect.sync(() => { + if (event.method === "session/prompt" && event.status === "started") + promptRequests += 1; + }), + }); + yield* runtime.getEvents().pipe( + Stream.runForEach((event) => { + if (event._tag === "EventStreamBarrier") { + return Deferred.succeed(event.acknowledge, undefined); + } + events.push(event); + if (event._tag === "ToolCallUpdated" && event.toolCall.status === "inProgress") { + return Deferred.succeed(toolStarted, undefined); + } + if (event._tag === "ThoughtDelta" && event.text === "native-cancel-received") { + return Deferred.succeed(cancelReceived, undefined); + } + return Effect.void; + }), + Effect.forkChild, + ); + yield* runtime.start(); + const prompt = yield* runtime + .prompt({ + prompt: [{ type: "text", text: "first" }], + }) + .pipe(Effect.forkChild); + yield* Deferred.await(toolStarted); + const cancellation = yield* runtime.cancel.pipe(Effect.forkChild); + yield* Deferred.await(cancelReceived); + const replacement = yield* runtime + .prompt({ + prompt: [{ type: "text", text: "second" }], + }) + .pipe(Effect.forkChild({ startImmediately: true })); + + expect(prompt.pollUnsafe()).toBeUndefined(); + expect(cancellation.pollUnsafe()).toBeUndefined(); + expect(promptRequests).toBe(1); + yield* runtime.request("_test/finish-cancel", {}); + yield* Fiber.join(cancellation); + + expect(yield* Fiber.join(prompt)).toEqual({ + stopReason: "cancelled", + _meta: { nativeCancel: true }, + }); + expect( + events.some( + (event) => + event._tag === "ToolCallUpdated" && + event.toolCall.status === "failed" && + event.toolCall.detail === "Cancelled.", + ), + ).toBe(true); + const cancelledDelta = events.find( + (event) => event._tag === "ContentDelta" && event.text === "Request cancelled.", + ); + expect(cancelledDelta?._tag).toBe("ContentDelta"); + if (cancelledDelta?._tag === "ContentDelta") { + expect( + events.filter( + (event) => + event._tag === "AssistantItemCompleted" && event.itemId === cancelledDelta.itemId, + ), + ).toHaveLength(1); + } + expect(yield* Fiber.join(replacement)).toMatchObject({ stopReason: "end_turn" }); + expect(promptRequests).toBe(2); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("retires a process when native cancellation times out", () => + Effect.gen(function* () { + const toolStarted = yield* Deferred.make(); + const cancelReceived = yield* Deferred.make(); + const runtime = yield* AcpSessionRuntime.make({ + ...mockRuntimeOptions, + spawn: { + ...mockRuntimeOptions.spawn, + env: { T3_ACP_COMPLETE_FIRST_PROMPT_ON_CANCEL: "1" }, + }, + cancelBehavior: "wait-for-prompt", + cancelTimeout: "1 second", + }); + yield* runtime.getEvents().pipe( + Stream.runForEach((event) => { + if (event._tag === "EventStreamBarrier") { + return Deferred.succeed(event.acknowledge, undefined); + } + if (event._tag === "ToolCallUpdated") { + return Deferred.succeed(toolStarted, undefined); + } + if (event._tag === "ThoughtDelta") { + return Deferred.succeed(cancelReceived, undefined); + } + return Effect.void; + }), + Effect.forkChild, + ); + yield* runtime.start(); + const prompt = yield* runtime + .prompt({ + prompt: [{ type: "text", text: "first" }], + }) + .pipe(Effect.forkChild); + yield* Deferred.await(toolStarted); + const cancellation = yield* runtime.cancel.pipe(Effect.forkChild); + yield* Deferred.await(cancelReceived); + yield* TestClock.adjust("2 seconds"); + + const error = yield* Fiber.join(cancellation).pipe(Effect.flip); + expect(error).toMatchObject({ + _tag: "AcpTransportError", + method: "session/cancel", + }); + expect(Exit.isFailure(yield* Fiber.await(prompt))).toBe(true); + expect( + yield* runtime + .prompt({ + prompt: [{ type: "text", text: "must not run" }], + }) + .pipe(Effect.flip), + ).toBe(error); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("reports an idle child exit and rejects later prompts", () => + Effect.gen(function* () { + const runtime = yield* AcpSessionRuntime.make(mockRuntimeOptions); + yield* runtime.start(); + yield* runtime.notify("_test/exit", {}); + const events = yield* runtime.getEvents().pipe(Stream.take(1), Stream.runCollect); + const event = events[0]; + expect(event).toMatchObject({ _tag: "ConnectionTerminated", error: { code: 19 } }); + if (event?._tag !== "ConnectionTerminated") return; + expect( + yield* runtime + .prompt({ + prompt: [{ type: "text", text: "must not run" }], + }) + .pipe(Effect.flip), + ).toBe(event.error); + expect(yield* runtime.start().pipe(Effect.flip)).toBe(event.error); + expect(yield* runtime.initialize().pipe(Effect.flip)).toBe(event.error); + expect( + yield* runtime.request("_test/environment", {}).pipe( + Effect.match({ + onFailure: (error) => error, + onSuccess: () => undefined, + }), + ), + ).toBe(event.error); + expect(yield* runtime.notify("_test/exit", {}).pipe(Effect.flip)).toBe(event.error); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("retires a native runtime when its prompt caller is interrupted", () => + Effect.gen(function* () { + const dispatched = yield* Deferred.make(); + const runtime = yield* AcpSessionRuntime.make({ + ...mockRuntimeOptions, + spawn: { + ...mockRuntimeOptions.spawn, + env: { T3_ACP_COMPLETE_FIRST_PROMPT_ON_CANCEL: "1" }, + }, + cancelBehavior: "wait-for-prompt", + }); + yield* runtime.start(); + const prompt = yield* runtime + .prompt( + { + prompt: [{ type: "text", text: "first" }], + }, + { dispatched }, + ) + .pipe(Effect.forkChild); + yield* Deferred.await(dispatched); + yield* Fiber.interrupt(prompt); + const events = yield* runtime.getEvents().pipe( + Stream.filter((event) => event._tag === "ConnectionTerminated"), + Stream.take(1), + Stream.runCollect, + ); + expect(events[0]).toMatchObject({ + error: { _tag: "AcpTransportError", method: "session/prompt" }, + }); + expect( + yield* runtime + .prompt({ + prompt: [{ type: "text", text: "must not run" }], + }) + .pipe(Effect.flip), + ).toBe(events[0]?.error); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + 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]) { + yield* Effect.gen(function* () { + const runtime = yield* AcpSessionRuntime.make({ + ...mockRuntimeOptions, + spawn: { ...mockRuntimeOptions.spawn, env: { T3_ACP_FLOOD_STDERR: "1" } }, + ...(logStderr + ? { + onStderr: (text: string) => + Effect.sync(() => { + lengths.push(text.length); + }), + } + : {}), + }); + expect(yield* runtime.initialize()).toMatchObject({ protocolVersion: 1 }); + }).pipe(Effect.scoped); + } + expect(lengths.length).toBeGreaterThan(0); + expect(Math.max(...lengths)).toBeGreaterThanOrEqual(16_384); + expect(Math.max(...lengths)).toBeLessThanOrEqual(32_768); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("releases a queued event drain when its runtime scope closes", () => + Effect.gen(function* () { + const scope = yield* Effect.acquireRelease(Scope.make(), (scope) => + Scope.close(scope, Exit.void), + ); + const barrierReceived = yield* Deferred.make(); + const runtime = yield* AcpSessionRuntime.make(mockRuntimeOptions).pipe( + Effect.provideService(Scope.Scope, scope), + ); + yield* runtime.start(); + yield* runtime.getEvents().pipe( + Stream.runForEach((event) => + event._tag === "EventStreamBarrier" + ? Deferred.succeed(barrierReceived, undefined).pipe(Effect.andThen(Effect.never)) + : Effect.void, + ), + Effect.forkIn(scope), + ); + const drain = yield* runtime.drainEvents.pipe(Effect.forkChild); + yield* Deferred.await(barrierReceived); + yield* Scope.close(scope, Exit.void); + yield* Fiber.join(drain); + yield* runtime.drainEvents; + expect(yield* runtime.initialize().pipe(Effect.flip)).toMatchObject({ + _tag: "AcpTransportError", + detail: "The ACP session runtime is closed.", + }); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("bounds native cancellation when its event consumer is absent", () => + Effect.gen(function* () { + const toolStarted = yield* Deferred.make(); + const cancelReceived = yield* Deferred.make(); + const runtime = yield* AcpSessionRuntime.make({ + ...mockRuntimeOptions, + spawn: { + ...mockRuntimeOptions.spawn, + env: { T3_ACP_COMPLETE_FIRST_PROMPT_ON_CANCEL: "1" }, + }, + cancelBehavior: "wait-for-prompt", + cancelTimeout: "1 second", + }); + yield* runtime.handleSessionUpdate((notification) => { + if (notification.update.sessionUpdate === "tool_call") { + return Deferred.succeed(toolStarted, undefined).pipe(Effect.asVoid); + } + if (notification.update.sessionUpdate === "agent_thought_chunk") { + return Deferred.succeed(cancelReceived, undefined).pipe(Effect.asVoid); + } + return Effect.void; + }); + yield* runtime.start(); + const prompt = yield* runtime + .prompt({ + prompt: [{ type: "text", text: "first" }], + }) + .pipe(Effect.forkChild); + yield* Deferred.await(toolStarted); + const cancellation = yield* runtime.cancel.pipe(Effect.forkChild); + yield* Deferred.await(cancelReceived); + yield* runtime.request("_test/finish-cancel", {}); + expect(yield* Fiber.join(prompt)).toMatchObject({ stopReason: "cancelled" }); + yield* TestClock.adjust("2 seconds"); + expect(yield* Fiber.join(cancellation).pipe(Effect.flip)).toMatchObject({ + _tag: "AcpTransportError", + method: "session/cancel", + }); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("does not restore ambient variables to a sanitized child environment", () => + Effect.gen(function* () { + yield* Effect.acquireRelease( + Effect.sync(() => { + const previous = process.env.T3_ACP_RUNTIME_AMBIENT; + process.env.T3_ACP_RUNTIME_AMBIENT = "sentinel"; + return previous; + }), + (previous) => + Effect.sync(() => { + if (previous === undefined) delete process.env.T3_ACP_RUNTIME_AMBIENT; + else process.env.T3_ACP_RUNTIME_AMBIENT = previous; + }), + ); + const runtime = yield* AcpSessionRuntime.make({ + ...mockRuntimeOptions, + spawn: { + command: process.execPath, + args: mockAgentArgs, + extendEnv: false, + env: { T3_ACP_RUNTIME_EXPLICIT: "kept" }, + }, + }); + yield* runtime.initialize(); + expect(yield* runtime.request("_test/environment", {})).toEqual({ + inherited: false, + explicit: true, + }); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + it.effect("merges custom initialize client capabilities into the ACP handshake", () => { const requestEvents: Array = []; return Effect.gen(function* () { diff --git a/apps/server/src/provider/acp/AcpRuntimeModel.test.ts b/apps/server/src/provider/acp/AcpRuntimeModel.test.ts index d752f5a3f454..a24b7d770df5 100644 --- a/apps/server/src/provider/acp/AcpRuntimeModel.test.ts +++ b/apps/server/src/provider/acp/AcpRuntimeModel.test.ts @@ -339,21 +339,27 @@ describe("AcpRuntimeModel", () => { ]); }); - it("parses available_commands_update into a CommandsUpdated event", () => { - const result = parseSessionUpdateEvent({ + it("parses available_commands_update into native and normalized command events", () => { + const availableCommands = [ + { name: "/help", description: "List available commands" }, + { name: "model", description: "Show or switch model", input: { hint: "model name" } }, + { name: "queue", description: "Queue a prompt", input: { hint: "prompt to run" } }, + { name: "steer", description: "Inject guidance" }, + ] satisfies ReadonlyArray; + const notification = { sessionId: "session-1", update: { sessionUpdate: "available_commands_update", - availableCommands: [ - { name: "/help", description: "List available commands" }, - { name: "model", description: "Show or switch model", input: { hint: "model name" } }, - { name: "queue", description: "Queue a prompt", input: { hint: "prompt to run" } }, - { name: "steer", description: "Inject guidance" }, - ], + availableCommands, }, - } satisfies EffectAcpSchema.SessionNotification); + } satisfies EffectAcpSchema.SessionNotification; - expect(result.events).toEqual([ + expect(parseSessionUpdateEvent(notification).events).toEqual([ + { + _tag: "AvailableCommandsUpdated", + availableCommands, + rawPayload: notification, + }, { _tag: "CommandsUpdated", commands: [ @@ -362,40 +368,61 @@ describe("AcpRuntimeModel", () => { { name: "queue", description: "Queue a prompt", inputHint: "prompt to run" }, { name: "steer", description: "Inject guidance" }, ], - rawPayload: { - sessionId: "session-1", - update: { - sessionUpdate: "available_commands_update", - availableCommands: [ - { name: "/help", description: "List available commands" }, - { name: "model", description: "Show or switch model", input: { hint: "model name" } }, - { name: "queue", description: "Queue a prompt", input: { hint: "prompt to run" } }, - { name: "steer", description: "Inject guidance" }, - ], - }, - }, + rawPayload: notification, }, ]); }); - it("preserves an empty available_commands_update", () => { + it("keeps thought chunks separate from assistant text", () => { const notification = { sessionId: "session-1", update: { - sessionUpdate: "available_commands_update", - availableCommands: [], + sessionUpdate: "agent_thought_chunk", + content: { type: "text", text: "Inspect the current implementation first." }, }, } satisfies EffectAcpSchema.SessionNotification; expect(parseSessionUpdateEvent(notification).events).toEqual([ { - _tag: "CommandsUpdated", - commands: [], + _tag: "ThoughtDelta", + text: "Inspect the current implementation first.", rawPayload: notification, }, ]); }); + it("preserves native command inputs and empty command lists", () => { + const availableCommands = [ + { name: "plan", description: "Plan a task", input: { hint: "task" } }, + { name: "logout", description: "Sign out" }, + ] satisfies ReadonlyArray; + + for (const commands of [availableCommands, []]) { + const notification = { + sessionId: "session-1", + update: { sessionUpdate: "available_commands_update", availableCommands: commands }, + } satisfies EffectAcpSchema.SessionNotification; + expect(parseSessionUpdateEvent(notification).events).toEqual([ + { + _tag: "AvailableCommandsUpdated", + availableCommands: commands, + rawPayload: notification, + }, + { + _tag: "CommandsUpdated", + commands: + commands.length === 0 + ? [] + : [ + { name: "plan", description: "Plan a task", inputHint: "task" }, + { name: "logout", description: "Sign out" }, + ], + rawPayload: notification, + }, + ]); + } + }); + it("keeps permission request parsing compatible with loose extension payloads", () => { const request = parsePermissionRequest({ sessionId: "session-1", diff --git a/apps/server/src/provider/acp/AcpRuntimeModel.ts b/apps/server/src/provider/acp/AcpRuntimeModel.ts index f8347a8711df..fa51dc6b70cf 100644 --- a/apps/server/src/provider/acp/AcpRuntimeModel.ts +++ b/apps/server/src/provider/acp/AcpRuntimeModel.ts @@ -86,11 +86,41 @@ export interface AcpAvailableCommand { readonly inputHint?: string | undefined; } +export function toAcpAvailableCommands( + commands: ReadonlyArray, +): ReadonlyArray { + const result: Array = []; + for (const command of commands) { + const name = command.name.trim(); + if (!name) { + continue; + } + const description = command.description?.trim() || undefined; + const inputHint = command.input?.hint.trim() || undefined; + result.push({ + name, + ...(description ? { description } : {}), + ...(inputHint ? { inputHint } : {}), + }); + } + return result; +} + export type AcpParsedSessionEvent = | { readonly _tag: "ModeChanged"; readonly modeId: string; } + | { + readonly _tag: "AvailableCommandsUpdated"; + readonly availableCommands: ReadonlyArray; + readonly rawPayload: unknown; + } + | { + readonly _tag: "ConfigOptionsUpdated"; + readonly configOptions: ReadonlyArray; + readonly rawPayload: unknown; + } | { readonly _tag: "AssistantItemStarted"; readonly itemId: string; @@ -115,6 +145,11 @@ export type AcpParsedSessionEvent = readonly text: string; readonly rawPayload: unknown; } + | { + readonly _tag: "ThoughtDelta"; + readonly text: string; + readonly rawPayload: unknown; + } | { readonly _tag: "CommandsUpdated"; readonly commands: ReadonlyArray; @@ -790,6 +825,27 @@ 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", + availableCommands: upd.availableCommands, + rawPayload: params, + }); + events.push({ + _tag: "CommandsUpdated", + commands: toAcpAvailableCommands(upd.availableCommands), + rawPayload: params, + }); + break; + } case "current_mode_update": { modeId = upd.currentModeId.trim(); if (modeId) { @@ -850,26 +906,14 @@ export function parseSessionUpdateEvent(params: EffectAcpSchema.SessionNotificat } break; } - case "available_commands_update": { - const commands: Array = []; - for (const command of upd.availableCommands) { - const name = command.name.trim(); - if (!name) { - continue; - } - const description = command.description?.trim() || undefined; - const inputHint = command.input?.hint.trim() || undefined; - commands.push({ - name, - ...(description ? { description } : {}), - ...(inputHint ? { inputHint } : {}), + case "agent_thought_chunk": { + if (upd.content.type === "text" && upd.content.text.length > 0) { + events.push({ + _tag: "ThoughtDelta", + text: upd.content.text, + rawPayload: params, }); } - events.push({ - _tag: "CommandsUpdated", - commands, - rawPayload: params, - }); break; } default: diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index ebc0eea631bf..63875e3ec713 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -5,6 +5,7 @@ import * as Crypto from "effect/Crypto"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -31,6 +32,7 @@ import { parseSessionModeState, parseSessionUpdateEvent, sessionUpdateIsReplay, + toAcpAvailableCommands, waitForSessionLoadReplayIdle, type SessionLoadGate, type AcpAvailableCommand, @@ -54,24 +56,39 @@ export interface AcpSessionEventStreamBarrier { readonly acknowledge: Deferred.Deferred; } -export type AcpSessionRuntimeEvent = AcpParsedSessionEvent | AcpSessionEventStreamBarrier; +export type AcpSessionRuntimeEvent = + | AcpParsedSessionEvent + | AcpSessionEventStreamBarrier + | { + readonly _tag: "ConnectionTerminated"; + readonly error: EffectAcpErrors.AcpError; + }; const defaultSessionLoadTimeout = Duration.seconds(90); const defaultSessionLoadReplayIdleGap = Duration.seconds(2); +const defaultCancelTimeout = Duration.seconds(15); +const maxStartupMetadataUpdates = 32; +// Antigravity can emit an accepted 16 KiB Google authorization URL on stderr. +const maxStderrChunkLength = 32_768; export interface AcpSpawnInput { readonly command: string; readonly args: ReadonlyArray; readonly cwd?: string; readonly env?: NodeJS.ProcessEnv; + readonly extendEnv?: boolean; } export interface AcpSessionRuntimeOptions { readonly spawn: AcpSpawnInput; readonly cwd: string; readonly resumeSessionId?: string; + readonly resumeMethod?: "load" | "resume"; readonly sessionLoadTimeout?: Duration.Input; readonly sessionLoadReplayIdleGap?: Duration.Input; + /** Native cancellation waits for the prompt response and the getEvents consumer to drain. */ + readonly cancelBehavior?: "interrupt" | "wait-for-prompt"; + readonly cancelTimeout?: Duration.Input; readonly clientCapabilities?: EffectAcpSchema.InitializeRequest["clientCapabilities"]; readonly clientInfo: { readonly name: string; @@ -88,6 +105,16 @@ export interface AcpSessionRuntimeOptions { | undefined | ((initializeResult: EffectAcpSchema.InitializeResponse) => string | undefined); readonly mcpServers?: ReadonlyArray; + /** Extra workspace roots the agent may read and write besides `cwd`. */ + readonly additionalDirectories?: ReadonlyArray; + /** Transforms provider stdout before protocol parsing and protocol logging. */ + readonly transformStdout?: EffectAcpClient.AcpClientOptions["transformStdout"]; + /** Normalizes provider-specific fields before notification queues or runtime state retain them. */ + readonly transformSessionUpdate?: ( + notification: EffectAcpSchema.SessionNotification, + ) => EffectAcpSchema.SessionNotification; + /** 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; @@ -207,9 +234,9 @@ export class AcpSessionRuntime extends Context.Service< * Concurrent calls share the same in-flight startup and a failed startup may be retried. */ readonly start: () => Effect.Effect; - /** Stream of parsed ACP session events emitted after startup. */ + /** Stream of parsed root-session events and connection failures. */ readonly getEvents: () => Stream.Stream; - /** Waits until the current event consumer has processed every queued event. */ + /** Waits for queued events to be processed, or for the runtime scope to close. */ readonly drainEvents: Effect.Effect; /** Latest mode state observed from session setup and `session/update` notifications. */ readonly getModeState: Effect.Effect; @@ -302,6 +329,11 @@ interface EnsureActiveAssistantSegmentResult { readonly startedEvent?: Extract; } +interface AcpActivePrompt { + readonly fiber: Fiber.Fiber; + readonly completed: Deferred.Deferred; +} + export const make = ( options: AcpSessionRuntimeOptions, ): Effect.Effect< @@ -330,12 +362,52 @@ export const make = ( const availableCommandsRef = yield* Ref.make>([]); const availableCommandsReady = yield* Deferred.make>(); const startStateRef = yield* Ref.make({ _tag: "NotStarted" }); - const promptStartCancelSemaphore = yield* Semaphore.make(1); - const activePromptFibersRef = yield* Ref.make< - ReadonlyArray> - >([]); + const startupMetadataRef = yield* Ref.make>( + [], + ); + const notificationSemaphore = yield* Semaphore.make(1); + const terminationErrorRef = yield* Ref.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); + const activePromptRef = yield* Ref.make>(Option.none()); const sessionLoadGateRef = yield* Ref.make>(Option.none()); + const ensureConnected = Effect.gen(function* () { + const error = yield* Ref.get(terminationErrorRef); + if (Option.isSome(error)) { + return yield* error.value; + } + if (yield* Ref.get(stoppingRef)) { + return yield* new EffectAcpErrors.AcpTransportError({ + detail: "The ACP session runtime is closed.", + cause: undefined, + }); + } + }); + + const recordTermination = Effect.fn("AcpSessionRuntime.recordTermination")(function* ( + error: EffectAcpErrors.AcpError, + ) { + if (yield* Ref.get(stoppingRef)) { + return; + } + const firstTermination = yield* Ref.modify(terminationErrorRef, (current) => + Option.isSome(current) + ? ([false, current] as const) + : ([true, Option.some(error)] as const), + ); + if (!firstTermination) { + return; + } + yield* closeActiveAssistantSegment({ queue: eventQueue, assistantSegmentRef }); + yield* Queue.offer(eventQueue, { _tag: "ConnectionTerminated", error }); + }); + const logRequest = (event: AcpSessionRequestLogEvent) => options.requestLogger ? options.requestLogger(event) : Effect.void; @@ -346,7 +418,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, @@ -367,16 +442,16 @@ export const make = ( ), ); - const spawnCommand = yield* resolveSpawnCommand( - options.spawn.command, - options.spawn.args, - options.spawn.env ? { env: options.spawn.env, extendEnv: true } : {}, - ); + const spawnCommand = yield* resolveSpawnCommand(options.spawn.command, options.spawn.args, { + ...(options.spawn.env ? { env: options.spawn.env } : {}), + extendEnv: options.spawn.extendEnv ?? true, + }); const child = yield* spawner .spawn( ChildProcess.make(spawnCommand.command, spawnCommand.args, { ...(options.spawn.cwd ? { cwd: options.spawn.cwd } : {}), - ...(options.spawn.env ? { env: options.spawn.env, extendEnv: true } : {}), + ...(options.spawn.env ? { env: options.spawn.env } : {}), + extendEnv: options.spawn.extendEnv ?? true, shell: spawnCommand.shell, }), ) @@ -391,8 +466,33 @@ export const make = ( ), ); + yield* child.stderr.pipe( + Stream.decodeText(), + Stream.runForEach((chunk) => + (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), + ); + const acpContext = yield* Layer.build( EffectAcpClient.layerChildProcess(child, { + ...(options.transformStdout ? { transformStdout: options.transformStdout } : {}), + ...(options.transformSessionUpdate + ? { transformSessionUpdate: options.transformSessionUpdate } + : {}), + onTermination: recordTermination, ...(options.protocolLogging?.logIncoming !== undefined ? { logIncoming: options.protocolLogging.logIncoming } : {}), @@ -405,59 +505,85 @@ export const make = ( const acp = yield* Effect.service(EffectAcpClient.AcpClient).pipe(Effect.provide(acpContext)); + const processSessionUpdate = (notification: EffectAcpSchema.SessionNotification) => + handleSessionUpdate({ + queue: eventQueue, + modeStateRef, + configOptionsRef, + toolCallsRef, + assistantSegmentRef, + assistantItemRuntimeId, + availableCommandsRef, + availableCommandsReady, + params: notification, + }); + yield* acp.handleSessionUpdate((notification) => - Effect.gen(function* () { - const gate = yield* Ref.get(sessionLoadGateRef); - const notificationSessionId = - typeof notification.sessionId === "string" ? notification.sessionId : undefined; - if ( - Option.isSome(gate) && - gate.value.active && - (notificationSessionId === undefined || notificationSessionId === gate.value.sessionId) - ) { - const lastActivityAtMillis = yield* Clock.currentTimeMillis; - yield* Ref.set( - sessionLoadGateRef, - Option.some({ - ...gate.value, - lastActivityAtMillis, - }), - ); - return; - } - if (sessionUpdateIsReplay(notification)) { - return; - } - const parsed = parseSessionUpdateEvent(notification); - const startState = yield* Ref.get(startStateRef); - if (startState._tag === "Starting") { - yield* recordAvailableCommands({ - events: parsed.events, - availableCommandsRef, - availableCommandsReady, - }); - return; - } - // One runtime projects one root ACP session. Child-session updates need - // explicit lineage routing and must never be flattened into this stream. - if ( - startState._tag !== "Started" || - notification.sessionId !== startState.result.sessionId - ) { - return; - } - yield* handleSessionUpdate({ - queue: eventQueue, - modeStateRef, - toolCallsRef, - assistantSegmentRef, - assistantItemRuntimeId, - availableCommandsRef, - availableCommandsReady, - parsed, - sessionId: notification.sessionId, - }); - }), + notificationSemaphore.withPermit( + Effect.gen(function* () { + if (Option.isSome(yield* Ref.get(terminationErrorRef))) { + return; + } + const gate = yield* Ref.get(sessionLoadGateRef); + const notificationSessionId = + typeof notification.sessionId === "string" ? notification.sessionId : undefined; + if ( + Option.isSome(gate) && + gate.value.active && + (notificationSessionId === undefined || + notificationSessionId === gate.value.sessionId || + notificationSessionId === options.resumeSessionId) + ) { + const lastActivityAtMillis = yield* Clock.currentTimeMillis; + yield* Ref.set( + sessionLoadGateRef, + Option.some({ + ...gate.value, + lastActivityAtMillis, + }), + ); + return; + } + if (sessionUpdateIsReplay(notification)) { + return; + } + const parsed = parseSessionUpdateEvent(notification); + const startState = yield* Ref.get(startStateRef); + if (startState._tag === "Starting") { + yield* recordAvailableCommands({ + events: parsed.events, + availableCommandsRef, + availableCommandsReady, + }); + if (isStartupMetadataUpdate(notification)) { + yield* Ref.update(startupMetadataRef, (current) => + [ + ...current.filter( + (previous) => + previous.sessionId !== notification.sessionId || + previous.update.sessionUpdate !== notification.update.sessionUpdate, + ), + notification, + ].slice(-maxStartupMetadataUpdates), + ); + } + return; + } + // One runtime projects one root ACP session. Child-session updates need + // explicit lineage routing and must never be flattened into this stream. + if ( + startState._tag !== "Started" || + notification.sessionId !== startState.result.sessionId + ) { + return; + } + yield* processSessionUpdate(notification); + }), + ), + ); + yield* Scope.addFinalizer( + runtimeScope, + Ref.set(stoppingRef, true).pipe(Effect.andThen(Deferred.succeed(runtimeClosed, undefined))), ); const initializeClientCapabilities = { fs: { @@ -474,6 +600,7 @@ export const make = ( } satisfies NonNullable; const getStartedState = Effect.gen(function* () { + yield* ensureConnected; const state = yield* Ref.get(startStateRef); if (state._tag === "Started") { return state.result; @@ -533,13 +660,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) => @@ -619,7 +750,43 @@ export const make = ( | EffectAcpSchema.LoadSessionResponse | EffectAcpSchema.NewSessionResponse | EffectAcpSchema.ResumeSessionResponse; - if (options.resumeSessionId) { + if (options.resumeSessionId && options.resumeMethod === "resume") { + if (!initializeResult.agentCapabilities?.sessionCapabilities?.resume) { + return yield* new EffectAcpErrors.AcpTransportError({ + method: "session/resume", + detail: "The ACP agent does not support session/resume.", + cause: undefined, + }); + } + const resumePayload = { + sessionId: options.resumeSessionId, + cwd: options.cwd, + mcpServers: options.mcpServers ?? [], + ...(options.additionalDirectories && options.additionalDirectories.length > 0 + ? { additionalDirectories: options.additionalDirectories } + : {}), + } satisfies EffectAcpSchema.ResumeSessionRequest; + sessionId = options.resumeSessionId; + sessionSetupResult = yield* runLoggedRequest( + "session/resume", + resumePayload, + acp.agent.resumeSession(resumePayload).pipe( + Effect.timeoutOption(options.sessionLoadTimeout ?? defaultSessionLoadTimeout), + Effect.flatMap((result) => + Option.isSome(result) + ? Effect.succeed(result.value) + : Effect.fail( + new EffectAcpErrors.AcpTransportError({ + operation: "call-rpc", + method: "session/resume", + detail: "session/resume timed out waiting for the agent response.", + cause: undefined, + }), + ), + ), + ), + ); + } else if (options.resumeSessionId) { const loadPayload = { sessionId: options.resumeSessionId, cwd: options.cwd, @@ -698,6 +865,9 @@ export const make = ( const createPayload = { cwd: options.cwd, mcpServers: options.mcpServers ?? [], + ...(options.additionalDirectories && options.additionalDirectories.length > 0 + ? { additionalDirectories: options.additionalDirectories } + : {}), } satisfies EffectAcpSchema.NewSessionRequest; const created = yield* runLoggedRequest( "session/new", @@ -721,6 +891,7 @@ export const make = ( }); const start = Effect.gen(function* () { + yield* ensureConnected; const deferred = yield* Deferred.make< AcpSessionRuntimeStartResult, EffectAcpErrors.AcpError @@ -735,13 +906,27 @@ export const make = ( return [ startOnce.pipe( Effect.tap((result) => - Ref.set(startStateRef, { _tag: "Started", result }).pipe( - Effect.andThen(Deferred.succeed(deferred, result)), + notificationSemaphore.withPermit( + Effect.gen(function* () { + const error = yield* Ref.get(terminationErrorRef); + if (Option.isSome(error)) { + return yield* error.value; + } + yield* Ref.set(startStateRef, { _tag: "Started", result }); + const metadata = yield* Ref.getAndSet(startupMetadataRef, []); + for (const notification of metadata) { + if (notification.sessionId === result.sessionId) { + yield* processSessionUpdate(notification); + } + } + yield* Deferred.succeed(deferred, result); + }), ), ), Effect.onError((cause) => Deferred.failCause(deferred, cause).pipe( Effect.andThen(Ref.set(startStateRef, { _tag: "NotStarted" })), + Effect.andThen(Ref.set(startupMetadataRef, [])), ), ), ), @@ -752,6 +937,61 @@ export const make = ( return yield* effect; }); + const drainEvents = Effect.gen(function* () { + if (yield* Ref.get(stoppingRef)) { + return; + } + const acknowledge = yield* Deferred.make(); + yield* Queue.offer(eventQueue, { _tag: "EventStreamBarrier", acknowledge }); + yield* Effect.raceFirst(Deferred.await(acknowledge), Deferred.await(runtimeClosed)); + }); + + const retireRuntime = Effect.fn("AcpSessionRuntime.retireRuntime")(function* ( + error: EffectAcpErrors.AcpError, + ) { + yield* recordTermination(error); + yield* child.kill({ forceKillAfter: "1 second" }).pipe(Effect.ignore); + }); + + const cancel = Effect.gen(function* () { + const started = yield* getStartedState; + const activePrompt = yield* Ref.get(activePromptRef); + if (options.cancelBehavior !== "wait-for-prompt") { + if (Option.isSome(activePrompt)) { + yield* Fiber.interrupt(activePrompt.value.fiber).pipe(Effect.ignore); + } + // Write cancel before a replacement prompt can reach the agent. + yield* acp.agent.cancel({ sessionId: started.sessionId }).pipe(Effect.ignore); + return; + } + + yield* acp.agent.cancel({ sessionId: started.sessionId }); + if (Option.isNone(activePrompt)) { + return; + } + const completed = yield* Effect.gen(function* () { + const result = yield* Fiber.await(activePrompt.value.fiber); + yield* Deferred.await(activePrompt.value.completed); + if (Option.isNone(yield* Ref.get(terminationErrorRef))) { + yield* drainEvents; + } + return result; + }).pipe(Effect.timeoutOption(options.cancelTimeout ?? defaultCancelTimeout)); + if (Option.isNone(completed)) { + const error = new EffectAcpErrors.AcpTransportError({ + operation: "call-rpc", + method: "session/cancel", + detail: "The ACP agent did not finish cancellation. Its process was stopped.", + cause: undefined, + }); + yield* retireRuntime(error); + return yield* error; + } + if (Exit.isFailure(completed.value)) { + return yield* Effect.failCause(completed.value.cause); + } + }); + return { handleRequestPermission: acp.handleRequestPermission, handleElicitation: acp.handleElicitation, @@ -768,87 +1008,77 @@ export const make = ( handleUnknownExtNotification: acp.handleUnknownExtNotification, handleExtRequest: acp.handleExtRequest, handleExtNotification: acp.handleExtNotification, - initialize: () => sendInitialize, + initialize: () => ensureConnected.pipe(Effect.andThen(sendInitialize)), start: () => start, getEvents: () => Stream.fromQueue(eventQueue), - drainEvents: Effect.gen(function* () { - const acknowledge = yield* Deferred.make(); - yield* Queue.offer(eventQueue, { - _tag: "EventStreamBarrier", - acknowledge, - }); - yield* Deferred.await(acknowledge); - }), + drainEvents, getModeState: Ref.get(modeStateRef), getConfigOptions: Ref.get(configOptionsRef), getAvailableCommands: Ref.get(availableCommandsRef), awaitAvailableCommands: Deferred.await(availableCommandsReady), prompt: (payload, promptOptions?) => - Effect.gen(function* () { - const started = yield* getStartedState; - yield* closeActiveAssistantSegment({ - queue: eventQueue, - assistantSegmentRef, - }); - const requestPayload = { - sessionId: started.sessionId, - ...payload, - } satisfies EffectAcpSchema.PromptRequest; - const cancelledResponse = { - stopReason: "cancelled", - } satisfies EffectAcpSchema.PromptResponse; - const promptRpcFiber = yield* promptStartCancelSemaphore.withPermit( - Effect.gen(function* () { - const fiber = yield* runLoggedRequest( - "session/prompt", - requestPayload, - acp.agent.prompt(requestPayload), - ).pipe(Effect.forkIn(runtimeScope)); - yield* Ref.update(activePromptFibersRef, (fibers) => [...fibers, fiber]); - return fiber; - }), - ); - if (promptOptions?.dispatched) { - yield* Deferred.succeed(promptOptions.dispatched, undefined); - } - return yield* Fiber.join(promptRpcFiber).pipe( - Effect.catchCause((cause) => - Cause.hasInterruptsOnly(cause) - ? Effect.succeed(cancelledResponse) - : Effect.failCause(cause), - ), - Effect.ensuring( + promptSerializationSemaphore.withPermit( + Effect.acquireUseRelease( + promptDispatchSemaphore.withPermit( Effect.gen(function* () { - yield* Fiber.interrupt(promptRpcFiber).pipe(Effect.ignore); - yield* Ref.update(activePromptFibersRef, (fibers) => - fibers.filter((fiber) => fiber !== promptRpcFiber), - ); + const started = yield* getStartedState; + yield* closeActiveAssistantSegment({ queue: eventQueue, assistantSegmentRef }); + const requestPayload = { + sessionId: started.sessionId, + ...payload, + } satisfies EffectAcpSchema.PromptRequest; + const completed = yield* Deferred.make(); + const fiber = yield* runLoggedRequest( + "session/prompt", + requestPayload, + acp.agent.prompt(requestPayload), + ).pipe(Effect.forkIn(runtimeScope)); + const active = { fiber, completed } satisfies AcpActivePrompt; + yield* Ref.set(activePromptRef, Option.some(active)); + if (promptOptions?.dispatched) { + yield* Deferred.succeed(promptOptions.dispatched, undefined); + } + return active; }), ), - Effect.tap(() => - closeActiveAssistantSegment({ - queue: eventQueue, - assistantSegmentRef, + (activePrompt) => + Fiber.join(activePrompt.fiber).pipe( + Effect.catchCause((cause) => + options.cancelBehavior !== "wait-for-prompt" && Cause.hasInterruptsOnly(cause) + ? Effect.succeed({ + stopReason: "cancelled", + } satisfies EffectAcpSchema.PromptResponse) + : Effect.failCause(cause), + ), + Effect.tap(() => + closeActiveAssistantSegment({ queue: eventQueue, assistantSegmentRef }), + ), + ), + (activePrompt, result) => + Effect.gen(function* () { + if ( + options.cancelBehavior === "wait-for-prompt" && + Exit.isFailure(result) && + Cause.hasInterrupts(result.cause) + ) { + yield* retireRuntime( + new EffectAcpErrors.AcpTransportError({ + method: "session/prompt", + detail: "The ACP prompt stopped before the agent confirmed completion.", + cause: undefined, + }), + ); + } + yield* Fiber.interrupt(activePrompt.fiber).pipe(Effect.ignore); + yield* Ref.set(activePromptRef, Option.none()); + yield* Deferred.succeed(activePrompt.completed, undefined); }), - ), - ); - }), - cancel: getStartedState.pipe( - Effect.flatMap((started) => - promptStartCancelSemaphore.withPermit( - Effect.gen(function* () { - const activePromptFibers = yield* Ref.get(activePromptFibersRef); - yield* Effect.forEach(activePromptFibers, (fiber) => - Fiber.interrupt(fiber).pipe(Effect.ignore), - ); - const payload = { sessionId: started.sessionId }; - yield* runLoggedRequest("session/cancel", payload, acp.agent.cancel(payload)).pipe( - Effect.ignore, - ); - }), ), ), - ), + cancel: + options.cancelBehavior === "wait-for-prompt" + ? promptDispatchSemaphore.withPermit(cancel) + : cancel, setMode: (modeId) => Ref.get(modeStateRef).pipe( Effect.flatMap((modeState) => { @@ -883,8 +1113,11 @@ export const make = ( }), ), request: (method, payload) => - runLoggedRequest(method, payload, acp.raw.request(method, payload)), - notify: acp.raw.notify, + ensureConnected.pipe( + Effect.andThen(runLoggedRequest(method, payload, acp.raw.request(method, payload))), + ), + notify: (method, payload) => + ensureConnected.pipe(Effect.andThen(acp.raw.notify(method, payload))), } satisfies AcpSessionRuntime["Service"]; }); @@ -920,41 +1153,58 @@ function configOptionCurrentValueMatches( return currentValue.trim() === String(value).trim(); } +function isStartupMetadataUpdate(notification: EffectAcpSchema.SessionNotification): boolean { + switch (notification.update.sessionUpdate) { + case "current_mode_update": + case "config_option_update": + case "available_commands_update": + return true; + default: + return false; + } +} + const handleSessionUpdate = ({ queue, modeStateRef, + configOptionsRef, toolCallsRef, assistantSegmentRef, assistantItemRuntimeId, availableCommandsRef, availableCommandsReady, - parsed, - sessionId, + params, }: { readonly queue: Queue.Queue; readonly modeStateRef: Ref.Ref; + readonly configOptionsRef: Ref.Ref>; readonly toolCallsRef: Ref.Ref>; readonly assistantSegmentRef: Ref.Ref; readonly assistantItemRuntimeId: string; readonly availableCommandsRef: Ref.Ref>; readonly availableCommandsReady: Deferred.Deferred>; - readonly parsed: ReturnType; - readonly sessionId: string; + readonly params: EffectAcpSchema.SessionNotification; }): Effect.Effect => Effect.gen(function* () { + if (params.update.sessionUpdate === "config_option_update") { + yield* Ref.set(configOptionsRef, params.update.configOptions); + } + const parsed = parseSessionUpdateEvent(params); if (parsed.modeId) { yield* Ref.update(modeStateRef, (current) => current === undefined ? current : updateModeState(current, parsed.modeId!), ); } for (const event of parsed.events) { - if (event._tag === "CommandsUpdated") { + if (event._tag === "CommandsUpdated" || event._tag === "AvailableCommandsUpdated") { yield* recordAvailableCommands({ events: [event], availableCommandsRef, availableCommandsReady, }); - yield* Queue.offer(queue, event); + if (event._tag === "AvailableCommandsUpdated") { + yield* Queue.offer(queue, event); + } continue; } if (event._tag === "ToolCallUpdated") { @@ -1006,7 +1256,7 @@ const handleSessionUpdate = ({ const itemId = yield* ensureActiveAssistantSegment({ queue, assistantSegmentRef, - sessionId, + sessionId: params.sessionId, assistantItemRuntimeId, }); yield* Queue.offer(queue, { @@ -1029,9 +1279,15 @@ const recordAvailableCommands = Effect.fn("AcpSessionRuntime.recordAvailableComm readonly availableCommandsReady: Deferred.Deferred>; }) { for (const event of events) { - if (event._tag !== "CommandsUpdated") continue; - yield* Ref.set(availableCommandsRef, event.commands); - yield* Deferred.succeed(availableCommandsReady, event.commands).pipe(Effect.asVoid); + if (event._tag === "CommandsUpdated") { + yield* Ref.set(availableCommandsRef, event.commands); + yield* Deferred.succeed(availableCommandsReady, event.commands).pipe(Effect.asVoid); + continue; + } + if (event._tag !== "AvailableCommandsUpdated") continue; + const commands = toAcpAvailableCommands(event.availableCommands); + yield* Ref.set(availableCommandsRef, commands); + yield* Deferred.succeed(availableCommandsReady, commands).pipe(Effect.asVoid); } }); diff --git a/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts b/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts new file mode 100644 index 000000000000..fd7590871073 --- /dev/null +++ b/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts @@ -0,0 +1,503 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import { + ANTIGRAVITY_DEFAULT_MODEL, + PROVIDER_SEND_TURN_MAX_FILE_BYTES, + PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, + type ChatAttachment, + type RuntimeMode, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as EffectAcpErrors from "effect-acp/errors"; +import type * as EffectAcpSchema from "effect-acp/schema"; + +import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { + ANTIGRAVITY_MAX_TEXT_ATTACHMENT_BYTES, + antigravityPermissionMode, + applyAntigravityAcpModelSelection, + buildAntigravityPrompt, +} from "./AntigravityAcpSupport.ts"; + +const modelConfig = { + id: "model", + name: "Model", + type: "select", + currentValue: "gemini-default", + options: [ + { value: "gemini-default", name: "Gemini default" }, + { value: "gemini-saved", name: "Gemini saved" }, + ], +} satisfies EffectAcpSchema.SessionConfigOption; + +function makeModelRuntime( + configOptions: ReadonlyArray = [modelConfig], + failure?: EffectAcpErrors.AcpError, +) { + const selections: string[] = []; + const setModel = Effect.fn("AntigravityAcpSupportTest.setModel")(function* (model: string) { + if (failure) return yield* failure; + selections.push(model); + }); + return { + runtime: { getConfigOptions: Effect.succeed(configOptions), setModel }, + selections, + }; +} + +describe("applyAntigravityAcpModelSelection", () => { + it.effect("restores the saved model instead of the cold-resume default", () => + Effect.gen(function* () { + const { runtime, selections } = makeModelRuntime(); + const model = yield* applyAntigravityAcpModelSelection({ + runtime, + model: "gemini-saved", + mapError: (cause) => cause, + }); + + expect(model).toBe("gemini-saved"); + expect(selections).toEqual(["gemini-saved"]); + }), + ); + + it.effect("reapplies an explicit selection even when setup reports the same model", () => + Effect.gen(function* () { + const { runtime, selections } = makeModelRuntime([ + { ...modelConfig, currentValue: "gemini-saved" }, + ]); + const model = yield* applyAntigravityAcpModelSelection({ + runtime, + model: "gemini-saved", + mapError: (cause) => cause, + }); + + expect(model).toBe("gemini-saved"); + expect(selections).toEqual(["gemini-saved"]); + }), + ); + + it.effect.each([undefined, null, ANTIGRAVITY_DEFAULT_MODEL])( + "uses the native default for %s without sending an internal model ID", + (requestedModel) => + Effect.gen(function* () { + const { runtime, selections } = makeModelRuntime(); + const model = yield* applyAntigravityAcpModelSelection({ + runtime, + model: requestedModel, + mapError: (cause) => cause, + }); + + expect(model).toBe("gemini-default"); + expect(selections).toEqual([]); + }), + ); + + it.effect("selects the manifest default for the alias when the account offers it", () => + Effect.gen(function* () { + const { runtime, selections } = makeModelRuntime(); + const model = yield* applyAntigravityAcpModelSelection({ + runtime, + model: ANTIGRAVITY_DEFAULT_MODEL, + defaultModel: "gemini-saved", + mapError: (cause) => cause, + }); + expect(model).toBe("gemini-saved"); + expect(selections).toEqual(["gemini-saved"]); + + const { runtime: other, selections: otherSelections } = makeModelRuntime(); + const fallback = yield* applyAntigravityAcpModelSelection({ + runtime: other, + model: ANTIGRAVITY_DEFAULT_MODEL, + defaultModel: "gemini-not-offered", + mapError: (cause) => cause, + }); + expect(fallback).toBe("gemini-default"); + expect(otherSelections).toEqual([]); + }), + ); + + it.effect.each(["gemini-removed", "Gemini saved", "gemini-saved[reasoning=high]"])( + "rejects unavailable or non-native model ID %s without selecting a fallback", + (model) => + Effect.gen(function* () { + const { runtime, selections } = makeModelRuntime(); + const error = yield* applyAntigravityAcpModelSelection({ + runtime, + model, + mapError: (cause) => cause, + }).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "AcpRequestError", + code: -32602, + errorMessage: expect.stringContaining(`'${model}' is unavailable`), + }); + expect(selections).toEqual([]); + }), + ); + + it.effect("accepts exact model IDs from grouped native options", () => + Effect.gen(function* () { + const { runtime, selections } = makeModelRuntime([ + { + ...modelConfig, + options: [{ group: "gemini", name: "Gemini", options: modelConfig.options }], + }, + ]); + const model = yield* applyAntigravityAcpModelSelection({ + runtime, + model: "gemini-saved", + mapError: (cause) => cause, + }); + + expect(model).toBe("gemini-saved"); + expect(selections).toEqual(["gemini-saved"]); + }), + ); + + it.effect("reports a native model-selection failure through the adapter error mapper", () => + Effect.gen(function* () { + const nativeError = EffectAcpErrors.AcpRequestError.invalidParams("Model access changed."); + const { runtime } = makeModelRuntime([modelConfig], nativeError); + const error = yield* applyAntigravityAcpModelSelection({ + runtime, + model: "gemini-saved", + mapError: (cause) => ({ operation: "select-model", cause }), + }).pipe(Effect.flip); + + expect(error).toEqual({ operation: "select-model", cause: nativeError }); + }), + ); +}); + +describe("antigravityPermissionMode", () => { + it.each([ + { runtimeMode: "approval-required", nativeMode: "default" }, + { runtimeMode: "auto", nativeMode: "default" }, + { runtimeMode: "medium-access", nativeMode: "default" }, + { runtimeMode: "auto-accept-edits", nativeMode: "auto_edit" }, + { runtimeMode: "full-access", nativeMode: "yolo" }, + ] satisfies ReadonlyArray<{ runtimeMode: RuntimeMode; nativeMode: string }>)( + "maps $runtimeMode to $nativeMode", + ({ runtimeMode, nativeMode }) => { + expect(antigravityPermissionMode(runtimeMode)).toBe(nativeMode); + }, + ); +}); + +const imageAttachment = { + type: "image", + id: "thread-00000000-0000-4000-8000-000000000001", + name: "screen.png", + mimeType: "image/png", + sizeBytes: 1, +} satisfies ChatAttachment; + +const textAttachment = { + type: "file", + id: "thread-00000000-0000-4000-8000-000000000002-tsx", + name: "example.tsx", + mimeType: "application/octet-stream", + sizeBytes: 1, +} satisfies ChatAttachment; + +const pdfAttachment = { + type: "file", + id: "thread-00000000-0000-4000-8000-000000000003-pdf", + name: "report.pdf", + mimeType: "application/pdf", + sizeBytes: 1, +} satisfies ChatAttachment; + +const makeAttachmentFixture = Effect.fn("AntigravityAcpSupportTest.makeAttachmentFixture")( + function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const attachmentsDir = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-antigravity-attachments-", + }); + const write = Effect.fn("AntigravityAcpSupportTest.writeAttachment")(function* ( + attachment: ChatAttachment, + content: string | Uint8Array, + ) { + const filePath = resolveAttachmentPath({ attachmentsDir, attachment }); + if (filePath === null) throw new Error("Invalid test attachment path."); + if (typeof content === "string") yield* fs.writeFileString(filePath, content); + else yield* fs.writeFile(filePath, content); + return { filePath, uri: (yield* path.toFileUrl(filePath)).href }; + }); + return { fs, attachmentsDir, write }; + }, +); + +it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { + it.effect("sends image bytes as native image content alongside the user prompt", () => + Effect.gen(function* () { + const fixture = yield* makeAttachmentFixture(); + const bytes = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jY9kAAAAASUVORK5CYII=", + "base64", + ); + yield* fixture.write(imageAttachment, bytes); + const prompt = yield* buildAntigravityPrompt({ + input: " Explain this image. ", + attachments: [imageAttachment], + attachmentsDir: fixture.attachmentsDir, + }); + + expect(prompt).toEqual([ + { type: "text", text: "Explain this image." }, + { type: "image", data: bytes.toString("base64"), mimeType: "image/png" }, + ]); + }), + ); + + it.effect("embeds UTF-8 code from the selected environment and keeps the upload in place", () => + Effect.gen(function* () { + const selectedEnvironment = yield* makeAttachmentFixture(); + const otherEnvironment = yield* makeAttachmentFixture(); + const source = ' const name = "caf\u00e9";\n'; + const upload = yield* selectedEnvironment.write(textAttachment, source); + yield* otherEnvironment.write(textAttachment, "Different environment."); + const prompt = yield* buildAntigravityPrompt({ + input: undefined, + attachments: [textAttachment], + attachmentsDir: selectedEnvironment.attachmentsDir, + }); + + expect(prompt).toEqual([ + { + type: "resource", + resource: { uri: upload.uri, mimeType: "application/octet-stream", text: source }, + }, + ]); + expect(yield* selectedEnvironment.fs.readFileString(upload.filePath)).toBe(source); + expect( + yield* selectedEnvironment.fs.readDirectory(selectedEnvironment.attachmentsDir), + ).toHaveLength(1); + }), + ); + + it.effect("sends supported audio files as native audio content", () => + Effect.gen(function* () { + const fixture = yield* makeAttachmentFixture(); + const audioAttachment = { + ...textAttachment, + id: "recording-1", + name: "recording.wav", + mimeType: "audio/wav", + } satisfies ChatAttachment; + const bytes = Buffer.from("RIFF....WAVEfmt ", "latin1"); + yield* fixture.write(audioAttachment, bytes); + const prompt = yield* buildAntigravityPrompt({ + input: "Transcribe this.", + attachments: [audioAttachment], + attachmentsDir: fixture.attachmentsDir, + }); + + expect(prompt).toEqual([ + { type: "text", text: "Transcribe this." }, + { type: "audio", data: bytes.toString("base64"), mimeType: "audio/wav" }, + ]); + }), + ); + + it.effect("uses a PDF file resource link without copying or reading its bytes", () => + Effect.gen(function* () { + const fixture = yield* makeAttachmentFixture(); + const upload = yield* fixture.write(pdfAttachment, "%PDF-1.7\n"); + const forbidFileAccess = () => + Effect.die("PDF prompt construction must only inspect the file."); + const prompt = yield* buildAntigravityPrompt({ + input: undefined, + attachments: [pdfAttachment], + attachmentsDir: fixture.attachmentsDir, + }).pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fixture.fs, + readFile: forbidFileAccess, + writeFile: forbidFileAccess, + writeFileString: forbidFileAccess, + copyFile: forbidFileAccess, + copy: forbidFileAccess, + rename: forbidFileAccess, + }), + ); + + expect(prompt).toEqual([ + { type: "resource_link", uri: upload.uri, name: "report.pdf", mimeType: "application/pdf" }, + ]); + }), + ); + + it.effect.each([ + { ...imageAttachment, name: "animation.gif", mimeType: "image/gif" }, + { ...textAttachment, name: "archive.zip", mimeType: "application/zip" }, + { ...textAttachment, name: "recording.aiff", mimeType: "audio/aiff" }, + ] satisfies ReadonlyArray)( + "rejects $name instead of silently dropping it from a valid prompt", + (attachment) => + Effect.gen(function* () { + const fixture = yield* makeAttachmentFixture(); + yield* fixture.write(imageAttachment, new Uint8Array([1, 2, 3])); + const error = yield* buildAntigravityPrompt({ + input: "Analyze every attachment.", + attachments: [imageAttachment, attachment], + attachmentsDir: fixture.attachmentsDir, + }).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "AcpRequestError", + code: -32602, + errorMessage: expect.stringContaining(`does not support '${attachment.name}'`), + }); + }), + ); + + it.effect.each([ + { attachment: textAttachment, bytes: ANTIGRAVITY_MAX_TEXT_ATTACHMENT_BYTES + 1 }, + { attachment: imageAttachment, bytes: PROVIDER_SEND_TURN_MAX_IMAGE_BYTES + 1 }, + { attachment: pdfAttachment, bytes: PROVIDER_SEND_TURN_MAX_FILE_BYTES + 1 }, + ])( + "rejects oversized $attachment.name using file size instead of upload metadata", + ({ attachment, bytes }) => + Effect.gen(function* () { + const fixture = yield* makeAttachmentFixture(); + const upload = yield* fixture.write(attachment, ""); + yield* fixture.fs.truncate(upload.filePath, bytes); + const error = yield* buildAntigravityPrompt({ + input: "Read this attachment.", + attachments: [attachment], + attachmentsDir: fixture.attachmentsDir, + }).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "AcpRequestError", + code: -32602, + errorMessage: expect.stringContaining(`'${attachment.name}' is too large`), + }); + }), + ); + + it.effect("accepts 50 MiB in total but rejects one byte more across files", () => + Effect.gen(function* () { + const fixture = yield* makeAttachmentFixture(); + const secondAttachment = { + ...pdfAttachment, + id: `${pdfAttachment.id}-second`, + name: "second.pdf", + }; + const first = yield* fixture.write(pdfAttachment, ""); + const second = yield* fixture.write(secondAttachment, ""); + yield* fixture.fs.truncate(first.filePath, PROVIDER_SEND_TURN_MAX_FILE_BYTES / 2); + yield* fixture.fs.truncate(second.filePath, PROVIDER_SEND_TURN_MAX_FILE_BYTES / 2); + const input = { + input: undefined, + attachments: [pdfAttachment, secondAttachment], + attachmentsDir: fixture.attachmentsDir, + }; + const prompt = yield* buildAntigravityPrompt(input); + expect(prompt).toEqual([ + { type: "resource_link", uri: first.uri, name: "report.pdf", mimeType: "application/pdf" }, + { type: "resource_link", uri: second.uri, name: "second.pdf", mimeType: "application/pdf" }, + ]); + + yield* fixture.fs.truncate(second.filePath, PROVIDER_SEND_TURN_MAX_FILE_BYTES / 2 + 1); + const error = yield* buildAntigravityPrompt(input).pipe(Effect.flip); + expect(error).toMatchObject({ + _tag: "AcpRequestError", + code: -32602, + errorMessage: expect.stringContaining("'second.pdf' is too large"), + }); + }), + ); + + it.effect("rejects aggregate overflow when a text upload grows after its size check", () => + Effect.gen(function* () { + const fixture = yield* makeAttachmentFixture(); + const pdf = yield* fixture.write(pdfAttachment, ""); + const text = yield* fixture.write(textAttachment, "a"); + yield* fixture.fs.truncate(pdf.filePath, PROVIDER_SEND_TURN_MAX_FILE_BYTES - 1); + const error = yield* buildAntigravityPrompt({ + input: undefined, + attachments: [pdfAttachment, textAttachment], + attachmentsDir: fixture.attachmentsDir, + }).pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fixture.fs, + stat: Effect.fn("AntigravityAcpSupportTest.growAfterStat")(function* (filePath: string) { + const info = yield* fixture.fs.stat(filePath); + if (filePath === text.filePath) { + yield* fixture.fs.writeFileString(filePath, "ab"); + } + return info; + }), + }), + Effect.flip, + ); + + expect(error).toMatchObject({ + _tag: "AcpRequestError", + code: -32602, + errorMessage: "Attachment 'example.tsx' changed while being read and is too large.", + }); + }), + ); + + it.effect.each([ + { bytes: new Uint8Array([0xff, 0xfe, 0x61]), message: "is not a UTF-8 text file" }, + { bytes: new Uint8Array([0x61, 0, 0x62]), message: "contains binary data" }, + ])("rejects binary data disguised as code: $message", ({ bytes, message }) => + Effect.gen(function* () { + const fixture = yield* makeAttachmentFixture(); + yield* fixture.write(textAttachment, bytes); + const error = yield* buildAntigravityPrompt({ + input: undefined, + attachments: [textAttachment], + attachmentsDir: fixture.attachmentsDir, + }).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "AcpRequestError", + code: -32602, + errorMessage: expect.stringContaining(message), + }); + }), + ); + + it.effect("reports a missing upload instead of sending only the remaining text", () => + Effect.gen(function* () { + const fixture = yield* makeAttachmentFixture(); + const error = yield* buildAntigravityPrompt({ + input: "Read this image.", + attachments: [imageAttachment], + attachmentsDir: fixture.attachmentsDir, + }).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "AcpRequestError", + code: -32602, + errorMessage: "Could not read attachment 'screen.png'.", + }); + }), + ); + + it.effect("rejects an empty turn", () => + Effect.gen(function* () { + const fixture = yield* makeAttachmentFixture(); + const error = yield* buildAntigravityPrompt({ + input: " ", + attachments: [], + attachmentsDir: fixture.attachmentsDir, + }).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "AcpRequestError", + code: -32602, + errorMessage: "A turn requires text or supported attachments.", + }); + }), + ); +}); diff --git a/apps/server/src/provider/acp/AntigravityAcpSupport.ts b/apps/server/src/provider/acp/AntigravityAcpSupport.ts new file mode 100644 index 000000000000..a481475ba914 --- /dev/null +++ b/apps/server/src/provider/acp/AntigravityAcpSupport.ts @@ -0,0 +1,363 @@ +import { + ANTIGRAVITY_DEFAULT_MODEL, + type AntigravityAuthMethod, + PROVIDER_SEND_TURN_MAX_FILE_BYTES, + PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, + type ProviderSendTurnInput, + type RuntimeMode, +} from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +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 Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import * as EffectAcpErrors from "effect-acp/errors"; +import type * as EffectAcpSchema from "effect-acp/schema"; + +import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { + makeAntigravityStderrHandler, + makeAntigravityStdoutTransform, +} from "../antigravityAuthSupport.ts"; +import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; +import { normalizeAntigravitySessionUpdate } from "./AntigravityProtocol.ts"; + +export interface AntigravityAcpRuntimeInput extends Omit< + AcpSessionRuntime.AcpSessionRuntimeOptions, + | "authMethodId" + | "cancelBehavior" + | "clientCapabilities" + | "onStderr" + | "resumeMethod" + | "transformSessionUpdate" + | "transformStdout" +> { + readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; + readonly onAuthorizationUrl?: (url: string) => Effect.Effect; + /** + * Advertise `fs.readTextFile` and `fs.writeTextFile`. The agent then routes + * workspace reads and writes through T3, which turns each edit into a + * `session/request_permission` with the file content, instead of writing + * through its own tools. Chat sessions turn this on. Setup, probe, and text + * generation helpers leave it off so they never touch a workspace. + */ + readonly clientFileSystem?: boolean; + /** ACP `authenticate` method id. Defaults to the personal Google account flow. */ + readonly authMethod?: AntigravityAuthMethod; +} + +/** Normal launches reject browser login; only the auth flow supplies `onAuthorizationUrl`. */ +export const makeAntigravityAcpRuntime = Effect.fn("makeAntigravityAcpRuntime")(function* ( + input: AntigravityAcpRuntimeInput, +): Effect.fn.Return< + AcpSessionRuntime.AcpSessionRuntime["Service"], + EffectAcpErrors.AcpError, + Crypto.Crypto | Scope.Scope +> { + const context = yield* Layer.build( + AcpSessionRuntime.layer({ + ...input, + authMethodId: input.authMethod ?? "oauth-personal", + resumeMethod: "resume", + cancelBehavior: "wait-for-prompt", + clientCapabilities: { + fs: { + readTextFile: input.clientFileSystem === true, + writeTextFile: input.clientFileSystem === true, + }, + terminal: false, + }, + transformStdout: makeAntigravityStdoutTransform( + input.onAuthorizationUrl ? { onAuthorizationUrl: input.onAuthorizationUrl } : {}, + ), + onStderr: makeAntigravityStderrHandler( + input.onAuthorizationUrl ? { onAuthorizationUrl: input.onAuthorizationUrl } : {}, + ), + transformSessionUpdate: normalizeAntigravitySessionUpdate, + }).pipe( + Layer.provide( + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, input.childProcessSpawner), + ), + ), + ); + return yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe(Effect.provide(context)); +}); + +export function antigravityPermissionMode(runtimeMode: RuntimeMode): string { + switch (runtimeMode) { + case "full-access": + return "yolo"; + case "auto-accept-edits": + return "auto_edit"; + case "auto": + case "approval-required": + case "medium-access": + return "default"; + } +} + +export function antigravityModelOptions( + configOptions: ReadonlyArray, +) { + const model = configOptions.find((option) => option.id === "model"); + if (model?.type !== "select") return []; + return model.options.flatMap((entry) => ("value" in entry ? [entry] : entry.options)); +} + +/** + * Resolves the model a turn should run on. A saved selection is reapplied + * as-is. The provider default alias resolves to `defaultModel` when the + * account offers it, so T3 can pick a newer model than the one Google marks + * current. Otherwise the agent's current selection stands. + */ +export function resolveAntigravityModel(input: { + readonly configOptions: ReadonlyArray; + readonly model: string | null | undefined; + readonly defaultModel?: string | undefined; +}): string | undefined { + const modelConfig = input.configOptions.find((option) => option.id === "model"); + const current = modelConfig?.type === "select" ? modelConfig.currentValue : undefined; + if (input.model && input.model !== ANTIGRAVITY_DEFAULT_MODEL) return input.model; + const options = antigravityModelOptions(input.configOptions); + return input.defaultModel && options.some((option) => option.value === input.defaultModel) + ? input.defaultModel + : current; +} + +/** Never replace a saved selection with the default returned by a cold resume. */ +export const applyAntigravityAcpModelSelection = Effect.fn("applyAntigravityAcpModelSelection")( + function* (input: { + readonly runtime: Pick< + AcpSessionRuntime.AcpSessionRuntime["Service"], + "getConfigOptions" | "setModel" + >; + readonly model: string | null | undefined; + /** Model to select for the provider default alias. See `resolveAntigravityModel`. */ + readonly defaultModel?: string | undefined; + readonly mapError: (cause: EffectAcpErrors.AcpError) => E; + }): Effect.fn.Return { + const configOptions = yield* input.runtime.getConfigOptions; + const modelConfig = configOptions.find((option) => option.id === "model"); + const current = modelConfig?.type === "select" ? modelConfig.currentValue : undefined; + const resolved = resolveAntigravityModel({ + configOptions, + model: input.model, + defaultModel: input.defaultModel, + }); + // The default alias never sends an internal ID. It selects the manifest + // default when that differs from the agent's current model, and otherwise + // leaves the agent's choice alone. + const explicit = Boolean(input.model) && input.model !== ANTIGRAVITY_DEFAULT_MODEL; + if (resolved === undefined || (!explicit && resolved === current)) return current; + const options = antigravityModelOptions(configOptions); + if (!options.some((option) => option.value === resolved)) { + return yield* Effect.fail( + input.mapError( + EffectAcpErrors.AcpRequestError.invalidParams( + `Antigravity model '${resolved}' is unavailable for this Google account. Select an available model.`, + ), + ), + ); + } + yield* input.runtime.setModel(resolved).pipe(Effect.mapError(input.mapError)); + return resolved; + }, +); + +const IMAGE_MIME_TYPES = new Set(["image/bmp", "image/jpeg", "image/png", "image/webp"]); +// Formats the bundled SDK's Audio type accepts. Anything else is rejected up front. +const AUDIO_MIME_TYPES = new Set([ + "audio/aac", + "audio/flac", + "audio/mp3", + "audio/mpeg", + "audio/mp4", + "audio/m4a", + "audio/x-m4a", + "audio/ogg", + "audio/wav", + "audio/x-wav", + "audio/webm", +]); +export const ANTIGRAVITY_MAX_AUDIO_ATTACHMENT_BYTES = 20 * 1024 * 1024; +const TEXT_MIME_TYPES = new Set([ + "application/json", + "application/ld+json", + "application/javascript", + "application/typescript", + "application/xml", + "application/yaml", + "application/x-yaml", + "application/x-sh", +]); +const TEXT_FILE_EXTENSIONS = new Set([ + ".txt", + ".md", + ".mdx", + ".json", + ".jsonl", + ".yaml", + ".yml", + ".toml", + ".xml", + ".csv", + ".tsv", + ".js", + ".jsx", + ".mjs", + ".cjs", + ".ts", + ".tsx", + ".html", + ".css", + ".scss", + ".less", + ".py", + ".rs", + ".go", + ".java", + ".kt", + ".swift", + ".c", + ".h", + ".cc", + ".cpp", + ".hpp", + ".cs", + ".rb", + ".php", + ".sh", + ".bash", + ".zsh", + ".sql", + ".graphql", + ".svelte", + ".vue", + ".log", + ".diff", + ".patch", + ".ini", + ".conf", +]); +export const ANTIGRAVITY_MAX_TEXT_ATTACHMENT_BYTES = 1024 * 1024; +const MAX_TOTAL_ATTACHMENT_BYTES = PROVIDER_SEND_TURN_MAX_FILE_BYTES; + +/** Sends uploads as native ACP content instead of workspace path hints. */ +export const buildAntigravityPrompt = Effect.fn("buildAntigravityPrompt")(function* (input: { + readonly input: ProviderSendTurnInput["input"]; + readonly attachments: ProviderSendTurnInput["attachments"]; + readonly attachmentsDir: string; +}): Effect.fn.Return< + ReadonlyArray, + EffectAcpErrors.AcpError, + FileSystem.FileSystem | Path.Path +> { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const blocks: Array = []; + const text = input.input?.trim(); + if (text) blocks.push({ type: "text", text }); + let totalBytes = 0; + + for (const attachment of input.attachments ?? []) { + const mimeType = attachment.mimeType.toLowerCase().split(";", 1)[0] ?? ""; + const image = attachment.type === "image" && IMAGE_MIME_TYPES.has(mimeType); + const audio = attachment.type === "file" && AUDIO_MIME_TYPES.has(mimeType); + const pdf = attachment.type === "file" && mimeType === "application/pdf"; + const textFile = + attachment.type === "file" && + (mimeType.startsWith("text/") || + TEXT_MIME_TYPES.has(mimeType) || + TEXT_FILE_EXTENSIONS.has(path.extname(attachment.name).toLowerCase())); + if (!image && !audio && !pdf && !textFile) { + return yield* EffectAcpErrors.AcpRequestError.invalidParams( + `Antigravity does not support '${attachment.name}' (${attachment.mimeType}). Attach a BMP, JPEG, PNG, WebP, PDF, audio, or text file.`, + ); + } + const attachmentPath = resolveAttachmentPath({ + attachmentsDir: input.attachmentsDir, + attachment, + }); + if (!attachmentPath) { + return yield* EffectAcpErrors.AcpRequestError.invalidParams( + `Invalid attachment '${attachment.name}'.`, + ); + } + const info = yield* fileSystem + .stat(attachmentPath) + .pipe( + Effect.mapError(() => + EffectAcpErrors.AcpRequestError.invalidParams( + `Could not read attachment '${attachment.name}'.`, + ), + ), + ); + const size = Number(info.size); + const limit = image + ? PROVIDER_SEND_TURN_MAX_IMAGE_BYTES + : audio + ? ANTIGRAVITY_MAX_AUDIO_ATTACHMENT_BYTES + : pdf + ? PROVIDER_SEND_TURN_MAX_FILE_BYTES + : ANTIGRAVITY_MAX_TEXT_ATTACHMENT_BYTES; + totalBytes += size; + if (info.type !== "File" || size > limit || totalBytes > MAX_TOTAL_ATTACHMENT_BYTES) { + return yield* EffectAcpErrors.AcpRequestError.invalidParams( + `Attachment '${attachment.name}' is too large. Antigravity accepts text files up to 1 MiB, images up to 10 MiB, audio up to 20 MiB, and 50 MiB total attachments.`, + ); + } + const uri = yield* path.toFileUrl(attachmentPath).pipe( + Effect.map((url) => url.href), + Effect.mapError(() => + EffectAcpErrors.AcpRequestError.invalidParams(`Invalid attachment '${attachment.name}'.`), + ), + ); + if (pdf) { + blocks.push({ type: "resource_link", uri, name: attachment.name, mimeType }); + continue; + } + const bytes = yield* fileSystem.stream(attachmentPath, { bytesToRead: limit + 1 }).pipe( + Stream.runCollect, + Effect.map((chunks) => Buffer.concat(chunks)), + Effect.mapError(() => + EffectAcpErrors.AcpRequestError.invalidParams( + `Could not read attachment '${attachment.name}'.`, + ), + ), + ); + totalBytes += bytes.length - size; + if (bytes.length > limit || totalBytes > MAX_TOTAL_ATTACHMENT_BYTES) { + return yield* EffectAcpErrors.AcpRequestError.invalidParams( + `Attachment '${attachment.name}' changed while being read and is too large.`, + ); + } + if (image) { + blocks.push({ type: "image", data: Buffer.from(bytes).toString("base64"), mimeType }); + } else if (audio) { + blocks.push({ type: "audio", data: Buffer.from(bytes).toString("base64"), mimeType }); + } else { + const decoded = yield* Effect.try({ + try: () => new TextDecoder("utf-8", { fatal: true }).decode(bytes), + catch: () => + EffectAcpErrors.AcpRequestError.invalidParams( + `Attachment '${attachment.name}' is not a UTF-8 text file.`, + ), + }); + if (decoded.includes("\0")) { + return yield* EffectAcpErrors.AcpRequestError.invalidParams( + `Attachment '${attachment.name}' contains binary data.`, + ); + } + blocks.push({ type: "resource", resource: { uri, mimeType, text: decoded } }); + } + } + if (blocks.length === 0) { + return yield* EffectAcpErrors.AcpRequestError.invalidParams( + "A turn requires text or supported attachments.", + ); + } + return blocks; +}); diff --git a/apps/server/src/provider/acp/AntigravityProtocol.test.ts b/apps/server/src/provider/acp/AntigravityProtocol.test.ts new file mode 100644 index 000000000000..4f3368353ddf --- /dev/null +++ b/apps/server/src/provider/acp/AntigravityProtocol.test.ts @@ -0,0 +1,485 @@ +import { describe, expect, it } from "vite-plus/test"; +import * as Schema from "effect/Schema"; +import * as EffectAcpSchema from "effect-acp/schema"; + +import { + extractAntigravityUserInputQuestion, + isAntigravityOpenCommand, + antigravityApprovalOptions, + antigravitySubagentResult, + isAntigravitySubagentReplayStart, + classifyAntigravitySubagentToolCall, + isAntigravityUserInputRequest, + makeAntigravityUserInputResponse, + normalizeAntigravitySessionUpdate, + normalizeAntigravityToolCall, + sanitizeAntigravityToolPayload, + selectAntigravityPermissionOptionId, +} from "./AntigravityProtocol.ts"; +import { mergeToolCallState, parseSessionUpdateEvent } from "./AcpRuntimeModel.ts"; + +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: { + toolCallId: "interaction_9960062f", + status: "pending", + title: "Which result label should be used for the verification?", + rawInput: {}, + }, + options: [ + { optionId: "1", name: "Verified", kind: "allow_once" }, + { optionId: "2", name: "Needs review", kind: "allow_once" }, + ], +} satisfies EffectAcpSchema.RequestPermissionRequest; + +const commandStarted = { + sessionId: "session-1", + update: { + sessionUpdate: "tool_call", + toolCallId: "fc28d0af6ad14be8bbba20f4258d4d3e", + title: "run_command", + kind: "execute", + status: "in_progress", + rawInput: { CommandLine: "cat probe.txt", Cwd: "/workspace" }, + }, +} satisfies EffectAcpSchema.SessionNotification; + +const commandCompleted = { + sessionId: "session-1", + update: { + toolCallId: commandStarted.update.toolCallId, + status: "completed", + rawOutput: { + commandLine: "cat probe.txt", + workingDir: "/workspace", + exitCode: 0, + exit_code: 0, + combinedOutput: "after\n", + formatted_output: "after\n", + }, + sessionUpdate: "tool_call_update", + }, +} satisfies EffectAcpSchema.SessionNotification; + +function parseToolUpdate(notification: EffectAcpSchema.SessionNotification) { + const result = parseSessionUpdateEvent(normalizeAntigravitySessionUpdate(notification)); + const event = result.events[0]; + if (event?._tag !== "ToolCallUpdated") { + throw new Error("Expected a tool update."); + } + return event; +} + +describe("Antigravity permissions and questions", () => { + const permissionRequest = { + sessionId: "session-1", + toolCall: { toolCallId: "command-1", kind: "execute", title: "Run command" }, + options: [ + { optionId: "remember-this-command", name: "Allow always", kind: "allow_always" }, + { optionId: "run-this-time", name: "Allow", kind: "allow_once" }, + { optionId: "stop-this-command", name: "Deny", kind: "reject_once" }, + ], + } satisfies EffectAcpSchema.RequestPermissionRequest; + + it("uses only the option IDs offered for each approval decision", () => { + expect(selectAntigravityPermissionOptionId(permissionRequest, "accept")).toBe("run-this-time"); + expect(selectAntigravityPermissionOptionId(permissionRequest, "acceptForSession")).toBe( + "remember-this-command", + ); + expect(selectAntigravityPermissionOptionId(permissionRequest, "acceptAlways")).toBe( + "remember-this-command", + ); + expect(selectAntigravityPermissionOptionId(permissionRequest, "decline")).toBe( + "stop-this-command", + ); + expect(selectAntigravityPermissionOptionId(permissionRequest, "cancel")).toBeUndefined(); + }); + + it("surfaces the agent's prompt injection warning on the remembered approval", () => { + const risky = { + ...permissionRequest, + options: [ + { + optionId: "remember-this-command", + name: "Allow Always (risky)", + kind: "allow_always", + _meta: { + "agy.security.warning": { + severity: "high", + risk: "prompt_injection", + title: "Allowing always can be risky", + message: "Untrusted files could re-run this action without asking.", + }, + }, + }, + { optionId: "run-this-time", name: "Allow", kind: "allow_once" }, + { optionId: "stop-this-command", name: "Deny", kind: "reject_once" }, + ], + } satisfies EffectAcpSchema.RequestPermissionRequest; + expect(antigravityApprovalOptions(risky)).toEqual([ + { decision: "accept", label: "Allow once" }, + { + decision: "acceptForSession", + label: "Allow for this thread", + warning: "Untrusted files could re-run this action without asking.", + }, + { decision: "decline", label: "Deny" }, + { decision: "cancel", label: "Cancel" }, + ]); + expect(antigravityApprovalOptions(permissionRequest)[1]).not.toHaveProperty("warning"); + }); + + it("does not replace unsupported remembered approval with a single approval", () => { + const request = { + ...permissionRequest, + options: permissionRequest.options.filter((option) => option.kind !== "allow_always"), + }; + expect(selectAntigravityPermissionOptionId(request, "acceptAlways")).toBeUndefined(); + expect(selectAntigravityPermissionOptionId(request, "acceptForSession")).toBeUndefined(); + expect(selectAntigravityPermissionOptionId(request, "accept")).toBe("run-this-time"); + expect( + selectAntigravityPermissionOptionId({ ...request, options: [] }, "decline"), + ).toBeUndefined(); + }); + + it("routes the captured native question to single-choice input, not approval", () => { + expect(isAntigravityUserInputRequest(questionRequest)).toBe(true); + expect(selectAntigravityPermissionOptionId(questionRequest, "accept")).toBeUndefined(); + expect(extractAntigravityUserInputQuestion(questionRequest)).toEqual({ + id: "interaction_9960062f", + header: "Question", + question: "Which result label should be used for the verification?", + multiSelect: false, + allowCustomAnswer: false, + options: [ + { value: "1", label: "Verified", description: "Verified" }, + { value: "2", label: "Needs review", description: "Needs review" }, + ], + }); + expect(extractAntigravityUserInputQuestion(permissionRequest)).toBeUndefined(); + }); + + it("returns exact opaque IDs and accepts a unique label from an older client", () => { + for (const answer of ["1", ["1"], "Verified"]) { + expect( + makeAntigravityUserInputResponse(questionRequest, { interaction_9960062f: answer }), + ).toEqual({ outcome: { outcome: "selected", optionId: "1" } }); + } + const request = { + ...questionRequest, + options: [{ optionId: " choice: opaque ", name: "Keep", kind: "allow_once" as const }], + }; + expect( + makeAntigravityUserInputResponse(request, { interaction_9960062f: " choice: opaque " }), + ).toEqual({ outcome: { outcome: "selected", optionId: " choice: opaque " } }); + }); + + it("does not treat a question's reject choice as cancellation", () => { + const request = { + ...questionRequest, + options: [{ optionId: "deny", name: "Do not trust", kind: "reject_once" as const }], + }; + expect(makeAntigravityUserInputResponse(request, { interaction_9960062f: "deny" })).toEqual({ + outcome: { outcome: "selected", optionId: "deny" }, + }); + }); + + it("preserves duplicate labels and rejects ambiguous label answers", () => { + const request = { + ...questionRequest, + options: questionRequest.options.map((option) => ({ ...option, name: "Same label" })), + }; + expect( + extractAntigravityUserInputQuestion(request)?.options.map((option) => option.value), + ).toEqual(["1", "2"]); + expect( + makeAntigravityUserInputResponse(request, { interaction_9960062f: "Same label" }), + ).toBeUndefined(); + expect(makeAntigravityUserInputResponse(request, { interaction_9960062f: "2" })).toEqual({ + outcome: { outcome: "selected", optionId: "2" }, + }); + }); + + it.each([undefined, null, "", "arbitrary answer", [], ["1", "2"], { answer: "1" }, 1])( + "keeps the question open for an unsupported answer: %j", + (answer) => { + expect( + makeAntigravityUserInputResponse(questionRequest, { interaction_9960062f: answer }), + ).toBeUndefined(); + }, + ); + + it("rejects missing or duplicate native option IDs", () => { + for (const optionId of ["", "1"]) { + const request = { + ...questionRequest, + options: [questionRequest.options[0]!, { ...questionRequest.options[1]!, optionId }], + }; + expect(extractAntigravityUserInputQuestion(request)).toBeUndefined(); + expect( + makeAntigravityUserInputResponse(request, { interaction_9960062f: "1" }), + ).toBeUndefined(); + } + }); + + it("bounds question text without changing choice IDs", () => { + const request = { + ...questionRequest, + toolCall: { ...questionRequest.toolCall, title: "Question ".repeat(2_000) }, + options: [ + { optionId: "stable-choice", name: "Choice ".repeat(2_000), kind: "allow_once" as const }, + ], + }; + const question = extractAntigravityUserInputQuestion(request); + expect(question?.question.length).toBeLessThanOrEqual(8_000); + expect(question?.options[0]?.label.length).toBeLessThanOrEqual(512); + expect(question?.options[0]?.value).toBe("stable-choice"); + }); +}); + +describe("Antigravity tool results", () => { + it("normalizes the captured command and completed output for the existing clients", () => { + const initial = parseToolUpdate(commandStarted).toolCall; + const completed = parseToolUpdate(commandCompleted).toolCall; + const toolCall = normalizeAntigravityToolCall(mergeToolCallState(initial, completed)); + + expect(toolCall).toMatchObject({ + kind: "execute", + status: "completed", + command: "cat probe.txt", + detail: "cat probe.txt", + data: { + command: "cat probe.txt", + cwd: "/workspace", + item: { + command: "cat probe.txt", + cwd: "/workspace", + aggregatedOutput: "after\n", + exitCode: 0, + }, + }, + }); + expect(toolCall.data.rawOutput).not.toHaveProperty("formatted_output"); + expect(commandCompleted.update.rawOutput.formatted_output).toBe("after\n"); + }); + + it.each([ + { CommandLine: "pwd", Cwd: "/one" }, + { CommandLine: "pwd", WorkingDirectory: "/one" }, + { command_line: "pwd", working_dir: "/one" }, + { commandLine: "pwd", workingDir: "/one" }, + { command: "pwd", cwd: "/one" }, + ])("handles native command input aliases: %j", (rawInput) => { + const event = parseToolUpdate({ + ...commandStarted, + update: { ...commandStarted.update, rawInput }, + }); + expect(normalizeAntigravityToolCall(event.toolCall)).toMatchObject({ + command: "pwd", + detail: "pwd", + data: { item: { command: "pwd", cwd: "/one" } }, + }); + }); + + it("recovers command fields from history and keeps nonzero exits separate from tool failure", () => { + const event = parseToolUpdate({ + ...commandCompleted, + update: { + ...commandCompleted.update, + rawOutput: { + command_line: "test -f missing.txt", + working_dir: "/workspace", + combined_output: "", + exit_code: 1, + }, + }, + }); + expect(normalizeAntigravityToolCall(event.toolCall)).toMatchObject({ + kind: "execute", + status: "completed", + command: "test -f missing.txt", + data: { item: { cwd: "/workspace", aggregatedOutput: "", exitCode: 1 } }, + }); + }); + + it("bounds both canonical output and retained raw output", () => { + const output = `${"output line\n".repeat(20_000)}last line\n`; + const event = parseToolUpdate({ + ...commandCompleted, + update: { + ...commandCompleted.update, + rawOutput: { + ...commandCompleted.update.rawOutput, + combinedOutput: output, + formatted_output: output, + }, + }, + }); + const toolCall = normalizeAntigravityToolCall(event.toolCall); + expect(toolCall.data).toMatchObject({ + item: { aggregatedOutput: expect.stringContaining("last line\n") }, + rawOutput: { combinedOutput: expect.stringContaining("last line\n") }, + }); + expect(JSON.stringify(toolCall).length).toBeLessThan(20_000); + expect(JSON.stringify(event.rawPayload).length).toBeLessThan(10_000); + expect(JSON.stringify(event.rawPayload)).not.toContain("formatted_output"); + }); + + it("removes inline images while preserving valid tool content and the local image path", () => { + const inlineImage = "inline-image-bytes".repeat(50_000); + const notification = { + sessionId: "session-1", + update: { + sessionUpdate: "tool_call_update", + toolCallId: "image-1", + status: "completed", + rawOutput: { imageName: "result", imagePath: "file:///workspace/brain/result%20image.png" }, + content: [ + { type: "content", content: { type: "image", mimeType: "image/png", data: inlineImage } }, + { type: "content", content: { type: "text", text: "Saved the image." } }, + { type: "diff", path: "/workspace/note.txt", oldText: "old", newText: "new" }, + ], + }, + } satisfies EffectAcpSchema.SessionNotification; + const normalized = normalizeAntigravitySessionUpdate(notification); + expect(isSessionNotification(normalized)).toBe(true); + expect(JSON.stringify(normalized)).not.toContain("inline-image-bytes"); + expect(normalized.update).toMatchObject({ + content: [ + { type: "content", content: { type: "text", text: "Saved the image." } }, + { type: "diff", path: "/workspace/note.txt", oldText: "old", newText: "new" }, + ], + }); + expect(normalizeAntigravityToolCall(parseToolUpdate(normalized).toolCall).data.imagePath).toBe( + "/workspace/brain/result image.png", + ); + }); + + it.each([ + ["/workspace/result.png", "/workspace/result.png"], + ["C:\\work\\result.png", "C:\\work\\result.png"], + ["file:///C:/work/result.png", "C:/work/result.png"], + ["https://example.com/result.png", undefined], + ["file://another-host/result.png", undefined], + ["data:image/png;base64,AAAA", undefined], + ])("only promotes local image references: %s", (imagePath, expected) => { + const toolCall = normalizeAntigravityToolCall({ + toolCallId: "image-1", + data: { rawOutput: { imagePath } }, + }); + expect(toolCall.data.imagePath).toBe(expected); + }); + + it("bounds nested tool data and drops image blobs and data URLs", () => { + const payload = sanitizeAntigravityToolPayload({ + rawOutput: { + text: "a".repeat(100_000), + result: { mimeType: "image/png", blob: "image-blob".repeat(100_000) }, + image: { type: "image", data: "inline-image".repeat(100_000) }, + uri: "data:image/png;base64,inline-data-url", + }, + }); + const serialized = JSON.stringify(payload); + expect(serialized.length).toBeLessThan(9_000); + expect(serialized).not.toContain("image-blob"); + expect(serialized).not.toContain("inline-image"); + expect(serialized).not.toContain("inline-data-url"); + }); + + it("keeps large assistant replies and startup command metadata unchanged", () => { + const notifications = [ + { + sessionId: "session-1", + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "a".repeat(20_000) }, + }, + }, + { + sessionId: "session-1", + update: { + sessionUpdate: "available_commands_update", + availableCommands: [{ name: "plan", description: "Make a plan" }], + }, + }, + ] satisfies ReadonlyArray; + for (const notification of notifications) { + expect(normalizeAntigravitySessionUpdate(notification)).toBe(notification); + } + }); + + it("tracks only commands that have passed approval and are still running", () => { + const running = normalizeAntigravityToolCall(parseToolUpdate(commandStarted).toolCall); + expect(isAntigravityOpenCommand(running)).toBe(true); + expect(isAntigravityOpenCommand({ ...running, status: "pending" })).toBe(false); + expect(isAntigravityOpenCommand({ ...running, kind: "read" })).toBe(false); + const completed = normalizeAntigravityToolCall( + mergeToolCallState(running, parseToolUpdate(commandCompleted).toolCall), + ); + expect(isAntigravityOpenCommand(completed)).toBe(false); + }); +}); diff --git a/apps/server/src/provider/acp/AntigravityProtocol.ts b/apps/server/src/provider/acp/AntigravityProtocol.ts new file mode 100644 index 000000000000..72048a8bb7e9 --- /dev/null +++ b/apps/server/src/provider/acp/AntigravityProtocol.ts @@ -0,0 +1,382 @@ +import type { + ProviderApprovalDecision, + ProviderApprovalOption, + ProviderUserInputAnswers, + UserInputQuestion, +} from "@t3tools/contracts"; +import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; +import * as Option from "effect/Option"; +import * as Predicate from "effect/Predicate"; +import * as Schema from "effect/Schema"; +import * as EffectAcpSchema from "effect-acp/schema"; + +import type { AcpToolCallState } from "./AcpRuntimeModel.ts"; + +const TOOL_TEXT_LIMIT = 8_000; +const TOOL_TEXT_TRUNCATED = "[Earlier output truncated]\n\n"; +const QUESTION_LABEL_LIMIT = 512; + +const NativeToolFields = Schema.Struct({ + command: Schema.optional(Schema.String), + CommandLine: Schema.optional(Schema.String), + command_line: Schema.optional(Schema.String), + commandLine: Schema.optional(Schema.String), + cwd: Schema.optional(Schema.String), + Cwd: Schema.optional(Schema.String), + WorkingDirectory: Schema.optional(Schema.String), + working_dir: Schema.optional(Schema.String), + workingDir: Schema.optional(Schema.String), + combinedOutput: Schema.optional(Schema.String), + combined_output: Schema.optional(Schema.String), + exitCode: Schema.optional(Schema.Int), + exit_code: Schema.optional(Schema.Int), + imagePath: Schema.optional(Schema.String), +}); +const decodeNativeToolFields = Schema.decodeUnknownOption(NativeToolFields); +const decodeSingleAnswer = Schema.decodeUnknownOption( + Schema.Union([Schema.String, Schema.Tuple([Schema.String])]), +); +const decodeToolCallContent = Schema.decodeUnknownOption(EffectAcpSchema.ToolCallContent); + +/** Native questions share the permission method, but their choices are not approvals. */ +export function isAntigravityUserInputRequest( + request: EffectAcpSchema.RequestPermissionRequest, +): boolean { + return request.toolCall.toolCallId.startsWith("interaction_"); +} + +export function selectAntigravityPermissionOptionId( + request: EffectAcpSchema.RequestPermissionRequest, + decision: ProviderApprovalDecision, +): string | undefined { + if (decision === "cancel" || isAntigravityUserInputRequest(request)) { + return undefined; + } + const kind = + decision === "accept" ? "allow_once" : decision === "decline" ? "reject_once" : "allow_always"; + const option = request.options.find((entry) => entry.kind === kind); + return option?.optionId.trim() ? option.optionId : undefined; +} + +/** Copy truncated text so V8 cannot retain the original large string. */ +const SECURITY_WARNING_META_KEY = "agy.security.warning"; +const WARNING_TEXT_LIMIT = 512; +const decodeSecurityWarning = Schema.decodeUnknownOption( + Schema.Struct({ + title: Schema.optional(Schema.String), + message: Schema.optional(Schema.String), + }), +); + +/** + * The agent marks "Allow Always" on shell and web tools with a prompt injection + * warning in `_meta`. Surface it as option text so both clients can show it. + */ +export function antigravitySecurityWarning( + option: EffectAcpSchema.PermissionOption, +): string | undefined { + const meta = option._meta; + if (!Predicate.isObject(meta)) return undefined; + const warning = Option.getOrUndefined(decodeSecurityWarning(meta[SECURITY_WARNING_META_KEY])); + const text = warning?.message?.trim() || warning?.title?.trim(); + if (!text) return undefined; + return text.length > WARNING_TEXT_LIMIT + ? copyBoundedText(`${text.slice(0, WARNING_TEXT_LIMIT - 3)}...`) + : text; +} + +/** Only advertise decisions that the native request can honor. */ +export function antigravityApprovalOptions( + request: EffectAcpSchema.RequestPermissionRequest, +): ReadonlyArray { + if (isAntigravityUserInputRequest(request)) return []; + const options: ProviderApprovalOption[] = []; + const optionWithKind = (kind: EffectAcpSchema.PermissionOption["kind"]) => + request.options.find((entry) => entry.kind === kind && entry.optionId.trim()); + const once = optionWithKind("allow_once"); + if (once) { + options.push({ decision: "accept", label: "Allow once" }); + } + const always = optionWithKind("allow_always"); + if (always) { + const warning = antigravitySecurityWarning(always); + options.push({ + decision: "acceptForSession", + label: "Allow for this thread", + ...(warning ? { warning } : {}), + }); + } + if (optionWithKind("reject_once")) { + options.push({ decision: "decline", label: "Deny" }); + } + options.push({ decision: "cancel", label: "Cancel" }); + return options; +} + +function copyBoundedText(text: string): string { + return Buffer.from(text, "utf16le").toString("utf16le"); +} + +function questionLabel(option: EffectAcpSchema.PermissionOption): string { + const label = option.name.trim() || option.optionId; + return label.length > QUESTION_LABEL_LIMIT + ? copyBoundedText(`${label.slice(0, QUESTION_LABEL_LIMIT - 3)}...`) + : label; +} + +export function extractAntigravityUserInputQuestion( + request: EffectAcpSchema.RequestPermissionRequest, +): UserInputQuestion | undefined { + if (!isAntigravityUserInputRequest(request) || request.options.length === 0) { + return undefined; + } + const ids = new Set(); + for (const option of request.options) { + if (!option.optionId.trim() || ids.has(option.optionId)) { + return undefined; + } + ids.add(option.optionId); + } + const question = request.toolCall.title?.trim() || "Choose an option."; + return { + id: request.toolCall.toolCallId, + header: "Question", + question: + question.length > TOOL_TEXT_LIMIT + ? copyBoundedText(`${question.slice(0, TOOL_TEXT_LIMIT - 3)}...`) + : question, + multiSelect: false, + allowCustomAnswer: false, + options: request.options.map((option) => ({ + value: option.optionId, + label: questionLabel(option), + description: questionLabel(option), + })), + }; +} + +/** Return undefined for an invalid answer so the adapter keeps the question open. */ +export function makeAntigravityUserInputResponse( + request: EffectAcpSchema.RequestPermissionRequest, + answers: ProviderUserInputAnswers, +): EffectAcpSchema.RequestPermissionResponse | undefined { + if (extractAntigravityUserInputQuestion(request) === undefined) { + return undefined; + } + const answer = Option.getOrUndefined(decodeSingleAnswer(answers[request.toolCall.toolCallId])); + const value = typeof answer === "string" ? answer : answer?.[0]; + if (value === undefined) { + return undefined; + } + const exact = request.options.find((option) => option.optionId === value); + if (exact) { + return { outcome: { outcome: "selected", optionId: exact.optionId } }; + } + const matchingLabels = request.options.filter((option) => questionLabel(option) === value); + const option = matchingLabels.length === 1 ? matchingLabels[0] : undefined; + return option ? { outcome: { outcome: "selected", optionId: option.optionId } } : undefined; +} + +function boundText(text: string, limit = TOOL_TEXT_LIMIT): string { + return text.length <= limit + ? text + : copyBoundedText(`${TOOL_TEXT_TRUNCATED}${text.slice(-limit)}`); +} + +interface ToolPayloadBudget { + nodes: number; + text: number; +} + +function sanitizeToolValue(value: unknown, budget: ToolPayloadBudget, depth: number): unknown { + if (depth > 12 || budget.nodes-- <= 0) { + return undefined; + } + if (typeof value === "string") { + if (/^data:image\//i.test(value) || budget.text <= 0) { + return undefined; + } + const text = boundText(value, Math.min(TOOL_TEXT_LIMIT, budget.text)); + budget.text -= text.length; + return text; + } + if (Array.isArray(value)) { + const result: unknown[] = []; + for (const entry of value) { + if (budget.nodes <= 0) break; + const sanitized = sanitizeToolValue(entry, budget, depth + 1); + if (sanitized !== undefined) result.push(sanitized); + } + return result; + } + if (!Predicate.isObject(value)) { + return value; + } + const entries: Array = []; + for (const [key, entry] of Object.entries(value)) { + if (budget.nodes <= 0) break; + if ( + (value.type === "image" && (key === "data" || key === "blob")) || + (key === "blob" && + typeof value.mimeType === "string" && + value.mimeType.startsWith("image/")) || + ((key === "formatted_output" || key === "formattedOutput") && + (entry === value.combinedOutput || entry === value.combined_output)) + ) { + continue; + } + const sanitized = sanitizeToolValue(entry, budget, depth + 1); + if (sanitized !== undefined) entries.push([key, sanitized]); + } + return Object.fromEntries(entries); +} + +/** Bound both retained raw events and display data before they enter the event stream. */ +export function sanitizeAntigravityToolPayload(payload: unknown): unknown { + return sanitizeToolValue(payload, { nodes: 512, text: 64_000 }, 0); +} + +/** The runtime uses this before it retains tool state or dispatches raw callbacks. */ +export function normalizeAntigravitySessionUpdate( + notification: EffectAcpSchema.SessionNotification, +): EffectAcpSchema.SessionNotification { + const update = notification.update; + if (update.sessionUpdate !== "tool_call" && update.sessionUpdate !== "tool_call_update") { + return notification; + } + const contentBudget = { nodes: 512, text: 32_000 }; + const content = update.content?.flatMap((entry) => { + const decoded = Option.getOrUndefined( + decodeToolCallContent(sanitizeToolValue(entry, contentBudget, 0)), + ); + return decoded === undefined ? [] : [decoded]; + }); + const meta = sanitizeAntigravityToolPayload(update._meta); + return { + ...notification, + update: { + ...update, + ...(typeof update.title === "string" ? { title: boundText(update.title) } : {}), + ...(update.rawInput !== undefined + ? { rawInput: sanitizeAntigravityToolPayload(update.rawInput) } + : {}), + ...(update.rawOutput !== undefined + ? { rawOutput: sanitizeAntigravityToolPayload(update.rawOutput) } + : {}), + ...(update.content !== undefined ? { content: content ?? [] } : {}), + ...(update._meta !== undefined ? { _meta: Predicate.isObject(meta) ? meta : null } : {}), + }, + }; +} + +function localImagePath(imagePath: string | undefined): string | undefined { + if (!imagePath || imagePath.length > TOOL_TEXT_LIMIT) { + return undefined; + } + const path = imagePath.trim(); + if (!isWorkspaceImagePreviewPath(path)) { + return undefined; + } + if (/^file:\/\//i.test(path)) { + try { + const url = new URL(path); + if (url.hostname && url.hostname !== "localhost") return undefined; + const pathname = decodeURIComponent(url.pathname); + return /^\/[a-z]:\//i.test(pathname) ? pathname.slice(1) : pathname; + } catch { + return undefined; + } + } + return /^[a-z][a-z\d+.-]*:/i.test(path) && !/^[a-z]:[\\/]/i.test(path) ? undefined : path; +} + +export function normalizeAntigravityToolCall(toolCall: AcpToolCallState): AcpToolCallState { + const input = Option.getOrUndefined(decodeNativeToolFields(toolCall.data.rawInput)); + const output = Option.getOrUndefined(decodeNativeToolFields(toolCall.data.rawOutput)); + const nativeCommand = + input?.CommandLine ?? + input?.command_line ?? + input?.commandLine ?? + input?.command ?? + output?.commandLine ?? + output?.command_line ?? + toolCall.command; + const command = nativeCommand?.trim() ? boundText(nativeCommand.trim()) : undefined; + const nativeCwd = + input?.Cwd ?? + input?.WorkingDirectory ?? + input?.working_dir ?? + input?.workingDir ?? + input?.cwd ?? + output?.workingDir ?? + output?.working_dir; + const cwd = nativeCwd?.trim() ? boundText(nativeCwd.trim()) : undefined; + const nativeOutput = output?.combinedOutput ?? output?.combined_output; + const aggregatedOutput = nativeOutput === undefined ? undefined : boundText(nativeOutput); + const exitCode = output?.exitCode ?? output?.exit_code; + const imagePath = localImagePath(output?.imagePath); + const sanitizedData = sanitizeAntigravityToolPayload(toolCall.data); + const data: Record = Predicate.isObject(sanitizedData) ? sanitizedData : {}; + const kind = toolCall.kind ?? (command !== undefined ? "execute" : undefined); + if (kind !== undefined) data.kind = kind; + if (command !== undefined) data.command = command; + if (cwd !== undefined) data.cwd = cwd; + if (imagePath !== undefined) data.imagePath = imagePath; + if (kind === "execute") { + data.item = { + ...(Predicate.isObject(data.item) ? data.item : {}), + ...(command !== undefined ? { command } : {}), + ...(cwd !== undefined ? { cwd } : {}), + ...(aggregatedOutput !== undefined ? { aggregatedOutput } : {}), + ...(exitCode !== undefined ? { exitCode } : {}), + }; + } + return { + ...toolCall, + ...(kind !== undefined ? { kind } : {}), + ...(command !== undefined ? { command } : {}), + ...(toolCall.title !== undefined ? { title: boundText(toolCall.title) } : {}), + ...(command !== undefined + ? { detail: command } + : toolCall.detail !== undefined + ? { detail: boundText(toolCall.detail) } + : {}), + data, + }; +} + +/** Only commands still running after end_turn become background tasks. */ +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/acp/AntigravitySessionFiles.ts b/apps/server/src/provider/acp/AntigravitySessionFiles.ts new file mode 100644 index 000000000000..b00bffbc1646 --- /dev/null +++ b/apps/server/src/provider/acp/AntigravitySessionFiles.ts @@ -0,0 +1,43 @@ +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +const isNativeSessionId = Schema.is(Schema.String.check(Schema.isUUID(4))); +const decodeSessionMetadata = Schema.decodeEffect( + Schema.fromJsonString(Schema.Struct({ cwd: Schema.String })), +); + +/** Call after the process closes. The unique temporary cwd proves which session we own. */ +export const removeAntigravitySessionFiles = Effect.fn("removeAntigravitySessionFiles")( + function* (input: { + readonly profileDirectory: string; + readonly sessionId: string | undefined; + readonly cwd: string; + }) { + if (input.sessionId === undefined || !isNativeSessionId(input.sessionId)) { + return; + } + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const acpDirectory = path.join(input.profileDirectory, "antigravity-acp"); + const base = path.join(acpDirectory, "conversations", input.sessionId); + if (!(yield* fs.exists(`${base}.meta`))) { + return; + } + const metadata = yield* fs + .readFileString(`${base}.meta`) + .pipe(Effect.flatMap(decodeSessionMetadata)); + if (metadata.cwd !== input.cwd) { + return; + } + for (const suffix of [".db", ".db-wal", ".db-shm", ".db-journal", ".meta"]) { + yield* fs.remove(`${base}${suffix}`, { force: true }); + } + yield* fs.remove(path.join(acpDirectory, "brain", input.sessionId), { + recursive: true, + force: true, + }); + }, + Effect.catch(() => Effect.logWarning("Could not remove temporary Antigravity session files.")), +); diff --git a/apps/server/src/provider/antigravityAuthSupport.test.ts b/apps/server/src/provider/antigravityAuthSupport.test.ts new file mode 100644 index 000000000000..03189018bd31 --- /dev/null +++ b/apps/server/src/provider/antigravityAuthSupport.test.ts @@ -0,0 +1,614 @@ +// @effect-diagnostics-next-line nodeBuiltinImport:off +import * as NodeChildProcess from "node:child_process"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { ProviderInstanceId } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +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 Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as Ndjson from "effect/unstable/encoding/Ndjson"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import * as AcpErrors from "effect-acp/errors"; + +import { + ANTIGRAVITY_AUTH_BROWSER_MARKER, + ANTIGRAVITY_AUTH_STDOUT_PREFIX, + ANTIGRAVITY_PERSONAL_AUTH, + ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE, + type AntigravityAuthConfig, + antigravityAuthConfigIssue, + type AntigravityProfile, + antigravityProfileSettings, + buildAntigravityAcpSpawnInput, + isAntigravitySignInRequiredError, + makeAntigravityStderrHandler, + makeAntigravityStdoutTransform, + parseAntigravityAuthorizationUrl, + prepareAntigravityProfile, + resolveAntigravityProfileDirectory, +} from "./antigravityAuthSupport.ts"; + +const authorizationUrl = + "https://accounts.google.com/o/oauth2/v2/auth?response_type=code" + + "&client_id=test-client&redirect_uri=http%3A%2F%2F127.0.0.1%3A46353%2F" + + "&state=test-opaque-state&code_challenge=test-challenge&code_challenge_method=S256"; +const authLine = `${ANTIGRAVITY_AUTH_STDOUT_PREFIX}${authorizationUrl}\n`; +const encode = (text: string) => new TextEncoder().encode(text); +const encodeUnknownJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); + +const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); + +describe("Antigravity process environment", () => { + const profile: AntigravityProfile = { + platform: "linux", + geminiHome: "/t3/userdata/providers/antigravity/profile", + acpDirectory: "/t3/userdata/providers/antigravity/profile/antigravity-acp", + tokenPath: "/t3/userdata/providers/antigravity/profile/antigravity-acp/acp_token.json", + browserCommand: "managed-browser-helper", + }; + + it("isolates the profile and harness after merging overrides without changing the base environment", () => { + const baseEnv = { + HOME: "/home/developer", + PATH: "/usr/bin", + GEMINI_API_KEY: "do-not-use-api-billing", + google_api_key: "case-insensitive-api-key", + GOOGLE_CLOUD_PROJECT: "do-not-use-project", + GOOGLE_CLOUD_LOCATION: "do-not-use-location", + GOOGLE_APPLICATION_CREDENTIALS: "/credentials.json", + GOOGLE_CLOUD_QUOTA_PROJECT: "do-not-use-quota-project", + GOOGLE_GENAI_USE_VERTEXAI: "true", + AGY_ACP_CCPA_PROJECT: "do-not-use-consumer-project", + AGY_ACP_ENABLE_OAUTH: "1", + GEMINI_HOME: "/shared-gemini-home", + gemini_home: "/alias-shared-home", + AGY_ACP_FORCE_FILE_STORAGE: "0", + ANTIGRAVITY_HARNESS_PATH: "/wrong-version/harness", + BROWSER: "open-real-browser", + browser: "another-real-browser", + PYTHONUNBUFFERED: "0", + ELECTRON_RUN_AS_NODE: "0", + CUSTOM_SETTING: "keep-this", + }; + const original = { ...baseEnv }; + const spawn = buildAntigravityAcpSpawnInput({ + installation: { executablePath: "/release/acp", harnessPath: "/release/harness" }, + profile, + cwd: "/project", + baseEnv, + }); + + expect(baseEnv).toEqual(original); + expect(spawn).toEqual({ + command: "/release/acp", + args: ["--uid="], + cwd: "/project", + extendEnv: false, + env: { + HOME: "/home/developer", + PATH: "/usr/bin", + CUSTOM_SETTING: "keep-this", + GEMINI_HOME: profile.geminiHome, + AGY_ACP_FORCE_FILE_STORAGE: "1", + ANTIGRAVITY_HARNESS_PATH: "/release/harness", + BROWSER: profile.browserCommand, + PYTHONUNBUFFERED: "1", + ELECTRON_RUN_AS_NODE: "1", + }, + }); + }); + + it("passes only the configured method's credential and keeps the GCP pair out of the environment", () => { + const baseEnv = { PATH: "/usr/bin", GOOGLE_API_KEY: "ambient-key" }; + const spawnFor = (auth: AntigravityAuthConfig) => + buildAntigravityAcpSpawnInput({ + installation: { executablePath: "/release/acp", harnessPath: "/release/harness" }, + profile, + cwd: "/project", + baseEnv, + auth, + }).env ?? {}; + + const geminiKey = spawnFor({ + authMethod: "gemini-api-key", + apiKey: "gemini-secret", + gcpProject: "proj", + gcpLocation: "us-central1", + }); + expect(geminiKey.GEMINI_API_KEY).toBe("gemini-secret"); + expect(geminiKey.GOOGLE_API_KEY).toBeUndefined(); + expect(geminiKey.GOOGLE_CLOUD_PROJECT).toBeUndefined(); + + const vertexKey = spawnFor({ + authMethod: "agent-platform", + apiKey: "vertex-secret", + gcpProject: "", + gcpLocation: "", + }); + expect(vertexKey.GOOGLE_API_KEY).toBe("vertex-secret"); + expect(vertexKey.GEMINI_API_KEY).toBeUndefined(); + + const business = spawnFor({ + authMethod: "oauth-business", + apiKey: "ignored", + gcpProject: "proj", + gcpLocation: "us-central1", + }); + expect(business.GEMINI_API_KEY).toBeUndefined(); + expect(business.GOOGLE_API_KEY).toBeUndefined(); + }); + + it("writes the auth method and GCP block into the agent's settings.json", () => { + expect( + decodeJson( + antigravityProfileSettings({ + authMethod: "oauth-business", + apiKey: "never-written", + gcpProject: "proj", + gcpLocation: "us-central1", + }), + ), + ).toEqual({ + auth: { type: "oauth-business" }, + gcp: { project: "proj", location: "us-central1" }, + }); + // The agent's logout reads auth.type to clear only that method's token. + expect(decodeJson(antigravityProfileSettings(ANTIGRAVITY_PERSONAL_AUTH))).toEqual({ + auth: { type: "oauth-personal" }, + }); + }); + + it("names the missing credential for each method", () => { + expect(antigravityAuthConfigIssue(ANTIGRAVITY_PERSONAL_AUTH)).toBeNull(); + expect( + antigravityAuthConfigIssue({ ...ANTIGRAVITY_PERSONAL_AUTH, authMethod: "gemini-api-key" }), + ).toContain("API key"); + expect( + antigravityAuthConfigIssue({ + ...ANTIGRAVITY_PERSONAL_AUTH, + authMethod: "oauth-business", + gcpProject: "proj", + }), + ).toContain("location"); + expect( + antigravityAuthConfigIssue({ + ...ANTIGRAVITY_PERSONAL_AUTH, + authMethod: "agent-platform", + gcpProject: "proj", + gcpLocation: "us-central1", + }), + ).toBeNull(); + }); + + it("uses the registry launch arguments for each supported host platform", () => { + for (const platform of ["linux", "darwin", "win32"] as const) { + const spawn = buildAntigravityAcpSpawnInput({ + installation: { executablePath: "/release/acp", harnessPath: "/release/harness" }, + profile: { ...profile, platform }, + cwd: "/project", + baseEnv: {}, + }); + expect(spawn.args).toEqual(platform === "linux" ? ["--uid="] : []); + } + }); + + it("keeps accounts separate even when instance IDs differ only by case", () => { + const first = resolveAntigravityProfileDirectory( + "/userdata", + ProviderInstanceId.make("antigravity"), + ); + const second = resolveAntigravityProfileDirectory( + "/userdata", + ProviderInstanceId.make("Antigravity"), + ); + expect(first.toLowerCase()).not.toBe(second.toLowerCase()); + expect( + resolveAntigravityProfileDirectory("/userdata", ProviderInstanceId.make("antigravity")), + ).toBe(first); + }); +}); + +describe("Antigravity authorization URL", () => { + it.effect("returns the official Google URL and its owned loopback target", () => + Effect.gen(function* () { + expect(yield* parseAntigravityAuthorizationUrl(authorizationUrl)).toEqual({ + authorizationUrl, + redirectUri: "http://127.0.0.1:46353/", + state: "test-opaque-state", + }); + }), + ); + + it.effect( + "rejects other origins, ambiguous state, and non-loopback redirects without retaining them", + () => + Effect.gen(function* () { + const invalidUrls = [ + authorizationUrl.replace("https:", "http:"), + authorizationUrl.replace("accounts.google.com", "accounts.google.com.example.invalid"), + authorizationUrl.replace("accounts.google.com", "secret@accounts.google.com"), + authorizationUrl.replace("/o/oauth2/v2/auth", "/another-path"), + `${authorizationUrl}#secret-fragment`, + `${authorizationUrl}&state=another-state`, + authorizationUrl.replace("test-opaque-state", ""), + authorizationUrl.replace("test-opaque-state", "opaque%0astate"), + authorizationUrl.replace("127.0.0.1", "localhost"), + authorizationUrl.replace("127.0.0.1", "169.254.169.254"), + authorizationUrl.replace("46353", "80"), + authorizationUrl.replace("46353", "70000"), + authorizationUrl.replace("46353%2F", "46353%2Fother"), + authorizationUrl.replace("response_type=code", "response_type=token"), + "not a URL containing secret-code", + ]; + for (const invalidUrl of invalidUrls) { + const result = yield* parseAntigravityAuthorizationUrl(invalidUrl).pipe(Effect.result); + expect(Result.isFailure(result)).toBe(true); + if (Result.isSuccess(result)) continue; + expect(result.failure._tag).toBe("AcpTransportError"); + expect(encodeUnknownJson(result.failure)).not.toContain("test-opaque-state"); + expect(encodeUnknownJson(result.failure)).not.toContain("secret-code"); + expect(encodeUnknownJson(result.failure)).not.toContain(invalidUrl); + } + }), + ); +}); + +describe("Antigravity sign-in errors", () => { + it("recognizes native auth-required errors and the blocked-login transport error", () => { + expect(isAntigravitySignInRequiredError(AcpErrors.AcpRequestError.authRequired())).toBe(true); + expect( + isAntigravitySignInRequiredError( + new AcpErrors.AcpTransportError({ + detail: ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE, + cause: undefined, + }), + ), + ).toBe(true); + }); + + it("does not treat other failures or arbitrary text as missing authentication", () => { + const otherErrors = [ + new AcpErrors.AcpTransportError({ detail: "The process stopped.", cause: undefined }), + AcpErrors.AcpRequestError.internalError(ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE), + new Error(ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE), + { detail: ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE }, + ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE, + undefined, + ]; + for (const error of otherErrors) expect(isAntigravitySignInRequiredError(error)).toBe(false); + }); +}); + +describe("Antigravity stdout compatibility", () => { + it.effect( + "handles fragmented login lines between JSON messages without changing protocol bytes", + () => + Effect.gen(function* () { + const urls: string[] = []; + const jsonBefore = '{"jsonrpc":"2.0","id":1,"result":{}}\r\n'; + const jsonAfter = '{"jsonrpc":"2.0","id":2,"result":{"text":"café"}}\n'; + const chunks = [ + encode(`${jsonBefore}${ANTIGRAVITY_AUTH_STDOUT_PREFIX.slice(0, 7)}`), + encode(ANTIGRAVITY_AUTH_STDOUT_PREFIX.slice(7)), + encode(authorizationUrl.slice(0, 40)), + encode(`${authorizationUrl.slice(40)}\r`), + encode(`\n${jsonAfter}`), + ]; + const result = yield* makeAntigravityStdoutTransform({ + onAuthorizationUrl: (url) => Effect.sync(() => void urls.push(url)), + })(Stream.fromIterable(chunks)).pipe(Stream.decodeText(), Stream.mkString); + expect(result).toBe(`${jsonBefore}${jsonAfter}`); + expect(urls).toEqual([authorizationUrl]); + }), + ); + + it.effect("handles an auth line without a final newline", () => + Effect.gen(function* () { + const urls: string[] = []; + const result = yield* makeAntigravityStdoutTransform({ + onAuthorizationUrl: (url) => Effect.sync(() => void urls.push(url)), + })(Stream.make(encode(authLine.slice(0, -1)))).pipe(Stream.runCollect); + expect(result).toEqual([]); + expect(urls).toEqual([authorizationUrl]); + }), + ); + + it.effect("ends normal work with a safe sign-in error instead of waiting for OAuth", () => + Effect.gen(function* () { + const result = yield* makeAntigravityStdoutTransform()(Stream.make(encode(authLine))).pipe( + Stream.runDrain, + Effect.result, + ); + expect(Result.isFailure(result)).toBe(true); + if (Result.isSuccess(result)) return; + expect(result.failure).toMatchObject({ + _tag: "AcpTransportError", + detail: ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE, + }); + expect(encodeUnknownJson(result.failure)).not.toContain(authorizationUrl); + expect(encodeUnknownJson(result.failure)).not.toContain("test-opaque-state"); + }), + ); + + it.effect("preserves typed errors from the flow owner", () => + Effect.gen(function* () { + const failure = new AcpErrors.AcpTransportError({ + detail: "This sign-in flow has expired.", + cause: undefined, + }); + const result = yield* makeAntigravityStdoutTransform({ + onAuthorizationUrl: () => Effect.fail(failure), + })(Stream.make(encode(authLine))).pipe(Stream.runDrain, Effect.result); + expect(Result.isFailure(result)).toBe(true); + if (Result.isSuccess(result)) return; + expect(result.failure).toBe(failure); + }), + ); + + it.effect("does not suppress malformed protocol data or similar login messages", () => + Effect.gen(function* () { + const unrelated = [ + "this is not JSON\n", + `${ANTIGRAVITY_AUTH_STDOUT_PREFIX.toLowerCase()}${authorizationUrl}\n`, + ` ${authLine}`, + ]; + for (const line of unrelated) { + const transform = makeAntigravityStdoutTransform(); + const output = yield* transform(Stream.make(encode(line))).pipe( + Stream.decodeText(), + Stream.mkString, + ); + expect(output).toBe(line); + const decoded = yield* transform(Stream.make(encode(line))).pipe( + Stream.pipeThroughChannel(Ndjson.decode()), + Stream.runDrain, + Effect.result, + ); + expect(Result.isFailure(decoded)).toBe(true); + } + }), + ); + + it.effect("bounds unfinished protocol lines", () => + Effect.gen(function* () { + const result = yield* makeAntigravityStdoutTransform()( + Stream.make(new Uint8Array(16 * 1024 * 1024), encode("x")), + ).pipe(Stream.runDrain, Effect.result); + expect(Result.isFailure(result)).toBe(true); + if (Result.isSuccess(result)) return; + expect(result.failure).toMatchObject({ + _tag: "AcpTransportError", + detail: "Antigravity sent a protocol line that is too large.", + }); + }), + ); +}); + +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* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const temporaryDirectory = yield* fs.makeTempDirectoryScoped(); + const profile = yield* prepareAntigravityProfile({ + profileDirectory: path.join(temporaryDirectory, "profile"), + }); + + expect(profile.geminiHome).toBe(path.join(temporaryDirectory, "profile")); + expect(yield* fs.exists(profile.acpDirectory)).toBe(true); + expect(yield* fs.exists(profile.tokenPath)).toBe(false); + if ((yield* HostProcessPlatform) !== "win32") { + expect((yield* fs.stat(profile.geminiHome)).mode & 0o777).toBe(0o700); + expect((yield* fs.stat(profile.acpDirectory)).mode & 0o777).toBe(0o700); + } + + yield* fs.writeFileString(profile.tokenPath, "synthetic-token-fixture"); + yield* prepareAntigravityProfile({ profileDirectory: profile.geminiHome }); + expect(yield* fs.readFileString(profile.tokenPath)).toBe("synthetic-token-fixture"); + }), + ); + + it.effect("rewrites the GCP block on every launch and never stores the API key", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const temporaryDirectory = yield* fs.makeTempDirectoryScoped(); + const profile = yield* prepareAntigravityProfile({ + profileDirectory: temporaryDirectory, + auth: { + authMethod: "agent-platform", + apiKey: "vertex-secret", + gcpProject: "proj", + gcpLocation: "us-central1", + }, + }); + const settingsPath = path.join(profile.acpDirectory, "settings.json"); + const first = yield* fs.readFileString(settingsPath); + expect(decodeJson(first)).toEqual({ + auth: { type: "agent-platform" }, + gcp: { project: "proj", location: "us-central1" }, + }); + expect(first).not.toContain("vertex-secret"); + + yield* prepareAntigravityProfile({ profileDirectory: temporaryDirectory }); + expect(decodeJson(yield* fs.readFileString(settingsPath))).toEqual({ + auth: { type: "oauth-personal" }, + }); + }), + ); + + it.effect("keeps the browser helper successful when cancellation closes stderr", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const temporaryDirectory = yield* fs.makeTempDirectoryScoped(); + let helperCommand: ChildProcess.StandardCommand | undefined; + yield* prepareAntigravityProfile({ profileDirectory: temporaryDirectory }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + if (ChildProcess.isStandardCommand(command)) helperCommand = command; + return spawner.spawn(command); + }), + ), + ); + expect(helperCommand).toBeDefined(); + if (!helperCommand) return; + const command = helperCommand; + const child = yield* Effect.acquireRelease( + Effect.sync(() => + NodeChildProcess.spawn(command.command, command.args, { + env: { ...command.options.env }, + stdio: ["ignore", "ignore", "pipe"], + }), + ), + (process) => Effect.sync(() => void process.kill()), + ); + child.stderr?.destroy(); + const exitCode = yield* Effect.promise( + () => + new Promise((resolve, reject) => { + child.once("error", reject); + child.once("exit", resolve); + }), + ); + expect(exitCode).toBe(0); + }), + ); + + it.effect("fails before creating a profile when the helper cannot start", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const temporaryDirectory = yield* fs.makeTempDirectoryScoped(); + const profileDirectory = path.join(temporaryDirectory, "unused-profile"); + const result = yield* prepareAntigravityProfile({ + profileDirectory, + runtimeExecutablePath: path.join(temporaryDirectory, "missing-runtime"), + }).pipe(Effect.result); + + expect(Result.isFailure(result)).toBe(true); + expect(yield* fs.exists(profileDirectory)).toBe(false); + }), + ); + + it.effect("rejects Python BROWSER delimiter collisions before starting a helper", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const temporaryDirectory = yield* fs.makeTempDirectoryScoped(); + for (const platform of ["linux", "win32"] as const) { + const profileDirectory = path.join(temporaryDirectory, platform); + const result = yield* prepareAntigravityProfile({ + profileDirectory, + platform, + runtimeExecutablePath: platform === "win32" ? "C:/bad;path/node.exe" : "/bad:path/node", + }).pipe(Effect.result); + expect(Result.isFailure(result)).toBe(true); + expect(yield* fs.exists(profileDirectory)).toBe(false); + } + }), + ); +}); diff --git a/apps/server/src/provider/antigravityAuthSupport.ts b/apps/server/src/provider/antigravityAuthSupport.ts new file mode 100644 index 000000000000..dd55df5973ba --- /dev/null +++ b/apps/server/src/provider/antigravityAuthSupport.ts @@ -0,0 +1,501 @@ +import * as NodeCrypto from "node:crypto"; +// @effect-diagnostics-next-line nodeBuiltinImport:off - resolveAntigravityProfileDirectory is a pure sync helper, so it cannot use the Path service. +import * as NodePath from "node:path"; + +import type { AntigravityAuthMethod, ProviderInstanceId } from "@t3tools/contracts"; +import { HostProcessExecutablePath, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import type * as PlatformError from "effect/PlatformError"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import * as AcpErrors from "effect-acp/errors"; + +import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; +import type { AcpSpawnInput } from "./acp/AcpSessionRuntime.ts"; + +export const ANTIGRAVITY_AUTH_STDOUT_PREFIX = + "Open the following link to authenticate the ACP server: "; +export const ANTIGRAVITY_AUTH_BROWSER_MARKER = "__T3_ANTIGRAVITY_AUTH_URL__"; +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( + Schema.Struct({ + project: Schema.optional(Schema.String), + location: Schema.optional(Schema.String), + }), + ), +}); +const encodeProfileSettings = Schema.encodeSync(Schema.fromJsonString(ProfileSettingsFile)); +const isAcpRequestError = Schema.is(AcpErrors.AcpRequestError); +const isAcpTransportError = Schema.is(AcpErrors.AcpTransportError); + +// Python splits BROWSER on the platform path separator before it parses quotes. +// Keep this source free of both colons and semicolons. EPIPE must still exit 0 +// so Python does not fall back to an OS browser after cancellation. +const browserHelperSource = + `process.stderr.on("error",()=>process.exit(0)).write(` + + `"${ANTIGRAVITY_AUTH_BROWSER_MARKER}"+JSON.stringify(process.argv[1])+"\\n",` + + `()=>process.exit(0))`; +const browserPreflightUrl = "https://example.invalid/t3-antigravity-browser-preflight"; + +const removedEnvironmentKeys = new Set([ + "GEMINI_API_KEY", + "GOOGLE_API_KEY", + "GOOGLE_APPLICATION_CREDENTIALS", + "GOOGLE_CLOUD_PROJECT", + "GOOGLE_CLOUD_LOCATION", + "GOOGLE_CLOUD_QUOTA_PROJECT", + "GOOGLE_GENAI_USE_VERTEXAI", + "GCLOUD_PROJECT", + "CLOUDSDK_CORE_PROJECT", + "AGY_ACP_CCPA_PROJECT", + "AGY_ACP_ENABLE_OAUTH", + "GEMINI_HOME", + "AGY_ACP_FORCE_FILE_STORAGE", + "ANTIGRAVITY_HARNESS_PATH", + "BROWSER", + "PYTHONUNBUFFERED", + "ELECTRON_RUN_AS_NODE", +]); + +export interface AntigravityProfile { + readonly platform: NodeJS.Platform; + readonly geminiHome: string; + readonly acpDirectory: string; + readonly tokenPath: string; + readonly browserCommand: string; +} + +/** + * Credentials for the non-personal ACP auth methods. The agent reads the API + * key from its environment and the GCP project and location from + * `settings.json` in the profile. Empty strings mean "not set". + */ +export interface AntigravityAuthConfig { + readonly authMethod: AntigravityAuthMethod; + readonly apiKey: string; + readonly gcpProject: string; + readonly gcpLocation: string; +} + +export const ANTIGRAVITY_PERSONAL_AUTH: AntigravityAuthConfig = { + authMethod: "oauth-personal", + apiKey: "", + gcpProject: "", + gcpLocation: "", +}; + +/** True for the two methods that open a Google sign-in page. */ +export function antigravityAuthUsesBrowser(authMethod: AntigravityAuthMethod): boolean { + return authMethod === "oauth-personal" || authMethod === "oauth-business"; +} + +/** Label shown on the provider card once the method has authenticated. */ +export function antigravityAuthLabel(authMethod: AntigravityAuthMethod): string { + switch (authMethod) { + case "oauth-personal": + return "Google account"; + case "oauth-business": + return "Gemini Enterprise"; + case "gemini-api-key": + return "Gemini API key"; + case "agent-platform": + return "Agent Platform"; + } +} + +/** + * Explains what is missing before a non-personal method can authenticate, or + * null when the config is complete. Personal sign-in never needs config. + */ +export function antigravityAuthConfigIssue(auth: AntigravityAuthConfig): string | null { + switch (auth.authMethod) { + case "oauth-personal": + return null; + case "oauth-business": + return auth.gcpProject && auth.gcpLocation + ? null + : "Gemini Enterprise needs a GCP project and location in the Antigravity provider settings."; + case "gemini-api-key": + return auth.apiKey ? null : "Enter a Gemini API key in the Antigravity provider settings."; + case "agent-platform": + return auth.apiKey || (auth.gcpProject && auth.gcpLocation) + ? null + : "Agent Platform needs an API key, or a GCP project and location, in the Antigravity provider settings."; + } +} + +/** + * `settings.json` content for the agent's profile. `auth.type` names the + * selected method so a native logout clears only that method's credentials + * instead of every stored token. The GCP block feeds Enterprise and Agent + * Platform. Never holds a credential. + */ +export function antigravityProfileSettings(auth: AntigravityAuthConfig): string { + const gcp = { + ...(auth.gcpProject ? { project: auth.gcpProject } : {}), + ...(auth.gcpLocation ? { location: auth.gcpLocation } : {}), + }; + return `${encodeProfileSettings({ + auth: { type: auth.authMethod }, + ...(Object.keys(gcp).length > 0 ? { gcp } : {}), + })}\n`; +} + +export interface AntigravityAuthorizationUrl { + readonly authorizationUrl: string; + readonly redirectUri: string; + readonly state: string; +} + +function authSupportError(detail: string) { + return new AcpErrors.AcpTransportError({ detail, cause: undefined }); +} + +/** Recognizes native auth failures and interactive login blocked by T3. */ +export function isAntigravitySignInRequiredError(error: unknown): boolean { + return ( + (isAcpRequestError(error) && error.code === -32000) || + (isAcpTransportError(error) && error.detail === ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE) + ); +} + +/** Keeps case-sensitive instance IDs separate on case-insensitive filesystems. */ +export function resolveAntigravityProfileDirectory( + stateDir: string, + instanceId: ProviderInstanceId, +): string { + const directoryName = NodeCrypto.createHash("sha256").update(instanceId).digest("hex"); + return NodePath.join(stateDir, "providers", "antigravity", directoryName); +} + +function quoteBrowserArgument(value: string): string { + return `'${value.replaceAll("'", `'"'"'`)}'`; +} + +function antigravityEnvironment( + profile: AntigravityProfile, + baseEnv: NodeJS.ProcessEnv, + auth: AntigravityAuthConfig, +) { + const environment: NodeJS.ProcessEnv = {}; + for (const [key, value] of Object.entries(baseEnv)) { + // Windows treats environment keys as case-insensitive. Remove aliases too. + if (!removedEnvironmentKeys.has(key.toUpperCase())) environment[key] = value; + } + // Only the configured method's credential reaches the agent. The agent + // prefers GOOGLE_API_KEY over the GCP pair for Agent Platform, so the pair + // goes through settings.json instead of the environment. + const credential = + auth.authMethod === "gemini-api-key" && auth.apiKey + ? { GEMINI_API_KEY: auth.apiKey } + : auth.authMethod === "agent-platform" && auth.apiKey + ? { GOOGLE_API_KEY: auth.apiKey } + : {}; + return { + ...environment, + ...credential, + GEMINI_HOME: profile.geminiHome, + AGY_ACP_FORCE_FILE_STORAGE: "1", + BROWSER: profile.browserCommand, + PYTHONUNBUFFERED: "1", + ELECTRON_RUN_AS_NODE: "1", + }; +} + +/** Prepares a private profile without reading or copying Google credentials. */ +export const prepareAntigravityProfile = Effect.fn("prepareAntigravityProfile")(function* (input: { + readonly profileDirectory: string; + readonly baseEnv?: NodeJS.ProcessEnv; + readonly runtimeExecutablePath?: string; + readonly platform?: NodeJS.Platform; + readonly auth?: AntigravityAuthConfig; +}) { + const auth = input.auth ?? ANTIGRAVITY_PERSONAL_AUTH; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const platform = input.platform ?? (yield* HostProcessPlatform); + const runtimeExecutablePath = input.runtimeExecutablePath ?? (yield* HostProcessExecutablePath); + const helperExecutable = + platform === "win32" ? runtimeExecutablePath.replaceAll("\\", "/") : runtimeExecutablePath; + const browserArguments = [helperExecutable, "-e", browserHelperSource, "--", "%s"]; + const browserCommand = browserArguments.map(quoteBrowserArgument).join(" "); + if ( + browserCommand.includes(platform === "win32" ? ";" : ":") || + helperExecutable.includes("\r") || + helperExecutable.includes("\n") || + helperExecutable.includes("\0") || + helperExecutable.includes("%s") + ) { + return yield* authSupportError( + "The T3 runtime path cannot be used to suppress Antigravity browser launches.", + ); + } + + const geminiHome = path.resolve(input.profileDirectory); + const acpDirectory = path.join(geminiHome, "antigravity-acp"); + const profile: AntigravityProfile = { + platform, + geminiHome, + acpDirectory, + tokenPath: path.join(acpDirectory, "acp_token.json"), + browserCommand, + }; + const environment = antigravityEnvironment(profile, input.baseEnv ?? process.env, auth); + yield* Effect.gen(function* () { + const child = yield* spawner.spawn( + ChildProcess.make(helperExecutable, ["-e", browserHelperSource, "--", browserPreflightUrl], { + env: environment, + extendEnv: false, + shell: false, + }), + ); + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + collectUint8StreamText({ stream: child.stdout, maxBytes: 4_096 }), + collectUint8StreamText({ stream: child.stderr, maxBytes: 4_096 }), + child.exitCode, + ], + { concurrency: "unbounded" }, + ); + if ( + Number(exitCode) !== 0 || + stdout.bytes !== 0 || + stdout.truncated || + stderr.truncated || + stderr.text !== `${ANTIGRAVITY_AUTH_BROWSER_MARKER}"${browserPreflightUrl}"\n` + ) { + return yield* authSupportError("Antigravity browser suppression could not be verified."); + } + }).pipe( + Effect.scoped, + Effect.timeoutOrElse({ + duration: "5 seconds", + orElse: () => + Effect.fail(authSupportError("Antigravity browser suppression verification timed out.")), + }), + Effect.mapError((error) => + error._tag === "AcpTransportError" + ? error + : authSupportError("Antigravity browser suppression could not be verified."), + ), + ); + + for (const directory of [geminiHome, acpDirectory]) { + yield* fs + .makeDirectory(directory, { recursive: true, mode: 0o700 }) + .pipe( + Effect.mapError(() => + authSupportError("The Antigravity profile directory could not be created."), + ), + ); + if (platform !== "win32") { + yield* fs + .chmod(directory, 0o700) + .pipe( + Effect.mapError(() => + authSupportError("The Antigravity profile directory permissions could not be set."), + ), + ); + } + } + // Rewriting on every launch keeps a method, project, or location edit in + // Settings effective. The agent also records auth.type here after a + // sign-in, which matches the value written below. + yield* fs + .writeFileString(path.join(acpDirectory, "settings.json"), antigravityProfileSettings(auth)) + .pipe( + Effect.mapError(() => + authSupportError("The Antigravity profile settings could not be written."), + ), + ); + return profile; +}); + +/** Applies the same subscription-only launch settings to every ACP process. */ +export function buildAntigravityAcpSpawnInput(input: { + readonly installation: { + readonly executablePath: string; + readonly harnessPath: string; + }; + readonly profile: AntigravityProfile; + readonly cwd: string; + readonly baseEnv?: NodeJS.ProcessEnv; + readonly auth?: AntigravityAuthConfig; +}): AcpSpawnInput { + return { + command: input.installation.executablePath, + args: input.profile.platform === "linux" ? ["--uid="] : [], + cwd: input.cwd, + env: { + ...antigravityEnvironment( + input.profile, + input.baseEnv ?? process.env, + input.auth ?? ANTIGRAVITY_PERSONAL_AUTH, + ), + ANTIGRAVITY_HARNESS_PATH: input.installation.harnessPath, + }, + extendEnv: false, + }; +} + +/** Reads only the public authorization request, never an OAuth token file. */ +export const parseAntigravityAuthorizationUrl = Effect.fn("parseAntigravityAuthorizationUrl")( + function* ( + authorizationUrl: string, + ): Effect.fn.Return { + const invalidUrl = () => + authSupportError("Antigravity returned an invalid Google sign-in URL."); + if (authorizationUrl.length > maxAuthorizationUrlLength || /\s/.test(authorizationUrl)) { + return yield* invalidUrl(); + } + const url = yield* decodeUrl(authorizationUrl).pipe(Effect.mapError(invalidUrl)); + const state = url.searchParams.get("state"); + const redirectUri = url.searchParams.get("redirect_uri"); + if ( + url.origin !== "https://accounts.google.com" || + url.pathname !== "/o/oauth2/v2/auth" || + url.username !== "" || + url.password !== "" || + url.hash !== "" || + url.searchParams.getAll("state").length !== 1 || + url.searchParams.getAll("redirect_uri").length !== 1 || + url.searchParams.getAll("response_type").length !== 1 || + url.searchParams.get("response_type") !== "code" || + state === null || + state.length === 0 || + state.length > 512 || + /\s/.test(state) || + redirectUri === null || + !/^http:\/\/127\.0\.0\.1:[1-9][0-9]{0,4}\/$/.test(redirectUri) + ) { + return yield* invalidUrl(); + } + const redirect = yield* decodeUrl(redirectUri).pipe(Effect.mapError(invalidUrl)); + if (Number(redirect.port) < 1_024) return yield* invalidUrl(); + return { authorizationUrl, redirectUri, state }; + }, +); + +export function makeAntigravityStdoutTransform( + input: { + readonly onAuthorizationUrl?: ( + authorizationUrl: string, + ) => Effect.Effect; + } = {}, +) { + const handleLine = Effect.fn("antigravityAuthSupport.handleStdoutLine")(function* ( + line: Uint8Array, + ) { + if (!authPrefixBytes.every((byte, index) => line[index] === byte)) return [line]; + const message = new TextDecoder().decode(line).replace(/\r?\n$/, ""); + const request = yield* parseAntigravityAuthorizationUrl( + message.slice(ANTIGRAVITY_AUTH_STDOUT_PREFIX.length), + ); + if (!input.onAuthorizationUrl) { + return yield* authSupportError(ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE); + } + yield* input.onAuthorizationUrl(request.authorizationUrl); + return []; + }); + + return ( + stdout: ChildProcessSpawner.ChildProcessHandle["stdout"], + ): Stream.Stream => + Stream.suspend(() => { + let pending: Uint8Array[] = []; + let pendingBytes = 0; + const finishLine = () => { + const line = Buffer.concat(pending, pendingBytes); + pending = []; + pendingBytes = 0; + return line; + }; + return stdout.pipe( + Stream.mapEffect( + Effect.fn("antigravityAuthSupport.splitStdoutLines")(function* (chunk: Uint8Array) { + const lines: Uint8Array[] = []; + let offset = 0; + while (offset < chunk.byteLength) { + const newline = chunk.indexOf(10, offset); + const end = newline === -1 ? chunk.byteLength : newline + 1; + const part = chunk.subarray(offset, end); + if (pendingBytes + part.byteLength > maxStdoutLineBytes) { + return yield* authSupportError( + "Antigravity sent a protocol line that is too large.", + ); + } + pending.push(part); + pendingBytes += part.byteLength; + if (newline !== -1) lines.push(finishLine()); + offset = end; + } + return lines; + }), + ), + Stream.flatMap(Stream.fromIterable), + Stream.concat( + Stream.suspend(() => (pendingBytes > 0 ? Stream.succeed(finishLine()) : Stream.empty)), + ), + Stream.mapEffect(handleLine), + Stream.flatMap(Stream.fromIterable), + ); + }); +} + +/** 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/antigravityCallback.test.ts b/apps/server/src/provider/antigravityCallback.test.ts new file mode 100644 index 000000000000..70ff3514bc04 --- /dev/null +++ b/apps/server/src/provider/antigravityCallback.test.ts @@ -0,0 +1,48 @@ +import { assert, it } from "@effect/vitest"; +import { ProviderInstanceId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; + +import { validateAntigravityCallbackUrl } from "./antigravityCallback.ts"; + +const instanceId = ProviderInstanceId.make("antigravity-callback-test"); +const pending = { redirectUri: "http://127.0.0.1:51234/", state: "owned-state" }; + +it.effect("accepts the exact owned Google callback and an explicit Google denial", () => + Effect.gen(function* () { + for (const response of ["code=example-code", "error=access_denied"]) { + const callback = `http://127.0.0.1:51234/?state=owned-state&${response}&iss=https%3A%2F%2Faccounts.google.com`; + const parsed = yield* validateAntigravityCallbackUrl(instanceId, pending, callback); + assert.equal(parsed.toString(), callback); + } + }), +); + +it.effect("rejects different targets, credentials, fragments, and duplicate OAuth fields", () => + Effect.gen(function* () { + const callbacks = [ + "https://127.0.0.1:51234/?state=owned-state&code=x", + "http://localhost:51234/?state=owned-state&code=x", + "http://127.0.0.2:51234/?state=owned-state&code=x", + "http://127.0.0.1:51235/?state=owned-state&code=x", + "http://127.0.0.1:51234/other?state=owned-state&code=x", + "http://user:password@127.0.0.1:51234/?state=owned-state&code=x", + "http://127.0.0.1:51234/?state=owned-state&code=x#fragment", + "http://127.0.0.1:51234/?state=wrong-state&code=x", + "http://127.0.0.1:51234/?state=owned-state&state=owned-state&code=x", + "http://127.0.0.1:51234/?state=owned-state&code=x&code=y", + "http://127.0.0.1:51234/?state=owned-state&code=x&error=access_denied", + "http://127.0.0.1:51234/?state=owned-state&error=access_denied&error=other", + "http://127.0.0.1:51234/?state=owned-state&code=", + "http://127.0.0.1:51234/?state=owned-state&iss=https%3A%2F%2Faccounts.google.com", + "http://127.0.0.1:51234/?state=owned-state&code=x&iss=https%3A%2F%2Fexample.com", + "http://127.0.0.1:51234/?state=owned-state&code=x&iss=https%3A%2F%2Faccounts.google.com&iss=https%3A%2F%2Faccounts.google.com", + ]; + for (const callback of callbacks) { + const result = yield* validateAntigravityCallbackUrl(instanceId, pending, callback).pipe( + Effect.exit, + ); + assert.isTrue(Exit.isFailure(result), callback); + } + }), +); diff --git a/apps/server/src/provider/antigravityCallback.ts b/apps/server/src/provider/antigravityCallback.ts new file mode 100644 index 000000000000..28a601396c22 --- /dev/null +++ b/apps/server/src/provider/antigravityCallback.ts @@ -0,0 +1,115 @@ +// @effect-diagnostics nodeBuiltinImport:off - node:http sends the one-shot loopback callback with no proxy, redirect handling, or response logging. +import * as NodeHttp from "node:http"; + +import { ProviderSetupError, type ProviderInstanceId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; + +export interface AntigravityPendingCallback { + readonly redirectUri: string; + readonly state: string; +} + +/** Only the callback advertised by this running ACP process may receive a request. */ +export const validateAntigravityCallbackUrl = Effect.fn("validateAntigravityCallbackUrl")( + function* ( + instanceId: ProviderInstanceId, + pending: AntigravityPendingCallback, + callbackUrl: string, + ) { + const invalid = (detail: string) => + new ProviderSetupError({ instanceId, operation: "complete", detail }); + if (callbackUrl.length > 16_384) { + return yield* invalid("The sign-in response URL is too long."); + } + const callback = yield* Effect.try({ + try: () => new URL(callbackUrl), + catch: () => invalid("Paste the complete redirect URL from the Google sign-in page."), + }); + const expected = new URL(pending.redirectUri); + if ( + callback.protocol !== "http:" || + callback.hostname !== "127.0.0.1" || + callback.origin !== expected.origin || + callback.pathname !== expected.pathname || + callback.username !== "" || + callback.password !== "" || + callback.hash !== "" + ) { + return yield* invalid("This redirect URL does not belong to the current sign-in."); + } + const states = callback.searchParams.getAll("state"); + if (states.length !== 1 || states[0] !== pending.state) { + return yield* invalid("This redirect URL does not belong to the current sign-in."); + } + const codes = callback.searchParams.getAll("code"); + const errors = callback.searchParams.getAll("error"); + if ( + !( + (codes.length === 1 && Boolean(codes[0]) && errors.length === 0) || + (errors.length === 1 && Boolean(errors[0]) && codes.length === 0) + ) + ) { + return yield* invalid("The redirect URL must contain one Google sign-in response."); + } + const issuers = callback.searchParams.getAll("iss"); + if ( + issuers.length > 1 || + (issuers.length === 1 && issuers[0] !== "https://accounts.google.com") + ) { + return yield* invalid("The redirect URL is not a Google sign-in response."); + } + return callback; + }, +); + +/** Sends one callback, without proxies, redirects, readiness probes, or response logging. */ +export const forwardAntigravityCallback = ( + instanceId: ProviderInstanceId, + callback: URL, +): Effect.Effect => + Effect.callback((resume) => { + const failed = () => + new ProviderSetupError({ + instanceId, + operation: "complete", + detail: "Could not deliver the sign-in response. Start sign-in again.", + }); + let response: NodeHttp.IncomingMessage | undefined; + const request = NodeHttp.request( + { + protocol: "http:", + hostname: callback.hostname, + port: callback.port, + path: `${callback.pathname}${callback.search}`, + method: "GET", + agent: false, + }, + (incoming) => { + response = incoming; + incoming.once("error", () => resume(Effect.fail(failed()))); + incoming.once("end", () => { + const status = incoming.statusCode ?? 0; + resume(status >= 200 && status < 300 ? Effect.void : Effect.fail(failed())); + }); + incoming.resume(); + }, + ); + request.once("error", () => resume(Effect.fail(failed()))); + request.end(); + return Effect.sync(() => { + request.destroy(); + response?.destroy(); + }); + }).pipe( + Effect.timeoutOrElse({ + duration: "10 seconds", + orElse: () => + Effect.fail( + new ProviderSetupError({ + instanceId, + operation: "complete", + detail: "The sign-in response timed out. Start sign-in again.", + }), + ), + }), + ); diff --git a/apps/server/src/provider/antigravityRelease.ts b/apps/server/src/provider/antigravityRelease.ts new file mode 100644 index 000000000000..c33f09cef465 --- /dev/null +++ b/apps/server/src/provider/antigravityRelease.ts @@ -0,0 +1,83 @@ +export const ANTIGRAVITY_RELEASE_VERSION = "agy_acp_server_1.1.1"; + +export interface AntigravityReleaseAsset { + readonly version: string; + readonly url: string; + readonly sha256: string; + readonly archiveBytes: number; + readonly executable: { + readonly name: string; + readonly bytes: number; + }; + readonly harness: { + readonly name: string; + readonly bytes: number; + }; +} + +// 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_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_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_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_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_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 }, + }, + ], +]); + +export function resolveAntigravityReleaseAsset( + platform: NodeJS.Platform, + arch: string, +): AntigravityReleaseAsset | null { + return releaseAssets.get(`${platform}-${arch}`) ?? null; +} diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index 3d082b43726c..bea885b02ffc 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -22,6 +22,7 @@ */ import { AcpDriver, type AcpDriverEnv } from "./Drivers/AcpDriver.ts"; import { AmpDriver, type AmpDriverEnv } from "./Drivers/AmpDriver.ts"; +import { AntigravityDriver, type AntigravityDriverEnv } from "./Drivers/AntigravityDriver.ts"; import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts"; import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; import { CopilotDriver, type CopilotDriverEnv } from "./Drivers/CopilotDriver.ts"; @@ -52,6 +53,7 @@ export type BuiltInDriversEnv = | GrokDriverEnv | HermesDriverEnv | OpenCodeDriverEnv + | AntigravityDriverEnv | OhMyPiDriverEnv | AmpDriverEnv | CopilotDriverEnv @@ -76,6 +78,7 @@ export const BUILT_IN_DRIVERS: ReadonlyArray[0]; + } = {}, +) { + const calls: string[] = []; + let protectedPaths: ReadonlyArray = []; + const configured = input.instance ?? instance(); + const router = yield* makeProviderInstallation().pipe( + Effect.provide( + Layer.mergeAll( + settingsLayerTest(input.settings), + Layer.mock(ProviderInstanceRegistry)({ + getInstance: (id) => + Effect.succeed(id === configured.instanceId ? configured : undefined), + listInstances: Effect.succeed([configured]), + }), + Layer.mock(ProviderRegistry)({ + refreshInstance: () => + Effect.sync(() => { + calls.push("refresh"); + return []; + }), + }), + Layer.mock(AntigravityInstallation)({ + managedDirectory: "/unused-managed-runtime", + start: Effect.sync(() => { + calls.push("start"); + return state; + }), + cancel: () => + Effect.sync(() => { + calls.push("cancel"); + return state; + }), + state: Effect.succeed(state), + changes: Stream.succeed(state), + remove: (paths) => + Effect.sync(() => { + protectedPaths = paths ?? []; + calls.push("remove"); + }), + }), + ), + ), + ); + return { router, calls, protectedPaths: () => protectedPaths }; +}); + +describe("provider installation routing", () => { + it.effect("allows explicit installation while the provider is disabled", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* harness.router.start({ instanceId }); + assert.deepEqual(harness.calls, ["start"]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("rejects another driver and unknown instances before installation", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ instance: instance(ProviderDriverKind.make("codex")) }); + const wrongDriver = yield* Effect.flip(harness.router.start({ instanceId })); + const missing = yield* Effect.flip( + harness.router.start({ instanceId: ProviderInstanceId.make("missing") }), + ); + assert.equal(wrongDriver._tag, "ProviderSetupError"); + assert.equal(missing._tag, "ProviderSetupError"); + assert.deepEqual(harness.calls, []); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("keeps external installs manual without hiding shared install status", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + settings: { providers: { antigravity: { binaryPath: "/external/agy" } } }, + }); + const start = yield* Effect.flip(harness.router.start({ instanceId })); + const remove = yield* Effect.flip(harness.router.remove({ instanceId })); + assert.include(start.detail, "custom executable"); + assert.include(remove.detail, "custom executable"); + const observed = yield* Stream.runCollect(harness.router.subscribe({ instanceId })); + assert.deepEqual(Array.from(observed), [state]); + assert.deepEqual(harness.calls, []); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("protects another instance's binary found through its own PATH", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const platform = yield* HostProcessPlatform; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-provider-install-route-" }); + const binary = platform === "win32" ? "agy-test.exe" : "agy-test"; + const executable = path.join(directory, binary); + yield* fs.writeFileString(executable, "test"); + yield* fs.chmod(executable, 0o755); + const other = ProviderInstanceId.make("antigravity-work"); + const harness = yield* makeHarness({ + settings: { + providerInstances: { + [other]: { + driver, + config: { binaryPath: binary }, + environment: [{ name: "PATH", value: directory, sensitive: false }], + }, + }, + }, + }); + yield* harness.router.remove({ instanceId }); + assert.include(harness.protectedPaths(), executable); + assert.deepEqual(harness.calls, ["remove", "refresh"]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); +}); diff --git a/apps/server/src/provider/providerInstallation.ts b/apps/server/src/provider/providerInstallation.ts new file mode 100644 index 000000000000..cdec50fb0bb2 --- /dev/null +++ b/apps/server/src/provider/providerInstallation.ts @@ -0,0 +1,137 @@ +import { + AntigravitySettings, + ProviderDriverKind, + type ProviderInstallCancelInput, + type ProviderInstanceId, + ProviderSetupError, + type ProviderSetupInput, +} from "@t3tools/contracts"; +import { resolveCommandPath } from "@t3tools/shared/shell"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; + +import { ServerSettingsService } from "../serverSettings.ts"; +import { + AntigravityInstallation, + type AntigravityInstallationError, +} from "./AntigravityInstallation.ts"; +import { deriveProviderInstanceConfigMap } from "./Layers/ProviderInstanceRegistryHydration.ts"; +import { ProviderInstanceRegistry } from "./Services/ProviderInstanceRegistry.ts"; +import { ProviderRegistry } from "./Services/ProviderRegistry.ts"; +import { mergeProviderInstanceEnvironment } from "./ProviderInstanceEnvironment.ts"; + +const ANTIGRAVITY = ProviderDriverKind.make("antigravity"); +const hasBinaryPath = Schema.is(Schema.Struct({ binaryPath: Schema.String })); +const decodeAntigravitySettings = Schema.decodeUnknownEffect(AntigravitySettings); + +/** Route instance setup to the environment-owned installer without owning the download. */ +export const makeProviderInstallation = Effect.fn("makeProviderInstallation")(function* () { + const installation = yield* AntigravityInstallation; + const instances = yield* ProviderInstanceRegistry; + const providers = yield* ProviderRegistry; + const settings = yield* ServerSettingsService; + + const readEntries = Effect.fn("ProviderInstallation.readEntries")(function* ( + instanceId: ProviderInstanceId, + operation: string, + ) { + const current = yield* settings.getSettings.pipe( + Effect.mapError( + () => + new ProviderSetupError({ + instanceId, + operation, + detail: "Could not read provider installation settings.", + }), + ), + ); + return deriveProviderInstanceConfigMap(current); + }); + + const requireInstance = Effect.fn("ProviderInstallation.requireInstance")(function* ( + instanceId: ProviderInstanceId, + operation: string, + managedOnly = false, + ) { + const instance = yield* instances.getInstance(instanceId); + if (instance?.driverKind !== ANTIGRAVITY) { + return yield* new ProviderSetupError({ + instanceId, + operation, + detail: "Managed installation is not available for this provider instance.", + }); + } + if (!managedOnly) return; + const entries = yield* readEntries(instanceId, operation); + const config = yield* decodeAntigravitySettings(entries[instanceId]?.config ?? {}).pipe( + Effect.mapError( + () => + new ProviderSetupError({ + instanceId, + operation, + detail: "The Antigravity instance configuration is invalid.", + }), + ), + ); + if (config.binaryPath) { + return yield* new ProviderSetupError({ + instanceId, + operation, + detail: + "This instance uses a custom executable. Clear its binary path to manage installation in T3 Code.", + }); + } + }); + + const failure = (instanceId: ProviderInstanceId) => (error: AntigravityInstallationError) => + new ProviderSetupError({ instanceId, operation: error.operation, detail: error.detail }); + + const start = Effect.fn("ProviderInstallation.start")(function* (input: ProviderSetupInput) { + yield* requireInstance(input.instanceId, "install", true); + return yield* installation.start.pipe(Effect.mapError(failure(input.instanceId))); + }); + + const cancel = Effect.fn("ProviderInstallation.cancel")(function* ( + input: ProviderInstallCancelInput, + ) { + yield* requireInstance(input.instanceId, "cancel-install"); + return yield* installation + .cancel(input.operationId) + .pipe(Effect.mapError(failure(input.instanceId))); + }); + + const subscribe = (input: ProviderSetupInput) => + Stream.unwrap( + requireInstance(input.instanceId, "observe-install").pipe(Effect.as(installation.changes)), + ); + + const remove = Effect.fn("ProviderInstallation.remove")(function* (input: ProviderSetupInput) { + yield* requireInstance(input.instanceId, "remove-install", true); + const entries = yield* readEntries(input.instanceId, "remove-install"); + const protectedPaths = yield* Effect.forEach(Object.values(entries), (entry) => { + if (!hasBinaryPath(entry.config) || !entry.config.binaryPath.trim()) { + return Effect.succeed([]); + } + const binaryPath = entry.config.binaryPath.trim(); + return resolveCommandPath(binaryPath, { + env: mergeProviderInstanceEnvironment(entry.environment), + }).pipe( + Effect.map((resolved) => [binaryPath, resolved]), + Effect.catch(() => Effect.succeed([binaryPath])), + ); + }); + yield* installation + .remove(protectedPaths.flat()) + .pipe(Effect.mapError(failure(input.instanceId))); + const allInstances = yield* instances.listInstances; + yield* Effect.forEach( + allInstances.filter((instance) => instance.driverKind === ANTIGRAVITY), + (instance) => providers.refreshInstance(instance.instanceId), + { discard: true }, + ); + return yield* installation.state; + }); + + return { start, cancel, subscribe, remove }; +}); diff --git a/apps/server/src/provider/providerStatusCache.ts b/apps/server/src/provider/providerStatusCache.ts index 31cdff723a8d..a4b260f43bf6 100644 --- a/apps/server/src/provider/providerStatusCache.ts +++ b/apps/server/src/provider/providerStatusCache.ts @@ -29,13 +29,32 @@ const mergeProviderModels = ( ]; }; +/** + * Built-in drivers in presentation order. Codex and Claude lead, the opt-in + * providers follow, and unknown or fork drivers sort after every built-in. + */ +const BUILT_IN_DRIVER_ORDER: ReadonlyArray = [ + "codex", + "claudeAgent", + "cursor", + "grok", + "opencode", + "antigravity", +]; + +const driverRank = (driver: string): number => { + const index = BUILT_IN_DRIVER_ORDER.indexOf(driver); + return index === -1 ? BUILT_IN_DRIVER_ORDER.length : index; +}; + export const orderProviderSnapshots = ( providers: ReadonlyArray, ): ReadonlyArray => [...providers].toSorted( (left, right) => - (left.displayName ?? "").localeCompare(right.displayName ?? "") || + driverRank(left.driver) - driverRank(right.driver) || left.driver.localeCompare(right.driver) || + (left.displayName ?? "").localeCompare(right.displayName ?? "") || left.instanceId.localeCompare(right.instanceId), ); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 7b587aceeceb..a72fc7e867cd 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -29,8 +29,11 @@ import { ORCHESTRATION_WS_METHODS, type PreviewEvent, ProjectId, + type ProviderAuthState, ProviderDriverKind, ProviderInstanceId, + type ProviderInstallState, + ProviderSetupError, ResolvedKeybindingRule, ThreadId, TurnId, @@ -112,6 +115,13 @@ import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts"; import { PersistenceSqlError } from "./persistence/Errors.ts"; import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; import * as ProviderService from "./provider/Services/ProviderService.ts"; +import { ProviderAuthService } from "./provider/Services/ProviderAuthService.ts"; +import { ProviderInstanceRegistry } from "./provider/Services/ProviderInstanceRegistry.ts"; +import { + AntigravityInstallation, + AntigravityInstallationError, +} from "./provider/AntigravityInstallation.ts"; +import type { ProviderInstance } from "./provider/ProviderDriver.ts"; import { ProviderAdapterRequestError } from "./provider/Errors.ts"; import { makeManualOnlyProviderMaintenanceCapabilities } from "./provider/providerMaintenance.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; @@ -192,6 +202,47 @@ const defaultModelSelection = { model: "gpt-5-codex", } as const; +const providerSetupInstanceId = ProviderInstanceId.make("antigravity-custom-profile"); +const providerSetupDriver = ProviderDriverKind.make("antigravity"); +const providerSetupInstallState: ProviderInstallState = { + driver: providerSetupDriver, + operationId: "install-operation", + phase: "downloading", + downloadedBytes: 128, + totalBytes: 256, + version: "test-release", + installedVersion: null, + canRemove: false, + message: null, +}; +const providerSetupAuthState: ProviderAuthState = { + instanceId: providerSetupInstanceId, + phase: "idle", + flowId: null, + authorizationUrl: null, + expiresAt: null, + message: null, +}; +const providerSetupInstance: ProviderInstance = { + instanceId: providerSetupInstanceId, + driverKind: providerSetupDriver, + enabled: false, + displayName: "Google account", + continuationIdentity: { + driverKind: providerSetupDriver, + continuationKey: providerSetupInstanceId, + }, + get adapter(): never { + throw new Error("Provider setup must not start a chat session."); + }, + get snapshot(): never { + throw new Error("Installation routing must not probe the provider."); + }, + get textGeneration(): never { + throw new Error("Provider setup must not generate text."); + }, +}; + const makeLiveToolActivityEvent = ( sequence: number, kind: "tool.updated" | "tool.completed" = "tool.updated", @@ -437,6 +488,9 @@ const buildAppUnderTest = (options?: { environmentTheme?: Partial; providerRegistry?: Partial; providerService?: Partial; + providerAuth?: Partial; + providerInstanceRegistry?: Partial; + antigravityInstallation?: Partial; serverSettings?: Partial; externalLauncher?: Partial; vcsDriver?: Partial; @@ -706,6 +760,18 @@ const buildAppUnderTest = (options?: { uploadFeedback: () => Effect.die("Provider feedback is not stubbed in this test"), ...options?.layers?.providerService, }), + Layer.mock(ProviderAuthService)({ + ...options?.layers?.providerAuth, + }), + Layer.mock(ProviderInstanceRegistry)({ + getInstance: () => Effect.succeed(undefined), + listInstances: Effect.succeed([]), + ...options?.layers?.providerInstanceRegistry, + }), + Layer.mock(AntigravityInstallation)({ + managedDirectory: "unused-test-antigravity-runtime", + ...options?.layers?.antigravityInstallation, + }), ), ), Layer.provide( @@ -4985,6 +5051,307 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("provider setup lets read-only clients observe installation but not change setup", () => + Effect.gen(function* () { + let installStarts = 0; + let authCalls = 0; + yield* buildAppUnderTest({ + layers: { + providerInstanceRegistry: { + getInstance: (instanceId) => + Effect.succeed( + instanceId === providerSetupInstanceId ? providerSetupInstance : undefined, + ), + }, + antigravityInstallation: { + start: Effect.sync(() => { + installStarts += 1; + return providerSetupInstallState; + }), + changes: Stream.succeed(providerSetupInstallState), + }, + providerAuth: { + start: () => + Effect.sync(() => { + authCalls += 1; + return providerSetupAuthState; + }), + subscribe: () => + Stream.fromEffect( + Effect.sync(() => { + authCalls += 1; + return providerSetupAuthState; + }), + ), + }, + }, + }); + const token = yield* exchangeAccessToken(defaultDesktopBootstrapToken, { + scope: "orchestration:read", + }); + assert.equal(token.response.status, 200); + const ticketResponse = yield* HttpClient.post("/api/auth/websocket-ticket", { + headers: { authorization: `Bearer ${token.body.access_token ?? ""}` }, + }); + const { ticket } = yield* responseJsonEffect<{ readonly ticket: string }>(ticketResponse); + const wsUrl = `${yield* getWsServerUrl("/ws", { authenticated: false })}?wsTicket=${encodeURIComponent(ticket)}`; + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + Effect.gen(function* () { + const observed = yield* client[WS_METHODS.providerInstallSubscribe]({ + instanceId: providerSetupInstanceId, + }).pipe(Stream.runHead, Effect.map(Option.getOrThrow)); + assert.deepEqual(observed, providerSetupInstallState); + const errors = [ + yield* client[WS_METHODS.providerInstallStart]({ + instanceId: providerSetupInstanceId, + }).pipe(Effect.flip), + yield* client[WS_METHODS.providerAuthStart]({ + instanceId: providerSetupInstanceId, + }).pipe(Effect.flip), + yield* client[WS_METHODS.providerAuthSubscribe]({ + instanceId: providerSetupInstanceId, + }).pipe(Stream.runHead, Effect.flip), + ]; + for (const error of errors) { + assert.equal(error._tag, "EnvironmentAuthorizationError"); + if (error._tag === "EnvironmentAuthorizationError") { + assert.equal(error.requiredScope, "orchestration:operate"); + } + } + }), + ), + ); + assert.equal(installStarts, 0); + assert.equal(authCalls, 0); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("provider setup binds private sign-in to the authenticated websocket session", () => + Effect.gen(function* () { + const flowId = "private-sign-in-flow"; + const callbackUrl = "http://127.0.0.1:51234/?state=test-state&code=test-code"; + const waiting: ProviderAuthState = { + ...providerSetupAuthState, + phase: "waiting", + flowId, + authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth?state=test-state", + expiresAt: "2026-09-02T00:05:00.000Z", + }; + const calls: Array<{ + readonly operation: string; + readonly instanceId: ProviderInstanceId; + readonly ownerSessionId: string; + }> = []; + const forwardedCallbacks: string[] = []; + const logoutInstances: ProviderInstanceId[] = []; + let flowOwner = ""; + yield* buildAppUnderTest({ + layers: { + providerAuth: { + start: (input, ownerSessionId) => + Effect.sync(() => { + flowOwner = ownerSessionId; + calls.push({ operation: "start", instanceId: input.instanceId, ownerSessionId }); + return waiting; + }), + subscribe: (input, ownerSessionId) => + Stream.fromEffect( + Effect.sync(() => { + calls.push({ + operation: "subscribe", + instanceId: input.instanceId, + ownerSessionId, + }); + return ownerSessionId === flowOwner + ? waiting + : { ...waiting, flowId: null, authorizationUrl: null, expiresAt: null }; + }), + ), + complete: (input, ownerSessionId) => + Effect.gen(function* () { + calls.push({ operation: "complete", instanceId: input.instanceId, ownerSessionId }); + if (ownerSessionId !== flowOwner) { + return yield* new ProviderSetupError({ + instanceId: input.instanceId, + operation: "complete", + detail: "This sign-in belongs to another client.", + }); + } + assert.equal(input.flowId, flowId); + forwardedCallbacks.push(input.callbackUrl); + return { ...waiting, phase: "verifying" as const, authorizationUrl: null }; + }), + cancel: (input, ownerSessionId) => + Effect.sync(() => { + assert.equal(input.flowId, flowId); + calls.push({ operation: "cancel", instanceId: input.instanceId, ownerSessionId }); + return { ...providerSetupAuthState, phase: "cancelled" as const, flowId }; + }), + logout: (input) => + Effect.sync(() => { + logoutInstances.push(input.instanceId); + return providerSetupAuthState; + }), + }, + }, + }); + const firstCookie = yield* getAuthenticatedSessionCookieHeader(); + const secondCookie = yield* getAuthenticatedSessionCookieHeader(); + const firstClients = yield* HttpClient.get("/api/auth/clients", { + headers: { cookie: firstCookie }, + }).pipe( + Effect.flatMap( + responseJsonEffect< + ReadonlyArray<{ readonly sessionId: string; readonly current: boolean }> + >, + ), + ); + const secondClients = yield* HttpClient.get("/api/auth/clients", { + headers: { cookie: secondCookie }, + }).pipe( + Effect.flatMap( + responseJsonEffect< + ReadonlyArray<{ readonly sessionId: string; readonly current: boolean }> + >, + ), + ); + const firstOwner = firstClients.find((session) => session.current)?.sessionId; + const secondOwner = secondClients.find((session) => session.current)?.sessionId; + assert.isString(firstOwner); + assert.isString(secondOwner); + assert.notEqual(firstOwner, secondOwner); + const baseWsUrl = yield* getWsServerUrl("/ws", { authenticated: false }); + const target = { + instanceId: providerSetupInstanceId, + ownerSessionId: "client-supplied-owner", + }; + yield* Effect.scoped( + withWsRpcClient(appendSessionCookieToWsUrl(baseWsUrl, firstCookie), (client) => + Effect.gen(function* () { + const started = yield* client[WS_METHODS.providerAuthStart](target); + assert.equal(started.flowId, flowId); + const ownState = yield* client[WS_METHODS.providerAuthSubscribe](target).pipe( + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + assert.equal(ownState.authorizationUrl, waiting.authorizationUrl); + yield* Effect.scoped( + withWsRpcClient(appendSessionCookieToWsUrl(baseWsUrl, secondCookie), (otherClient) => + Effect.gen(function* () { + const otherState = yield* otherClient[WS_METHODS.providerAuthSubscribe]( + target, + ).pipe(Stream.runHead, Effect.map(Option.getOrThrow)); + assert.isNull(otherState.authorizationUrl); + assert.isNull(otherState.flowId); + const forged = { ...target, ownerSessionId: firstOwner, flowId, callbackUrl }; + const denied = yield* otherClient[WS_METHODS.providerAuthComplete](forged).pipe( + Effect.flip, + ); + assert.equal(denied._tag, "ProviderSetupError"); + assert.deepEqual(forwardedCallbacks, []); + }), + ), + ); + const completed = yield* client[WS_METHODS.providerAuthComplete]({ + ...target, + flowId, + callbackUrl, + }); + assert.equal(completed.phase, "verifying"); + const cancelled = yield* client[WS_METHODS.providerAuthCancel]({ ...target, flowId }); + assert.equal(cancelled.phase, "cancelled"); + const signedOut = yield* client[WS_METHODS.providerAuthLogout](target); + assert.equal(signedOut.phase, "idle"); + }), + ), + ); + assert.deepEqual(forwardedCallbacks, [callbackUrl]); + assert.deepEqual(logoutInstances, [providerSetupInstanceId]); + assert.isTrue(calls.every((call) => call.instanceId === providerSetupInstanceId)); + assert.deepEqual( + calls.map((call) => call.ownerSessionId), + [firstOwner, firstOwner, secondOwner, secondOwner, firstOwner, firstOwner], + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect( + "provider setup routes installation operations and returns only safe typed errors", + () => + Effect.gen(function* () { + const calls: string[] = []; + let state = providerSetupInstallState; + yield* buildAppUnderTest({ + layers: { + providerInstanceRegistry: { + getInstance: (instanceId) => + Effect.succeed( + instanceId === providerSetupInstanceId ? providerSetupInstance : undefined, + ), + }, + antigravityInstallation: { + start: Effect.sync(() => { + calls.push("start"); + return state; + }), + cancel: (operationId) => + Effect.gen(function* () { + calls.push(`cancel:${operationId}`); + if (operationId !== state.operationId) { + return yield* new AntigravityInstallationError({ + operation: "cancel", + detail: "This installation is no longer running.", + cause: new Error("Private download diagnostics."), + }); + } + state = { ...state, phase: "cancelled" }; + return state; + }), + changes: Stream.fromEffect(Effect.sync(() => state)), + }, + }, + }); + const wsUrl = yield* getWsServerUrl("/ws"); + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + Effect.gen(function* () { + const unknownInstance = yield* client[WS_METHODS.providerInstallStart]({ + instanceId: ProviderInstanceId.make("unknown-instance"), + }).pipe(Effect.flip); + assert.equal(unknownInstance._tag, "ProviderSetupError"); + assert.deepEqual(calls, []); + const started = yield* client[WS_METHODS.providerInstallStart]({ + instanceId: providerSetupInstanceId, + }); + assert.deepEqual(started, providerSetupInstallState); + const stale = yield* client[WS_METHODS.providerInstallCancel]({ + instanceId: providerSetupInstanceId, + operationId: "old-operation", + }).pipe(Effect.flip); + assert.equal(stale._tag, "ProviderSetupError"); + if (stale._tag === "ProviderSetupError") { + assert.equal(stale.instanceId, providerSetupInstanceId); + assert.equal(stale.operation, "cancel"); + assert.equal(stale.detail, "This installation is no longer running."); + assert.notProperty(stale, "cause"); + } + const cancelled = yield* client[WS_METHODS.providerInstallCancel]({ + instanceId: providerSetupInstanceId, + operationId: "install-operation", + }); + assert.equal(cancelled.phase, "cancelled"); + const observed = yield* client[WS_METHODS.providerInstallSubscribe]({ + instanceId: providerSetupInstanceId, + }).pipe(Stream.runHead, Effect.map(Option.getOrThrow)); + assert.deepEqual(observed, cancelled); + }), + ), + ); + assert.deepEqual(calls, ["start", "cancel:old-operation", "cancel:install-operation"]); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("routes websocket rpc subscribeServerConfig streams snapshot then update", () => Effect.gen(function* () { const path = yield* Path.Path; diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index cd10b0e549b5..7d8875738f17 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -1,9 +1,10 @@ -import { EnvironmentHttpApi } from "@t3tools/contracts"; +import { EnvironmentHttpApi, ProviderDriverKind } from "@t3tools/contracts"; import * as Duration from "effect/Duration"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schedule from "effect/Schedule"; +import * as Stream from "effect/Stream"; import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http"; import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; @@ -36,6 +37,10 @@ import * as ModelManifest from "./provider/ModelManifest.ts"; import * as ProviderEventLoggers from "./provider/Layers/ProviderEventLoggers.ts"; import { HermesGatewayBrokerLive } from "./provider/Layers/HermesGatewayBroker.ts"; import { ProviderServiceLive } from "./provider/Layers/ProviderService.ts"; +import { ProviderAuthServiceLive } from "./provider/Layers/ProviderAuthService.ts"; +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 * as OpenCodeRuntime from "./provider/opencodeRuntime.ts"; import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; @@ -412,7 +417,36 @@ const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe( Layer.provideMerge(OrchestrationLayerLive), ); +const AntigravityInstallationRefreshLive = Layer.effectDiscard( + Effect.gen(function* () { + const installation = yield* AntigravityInstallation; + const instances = yield* ProviderInstanceRegistry; + const providers = yield* ProviderRegistry; + yield* installation.changes.pipe( + Stream.map((state) => state.installedVersion), + Stream.changes, + Stream.drop(1), + Stream.runForEach(() => + instances.listInstances.pipe( + Effect.flatMap((entries) => + Effect.forEach( + entries.filter( + (instance) => instance.driverKind === ProviderDriverKind.make("antigravity"), + ), + (instance) => providers.refreshInstance(instance.instanceId), + { discard: true }, + ), + ), + ), + ), + Effect.forkScoped, + ); + }), +); + const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( + Layer.provideMerge(AntigravityInstallationRefreshLive), + Layer.provideMerge(ProviderAuthServiceLive), // Core Services // The broker re-exports the same settings layer it consumes so all runtime // services share one settings cache and write semaphore. @@ -436,6 +470,8 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( // `providerInstances` hydration merges `settings.providers.` // with explicit `providerInstances` entries on boot. Layer.provideMerge(ProviderInstanceRegistryHydrationLive), +).pipe( + Layer.provideMerge(AntigravityInstallation.layer), // Shared native/canonical NDJSON writers used by both the per-instance // drivers (native stream, written from inside each `Adapter`) and // `ProviderService` (canonical stream, written after event normalization). diff --git a/apps/server/src/serverRuntimeStartup.reconcile.test.ts b/apps/server/src/serverRuntimeStartup.reconcile.test.ts index c1359db2d6a7..42948857038b 100644 --- a/apps/server/src/serverRuntimeStartup.reconcile.test.ts +++ b/apps/server/src/serverRuntimeStartup.reconcile.test.ts @@ -56,6 +56,7 @@ const makeProviderService = (liveThreadIds: ReadonlyArray = []) => stopSession: () => Effect.die("unused"), listSessions: () => Effect.succeed(liveThreadIds.map((threadId) => ({ threadId }) as never)), getCapabilities: () => Effect.die("unused"), + assertConversationRollbackSupported: () => Effect.die("unused"), getInstanceInfo: () => Effect.die("unused"), rollbackConversation: () => Effect.die("unused"), uploadFeedback: () => Effect.die("unused"), diff --git a/apps/server/src/textGeneration/AntigravityTextGeneration.test.ts b/apps/server/src/textGeneration/AntigravityTextGeneration.test.ts new file mode 100644 index 000000000000..3060e39a129b --- /dev/null +++ b/apps/server/src/textGeneration/AntigravityTextGeneration.test.ts @@ -0,0 +1,633 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it } from "@effect/vitest"; +import { + ANTIGRAVITY_DEFAULT_MODEL, + ProviderInstanceId, + ProviderSetupError, +} from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Queue from "effect/Queue"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; +import { type AcpError, AcpRequestError } from "effect-acp/errors"; +import type * as AcpSchema from "effect-acp/schema"; +import { expect } from "vite-plus/test"; + +import type { AcpSessionRuntimeEvent } from "../provider/acp/AcpSessionRuntime.ts"; +import { removeAntigravitySessionFiles } from "../provider/acp/AntigravitySessionFiles.ts"; + +import { + type AntigravityTextGenerationOptions, + isAntigravityTextGenerationAvailable, + makeAntigravityTextGeneration, +} from "./AntigravityTextGeneration.ts"; + +type TextRuntime = Effect.Success>; + +const SESSION_ID = "047c62f6-607b-44db-bfbe-f83b67e9e8b1"; +const modelSelection = { + instanceId: ProviderInstanceId.make("antigravity-test"), + model: "gemini-test", +}; +const encodeMetadata = Schema.encodeEffect( + Schema.fromJsonString(Schema.Struct({ cwd: Schema.String })), +); + +interface PromptContext { + readonly emit: ( + update: AcpSchema.SessionNotification["update"], + sessionId?: string, + ) => Effect.Effect; + permission: Parameters[0]; + question: Parameters[0]; + readFile: Parameters[0]; + createTerminal: Parameters[0]; + extension: Parameters[0]; + readonly cwd: string; +} + +const makeFixture = Effect.fn("makeAntigravityTextGenerationFixture")(function* ( + options: { + readonly outputs?: ReadonlyArray; + readonly prompt?: (context: PromptContext) => Effect.Effect; + readonly startError?: AcpError; + readonly rejectAdmission?: boolean; + readonly sessionId?: string; + } = {}, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-antigravity-text-test-" }); + const profileDirectory = path.join(root, "profile"); + const projectDirectory = path.join(root, "project"); + const conversations = path.join(profileDirectory, "antigravity-acp", "conversations"); + const nativeSessionId = options.sessionId ?? SESSION_ID; + const sessionBase = path.join(conversations, SESSION_ID); + const brainDirectory = path.join(profileDirectory, "antigravity-acp", "brain", SESSION_ID); + yield* fs.makeDirectory(projectDirectory, { recursive: true }); + yield* fs.makeDirectory(conversations, { recursive: true }); + yield* fs.writeFileString(path.join(projectDirectory, "keep.txt"), "untouched"); + yield* fs.writeFileString(path.join(conversations, "user-session.db"), "keep native history"); + const enteredPrompt = yield* Deferred.make(); + const state = { + workspaces: [] as Array, + closed: [] as Array, + prompts: [] as Array[0]>, + selectedModels: [] as Array, + selectedModes: [] as Array, + nativeFilesAtClose: [] as Array, + cancellations: 0, + stop: undefined as Effect.Effect | undefined, + }; + const outputs = [...(options.outputs ?? ['{"title":"Repair login"}'])]; + const incoming: Omit = { + permission: () => Effect.die("No permission handler."), + question: () => Effect.die("No question handler."), + readFile: () => Effect.die("No file handler."), + createTerminal: () => Effect.die("No terminal handler."), + extension: () => Effect.die("No extension handler."), + }; + + const withProcess: AntigravityTextGenerationOptions["withProcess"] = (stop, task) => + Effect.gen(function* () { + if (options.rejectAdmission) { + return yield* new ProviderSetupError({ + instanceId: modelSelection.instanceId, + operation: "launch", + detail: "Sign in before starting Antigravity.", + }); + } + const scope = yield* Scope.Scope; + const child = yield* Effect.forkIn(task, scope); + state.stop = Fiber.interrupt(child).pipe(Effect.andThen(stop)); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + state.stop = undefined; + }), + ); + return yield* Fiber.await(child).pipe( + Effect.flatMap((exit) => exit), + Effect.ensuring(Fiber.interrupt(child)), + ); + }); + + const makeRuntime: AntigravityTextGenerationOptions["makeRuntime"] = (cwd) => + Effect.gen(function* () { + const events = yield* Queue.unbounded(); + state.workspaces.push(cwd); + expect(yield* fs.readDirectory(cwd).pipe(Effect.orDie)).toEqual([]); + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + state.nativeFilesAtClose.push(yield* fs.exists(`${sessionBase}.db`).pipe(Effect.orDie)); + state.closed.push(cwd); + }), + ); + let sessionUpdate: Parameters[0] = () => Effect.void; + + return { + start: () => + Effect.gen(function* () { + if (options.startError) return yield* options.startError; + yield* fs.makeDirectory(brainDirectory, { recursive: true }).pipe(Effect.orDie); + yield* fs.writeFileString(`${sessionBase}.db`, "helper session").pipe(Effect.orDie); + yield* fs.writeFileString(`${sessionBase}.db-wal`, "helper journal").pipe(Effect.orDie); + const metadata = yield* encodeMetadata({ cwd }).pipe(Effect.orDie); + yield* fs.writeFileString(`${sessionBase}.meta`, metadata).pipe(Effect.orDie); + yield* fs + .writeFileString(path.join(brainDirectory, "output.txt"), "helper artifact") + .pipe(Effect.orDie); + return { + sessionId: nativeSessionId, + initializeResult: { protocolVersion: 1 }, + sessionSetupResult: { sessionId: nativeSessionId }, + modelConfigId: "model", + }; + }), + setMode: (mode) => + Effect.sync(() => { + state.selectedModes.push(mode); + return {}; + }), + getConfigOptions: Effect.succeed([ + { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: modelSelection.model, + options: [{ value: modelSelection.model, name: "Gemini test" }], + }, + ]), + getEvents: () => Stream.fromQueue(events), + setModel: (model) => + Effect.sync(() => { + state.selectedModels.push(model); + }), + prompt: (request) => + Effect.gen(function* () { + state.prompts.push(request); + yield* Deferred.succeed(enteredPrompt, undefined); + const emit: PromptContext["emit"] = (update, sessionId = nativeSessionId) => + sessionUpdate({ sessionId, update }); + if (options.prompt) { + return yield* options.prompt({ + emit, + ...incoming, + cwd, + }); + } + const output = outputs.shift() ?? '{"title":"Repair login"}'; + yield* emit({ + sessionUpdate: "agent_thought_chunk", + content: { type: "text", text: "Choose concise text." }, + }); + yield* emit( + { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "unrelated session" }, + }, + "another-session", + ); + for (const text of [output.slice(0, 9), output.slice(9)]) { + yield* emit({ + sessionUpdate: "agent_message_chunk", + content: { type: "text", text }, + }); + } + return { stopReason: "end_turn" }; + }), + cancel: Effect.gen(function* () { + state.cancellations += 1; + const acknowledge = yield* Deferred.make(); + yield* Queue.offer(events, { _tag: "EventStreamBarrier", acknowledge }); + yield* Deferred.await(acknowledge); + }), + handleSessionUpdate: (handler) => + Effect.sync(() => { + sessionUpdate = handler; + }), + handleRequestPermission: (handler) => + Effect.sync(() => { + incoming.permission = handler; + }), + handleElicitation: (handler) => + Effect.sync(() => { + incoming.question = handler; + }), + handleReadTextFile: (handler) => + Effect.sync(() => { + incoming.readFile = handler; + }), + handleWriteTextFile: () => Effect.void, + handleCreateTerminal: (handler) => + Effect.sync(() => { + incoming.createTerminal = handler; + }), + handleTerminalOutput: () => Effect.void, + handleTerminalWaitForExit: () => Effect.void, + handleTerminalKill: () => Effect.void, + handleTerminalRelease: () => Effect.void, + handleUnknownExtRequest: (handler) => + Effect.sync(() => { + incoming.extension = handler; + }), + } satisfies TextRuntime; + }); + + const textGeneration = yield* makeAntigravityTextGeneration({ + profileDirectory, + makeRuntime, + withProcess, + }); + const titleInput = { cwd: projectDirectory, message: "Repair Google login", modelSelection }; + const assertCleaned = Effect.gen(function* () { + expect(state.closed).toEqual(state.workspaces); + for (const workspace of state.workspaces) { + expect(yield* fs.exists(workspace)).toBe(false); + } + expect(yield* fs.exists(`${sessionBase}.db`)).toBe(false); + expect(yield* fs.exists(`${sessionBase}.db-wal`)).toBe(false); + expect(yield* fs.exists(`${sessionBase}.meta`)).toBe(false); + expect(yield* fs.exists(brainDirectory)).toBe(false); + expect(yield* fs.readFileString(path.join(projectDirectory, "keep.txt"))).toBe("untouched"); + expect(yield* fs.readFileString(path.join(conversations, "user-session.db"))).toBe( + "keep native history", + ); + }); + return { + fs, + path, + profileDirectory, + projectDirectory, + sessionBase, + brainDirectory, + state, + incoming, + enteredPrompt, + textGeneration, + titleInput, + assertCleaned, + }; +}); + +it.layer(NodeServices.layer)("AntigravityTextGeneration", (it) => { + it.effect( + "generates all helper types in empty workspaces and removes only owned session files", + () => + Effect.gen(function* () { + const fixture = yield* makeFixture({ + outputs: [ + '{"subject":" Repair Google login.\\nExtra line","body":" Keep the remote callback. ","branch":"Repair Login"}', + '```json\n{"title":" Repair Google login\\nExtra line","body":" ## Summary\\nSupport remote callbacks. "}\n```', + '{"branch":" Repair Google Login "}', + '{"title":" \\"Repair Google login\\" "}', + ], + }); + const common = { cwd: fixture.projectDirectory, modelSelection }; + expect( + yield* fixture.textGeneration.generateCommitMessage({ + ...common, + branch: "feature/login", + stagedSummary: "M login.ts", + stagedPatch: "+handleRemoteCallback()", + includeBranch: true, + }), + ).toEqual({ + subject: "Repair Google login", + body: "Keep the remote callback.", + branch: "feature/repair-login", + }); + expect( + yield* fixture.textGeneration.generatePrContent({ + ...common, + baseBranch: "main", + headBranch: "feature/login", + commitSummary: "Repair login", + diffSummary: "M login.ts", + diffPatch: "+handleRemoteCallback()", + }), + ).toEqual({ title: "Repair Google login", body: "## Summary\nSupport remote callbacks." }); + expect( + yield* fixture.textGeneration.generateBranchName({ + ...common, + message: "Repair Google login", + }), + ).toEqual({ branch: "repair-google-login" }); + expect(yield* fixture.textGeneration.generateThreadTitle(fixture.titleInput)).toEqual({ + title: "Repair Google login", + }); + expect(new Set(fixture.state.workspaces).size).toBe(4); + expect(fixture.state.workspaces).not.toContain(fixture.projectDirectory); + expect(fixture.state.nativeFilesAtClose).toEqual([true, true, true, true]); + expect(fixture.state.selectedModes).toEqual(["default", "default", "default", "default"]); + expect(fixture.state.selectedModels).toEqual(Array(4).fill(modelSelection.model)); + expect(fixture.state.prompts[0]?.prompt).toEqual([ + { type: "text", text: expect.stringContaining("+handleRemoteCallback()") }, + ]); + yield* fixture.assertCleaned; + }).pipe(Effect.scoped), + ); + + it.effect.each(["tool_call", "tool_call_update"] as const)( + "aborts on %s even without a permission request", + (sessionUpdate) => + Effect.gen(function* () { + const fixture = yield* makeFixture({ + prompt: ({ emit }) => + emit({ sessionUpdate, toolCallId: "tool-1", title: "Read files" }).pipe( + Effect.andThen(Effect.never), + ), + }); + const error = yield* fixture.textGeneration + .generateThreadTitle(fixture.titleInput) + .pipe(Effect.flip); + expect(error.detail).toContain("tool work"); + expect(fixture.state.cancellations).toBe(1); + yield* fixture.assertCleaned; + }).pipe(Effect.scoped), + ); + + it.effect("rejects native permission and choice requests instead of answering them", () => + Effect.gen(function* () { + const fixture = yield* makeFixture({ prompt: () => Effect.never }); + const child = yield* fixture.textGeneration + .generateThreadTitle(fixture.titleInput) + .pipe(Effect.forkChild); + yield* Deferred.await(fixture.enteredPrompt); + const reply = yield* fixture.incoming.permission({ + sessionId: SESSION_ID, + toolCall: { toolCallId: "question-1", title: "Choose a title", kind: "other" }, + options: [ + { optionId: "first-native-choice", name: "Use this title", kind: "allow_once" }, + { optionId: "second-native-choice", name: "Use this title", kind: "allow_once" }, + ], + }); + const error = yield* Fiber.join(child).pipe(Effect.flip); + expect(reply).toEqual({ outcome: { outcome: "cancelled" } }); + expect(error.detail).toContain("permission or user input"); + yield* fixture.assertCleaned; + }).pipe(Effect.scoped), + ); + + it.effect("declines elicitation and stops the helper", () => + Effect.gen(function* () { + const fixture = yield* makeFixture({ prompt: () => Effect.never }); + const child = yield* fixture.textGeneration + .generateThreadTitle(fixture.titleInput) + .pipe(Effect.forkChild); + yield* Deferred.await(fixture.enteredPrompt); + const reply = yield* fixture.incoming.question({ + sessionId: SESSION_ID, + mode: "form", + message: "Name this branch", + requestedSchema: { type: "object", properties: {} }, + }); + const error = yield* Fiber.join(child).pipe(Effect.flip); + expect(reply).toEqual({ action: { action: "decline" } }); + expect(error.detail).toContain("user input"); + yield* fixture.assertCleaned; + }).pipe(Effect.scoped), + ); + + it.effect.each(["file", "terminal", "extension"] as const)( + "denies unexpected %s requests", + (kind) => + Effect.gen(function* () { + const fixture = yield* makeFixture({ prompt: () => Effect.never }); + const child = yield* fixture.textGeneration + .generateThreadTitle(fixture.titleInput) + .pipe(Effect.forkChild); + yield* Deferred.await(fixture.enteredPrompt); + const request = + kind === "file" + ? fixture.incoming.readFile({ sessionId: SESSION_ID, path: "/not-allowed" }) + : kind === "terminal" + ? fixture.incoming.createTerminal({ sessionId: SESSION_ID, command: "not-allowed" }) + : fixture.incoming.extension("_ask_user", {}); + const denied = yield* request.pipe(Effect.exit); + expect(Exit.isFailure(denied)).toBe(true); + const error = yield* Fiber.join(child).pipe(Effect.flip); + expect(error.detail).toContain("tool or user input"); + yield* fixture.assertCleaned; + }).pipe(Effect.scoped), + ); + + it.effect.each([ + { output: " ", detail: "empty" }, + { output: "No JSON here", detail: "invalid structured output" }, + { output: '{"title":42}', detail: "invalid structured output" }, + { output: "x".repeat(128_001), detail: "output limit" }, + ])("rejects $detail output and closes the runtime", ({ output, detail }) => + Effect.gen(function* () { + const fixture = yield* makeFixture({ outputs: [output] }); + const error = yield* fixture.textGeneration + .generateThreadTitle(fixture.titleInput) + .pipe(Effect.flip); + expect(error.detail).toContain(detail); + yield* fixture.assertCleaned; + }).pipe(Effect.scoped), + ); + + it.effect("does not return partial JSON after native cancellation", () => + Effect.gen(function* () { + const fixture = yield* makeFixture({ + prompt: ({ emit }) => + emit({ + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: '{"title":"Partial title"}' }, + }).pipe(Effect.as({ stopReason: "cancelled" })), + }); + const error = yield* fixture.textGeneration + .generateThreadTitle(fixture.titleInput) + .pipe(Effect.flip); + expect(error.detail).toContain("cancelled"); + yield* fixture.assertCleaned; + }).pipe(Effect.scoped), + ); + + it.effect("rejects a helper that writes files in its temporary workspace", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const fixture = yield* makeFixture({ + prompt: ({ cwd, emit }) => + Effect.gen(function* () { + yield* fs + .writeFileString(path.join(cwd, "unexpected.txt"), "unexpected") + .pipe(Effect.orDie); + yield* emit({ + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: '{"title":"Ignored output"}' }, + }); + return { stopReason: "end_turn" }; + }), + }); + const error = yield* fixture.textGeneration + .generateThreadTitle(fixture.titleInput) + .pipe(Effect.flip); + expect(error.detail).toContain("wrote files"); + yield* fixture.assertCleaned; + }).pipe(Effect.scoped), + ); + + it.effect("fails an unavailable model before prompting", () => + Effect.gen(function* () { + const fixture = yield* makeFixture(); + const error = yield* fixture.textGeneration + .generateThreadTitle({ + ...fixture.titleInput, + modelSelection: { ...modelSelection, model: "not-in-the-account" }, + }) + .pipe(Effect.flip); + expect(error.detail).toContain("select the Antigravity model"); + expect(fixture.state.prompts).toEqual([]); + yield* fixture.assertCleaned; + }).pipe(Effect.scoped), + ); + + it.effect("uses the native default without sending T3's default selection as a model ID", () => + Effect.gen(function* () { + const fixture = yield* makeFixture(); + const result = yield* fixture.textGeneration.generateThreadTitle({ + ...fixture.titleInput, + modelSelection: { ...modelSelection, model: ANTIGRAVITY_DEFAULT_MODEL }, + }); + expect(result).toEqual({ title: "Repair login" }); + expect(fixture.state.selectedModels).toEqual([]); + yield* fixture.assertCleaned; + }).pipe(Effect.scoped), + ); + + it.effect.each([ + { + name: "hooks.json", + value: '{"default":{"PreInvocation":[{"command":"do-not-run"}]}}', + available: false, + }, + { + name: "mcp_config.json", + value: '{"mcpServers":{"server":{"command":"do-not-run"}}}', + available: false, + }, + { name: "hooks.json", value: "invalid JSON", available: false }, + { name: "mcp_config.json", value: "x".repeat(64_001), available: false }, + { name: "hooks.json", value: "{}", available: true }, + { name: "hooks.json", value: '{"hooks":{}}', available: true }, + { name: "mcp_config.json", value: '{"mcpServers":{}}', available: true }, + ])("checks $name before starting a helper, available=$available", ({ name, value, available }) => + Effect.gen(function* () { + const fixture = yield* makeFixture(); + const configDirectory = fixture.path.join(fixture.profileDirectory, "config"); + yield* fixture.fs.makeDirectory(configDirectory, { recursive: true }); + yield* fixture.fs.writeFileString(fixture.path.join(configDirectory, name), value); + expect(yield* isAntigravityTextGenerationAvailable(fixture.profileDirectory)).toBe(available); + const result = yield* fixture.textGeneration + .generateThreadTitle(fixture.titleInput) + .pipe(Effect.exit); + expect(Exit.isSuccess(result)).toBe(available); + expect(fixture.state.workspaces.length).toBe(available ? 1 : 0); + yield* fixture.assertCleaned; + }).pipe(Effect.scoped), + ); + + it.effect("does not start another runtime or prompt after authentication fails", () => + Effect.gen(function* () { + const fixture = yield* makeFixture({ + startError: new AcpRequestError({ code: -32000, errorMessage: "Authentication required" }), + }); + const error = yield* fixture.textGeneration + .generateThreadTitle(fixture.titleInput) + .pipe(Effect.flip); + expect(error._tag).toBe("TextGenerationError"); + expect(fixture.state.workspaces).toHaveLength(1); + expect(fixture.state.prompts).toEqual([]); + yield* fixture.assertCleaned; + }).pipe(Effect.scoped), + ); + + it.effect("does not launch during sign-out or before sign-in", () => + Effect.gen(function* () { + const fixture = yield* makeFixture({ rejectAdmission: true }); + const error = yield* fixture.textGeneration + .generateThreadTitle(fixture.titleInput) + .pipe(Effect.flip); + expect(error._tag).toBe("TextGenerationError"); + expect(fixture.state.workspaces).toEqual([]); + yield* fixture.assertCleaned; + }).pipe(Effect.scoped), + ); + + it.effect("times out a stalled helper and waits for cleanup", () => + Effect.gen(function* () { + const fixture = yield* makeFixture({ prompt: () => Effect.never }); + const child = yield* fixture.textGeneration + .generateThreadTitle(fixture.titleInput) + .pipe(Effect.forkChild); + yield* Deferred.await(fixture.enteredPrompt); + yield* TestClock.adjust(180_000); + const error = yield* Fiber.join(child).pipe(Effect.flip); + expect(error.detail).toContain("timed out"); + expect(fixture.state.cancellations).toBe(1); + yield* fixture.assertCleaned; + }).pipe(Effect.scoped), + ); + + it.effect.each(["caller", "sign-out"] as const)( + "closes all helper resources when stopped by %s", + (source) => + Effect.gen(function* () { + const fixture = yield* makeFixture({ prompt: () => Effect.never }); + const child = yield* fixture.textGeneration + .generateThreadTitle(fixture.titleInput) + .pipe(Effect.forkChild); + yield* Deferred.await(fixture.enteredPrompt); + if (source === "caller") { + yield* Fiber.interrupt(child); + } else { + if (!fixture.state.stop) return yield* Effect.die("No tracked helper to stop."); + yield* fixture.state.stop; + yield* Fiber.await(child); + } + expect(fixture.state.cancellations).toBe(1); + yield* fixture.assertCleaned; + }).pipe(Effect.scoped), + ); + + it.effect("refuses unsafe native session IDs without deleting other files", () => + Effect.gen(function* () { + const fixture = yield* makeFixture({ sessionId: "../../user-session" }); + const error = yield* fixture.textGeneration + .generateThreadTitle(fixture.titleInput) + .pipe(Effect.flip); + expect(error.detail).toContain("invalid text helper session ID"); + expect(fixture.state.prompts).toEqual([]); + expect(yield* fixture.fs.readFileString(`${fixture.sessionBase}.db`)).toBe("helper session"); + expect(fixture.state.closed).toEqual(fixture.state.workspaces); + }).pipe(Effect.scoped), + ); + + it.effect("keeps native sessions whose metadata belongs to a different workspace", () => + Effect.gen(function* () { + const fixture = yield* makeFixture(); + yield* fixture.fs.writeFileString(`${fixture.sessionBase}.db`, "keep this session"); + const metadata = yield* encodeMetadata({ cwd: fixture.projectDirectory }); + yield* fixture.fs.writeFileString(`${fixture.sessionBase}.meta`, metadata); + yield* removeAntigravitySessionFiles({ + profileDirectory: fixture.profileDirectory, + sessionId: SESSION_ID, + cwd: "different-temporary-workspace", + }); + expect(yield* fixture.fs.readFileString(`${fixture.sessionBase}.db`)).toBe( + "keep this session", + ); + expect(yield* fixture.fs.readFileString(`${fixture.sessionBase}.meta`)).toBe(metadata); + }).pipe(Effect.scoped), + ); +}); diff --git a/apps/server/src/textGeneration/AntigravityTextGeneration.ts b/apps/server/src/textGeneration/AntigravityTextGeneration.ts new file mode 100644 index 000000000000..f81bb3f71d4e --- /dev/null +++ b/apps/server/src/textGeneration/AntigravityTextGeneration.ts @@ -0,0 +1,410 @@ +import { + type ModelSelection, + type ProviderSetupError, + TextGenerationError, +} from "@t3tools/contracts"; +import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; +import { extractJsonObject } from "@t3tools/shared/schemaJson"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +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 Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import { type AcpError, AcpRequestError } from "effect-acp/errors"; + +import { applyAntigravityAcpModelSelection } from "../provider/acp/AntigravityAcpSupport.ts"; +import { removeAntigravitySessionFiles } from "../provider/acp/AntigravitySessionFiles.ts"; +import type { AcpSessionRuntime } from "../provider/acp/AcpSessionRuntime.ts"; +import type * as TextGeneration from "./TextGeneration.ts"; +import { + buildBranchNamePrompt, + buildCommitMessagePrompt, + buildPrContentPrompt, + buildThreadTitlePrompt, +} from "./TextGenerationPrompts.ts"; +import { + sanitizeCommitSubject, + sanitizePrTitle, + sanitizeThreadTitle, +} from "./TextGenerationUtils.ts"; + +const ANTIGRAVITY_TIMEOUT_MS = 180_000; +const MAX_OUTPUT_CHARS = 128_000; +const isTextGenerationError = Schema.is(TextGenerationError); +const isNativeSessionId = Schema.is(Schema.String.check(Schema.isUUID(4))); +const Configuration = Schema.Record(Schema.String, Schema.Unknown); +const decodeConfiguration = Schema.decodeEffect(Schema.fromJsonString(Configuration)); +const decodeConfigurationObject = Schema.decodeUnknownEffect(Configuration); + +type AntigravityTextRuntime = Pick< + AcpSessionRuntime["Service"], + | "start" + | "setMode" + | "getConfigOptions" + | "getEvents" + | "setModel" + | "prompt" + | "cancel" + | "handleSessionUpdate" + | "handleRequestPermission" + | "handleElicitation" + | "handleReadTextFile" + | "handleWriteTextFile" + | "handleCreateTerminal" + | "handleTerminalOutput" + | "handleTerminalWaitForExit" + | "handleTerminalKill" + | "handleTerminalRelease" + | "handleUnknownExtRequest" +>; + +export interface AntigravityTextGenerationOptions { + readonly profileDirectory: string; + /** Model the provider default alias selects, when the account offers it. */ + readonly defaultModel?: Effect.Effect; + /** Uses the instance's personal Google login, with no injected MCP servers or client tools. */ + readonly makeRuntime: ( + cwd: string, + ) => Effect.Effect; + /** Registers the whole helper so sign-out can stop it before clearing credentials. */ + readonly withProcess: ( + stop: Effect.Effect, + task: Effect.Effect, + ) => Effect.Effect; +} + +/** Global hooks and MCP servers can run before a helper can deny a tool request. */ +export const isAntigravityTextGenerationAvailable = Effect.fn( + "isAntigravityTextGenerationAvailable", +)(function* (profileDirectory: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + for (const name of ["hooks.json", "mcp_config.json"]) { + const configurationPath = path.join(profileDirectory, "config", name); + if (!(yield* fs.exists(configurationPath))) { + continue; + } + const info = yield* fs.stat(configurationPath); + if (info.type !== "File" || info.size > 64_000n) { + return false; + } + const empty = yield* fs.readFileString(configurationPath).pipe( + Effect.flatMap(decodeConfiguration), + Effect.flatMap((configuration) => + decodeConfigurationObject( + configuration[name === "hooks.json" ? "hooks" : "mcpServers"] ?? configuration, + ), + ), + Effect.map((configuration) => Object.keys(configuration).length === 0), + Effect.orElseSucceed(() => false), + ); + if (!empty) return false; + } + return true; +}); + +/** Runs short-lived subscription helpers without giving them the user's workspace. */ +export const makeAntigravityTextGeneration = Effect.fn("makeAntigravityTextGeneration")(function* ( + options: AntigravityTextGenerationOptions, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const available = isAntigravityTextGenerationAvailable(options.profileDirectory).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + ); + + const runAntigravityJson = Effect.fn("AntigravityTextGeneration.runJson")( + function* (input: { + readonly operation: keyof TextGeneration.TextGeneration["Service"]; + readonly prompt: string; + readonly outputSchema: S; + readonly modelSelection: ModelSelection; + }) { + const { operation } = input; + const scope = yield* Scope.make(); + yield* Effect.addFinalizer((exit) => Scope.close(scope, exit)); + const helper = Effect.gen(function* () { + if (!(yield* available)) { + return yield* new TextGenerationError({ + operation, + detail: + "Antigravity text generation is unavailable for profiles with global hooks or MCP configuration. Select another system model.", + }); + } + + const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-antigravity-text-" }); + let sessionId: string | undefined; + yield* Effect.addFinalizer(() => + removeAntigravitySessionFiles({ + profileDirectory: options.profileDirectory, + sessionId, + cwd, + }).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + ), + ); + + const rawResult = yield* Effect.gen(function* () { + const runtime = yield* options.makeRuntime(cwd); + yield* runtime.getEvents().pipe( + Stream.runForEach((event) => + event._tag === "EventStreamBarrier" + ? Deferred.succeed(event.acknowledge, undefined).pipe(Effect.asVoid) + : Effect.void, + ), + Effect.forkScoped, + ); + const output = yield* Ref.make(""); + const rejected = yield* Deferred.make(); + const reject = (detail: string) => + Deferred.fail(rejected, new TextGenerationError({ operation, detail })).pipe( + Effect.asVoid, + ); + const rejectToolRequest = () => + reject("Antigravity text generation requested a tool or user input.").pipe( + Effect.andThen( + Effect.fail( + new AcpRequestError({ + code: -32601, + errorMessage: "Tools and user input are disabled for text generation.", + }), + ), + ), + ); + + yield* runtime.handleRequestPermission(() => + reject("Antigravity text generation requested a tool permission or user input.").pipe( + Effect.as({ outcome: { outcome: "cancelled" as const } }), + ), + ); + yield* runtime.handleElicitation(() => + reject("Antigravity text generation requested user input.").pipe( + Effect.as({ action: { action: "decline" as const } }), + ), + ); + yield* runtime.handleReadTextFile(rejectToolRequest); + yield* runtime.handleWriteTextFile(rejectToolRequest); + yield* runtime.handleCreateTerminal(rejectToolRequest); + yield* runtime.handleTerminalOutput(rejectToolRequest); + yield* runtime.handleTerminalWaitForExit(rejectToolRequest); + yield* runtime.handleTerminalKill(rejectToolRequest); + yield* runtime.handleTerminalRelease(rejectToolRequest); + yield* runtime.handleUnknownExtRequest(rejectToolRequest); + yield* runtime.handleSessionUpdate((notification) => + Effect.gen(function* () { + const update = notification.update; + if ( + update.sessionUpdate === "tool_call" || + update.sessionUpdate === "tool_call_update" + ) { + return yield* reject("Antigravity attempted tool work during text generation."); + } + if ( + notification.sessionId !== sessionId || + update.sessionUpdate !== "agent_message_chunk" || + update.content.type !== "text" + ) { + return; + } + const text = update.content.text; + const exceeded = yield* Ref.modify(output, (current) => + current.length + text.length > MAX_OUTPUT_CHARS + ? [true, current] + : [false, current + text], + ); + if (exceeded) { + return yield* reject("Antigravity text generation exceeded the output limit."); + } + }), + ); + + return yield* Effect.gen(function* () { + const started = yield* runtime.start(); + sessionId = started.sessionId; + if (!isNativeSessionId(sessionId)) { + return yield* new TextGenerationError({ + operation, + detail: "Antigravity returned an invalid text helper session ID.", + }); + } + yield* runtime.setMode("default"); + yield* applyAntigravityAcpModelSelection({ + runtime, + model: input.modelSelection.model, + defaultModel: yield* options.defaultModel ?? Effect.succeed(undefined), + mapError: (cause) => + new TextGenerationError({ + operation, + detail: "Could not select the Antigravity model for text generation.", + cause, + }), + }); + + const result = yield* runtime.prompt({ + prompt: [ + { + type: "text", + text: [ + "Use only the input below. Do not use tools, read or write files, run commands, or ask questions.", + "Return only the requested JSON object.", + "", + input.prompt, + ].join("\n"), + }, + ], + }); + if (yield* Deferred.isDone(rejected)) { + return yield* Deferred.await(rejected); + } + if (result.stopReason === "cancelled") { + return yield* new TextGenerationError({ + operation, + detail: "Antigravity text generation was cancelled.", + }); + } + return (yield* Ref.get(output)).trim(); + }).pipe( + Effect.onInterrupt(() => + runtime.cancel.pipe(Effect.timeoutOption(2_000), Effect.ignore), + ), + Effect.raceFirst(Deferred.await(rejected)), + ); + }).pipe(Effect.scoped); + + if ((yield* fs.readDirectory(cwd)).length > 0) { + return yield* new TextGenerationError({ + operation, + detail: "Antigravity wrote files during text generation.", + }); + } + if (!rawResult) { + return yield* new TextGenerationError({ + operation, + detail: "Antigravity returned empty text generation output.", + }); + } + const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(input.outputSchema)); + return yield* decodeOutput(extractJsonObject(rawResult)).pipe( + Effect.mapError( + (cause) => + new TextGenerationError({ + operation, + detail: "Antigravity returned invalid structured output.", + cause, + }), + ), + ); + }).pipe( + Effect.scoped, + Effect.timeoutOption(ANTIGRAVITY_TIMEOUT_MS), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Antigravity text generation timed out.", + }), + ), + onSome: Effect.succeed, + }), + ), + ); + + return yield* options + .withProcess(Scope.close(scope, Exit.void), helper) + .pipe(Effect.provideService(Scope.Scope, scope)); + }, + (effect, input) => + effect.pipe( + Effect.mapError((cause) => + isTextGenerationError(cause) + ? cause + : new TextGenerationError({ + operation: input.operation, + detail: "Antigravity text generation failed.", + cause, + }), + ), + Effect.scoped, + ), + ); + + const generateCommitMessage: TextGeneration.TextGeneration["Service"]["generateCommitMessage"] = + Effect.fn("AntigravityTextGeneration.generateCommitMessage")(function* (input) { + const generated = yield* runAntigravityJson({ + operation: "generateCommitMessage", + ...buildCommitMessagePrompt({ + branch: input.branch, + stagedSummary: input.stagedSummary, + stagedPatch: input.stagedPatch, + includeBranch: input.includeBranch === true, + policy: input.policy, + }), + modelSelection: input.modelSelection, + }); + return { + subject: sanitizeCommitSubject(generated.subject), + body: generated.body.trim(), + ...("branch" in generated && typeof generated.branch === "string" + ? { branch: sanitizeFeatureBranchName(generated.branch) } + : {}), + }; + }); + + const generatePrContent: TextGeneration.TextGeneration["Service"]["generatePrContent"] = + Effect.fn("AntigravityTextGeneration.generatePrContent")(function* (input) { + const generated = yield* runAntigravityJson({ + operation: "generatePrContent", + ...buildPrContentPrompt({ + baseBranch: input.baseBranch, + headBranch: input.headBranch, + commitSummary: input.commitSummary, + diffSummary: input.diffSummary, + diffPatch: input.diffPatch, + policy: input.policy, + changeRequestTemplate: input.changeRequestTemplate, + }), + modelSelection: input.modelSelection, + }); + return { title: sanitizePrTitle(generated.title), body: generated.body.trim() }; + }); + + const generateBranchName: TextGeneration.TextGeneration["Service"]["generateBranchName"] = + Effect.fn("AntigravityTextGeneration.generateBranchName")(function* (input) { + const generated = yield* runAntigravityJson({ + operation: "generateBranchName", + ...buildBranchNamePrompt({ message: input.message, attachments: input.attachments }), + modelSelection: input.modelSelection, + }); + return { branch: sanitizeBranchFragment(generated.branch) }; + }); + + const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] = + Effect.fn("AntigravityTextGeneration.generateThreadTitle")(function* (input) { + const generated = yield* runAntigravityJson({ + operation: "generateThreadTitle", + ...buildThreadTitlePrompt({ + message: input.message, + previousTitle: input.previousTitle, + attachments: input.attachments, + }), + modelSelection: input.modelSelection, + }); + return { title: sanitizeThreadTitle(generated.title) }; + }); + + return { + generateCommitMessage, + generatePrContent, + generateBranchName, + generateThreadTitle, + } satisfies TextGeneration.TextGeneration["Service"]; +}); diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index 8fc86ee3d462..63b74510e29e 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -8,11 +8,13 @@ import { assert, describe, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; import { UsageDay, type UsageSummaryInput } from "@t3tools/contracts"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Scheduler from "effect/Scheduler"; +import * as TestClock from "effect/testing/TestClock"; import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import * as ServerConfig from "../config.ts"; @@ -65,6 +67,8 @@ const serviceLayers = (input: { readonly home: string; readonly settings: Parameters[0]; readonly onRatesFetch?: () => void; + /** Defaults to an unparsable document so every scan retries the fetch. */ + readonly ratesDocument?: unknown; }) => ServerConfig.layerTest(process.cwd(), { prefix: input.prefix }).pipe( Layer.provideMerge(NodeServices.layer), @@ -77,7 +81,7 @@ const serviceLayers = (input: { input.onRatesFetch?.(); // Unparsable rates: every scan retries the fetch, which makes the // fetch count a boundary-level observation of how many scans ran. - return HttpClientResponse.fromWeb(request, Response.json({})); + return HttpClientResponse.fromWeb(request, Response.json(input.ratesDocument ?? {})); }), ), ), @@ -142,6 +146,48 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); + it.live("refetches a rate table inside its TTL only when the client asks", () => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + yield* Effect.promise(() => NodeFSP.writeFile(transcript, claudeLine(1, 5))); + + let ratesFetches = 0; + const service = yield* UsageService.make.pipe( + Effect.provide( + serviceLayers({ + prefix: "usage-service-rates-refresh-test", + home, + settings, + ratesDocument: { + "claude-fable-5": { input_cost_per_token: 1e-5, output_cost_per_token: 5e-5 }, + }, + onRatesFetch: () => { + ratesFetches += 1; + }, + }), + ), + ); + + const first = yield* service.readSummary(WINDOW); + assert.strictEqual(ratesFetches, 1); + assert.strictEqual(first.pricing.status, "fresh"); + + // Inside the daily TTL a plain rescan keeps the cached table. + yield* TestClock.adjust(Duration.minutes(2)); + yield* service.readSummary(WINDOW); + assert.strictEqual(ratesFetches, 1); + + // An explicit refresh fetches again so a newly listed model gets priced. + // A burst of refreshes shares that one fetch. + const [refreshed] = yield* Effect.all([service.refreshRates, service.refreshRates], { + concurrency: 2, + }); + assert.strictEqual(ratesFetches, 2); + assert.strictEqual(refreshed.status, "fresh"); + assert.strictEqual(refreshed.knownModels, 1); + }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), + ); + it.live("does not orphan an in-flight scan when its first caller is interrupted", () => Effect.gen(function* () { const { settings, home } = yield* setup; diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 16a7478d954e..7c446ae14f96 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -18,6 +18,7 @@ import { USAGE_CONTRACT_VERSION, type UsageProviderKind, type UsageSource, + type UsagePricing, type UsageSummary, type UsageSummaryInput, UsageReadError, @@ -34,6 +35,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import { ServerConfig } from "../config.ts"; @@ -63,6 +65,9 @@ const LITELLM_RATES_URL = /** Rates move rarely; a day-old table keeps the page working offline. */ const RATES_TTL_MS = 24 * 60 * 60 * 1000; +/** An explicit refresh ignores the TTL, but not a table fetched this recently. */ +const RATES_REFRESH_FLOOR_MS = 60 * 1000; + /** * Files are filtered by mtime before opening. The slack covers a session whose * last write lands just before local midnight on the window's first day. @@ -94,9 +99,18 @@ export class UsageService extends Context.Service< UsageService, { readonly readSummary: (input: UsageSummaryInput) => Effect.Effect; + /** Refetches the rate table ahead of its TTL. See `ensureRates`. */ + readonly refreshRates: Effect.Effect; } >()("t3/usage/UsageService") {} +const EMPTY_PRICING: UsagePricing = { + status: "unavailable", + source: LITELLM_RATES_URL, + fetchedAt: null, + knownModels: 0, +}; + /** Empty summary, for suites that only need the RPC surface to resolve. */ export const layerTest = Layer.succeed( UsageService, @@ -110,14 +124,10 @@ export const layerTest = Layer.succeed( untilDay: input.untilDay, buckets: [], sources: [], - pricing: { - status: "unavailable", - source: LITELLM_RATES_URL, - fetchedAt: null, - knownModels: 0, - }, + pricing: EMPTY_PRICING, scanDurationMs: 0, }), + refreshRates: Effect.succeed(EMPTY_PRICING), }), ); @@ -136,16 +146,29 @@ export const make = Effect.gen(function* () { const scanCachePath = path.join(config.stateDir, "usage-scan-cache.json"); let rates: RateTable = new Map(); let ratesFetchedAtMs: number | null = null; - let ratesStatus: UsageSummary["pricing"]["status"] = "unavailable"; + let ratesStatus: UsagePricing["status"] = "unavailable"; + // One fetch at a time. A burst of refreshes from several clients waits on + // the first fetch and then sees a table young enough to skip its own. + const ratesLock = yield* Semaphore.make(1); + + const pricing = (): UsagePricing => ({ + status: ratesStatus, + source: LITELLM_RATES_URL, + fetchedAt: + ratesFetchedAtMs === null ? null : DateTime.formatIso(DateTime.makeUnsafe(ratesFetchedAtMs)), + knownModels: rates.size, + }); /** * Loads the LiteLLM rate table, preferring a fresh copy and falling back to * the on-disk snapshot. With neither, every model reports as unpriced rather - * than the page failing. + * than the page failing. `force` refetches inside the TTL so a model that + * LiteLLM added since the last fetch gets priced now. */ - const ensureRates = Effect.fn("UsageService.ensureRates")(function* () { + const loadRates = Effect.fn("UsageService.loadRates")(function* (force: boolean) { const now = yield* Clock.currentTimeMillis; - if (ratesFetchedAtMs !== null && now - ratesFetchedAtMs < RATES_TTL_MS) return; + const maxAgeMs = force ? RATES_REFRESH_FLOOR_MS : RATES_TTL_MS; + if (ratesFetchedAtMs !== null && now - ratesFetchedAtMs < maxAgeMs) return; if (ratesFetchedAtMs === null) { const fromDisk = yield* fileSystem.readFileString(ratesCachePath).pipe( @@ -158,7 +181,7 @@ export const make = Effect.gen(function* () { rates = parsed; ratesFetchedAtMs = fromDisk.fetchedAtMs; ratesStatus = "cached"; - if (now - fromDisk.fetchedAtMs < RATES_TTL_MS) return; + if (now - fromDisk.fetchedAtMs < maxAgeMs) return; } } } @@ -189,6 +212,13 @@ export const make = Effect.gen(function* () { ); }); + const ensureRates = (force: boolean) => ratesLock.withPermit(loadRates(force)); + + const refreshRates = ensureRates(true).pipe( + Effect.map(pricing), + Effect.withSpan("UsageService.refreshRates"), + ); + /** * Claude's config dir is the home itself when overridden, but a default * install nests transcripts under `~/.claude/projects`. Probe both. @@ -425,7 +455,7 @@ export const make = Effect.gen(function* () { // Pricing only matters once records are aggregated, so the rate table // loads while transcripts stream instead of gating them: a cold rates // fetch on a slow network no longer delays the scan by its own timeout. - const [, scannedDirs] = yield* Effect.all([ensureRates(), collectDirs(windowStartMs)], { + const [, scannedDirs] = yield* Effect.all([ensureRates(false), collectDirs(windowStartMs)], { concurrency: 2, }); @@ -511,15 +541,7 @@ export const make = Effect.gen(function* () { untilDay: input.untilDay, buckets: aggregated.buckets, sources, - pricing: { - status: ratesStatus, - source: LITELLM_RATES_URL, - fetchedAt: - ratesFetchedAtMs === null - ? null - : DateTime.formatIso(DateTime.makeUnsafe(ratesFetchedAtMs)), - knownModels: rates.size, - }, + pricing: pricing(), scanDurationMs: Math.max(0, finishedAtMs - startedAtMs), } satisfies UsageSummary; }); @@ -570,7 +592,7 @@ export const make = Effect.gen(function* () { return yield* Deferred.await(deferred); }); - return { readSummary } as const; + return { readSummary, refreshRates } as const; }); export const layer = Layer.effect(UsageService, make); diff --git a/apps/server/src/usage/usagePricing.test.ts b/apps/server/src/usage/usagePricing.test.ts index 2ea27375b148..45113414f69c 100644 --- a/apps/server/src/usage/usagePricing.test.ts +++ b/apps/server/src/usage/usagePricing.test.ts @@ -31,6 +31,15 @@ describe("usage pricing", () => { } }); + it("prices a bracketed context-tier variant at the base model's rate", () => { + const table = parseRateTable({ "claude-fable-5-1": rate(1e-5, 2.5e-7) }); + + expect(lookupRate(table, "claude-fable-5-1[1m]")).toEqual( + lookupRate(table, "claude-fable-5-1"), + ); + expect(lookupRate(table, "anthropic/Claude-Fable-5-1[1m]")).toBeNull(); + }); + it("adds a bare alias when every qualified entry has the same rate", () => { const table = parseRateTable({ "provider-a/example-model": rate(1), diff --git a/apps/server/src/usage/usagePricing.ts b/apps/server/src/usage/usagePricing.ts index 3d7f5fd29485..a7beecb1f552 100644 --- a/apps/server/src/usage/usagePricing.ts +++ b/apps/server/src/usage/usagePricing.ts @@ -119,6 +119,16 @@ function bareModelName(key: string): string { return slash === -1 ? key : key.slice(slash + 1); } +/** + * Drops a bracketed variant suffix such as `claude-fable-5-1[1m]`, which + * Claude Code writes for the 1M context tier. The rate table only knows the + * base name, and we price at the base tier anyway. + */ +function stripVariantSuffix(key: string): string { + const bracket = key.indexOf("["); + return bracket === -1 ? key : key.slice(0, bracket); +} + /** * Models we never price, regardless of the table. * @@ -136,7 +146,7 @@ const UNPRICEABLE_MODELS = new Set([ ]); export function lookupRate(table: RateTable, model: string): ModelRate | null { - const key = normalizeRateKey(model); + const key = stripVariantSuffix(normalizeRateKey(model)); const bareName = bareModelName(key); if (bareName.length === 0 || UNPRICEABLE_MODELS.has(bareName)) return null; return table.get(key) ?? null; 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..8e76413496b7 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(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index f53f90865de5..65d6ec7ed959 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -54,6 +54,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); @@ -2207,6 +2211,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* "--no-ext-diff", "--no-textconv", "--minimal", + ...PATCH_RENDER_PREFIX_ARGS, "--", "/dev/null", relativePath, @@ -2259,6 +2264,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* "--no-ext-diff", "--no-textconv", "--minimal", + ...PATCH_RENDER_PREFIX_ARGS, ...(input.ignoreWhitespace ? ["--ignore-all-space"] : []), "HEAD", "--", @@ -2295,6 +2301,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`, ], diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 105f0f403d86..a7771292ae73 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -48,6 +48,7 @@ import { ProjectSearchEntriesError, ProjectWriteFileError, ProviderUploadFeedbackError, + ProviderSetupError, RelayClientInstallFailedError, type RelayClientInstallProgressEvent, ServerSelfUpdateError, @@ -96,6 +97,9 @@ import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; import { HermesGatewayBroker } from "./provider/Services/HermesGatewayBroker.ts"; import * as ProviderService from "./provider/Services/ProviderService.ts"; import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; +import { ProviderAuthService } from "./provider/Services/ProviderAuthService.ts"; +import { ProviderInstanceRegistry } from "./provider/Services/ProviderInstanceRegistry.ts"; +import { makeProviderInstallation } from "./provider/providerInstallation.ts"; import * as ServerSelfUpdate from "./cloud/selfUpdate.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; @@ -511,6 +515,9 @@ const makeWsRpcLayer = ( const hermesGatewayBroker = yield* HermesGatewayBroker; const providerService = yield* ProviderService.ProviderService; const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner; + const providerAuth = yield* ProviderAuthService; + const providerInstances = yield* ProviderInstanceRegistry; + const providerInstallation = yield* makeProviderInstallation(); const serverUpdate = yield* ServerSelfUpdate.ServerSelfUpdate; const config = yield* ServerConfig.ServerConfig; const lifecycleEvents = yield* ServerLifecycleEvents.ServerLifecycleEvents; @@ -1684,15 +1691,44 @@ const makeWsRpcLayer = ( [WS_METHODS.serverRefreshProviders]: (input) => observeRpcEffect( WS_METHODS.serverRefreshProviders, - (input.cwd !== undefined && input.instanceId !== undefined - ? providerRegistry.refreshWorkspaceSnapshot({ - instanceId: input.instanceId, - cwd: input.cwd, - }) - : input.instanceId !== undefined - ? providerRegistry.refreshInstance(input.instanceId) - : providerRegistry.refresh() - ).pipe(Effect.map((providers) => ({ providers }))), + Effect.gen(function* () { + let providers = yield* input.cwd !== undefined && input.instanceId !== undefined + ? providerRegistry.refreshWorkspaceSnapshot({ + instanceId: input.instanceId, + cwd: input.cwd, + }) + : input.instanceId !== undefined + ? providerRegistry.refreshInstance(input.instanceId) + : providerRegistry.refresh(); + if (input.refreshModels) { + const instances = yield* providerInstances.listInstances; + for (const instance of instances) { + if ( + !instance.refreshModels || + (input.instanceId !== undefined && input.instanceId !== instance.instanceId) || + !providers.some( + (provider) => + provider.instanceId === instance.instanceId && + provider.enabled && + provider.installed, + ) + ) + continue; + yield* instance.refreshModels().pipe( + Effect.mapError( + (error) => + new ProviderSetupError({ + instanceId: instance.instanceId, + operation: "refresh-models", + detail: error.detail, + }), + ), + ); + providers = yield* providerRegistry.refreshInstance(instance.instanceId); + } + } + return { providers }; + }), { "rpc.aggregate": "server" }, ), [WS_METHODS.providerUploadFeedback]: (input) => @@ -1717,6 +1753,52 @@ const makeWsRpcLayer = ( "rpc.aggregate": "server", }, ), + [WS_METHODS.providerAuthStart]: (input) => + observeRpcEffect( + WS_METHODS.providerAuthStart, + providerAuth.start(input, currentSessionId), + { "rpc.aggregate": "provider" }, + ), + [WS_METHODS.providerAuthComplete]: (input) => + observeRpcEffect( + WS_METHODS.providerAuthComplete, + providerAuth.complete(input, currentSessionId), + { "rpc.aggregate": "provider" }, + ), + [WS_METHODS.providerAuthCancel]: (input) => + observeRpcEffect( + WS_METHODS.providerAuthCancel, + providerAuth.cancel(input, currentSessionId), + { "rpc.aggregate": "provider" }, + ), + [WS_METHODS.providerAuthLogout]: (input) => + observeRpcEffect(WS_METHODS.providerAuthLogout, providerAuth.logout(input), { + "rpc.aggregate": "provider", + }), + [WS_METHODS.providerAuthSubscribe]: (input) => + observeRpcStream( + WS_METHODS.providerAuthSubscribe, + providerAuth.subscribe(input, currentSessionId), + { "rpc.aggregate": "provider" }, + ), + [WS_METHODS.providerInstallStart]: (input) => + observeRpcEffect(WS_METHODS.providerInstallStart, providerInstallation.start(input), { + "rpc.aggregate": "provider", + }), + [WS_METHODS.providerInstallCancel]: (input) => + observeRpcEffect(WS_METHODS.providerInstallCancel, providerInstallation.cancel(input), { + "rpc.aggregate": "provider", + }), + [WS_METHODS.providerInstallSubscribe]: (input) => + observeRpcStream( + WS_METHODS.providerInstallSubscribe, + providerInstallation.subscribe(input), + { "rpc.aggregate": "provider" }, + ), + [WS_METHODS.providerInstallRemove]: (input) => + observeRpcEffect(WS_METHODS.providerInstallRemove, providerInstallation.remove(input), { + "rpc.aggregate": "provider", + }), [WS_METHODS.serverUpdateServer]: (input) => observeRpcEffect(WS_METHODS.serverUpdateServer, serverUpdate.update(input), { "rpc.aggregate": "server", @@ -1871,6 +1953,10 @@ const makeWsRpcLayer = ( observeRpcEffect(WS_METHODS.serverGetUsageSummary, usage.readSummary(input), { "rpc.aggregate": "server", }), + [WS_METHODS.serverRefreshUsageRates]: (_input) => + observeRpcEffect(WS_METHODS.serverRefreshUsageRates, usage.refreshRates, { + "rpc.aggregate": "server", + }), [WS_METHODS.serverRetryResourceTelemetry]: (_input) => observeRpcEffect(WS_METHODS.serverRetryResourceTelemetry, resourceTelemetry.retry, { "rpc.aggregate": "server", 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/browserTargetResolver.test.ts b/apps/web/src/browser/browserTargetResolver.test.ts index cbce157f9a05..c2b3432402ed 100644 --- a/apps/web/src/browser/browserTargetResolver.test.ts +++ b/apps/web/src/browser/browserTargetResolver.test.ts @@ -25,7 +25,7 @@ describe("browser target resolver", () => { }); }); - it("maps localhost URL navigation onto a remote Tailscale IPv4 host", async () => { + it("preserves explicit loopback URL navigation for a remote Tailscale environment", async () => { readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://100.65.180.100:3773" }); const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); expect( @@ -35,13 +35,29 @@ describe("browser target resolver", () => { }), ).toEqual({ requestedUrl: "http://localhost:5173/dashboard?mode=test#results", - resolvedUrl: "http://100.65.180.100:5173/dashboard?mode=test#results", - resolutionKind: "direct-private-network", + resolvedUrl: "http://localhost:5173/dashboard?mode=test#results", + resolutionKind: "direct", environmentId: "environment-1", }); }); - it("preserves URL credentials when mapping localhost onto a remote host", async () => { + it("preserves explicit IPv4 loopback URL navigation for a private network environment", async () => { + readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://192.168.1.50:3773" }); + const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); + expect( + resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { + kind: "url", + url: "http://127.0.0.1:5999/", + }), + ).toEqual({ + requestedUrl: "http://127.0.0.1:5999/", + resolvedUrl: "http://127.0.0.1:5999/", + resolutionKind: "direct", + environmentId: "environment-1", + }); + }); + + it("preserves URL credentials on explicit loopback navigation", async () => { readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://100.65.180.100:3773" }); const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); expect( @@ -49,10 +65,10 @@ describe("browser target resolver", () => { kind: "url", url: "http://user:p%40ss@localhost:5173/dashboard", }).resolvedUrl, - ).toBe("http://user:p%40ss@100.65.180.100:5173/dashboard"); + ).toBe("http://user:p%40ss@localhost:5173/dashboard"); }); - it("maps credentialed localhost URLs onto private IPv6 hosts", async () => { + it("preserves credentialed loopback URLs for private IPv6 environments", async () => { readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://[fd7a:115c:a1e0::53]:3773", }); @@ -62,10 +78,10 @@ describe("browser target resolver", () => { kind: "url", url: "http://user:p%40ss@localhost:5173/dashboard?mode=test#results", }).resolvedUrl, - ).toBe("http://user:p%40ss@[fd7a:115c:a1e0::53]:5173/dashboard?mode=test#results"); + ).toBe("http://user:p%40ss@localhost:5173/dashboard?mode=test#results"); }); - it("maps schemeless localhost navigation onto a remote environment host", async () => { + it("preserves schemeless localhost navigation for a remote environment", async () => { readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://192.168.1.25:3773" }); const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); expect( @@ -73,7 +89,7 @@ describe("browser target resolver", () => { kind: "url", url: "localhost:3000/app", }).resolvedUrl, - ).toBe("http://192.168.1.25:3000/app"); + ).toBe("localhost:3000/app"); }); it("keeps localhost navigation local for a local environment", async () => { @@ -117,12 +133,12 @@ describe("browser target resolver", () => { port: 5173, }), ).toThrow(/authenticated preview gateway/); - expect(() => + expect( resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { kind: "url", url: "http://localhost:5173", }), - ).toThrow(/authenticated preview gateway/); + ).toMatchObject({ resolvedUrl: "http://localhost:5173", resolutionKind: "direct" }); }); it("normalizes schemeless localhost server-picker values", async () => { @@ -136,6 +152,14 @@ describe("browser target resolver", () => { ).toBe("http://localhost:3000/app"); }); + it("maps discovered loopback servers onto a remote environment host", async () => { + readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://192.168.1.25:3773" }); + const { resolveDiscoveredServerUrl } = await import("./browserTargetResolver"); + expect( + resolveDiscoveredServerUrl(EnvironmentId.make("environment-1"), "localhost:3000/app"), + ).toBe("http://192.168.1.25:3000/app"); + }); + it("preserves localhost server-picker values when the prepared base is 127.0.0.1", async () => { readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://127.0.0.1:3773" }); const { resolveDiscoveredServerUrl } = await import("./browserTargetResolver"); diff --git a/apps/web/src/browser/browserTargetResolver.ts b/apps/web/src/browser/browserTargetResolver.ts index 684247e28022..c06c60b5f740 100644 --- a/apps/web/src/browser/browserTargetResolver.ts +++ b/apps/web/src/browser/browserTargetResolver.ts @@ -207,30 +207,6 @@ export function resolveBrowserNavigationTarget( target: BrowserNavigationTarget, ): PreviewUrlResolution { if (target.kind === "url") { - let parsed: URL | null = null; - try { - parsed = new URL(normalizePreviewUrl(target.url)); - } catch { - // Preserve the existing direct-navigation behavior so the preview host - // reports malformed URL errors through its normal navigation path. - } - if (parsed && isLoopbackHost(parsed.hostname)) { - const environmentUrl = readEnvironmentUrl(environmentId); - if (parsed.hostname === "0.0.0.0" || !isLocalLoopbackHost(environmentUrl.hostname)) { - return resolveEnvironmentPortTarget( - environmentId, - { - kind: "environment-port", - port: Number(parsed.port || (parsed.protocol === "https:" ? 443 : 80)), - protocol: parsed.protocol === "https:" ? "https" : "http", - path: `${parsed.pathname}${parsed.search}${parsed.hash}`, - }, - environmentUrl, - target.url, - parsed, - ); - } - } return { requestedUrl: target.url, resolvedUrl: target.url, @@ -244,10 +220,20 @@ export function resolveBrowserNavigationTarget( export function resolveDiscoveredServerUrl(environmentId: EnvironmentId, rawUrl: string): string { try { const normalizedUrl = normalizePreviewUrl(rawUrl); - return resolveBrowserNavigationTarget(environmentId, { - kind: "url", - url: normalizedUrl, - }).resolvedUrl; + const parsed = new URL(normalizedUrl); + if (!isLoopbackHost(parsed.hostname)) return normalizedUrl; + return resolveEnvironmentPortTarget( + environmentId, + { + kind: "environment-port", + port: Number(parsed.port || (parsed.protocol === "https:" ? 443 : 80)), + protocol: parsed.protocol === "https:" ? "https" : "http", + path: `${parsed.pathname}${parsed.search}${parsed.hash}`, + }, + readEnvironmentUrl(environmentId), + rawUrl, + parsed, + ).resolvedUrl; } catch { return rawUrl; } 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 70b08ac2ce5f..01ea87f3a389 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, @@ -792,7 +793,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 6be48b94ed37..4cc750d45236 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -1,14 +1,19 @@ import { + CheckpointRef, EnvironmentId, MessageId, ProjectId, + ProviderDriverKind, ProviderInstanceId, + type ServerProvider, ThreadId, TurnId, } from "@t3tools/contracts"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import type { Thread, ThreadShell } from "../types"; +import type { Thread, ThreadShell, TurnDiffSummary } from "../types"; +import type { TimelineEntry } from "../session-logic"; +import { deriveProviderInstanceEntries, NO_PROVIDER_MODEL_SELECTION } from "../providerInstances"; import type { CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates"; import type { RightPanelSurface } from "../rightPanelStore"; import { @@ -18,11 +23,13 @@ import { branchMismatchKey, buildExpiredTerminalContextToastCopy, buildLoadingThreadFromShell, + buildRevertTurnCountByUserMessageId, buildThreadTurnInterruptInput, createLocalDispatchSnapshot, deriveComposerSendState, dismissBranchMismatchForSession, ENVIRONMENT_RECONNECT_WARNING_GRACE_MS, + getAntigravitySendBlockReason, getStartedThreadModelChangeBlockReason, hasEnvironmentReconnectWarningGraceElapsed, hasServerAcknowledgedLocalDispatch, @@ -30,6 +37,8 @@ import { reconcileMountedTerminalThreadIds, reconcileRetainedMountedThreadIds, resolveBackgroundDraftWorkspaceOptions, + resolveComposerInteractionMode, + resolveComposerProviderSelection, resolveDraftPromotionNavigationTarget, resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, @@ -41,6 +50,7 @@ import { shouldReleaseTimelineAnchorForToolActivity, shouldOpenProactivePullRequest, shouldOpenProactiveTurnDiff, + shouldRenderPreviewMiniPlayer, shouldShowBranchMismatchBanner, shouldShowPlanFollowUpPrompt, shouldWriteThreadErrorToCurrentServerThread, @@ -84,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); @@ -661,6 +692,323 @@ describe("buildThreadTurnInterruptInput", () => { }); }); +describe("resolveComposerProviderSelection", () => { + const catalogModels: ServerProvider["models"] = [ + { slug: "gemini-pro", name: "Gemini Pro", isCustom: false, capabilities: null }, + ]; + + function entry(driver: string, instanceId = driver, overrides: Partial = {}) { + return deriveProviderInstanceEntries([ + { + driver: ProviderDriverKind.make(driver), + instanceId: ProviderInstanceId.make(instanceId), + enabled: true, + installed: true, + status: "ready", + auth: { status: "authenticated" }, + version: null, + checkedAt: now, + models: [], + slashCommands: [], + skills: [], + ...overrides, + }, + ])[0]!; + } + + it("uses the custom instance's capability instead of the default instance", () => { + const defaultEntry = entry("antigravity", "antigravity", { + showInteractionModeToggle: true, + }); + const customEntry = entry("antigravity", "google_work", { + showInteractionModeToggle: false, + }); + const selection = resolveComposerProviderSelection({ + entries: [defaultEntry, customEntry], + candidateInstanceIds: [customEntry.instanceId], + lockedProvider: null, + lockedInstanceId: null, + }); + + expect(selection.selectedProviderEntry?.instanceId).toBe(customEntry.instanceId); + expect( + resolveComposerInteractionMode({ + provider: selection.selectedProviderEntry?.snapshot, + planModeEnabled: true, + interactionMode: "plan", + }), + ).toEqual({ enabled: false, interactionMode: "default" }); + }); + + it("uses the fallback provider's plan capability after the draft's instance is disabled", () => { + const disabledEntry = entry("antigravity", "antigravity", { + enabled: false, + showInteractionModeToggle: false, + }); + const fallbackEntry = entry("codex"); + const selection = resolveComposerProviderSelection({ + entries: [disabledEntry, fallbackEntry], + candidateInstanceIds: [disabledEntry.instanceId], + lockedProvider: null, + lockedInstanceId: null, + }); + + expect(selection.selectedProviderEntry?.instanceId).toBe(fallbackEntry.instanceId); + expect( + resolveComposerInteractionMode({ + provider: selection.selectedProviderEntry?.snapshot, + planModeEnabled: true, + interactionMode: "plan", + }), + ).toEqual({ enabled: true, interactionMode: "plan" }); + }); + + it("keeps a signed-out selection instead of silently switching providers", () => { + const signedOutEntry = entry("antigravity", "google_work", { + status: "error", + auth: { status: "unauthenticated" }, + models: catalogModels, + }); + const selection = resolveComposerProviderSelection({ + entries: [entry("codex"), signedOutEntry], + candidateInstanceIds: [signedOutEntry.instanceId], + lockedProvider: null, + lockedInstanceId: null, + }); + + expect(selection.selectedProviderEntry?.instanceId).toBe(signedOutEntry.instanceId); + expect( + getAntigravitySendBlockReason(selection.selectedProviderEntry?.snapshot, "gemini-pro"), + ).toBe("Sign in to Antigravity in provider settings before sending."); + }); + + it("blocks sends until the selected Antigravity profile is installed", () => { + const provider = entry("antigravity", "google_work", { + installed: false, + models: catalogModels, + }).snapshot; + + expect(getAntigravitySendBlockReason(provider, "gemini-pro")).toBe( + "Install Antigravity in provider settings before sending.", + ); + }); + + it("blocks sends until Antigravity confirms authentication", () => { + const provider = entry("antigravity", "google_work", { + auth: { status: "unknown" }, + models: catalogModels, + }).snapshot; + + expect(getAntigravitySendBlockReason(provider, "gemini-pro")).toBe( + "Sign in to Antigravity in provider settings before sending.", + ); + }); + + it("blocks saved model sends until Antigravity loads its account catalog", () => { + expect(getAntigravitySendBlockReason(entry("antigravity").snapshot, "gemini-pro")).toBe( + "Refresh Antigravity models in provider settings before sending.", + ); + }); + + it("blocks an empty Antigravity selection after the catalog has loaded", () => { + const provider = entry("antigravity", "google_work", { models: catalogModels }).snapshot; + + expect(getAntigravitySendBlockReason(provider, "")).toBe( + "Choose an Antigravity model before sending.", + ); + }); + + it("blocks a saved model that a ready catalog no longer lists", () => { + const provider = entry("antigravity", "google_work", { + status: "ready", + models: catalogModels, + }).snapshot; + + expect(getAntigravitySendBlockReason(provider, "saved-model-not-in-current-catalog")).toBe( + "That Antigravity model is no longer available. Choose another model.", + ); + expect(getAntigravitySendBlockReason(provider, "gemini-pro")).toBeNull(); + }); + + it("allows a saved native model to retry after a provider error without changing it", () => { + const provider = entry("antigravity", "google_work", { + status: "error", + models: catalogModels, + }).snapshot; + + expect( + getAntigravitySendBlockReason(provider, "saved-model-not-in-current-catalog"), + ).toBeNull(); + }); + + it("keeps existing send behavior for other providers", () => { + const provider = entry("codex", "codex", { + installed: false, + auth: { status: "unknown" }, + models: [], + }).snapshot; + + expect(getAntigravitySendBlockReason(provider, "gpt-model")).toBeNull(); + }); + + it("does not continue an existing Antigravity thread in another profile after deletion", () => { + const missingInstanceId = ProviderInstanceId.make("google_work"); + const selection = resolveComposerProviderSelection({ + entries: [entry("antigravity")], + candidateInstanceIds: [missingInstanceId], + lockedProvider: ProviderDriverKind.make("antigravity"), + lockedInstanceId: missingInstanceId, + }); + + expect(selection.selectedProviderEntry).toBeUndefined(); + expect(selection.unavailableProviderInstanceId).toBe(missingInstanceId); + }); + + it("does not treat the empty draft placeholder as a provider setup target", () => { + const selection = resolveComposerProviderSelection({ + entries: [entry("antigravity", "antigravity", { enabled: false })], + candidateInstanceIds: [NO_PROVIDER_MODEL_SELECTION.instanceId], + lockedProvider: null, + lockedInstanceId: null, + }); + + expect(selection.selectedProviderEntry).toBeUndefined(); + expect(selection.unavailableProviderInstanceId).toBeUndefined(); + }); + + it("keeps the session's continuation group when another instance was selected", () => { + const sessionEntry = entry("antigravity", "google_work", { + enabled: false, + continuation: { groupKey: "work-profile" }, + }); + const anotherEntry = entry("antigravity", "google_personal", { + continuation: { groupKey: "personal-profile" }, + }); + const selection = resolveComposerProviderSelection({ + entries: [sessionEntry, anotherEntry], + candidateInstanceIds: [anotherEntry.instanceId, sessionEntry.instanceId], + lockedProvider: ProviderDriverKind.make("antigravity"), + lockedInstanceId: sessionEntry.instanceId, + }); + + expect(selection.selectedProviderEntry).toBeUndefined(); + }); +}); + +describe("resolveComposerInteractionMode", () => { + it("resets a restored plan draft when the selected instance does not support plan mode", () => { + expect( + resolveComposerInteractionMode({ + planModeEnabled: true, + provider: { showInteractionModeToggle: false }, + interactionMode: "plan", + }), + ).toEqual({ enabled: false, interactionMode: "default" }); + }); + + it("keeps legacy plan behavior for providers that omit the capability", () => { + expect( + resolveComposerInteractionMode({ + planModeEnabled: true, + provider: {}, + interactionMode: "plan", + }), + ).toEqual({ enabled: true, interactionMode: "plan" }); + }); + + it("resets a restored plan draft when the beta setting is off", () => { + expect( + resolveComposerInteractionMode({ + planModeEnabled: false, + provider: { showInteractionModeToggle: true }, + interactionMode: "plan", + }), + ).toEqual({ enabled: false, interactionMode: "default" }); + }); + + it("disables plan mode until the selected provider is available", () => { + expect( + resolveComposerInteractionMode({ + planModeEnabled: true, + provider: null, + interactionMode: "plan", + }), + ).toEqual({ enabled: false, interactionMode: "default" }); + }); +}); + +describe("buildRevertTurnCountByUserMessageId", () => { + const userMessageId = MessageId.make("rewind-user-message"); + const assistantMessageId = MessageId.make("rewind-assistant-message"); + const turnId = TurnId.make("rewind-turn"); + const timelineEntries = [ + { + id: userMessageId, + kind: "message", + createdAt: now, + message: { + id: userMessageId, + role: "user", + text: "Update the file", + turnId, + createdAt: now, + updatedAt: now, + streaming: false, + }, + }, + { + id: assistantMessageId, + kind: "message", + createdAt: now, + message: { + id: assistantMessageId, + role: "assistant", + text: "Updated the file", + turnId, + createdAt: now, + updatedAt: now, + streaming: false, + }, + }, + ] satisfies ReadonlyArray; + const turnDiffSummaryByAssistantMessageId = new Map([ + [ + assistantMessageId, + { + turnId, + checkpointTurnCount: 1, + checkpointRef: CheckpointRef.make("refs/t3/checkpoints/rewind-turn"), + status: "ready", + files: [], + assistantMessageId, + completedAt: now, + }, + ], + ]); + + it("offers the checkpoint before the user message when conversation rollback is supported", () => { + expect( + buildRevertTurnCountByUserMessageId({ + supportsConversationRollback: true, + timelineEntries, + turnDiffSummaryByAssistantMessageId, + inferredCheckpointTurnCountByTurnId: {}, + }), + ).toEqual(new Map([[userMessageId, 0]])); + }); + + it("offers no rewind action when file checkpoints exist but conversation rollback is unsupported", () => { + expect( + buildRevertTurnCountByUserMessageId({ + supportsConversationRollback: false, + timelineEntries, + turnDiffSummaryByAssistantMessageId, + inferredCheckpointTurnCountByTurnId: {}, + }).size, + ).toBe(0); + }); +}); + describe("deriveComposerSendState", () => { it("treats expired terminal pills as non-sendable content", () => { const state = deriveComposerSendState({ diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index cccc0a8dfe88..46ff8c473c6d 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -1,4 +1,5 @@ import { + ANTIGRAVITY_DEFAULT_MODEL, type AssetCreateUrlInput, type AssetCreateUrlResult, type ChatFileAttachment, @@ -8,7 +9,8 @@ import { type MessageId, type ModelSelection, type ProviderInteractionMode, - type ProviderDriverKind, + ProviderDriverKind, + type ProviderInstanceId, type ServerProvider, type ScopedProjectRef, type ScopedThreadRef, @@ -32,6 +34,7 @@ import { type SessionPhase, type Thread, type ThreadShell, + type TurnDiffSummary, } from "../types"; import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; import * as Schema from "effect/Schema"; @@ -47,6 +50,11 @@ import type { ComposerSubmissionIntent } from "../composer-logic"; import type { TimelineEntry } from "../session-logic"; import type { DesktopPreviewOverlay } from "../previewStateStore"; import type { RightPanelSurface } from "../rightPanelStore"; +import { + NO_PROVIDER_MODEL_SELECTION, + resolveSelectableProviderInstanceEntry, + type ProviderInstanceEntry, +} from "../providerInstances"; export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10; @@ -78,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, @@ -317,6 +338,151 @@ export function buildThreadTurnInterruptInput(thread: Pick; + candidateInstanceIds: ReadonlyArray; + lockedProvider: ProviderDriverKind | null; + lockedInstanceId: ProviderInstanceId | null | undefined; +}) { + const requestedInstanceId = input.candidateInstanceIds.find( + (candidate) => candidate != null && candidate !== NO_PROVIDER_MODEL_SELECTION.instanceId, + ); + const requestedDriverKind = + input.lockedProvider ?? + input.entries.find((entry) => entry.instanceId === requestedInstanceId)?.driverKind ?? + input.entries[0]?.driverKind ?? + ProviderDriverKind.make("unconfigured"); + const lockedContinuationGroupKey = input.lockedProvider + ? (input.entries.find((entry) => entry.instanceId === input.lockedInstanceId) + ?.continuationGroupKey ?? null) + : null; + // Missing metadata must not move Antigravity history into another Google profile. + const requiresExactInstance = + input.lockedProvider === "antigravity" && + input.lockedInstanceId != null && + lockedContinuationGroupKey === null; + const compatibleEntries = input.entries.filter( + (entry) => + (!input.lockedProvider || entry.driverKind === input.lockedProvider) && + (!lockedContinuationGroupKey || entry.continuationGroupKey === lockedContinuationGroupKey) && + (!requiresExactInstance || entry.instanceId === input.lockedInstanceId), + ); + const selectedProviderEntry = + input.candidateInstanceIds + .map((candidate) => + compatibleEntries.find( + (entry) => entry.instanceId === candidate && entry.enabled && entry.isAvailable, + ), + ) + .find((entry) => entry !== undefined) ?? + resolveSelectableProviderInstanceEntry( + compatibleEntries.filter((entry) => entry.driverKind === requestedDriverKind), + undefined, + ) ?? + resolveSelectableProviderInstanceEntry(compatibleEntries, undefined); + const unavailableProviderInstanceId = selectedProviderEntry + ? undefined + : input.lockedProvider + ? (input.lockedInstanceId ?? requestedInstanceId) + : requestedInstanceId; + return { + selectedProviderEntry, + requestedDriverKind, + lockedContinuationGroupKey, + unavailableProviderInstanceId, + }; +} + +/** Keep restored drafts and every plan control on the selected instance's supported mode. */ +export function resolveComposerInteractionMode(input: { + planModeEnabled: boolean; + provider: Pick | null | undefined; + interactionMode: ProviderInteractionMode; +}): { enabled: boolean; interactionMode: ProviderInteractionMode } { + const enabled = + input.planModeEnabled && + input.provider != null && + input.provider.showInteractionModeToggle !== false; + return { + enabled, + interactionMode: enabled ? input.interactionMode : "default", + }; +} + +export function getAntigravitySendBlockReason( + provider: + | Pick + | null + | undefined, + model: string, +): string | null { + if (provider?.driver !== "antigravity") return null; + if (!provider.installed) { + return "Install Antigravity in provider settings before sending."; + } + if (provider.auth.status !== "authenticated") { + return "Sign in to Antigravity in provider settings before sending."; + } + if (provider.models.length === 0) { + return "Refresh Antigravity models in provider settings before sending."; + } + const slug = model.trim(); + if (slug.length === 0) return "Choose an Antigravity model before sending."; + // A saved model that left the catalog is kept in the picker as unavailable + // so the user sees what the thread used. The server rejects it at turn + // start, so block here unless the provider is in an error state, where a + // retry with the same model is the right move. + if ( + provider.status === "ready" && + slug !== ANTIGRAVITY_DEFAULT_MODEL && + !provider.models.some((entry) => entry.slug === slug || entry.aliases?.includes(slug)) + ) { + return "That Antigravity model is no longer available. Choose another model."; + } + return null; +} + +export function buildRevertTurnCountByUserMessageId(input: { + supportsConversationRollback: boolean; + timelineEntries: ReadonlyArray; + turnDiffSummaryByAssistantMessageId: ReadonlyMap; + inferredCheckpointTurnCountByTurnId: Readonly>; +}) { + const byUserMessageId = new Map(); + if (!input.supportsConversationRollback) { + return byUserMessageId; + } + for (let index = 0; index < input.timelineEntries.length; index += 1) { + const entry = input.timelineEntries[index]; + if (!entry || entry.kind !== "message" || entry.message.role !== "user") { + continue; + } + + for (let nextIndex = index + 1; nextIndex < input.timelineEntries.length; nextIndex += 1) { + const nextEntry = input.timelineEntries[nextIndex]; + if (!nextEntry || nextEntry.kind !== "message") { + continue; + } + if (nextEntry.message.role === "user") { + break; + } + const summary = input.turnDiffSummaryByAssistantMessageId.get(nextEntry.message.id); + if (!summary) { + continue; + } + const turnCount = + summary.checkpointTurnCount ?? input.inferredCheckpointTurnCountByTurnId[summary.turnId]; + if (typeof turnCount !== "number") { + break; + } + byUserMessageId.set(entry.message.id, Math.max(0, turnCount - 1)); + break; + } + } + return byUserMessageId; +} + export function reconcileMountedTerminalThreadIds(input: { currentThreadIds: ReadonlyArray; openThreadIds: ReadonlyArray; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 5f5663f2cadb..db9fb003906d 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -4,7 +4,6 @@ import { type AssetResource, type ChatFileAttachment, DEFAULT_MODEL, - defaultInstanceIdForDriver, type EnvironmentId, type MessageId, type ModelSelection, @@ -207,10 +206,11 @@ import { import { newDraftId, newMessageId, newThreadId } from "~/lib/utils"; import { useBrowserHistoryStore } from "~/browserHistoryStore"; import { registerFaviconProjectForThread } from "~/browserFaviconStore"; -import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; +import { getProviderModelCapabilities } from "../providerModels"; import { applyProviderInstanceSettings, deriveProviderInstanceEntries, + sortProviderInstanceEntries, NO_PROVIDER_MODEL_SELECTION, } from "../providerInstances"; import { @@ -352,6 +352,7 @@ import { buildExpiredTerminalContextToastCopy, buildLocalDraftThread, buildLoadingThreadFromShell, + buildRevertTurnCountByUserMessageId, buildThreadTurnInterruptInput, collectUserMessageBlobPreviewUrls, createLocalDispatchSnapshot, @@ -367,6 +368,7 @@ import { shouldShowPlanFollowUpPrompt, shouldOpenProactivePullRequest, shouldOpenProactiveTurnDiff, + shouldRenderPreviewMiniPlayer, getStartedThreadModelChangeBlockReason, LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, LastInvokedScriptByProjectSchema, @@ -378,6 +380,8 @@ import { resolveFileAttachmentUrl, reconcileMountedTerminalThreadIds, resolveBackgroundDraftWorkspaceOptions, + resolveComposerInteractionMode, + resolveComposerProviderSelection, resolveDraftHeroState, resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, @@ -1721,13 +1725,6 @@ function ChatViewContent(props: ChatViewProps) { // the branch mismatch banner. const [, setThreadErrorBannerDismissTick] = useState(0); const runtimeMode = composerRuntimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE; - // Plan mode is legacy (Settings → Beta). With the flag off the effective - // mode is forced to "default" — even for threads with a stored plan mode — - // so nobody is trapped in plan mode while its toggle is hidden. The next - // send persists "default" back to the thread. - const interactionMode = settings.planModeEnabled - ? (composerInteractionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE) - : DEFAULT_INTERACTION_MODE; const isLocalDraftThread = !isServerThread && localDraftThread !== undefined; const canCheckoutPullRequestIntoThread = isLocalDraftThread; const activeThreadId = activeThread?.id ?? null; @@ -1853,6 +1850,10 @@ function ChatViewContent(props: ChatViewProps) { rightPanelPresent && (!shouldUseRightPanelSheet || 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; @@ -1868,20 +1869,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]); @@ -2428,11 +2419,52 @@ function ChatViewContent(props: ChatViewProps) { versionMismatchServerLabel, ]); const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS; - const unlockedSelectedProvider = resolveSelectableProvider( - providerStatuses, - selectedProviderByThreadId ?? threadProvider, + const providerInstanceEntries = useMemo( + () => + sortProviderInstanceEntries( + applyProviderInstanceSettings(deriveProviderInstanceEntries(providerStatuses), settings), + ), + [providerStatuses, settings], + ); + const { selectedProviderEntry, requestedDriverKind } = useMemo( + () => + resolveComposerProviderSelection({ + entries: providerInstanceEntries, + candidateInstanceIds: [ + selectedProviderByThreadId, + activeThread?.session?.providerInstanceId, + activeThread?.modelSelection.instanceId, + activeProject?.defaultModelSelection?.instanceId, + ], + lockedProvider, + lockedInstanceId: + activeThread?.session?.providerInstanceId ?? activeThread?.modelSelection.instanceId, + }), + [ + activeProject?.defaultModelSelection?.instanceId, + activeThread?.modelSelection.instanceId, + activeThread?.session?.providerInstanceId, + lockedProvider, + providerInstanceEntries, + selectedProviderByThreadId, + ], ); - const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider; + const selectedProvider = selectedProviderEntry?.driverKind ?? requestedDriverKind; + const activeProviderInstanceId = selectedProviderEntry?.instanceId ?? null; + const activeProviderStatus = selectedProviderEntry?.snapshot ?? null; + const { enabled: interactionModeEnabled, interactionMode } = resolveComposerInteractionMode({ + planModeEnabled: settings.planModeEnabled, + provider: activeProviderStatus, + interactionMode: + composerInteractionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE, + }); + const conversationProviderStatus = + providerStatuses.find( + (status) => status.instanceId === activeThread?.session?.providerInstanceId, + ) ?? activeProviderStatus; + const supportsConversationRollback = + conversationProviderStatus !== null && + conversationProviderStatus.supportsConversationRollback !== false; const phase = derivePhase(activeThread?.session ?? null); const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES; const latestCheckpointCompletedAt = activeThread?.checkpoints.at(-1)?.completedAt ?? null; @@ -2876,38 +2908,21 @@ function ChatViewContent(props: ChatViewProps) { } return byMessageId; }, [turnDiffSummaries]); - const revertTurnCountByUserMessageId = useMemo(() => { - const byUserMessageId = new Map(); - for (let index = 0; index < timelineEntries.length; index += 1) { - const entry = timelineEntries[index]; - if (!entry || entry.kind !== "message" || entry.message.role !== "user") { - continue; - } - - for (let nextIndex = index + 1; nextIndex < timelineEntries.length; nextIndex += 1) { - const nextEntry = timelineEntries[nextIndex]; - if (!nextEntry || nextEntry.kind !== "message") { - continue; - } - if (nextEntry.message.role === "user") { - break; - } - const summary = turnDiffSummaryByAssistantMessageId.get(nextEntry.message.id); - if (!summary) { - continue; - } - const turnCount = - summary.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[summary.turnId]; - if (typeof turnCount !== "number") { - break; - } - byUserMessageId.set(entry.message.id, Math.max(0, turnCount - 1)); - break; - } - } - - return byUserMessageId; - }, [inferredCheckpointTurnCountByTurnId, timelineEntries, turnDiffSummaryByAssistantMessageId]); + const revertTurnCountByUserMessageId = useMemo( + () => + buildRevertTurnCountByUserMessageId({ + supportsConversationRollback, + timelineEntries, + turnDiffSummaryByAssistantMessageId, + inferredCheckpointTurnCountByTurnId, + }), + [ + supportsConversationRollback, + inferredCheckpointTurnCountByTurnId, + timelineEntries, + turnDiffSummaryByAssistantMessageId, + ], + ); const gitCwd = activeProject ? projectScriptCwd({ @@ -2932,26 +2947,10 @@ function ChatViewContent(props: ChatViewProps) { }); const keybindings = useAtomValue(primaryServerKeybindingsAtom); const availableEditors = useAtomValue(primaryServerAvailableEditorsAtom); - // Prefer an instance-id match so a custom Codex instance (e.g. - // `codex_personal`) surfaces its own status/message in the banner rather - // than the default Codex's. Falls back to first-match-by-kind when no - // saved instance id is available or the instance no longer exists. - const selectedProviderInstanceId = - providerStatuses.find((status) => status.instanceId === selectedProviderByThreadId) - ?.instanceId ?? null; - const activeProviderInstanceId = - selectedProviderInstanceId ?? - activeThread?.session?.providerInstanceId ?? - activeThread?.modelSelection.instanceId ?? - activeProject?.defaultModelSelection?.instanceId ?? - null; const compactionProviderAvailable = useMemo( () => hasAvailableClaudeCompactionProvider({ - providers: applyProviderInstanceSettings( - deriveProviderInstanceEntries(providerStatuses), - settings, - ), + providers: providerInstanceEntries, instanceId: activeProviderInstanceId, lockedInstanceId: lockedProvider ? (activeThread?.session?.providerInstanceId ?? @@ -2964,19 +2963,9 @@ function ChatViewContent(props: ChatViewProps) { activeThread?.modelSelection.instanceId, activeThread?.session?.providerInstanceId, lockedProvider, - providerStatuses, - settings, + providerInstanceEntries, ], ); - const activeProviderStatus = useMemo(() => { - if (activeProviderInstanceId) { - return ( - providerStatuses.find((status) => status.instanceId === activeProviderInstanceId) ?? null - ); - } - const defaultInstanceId = defaultInstanceIdForDriver(selectedProvider); - return providerStatuses.find((status) => status.instanceId === defaultInstanceId) ?? null; - }, [activeProviderInstanceId, providerStatuses, selectedProvider]); const [resumeCompactionPermanentlyDismissed, setResumeCompactionPermanentlyDismissed] = useLocalStorage( `t3code:resume-compaction-dismissed:${environmentId}:${activeProviderInstanceId ?? "claudeAgent"}`, @@ -3622,6 +3611,7 @@ function ChatViewContent(props: ChatViewProps) { const handleInteractionModeChange = useCallback( (mode: ProviderInteractionMode) => { + if (mode === "plan" && !interactionModeEnabled) return; if (mode === interactionMode) return; setComposerDraftInteractionMode(composerDraftTarget, mode); if (isLocalDraftThread) { @@ -3631,6 +3621,7 @@ function ChatViewContent(props: ChatViewProps) { }, [ interactionMode, + interactionModeEnabled, isLocalDraftThread, scheduleComposerFocus, composerDraftTarget, @@ -3639,8 +3630,18 @@ function ChatViewContent(props: ChatViewProps) { ], ); const toggleInteractionMode = useCallback(() => { + if (!interactionModeEnabled) return; handleInteractionModeChange(interactionMode === "plan" ? "default" : "plan"); - }, [handleInteractionModeChange, interactionMode]); + }, [handleInteractionModeChange, interactionMode, interactionModeEnabled]); + const openProviderSetup = useCallback( + (instanceId: ProviderInstanceId) => { + void navigate({ + to: "/settings/providers", + search: { environmentId, instanceId }, + }); + }, + [environmentId, navigate], + ); const createBrowserSurface = useCallback( (profileId?: string) => { if (!activeThreadRef) return; @@ -5918,6 +5919,13 @@ function ChatViewContent(props: ChatViewProps) { const localApi = readLocalApi(); if (!localApi || !activeThread || isRevertingCheckpoint) return; + if (!supportsConversationRollback) { + setThreadError( + activeThread.id, + "This provider does not support reverting conversation history. Start a new thread instead.", + ); + return; + } if (activeEnvironmentUnavailable && activeEnvironmentUnavailableLabel) { setThreadError( activeThread.id, @@ -5970,6 +5978,7 @@ function ChatViewContent(props: ChatViewProps) { phase, revertThreadCheckpoint, setThreadError, + supportsConversationRollback, ], ); @@ -6038,6 +6047,8 @@ function ChatViewContent(props: ChatViewProps) { selectedProviderModels: ctxSelectedProviderModels, selectedPromptEffort: ctxSelectedPromptEffort, selectedModelSelection: ctxSelectedModelSelection, + interactionMode: sendInteractionMode, + interactionModeEnabled: sendInteractionModeEnabled, } = sendCtx; const annotationImageAlreadyAttached = directAnnotation?.image !== undefined && @@ -6184,6 +6195,7 @@ function ChatViewContent(props: ChatViewProps) { } if ( !directAnnotation && + sendInteractionModeEnabled && showPlanFollowUpPrompt && activeProposedPlan && composerImages.length === 0 && @@ -6212,10 +6224,9 @@ function ChatViewContent(props: ChatViewProps) { }); return; } - // Legacy plan mode: /plan and /default only act when the beta flag is on; - // otherwise they send as plain text like any other message. + // Providers without the legacy toggle receive their native commands unchanged. const standaloneSlashCommand = - settings.planModeEnabled && + sendInteractionModeEnabled && composerImages.length === 0 && composerFiles.length === 0 && sendableComposerTerminalContexts.length === 0 && @@ -6535,7 +6546,7 @@ function ChatViewContent(props: ChatViewProps) { ? { branch: localCheckoutBranchMismatch.currentBranch } : {}), runtimeMode, - interactionMode, + interactionMode: sendInteractionMode, }); if (settingsResult._tag === "Failure") { failure = settingsResult; @@ -6566,7 +6577,7 @@ function ChatViewContent(props: ChatViewProps) { title, modelSelection: threadCreateModelSelection, runtimeMode, - interactionMode, + interactionMode: sendInteractionMode, branch: activeThreadBranch, worktreePath: activeThread.worktreePath, createdAt: activeThread.createdAt, @@ -6606,7 +6617,7 @@ function ChatViewContent(props: ChatViewProps) { modelSelection: ctxSelectedModelSelection, titleSeed: title, runtimeMode, - interactionMode, + interactionMode: sendInteractionMode, ...(bootstrap ? { bootstrap } : {}), createdAt: messageCreatedAt, }, @@ -6828,7 +6839,7 @@ function ChatViewContent(props: ChatViewProps) { ); const onSelectActivePendingUserInputOption = useCallback( - (questionId: string, optionLabel: string) => { + (questionId: string, optionValue: string) => { if (!activePendingUserInput) { return; } @@ -6849,7 +6860,7 @@ function ChatViewContent(props: ChatViewProps) { [questionId]: togglePendingUserInputOptionSelection( question, existing[activePendingUserInput.requestId]?.[questionId], - optionLabel, + optionValue, ), }, }; @@ -6871,6 +6882,10 @@ function ChatViewContent(props: ChatViewProps) { if (!activePendingUserInput) { return; } + const question = activePendingUserInput.questions.find((entry) => entry.id === questionId); + if (!question || question.allowCustomAnswer === false) { + return; + } promptRef.current = value; setPendingUserInputAnswersByRequestId((existing) => ({ ...existing, @@ -6944,7 +6959,7 @@ function ChatViewContent(props: ChatViewProps) { } const sendCtx = composerRef.current?.getSendContext(); - if (!sendCtx?.providerAvailable) { + if (!sendCtx?.providerAvailable || !sendCtx.interactionModeEnabled) { return; } const { @@ -7092,7 +7107,7 @@ function ChatViewContent(props: ChatViewProps) { } const sendCtx = composerRef.current?.getSendContext(); - if (!sendCtx?.providerAvailable) { + if (!sendCtx?.providerAvailable || !sendCtx.interactionModeEnabled) { return; } const { @@ -7684,6 +7699,7 @@ function ChatViewContent(props: ChatViewProps) { setDismissedProviderStatusBannerKey(providerStatusBannerKey)} + onOpenProviderSetup={openProviderSetup} />
{/* Messages Wrapper */} @@ -7896,6 +7912,7 @@ function ChatViewContent(props: ChatViewProps) { onChangeActivePendingUserInputCustomAnswer } onProviderModelSelect={onProviderModelSelect} + onOpenProviderSetup={openProviderSetup} getModelDisabledReason={getModelDisabledReason} toggleInteractionMode={toggleInteractionMode} handleRuntimeModeChange={handleRuntimeModeChange} @@ -7957,7 +7974,7 @@ function ChatViewContent(props: ChatViewProps) {
- {activeThreadRef && activePreviewMiniPlayer ? ( + {activeThreadRef && activePreviewMiniPlayer && previewMiniPlayerVisible ? ( void; -}>({ openComment: null, onOpenChange: () => {} }); + onSubmitAndSend: () => void; +}>({ openComment: null, onOpenChange: () => {}, onSubmitAndSend: () => {} }); /** Consume a cite action once its controlled prompt has been committed to the editor. */ export function $consumeComposerCitationCommentRequest(requestRef: { @@ -127,6 +128,11 @@ function ComposerCitationDecorator(props: { citation: AssistantCitation; nodeKey commentContext.onOpenChange(props.nodeKey, open); }, onSave: onSaveComment, + onSaveAndSend: (comment) => { + if (!onSaveComment(comment)) return false; + commentContext.onSubmitAndSend(); + return true; + }, }} onRemove={onRemove} /> diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 1676b4f01e7d..695c5e3d0858 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -924,6 +924,7 @@ interface ComposerPromptEditorProps { onPageScrollKeyDown?: (key: "PageUp" | "PageDown") => void; onPageScrollKeyUp?: (key: string) => void; onPageScrollRelease?: () => void; + onCitationSubmitAndSend?: () => void; onPaste: React.ClipboardEventHandler; editorRef: React.RefObject; } @@ -1571,6 +1572,7 @@ function ComposerPromptEditorInner({ onPageScrollKeyDown, onPageScrollKeyUp, onPageScrollRelease, + onCitationSubmitAndSend, onPaste, editorRef, }: ComposerPromptEditorProps) { @@ -1603,8 +1605,9 @@ function ComposerPromptEditorInner({ open ? { nodeKey } : current?.nodeKey === nodeKey ? null : current, ); }, + onSubmitAndSend: onCitationSubmitAndSend ?? (() => {}), }), - [openCitationComment], + [onCitationSubmitAndSend, openCitationComment], ); const terminalContextActions = useMemo( () => ({ onRemoveTerminalContext }), @@ -1969,6 +1972,7 @@ export function ComposerPromptEditor({ onPageScrollKeyDown, onPageScrollKeyUp, onPageScrollRelease, + onCitationSubmitAndSend, onPaste, editorRef, }: ComposerPromptEditorProps) { @@ -2013,6 +2017,7 @@ export function ComposerPromptEditor({ onChange={onChange} {...(onVisibleSelectionChange ? { onVisibleSelectionChange } : {})} onPaste={onPaste} + {...(onCitationSubmitAndSend ? { onCitationSubmitAndSend } : {})} editorRef={editorRef} {...(onCommandKeyDown ? { onCommandKeyDown } : {})} {...(onPageScrollKeyDown ? { onPageScrollKeyDown } : {})} diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index b1a91ff2a72c..6040adba4df0 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -836,7 +836,12 @@ export const OpenCodeIcon: Icon = ({ monochrome, ...props }) => { return ( - + @@ -850,9 +855,19 @@ export const OpenCodeIcon: Icon = ({ monochrome, ...props }) => { return ( - + - + diff --git a/apps/web/src/components/ProjectFavicon.test.tsx b/apps/web/src/components/ProjectFavicon.test.tsx index 557f4d722adc..bfb5487031b7 100644 --- a/apps/web/src/components/ProjectFavicon.test.tsx +++ b/apps/web/src/components/ProjectFavicon.test.tsx @@ -115,28 +115,40 @@ describe("ProjectFavicon", () => { testState.faviconUrl = "https://environment.test/api/assets/token-a/v1-20-favicon.svg"; }); - it("shows a project-name emoji when no favicon exists", () => { + it("shows a project-name icon when no favicon exists", () => { testState.faviconUrl = `https://environment.test/api/assets/token/${PROJECT_FAVICON_FALLBACK_MARKER}`; const element = ProjectFavicon({ environmentId: "environment-test" as EnvironmentId, cwd: "/workspace/analytics-db", projectName: "analytics-db", - }) as ReactElement<{ readonly emoji?: string }>; + }) as ReactElement<{ + readonly colorClassName?: string; + readonly emoji?: string; + readonly icon?: ComponentType<{ className?: string }>; + }>; - expect(element.props.emoji).toBe("🗄️"); + expect(element.props.icon).toBeDefined(); + expect(element.props.emoji).toBeUndefined(); + expect(element.props.colorClassName).toContain("text-cyan-600"); }); - it("chooses a deterministic semantic emoji", () => { + it("chooses a deterministic semantic icon", () => { testState.faviconUrl = `https://environment.test/api/assets/token/${PROJECT_FAVICON_FALLBACK_MARKER}`; const element = ProjectFavicon({ environmentId: "environment-test" as EnvironmentId, cwd: "/workspace/agent-runtime", projectName: "agent-runtime", - }) as ReactElement<{ readonly emoji?: string }>; + }) as ReactElement<{ + readonly colorClassName?: string; + readonly emoji?: string; + readonly icon?: ComponentType<{ className?: string }>; + }>; - expect(element.props.emoji).toBe("🤖"); + expect(element.props.icon).toBeDefined(); + expect(element.props.emoji).toBeUndefined(); + expect(element.props.colorClassName).toContain("text-violet-600"); }); it("renders a saved Lucide icon and color ahead of an uploaded favicon", () => { 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 5a9356a0fffd..8f1fff8dc728 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -9,6 +9,8 @@ import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; import { Bot, ChevronDown, + ChevronLeft, + ChevronRight, FileDiff, Files, GitPullRequest, @@ -170,6 +172,12 @@ type TabContextMenuAction = | "close-to-right" | "close-all"; +const TAB_SCROLL_EDGE_TOLERANCE = 1; + +function tabScrollViewport(root: HTMLDivElement | null): HTMLDivElement | null { + return root?.querySelector('[data-slot="scroll-area-viewport"]') ?? null; +} + /** * Desktop preview tab backing a surface, or null for non-preview surfaces, the * "new browser tab" placeholder, and the web build where no desktop tab exists. @@ -720,6 +728,42 @@ export function RightPanelTabs(props: RightPanelTabsProps) { const { resolvedTheme } = useTheme(); const tabListRef = useRef(null); const [addSurfaceMenuOpen, setAddSurfaceMenuOpen] = useState(false); + const [tabScrollState, setTabScrollState] = useState({ + hasOverflow: false, + canScrollLeft: false, + canScrollRight: false, + }); + + const updateTabScrollState = useCallback(() => { + const viewport = tabScrollViewport(tabListRef.current); + if (!viewport) return; + + const hasOverflow = viewport.scrollWidth - viewport.clientWidth > TAB_SCROLL_EDGE_TOLERANCE; + const canScrollLeft = hasOverflow && viewport.scrollLeft > TAB_SCROLL_EDGE_TOLERANCE; + const canScrollRight = + hasOverflow && + viewport.scrollLeft + viewport.clientWidth < viewport.scrollWidth - TAB_SCROLL_EDGE_TOLERANCE; + setTabScrollState((current) => { + if ( + current.hasOverflow === hasOverflow && + current.canScrollLeft === canScrollLeft && + current.canScrollRight === canScrollRight + ) { + return current; + } + return { hasOverflow, canScrollLeft, canScrollRight }; + }); + }, []); + + const scrollTabs = useCallback((direction: -1 | 1) => { + const viewport = tabScrollViewport(tabListRef.current); + if (!viewport) return; + const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + viewport.scrollBy({ + left: direction * Math.max(120, viewport.clientWidth * 0.75), + behavior: reduceMotion ? "auto" : "smooth", + }); + }, []); const addSurfaceActions = [ { @@ -886,9 +930,49 @@ export function RightPanelTabs(props: RightPanelTabsProps) { ); useEffect(() => { + if (!props.activeSurfaceId || !tabScrollState.hasOverflow) return; const activeTab = tabListRef.current?.querySelector("[data-active-tab='true']"); activeTab?.scrollIntoView({ block: "nearest", inline: "nearest" }); - }, [props.activeSurfaceId]); + }, [props.activeSurfaceId, tabScrollState.hasOverflow]); + + useEffect(() => { + const viewport = tabScrollViewport(tabListRef.current); + if (!viewport) return; + + const content = viewport.firstElementChild; + const resizeObserver = new ResizeObserver(updateTabScrollState); + resizeObserver.observe(viewport); + if (content) resizeObserver.observe(content); + viewport.addEventListener("scroll", updateTabScrollState, { passive: true }); + updateTabScrollState(); + + return () => { + resizeObserver.disconnect(); + viewport.removeEventListener("scroll", updateTabScrollState); + }; + }, [updateTabScrollState]); + + useEffect(() => { + const viewport = tabScrollViewport(tabListRef.current); + if (!viewport) return; + + const handleWheel = (event: WheelEvent) => { + if (event.ctrlKey) return; + let delta = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY; + if (event.deltaMode === WheelEvent.DOM_DELTA_LINE) delta *= 16; + if (event.deltaMode === WheelEvent.DOM_DELTA_PAGE) delta *= viewport.clientWidth; + if (delta === 0) return; + + const previousScrollLeft = viewport.scrollLeft; + viewport.scrollLeft += delta; + if (viewport.scrollLeft === previousScrollLeft) return; + event.preventDefault(); + updateTabScrollState(); + }; + + viewport.addEventListener("wheel", handleWheel, { passive: false }); + return () => viewport.removeEventListener("wheel", handleWheel); + }, [updateTabScrollState]); return (
@@ -1099,6 +1187,50 @@ export function RightPanelTabs(props: RightPanelTabsProps) { ) : null}
+ {tabScrollState.hasOverflow ? ( +
+ + + + + } + /> + Scroll tabs left + + + + + + } + /> + Scroll tabs right + +
+ ) : null} {props.layoutControls}
diff --git a/apps/web/src/components/chat/AssistantCitationChip.tsx b/apps/web/src/components/chat/AssistantCitationChip.tsx index 4afaefe3f489..7582bb19c5c6 100644 --- a/apps/web/src/components/chat/AssistantCitationChip.tsx +++ b/apps/web/src/components/chat/AssistantCitationChip.tsx @@ -42,6 +42,7 @@ export function AssistantCitationChip({ sourceAnchor?: AssistantCitationSourceAnchor | undefined; onOpenChange: (open: boolean) => void; onSave: (comment: string) => boolean; + onSaveAndSend?: (comment: string) => boolean; }; }) { const navigate = useNavigate(); @@ -158,6 +159,15 @@ export function AssistantCitationChip({ commentEditor.onOpenChange(false); return true; }} + {...(commentEditor.onSaveAndSend + ? { + onSubmitAndSend: (comment: string) => { + if (!commentEditor.onSaveAndSend?.(comment)) return false; + commentEditor.onOpenChange(false); + return true; + }, + } + : {})} onCancel={() => commentEditor.onOpenChange(false)} /> diff --git a/apps/web/src/components/chat/AssistantCitationCommentEditor.tsx b/apps/web/src/components/chat/AssistantCitationCommentEditor.tsx index 3968d4568655..4dc422210de0 100644 --- a/apps/web/src/components/chat/AssistantCitationCommentEditor.tsx +++ b/apps/web/src/components/chat/AssistantCitationCommentEditor.tsx @@ -7,11 +7,13 @@ export function AssistantCitationCommentEditor({ citation, inputRef, onSubmit, + onSubmitAndSend, onCancel, }: { citation: AssistantCitation; inputRef?: Ref; onSubmit: (comment: string) => boolean; + onSubmitAndSend?: (comment: string) => boolean; onCancel: () => void; }) { const [comment, setComment] = useState(citation.comment ?? ""); @@ -19,6 +21,14 @@ export function AssistantCitationCommentEditor({ const submit = () => { if (!commentTooLong) onSubmit(comment); }; + const submitAndSend = () => { + if (commentTooLong) return; + if (onSubmitAndSend) { + onSubmitAndSend(comment); + } else { + onSubmit(comment); + } + }; return (
diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index fc250cc2859b..30b51b28ad15 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -47,13 +47,20 @@ import { replaceTextRange, } from "../../composer-logic"; import { DISCONNECTED_COMPOSER_PLACEHOLDER } from "../../composerPlaceholder"; -import { deriveComposerSendState, readFileAsDataUrl } from "../ChatView.logic"; +import { + deriveComposerSendState, + getAntigravitySendBlockReason, + readFileAsDataUrl, + resolveComposerInteractionMode, + resolveComposerProviderSelection, +} from "../ChatView.logic"; import { dataTransferHasComposerMention, makeComposerMentionDragHandlers, } from "./composerMentionDrag"; import { composerFloatingLayerProps, + isInsideCollapsedComposerControls, isInsideComposerFloatingLayer, isInsideRestingComposerControlScope, } from "./composerEventScope"; @@ -763,13 +770,11 @@ import { } from "lucide-react"; import { getRuntimeModeConfig, getRuntimeModeOptions } from "./runtimeModePresentation"; import { proposedPlanTitle } from "../../proposedPlan"; -import { getProviderInteractionModeToggle } from "../../providerModels"; +import { hasProviderSetup } from "./ProviderStatusBanner"; import { applyProviderInstanceSettings, deriveProviderInstanceEntries, NO_PROVIDER_MODEL_SELECTION, - resolveProviderDriverKindForInstanceSelection, - resolveSelectableProviderInstanceEntry, sortProviderInstanceEntries, type ProviderInstanceEntry, } from "../../providerInstances"; @@ -1088,6 +1093,8 @@ export interface ChatComposerHandle { selectedProvider: ProviderDriverKind; selectedModel: string; selectedProviderModels: ReadonlyArray; + interactionMode: ProviderInteractionMode; + interactionModeEnabled: boolean; }; /** Validate the fully composed text immediately before a provider turn starts. */ validateProviderInput: (providerInput: string) => boolean; @@ -1137,7 +1144,11 @@ export interface ChatComposerProps { isLastQuestion: boolean; canAdvance: boolean; customAnswer: string; - activeQuestion: { id: string; multiSelect?: boolean | undefined } | null; + activeQuestion: { + id: string; + multiSelect?: boolean | undefined; + allowCustomAnswer?: boolean | undefined; + } | null; } | null; activePendingResolvedAnswers: Record | null; activePendingIsResponding: boolean; @@ -1199,7 +1210,7 @@ export interface ChatComposerProps { requestId: ApprovalRequestId, decision: ProviderApprovalDecision, ) => Promise; - onSelectActivePendingUserInputOption: (questionId: string, optionLabel: string) => void; + onSelectActivePendingUserInputOption: (questionId: string, optionValue: string) => void; onAdvanceActivePendingUserInput: () => void; onPreviousActivePendingUserInputQuestion: () => void; onChangeActivePendingUserInputCustomAnswer: ( @@ -1211,6 +1222,7 @@ export interface ChatComposerProps { ) => void; onProviderModelSelect: (instanceId: ProviderInstanceId, model: string) => void; + onOpenProviderSetup: (instanceId: ProviderInstanceId) => void; getModelDisabledReason: (instanceId: ProviderInstanceId, model: string) => string | null; toggleInteractionMode: () => void; handleRuntimeModeChange: (mode: RuntimeMode) => void; @@ -1262,7 +1274,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) showPlanFollowUpPrompt, activeProposedPlan, runtimeMode, - interactionMode, + interactionMode: requestedInteractionMode, lockedProvider, providerStatuses, activeProjectDefaultModelSelection, @@ -1299,6 +1311,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) onPreviousActivePendingUserInputQuestion, onChangeActivePendingUserInputCustomAnswer, onProviderModelSelect, + onOpenProviderSetup, getModelDisabledReason, toggleInteractionMode, handleRuntimeModeChange, @@ -1365,10 +1378,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) environmentId, }) : null); - const sendDisabledReason = - externalSendDisabledReason ?? (activePendingProgress ? null : attachmentBlockReason); - const isSendDisabled = sendDisabledReason !== null; - const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); const addComposerDraftImage = useComposerDraftStore((store) => store.addImage); const addComposerDraftImages = useComposerDraftStore((store) => store.addImages); @@ -1495,105 +1504,43 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) [providerStatuses, settings], ); const selectedProviderByThreadId = composerDraft.activeProvider ?? null; - const threadProvider = - activeThread?.session?.providerInstanceId ?? - activeThreadModelSelection?.instanceId ?? - activeProjectDefaultModelSelection?.instanceId ?? - null; - const explicitSelectedInstanceId = selectedProviderByThreadId ?? threadProvider; - - const unlockedSelectedProvider = - resolveProviderDriverKindForInstanceSelection( - providerInstanceEntries, - providerStatuses, - explicitSelectedInstanceId, - ) ?? - providerInstanceEntries[0]?.driverKind ?? - ProviderDriverKind.make("unconfigured"); - const requestedDriverKind: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider; - const lockedContinuationGroupKey = useMemo((): string | null => { - if (!lockedProvider || !activeThread) return null; - const lockedInstanceId = - activeThread.session?.providerInstanceId ?? activeThreadModelSelection?.instanceId; - if (!lockedInstanceId) return null; - return ( - providerInstanceEntries.find((entry) => entry.instanceId === lockedInstanceId) - ?.continuationGroupKey ?? null - ); - }, [ - activeThread, - activeThreadModelSelection?.instanceId, - lockedProvider, - providerInstanceEntries, - ]); - - // Resolve which configured instance the composer is currently targeting. - // Priority: - // 1. The composer draft's `activeProvider` — the user's unsaved pick - // from the model picker (must win, otherwise the UI appears to - // ignore picker selections). - // 2. Thread's persisted instance id (server-side saved selection). - // 3. Project default's instance id. - // 4. First enabled entry matching the current driver kind. - // 5. First enabled entry overall / default instance for the kind. - // - const selectedInstanceId = useMemo(() => { - const candidates: Array = [ - composerDraft.activeProvider, + const { + selectedProviderEntry, + requestedDriverKind, + lockedContinuationGroupKey, + unavailableProviderInstanceId, + } = useMemo( + () => + resolveComposerProviderSelection({ + entries: providerInstanceEntries, + candidateInstanceIds: [ + selectedProviderByThreadId, + activeThread?.session?.providerInstanceId, + activeThreadModelSelection?.instanceId, + activeProjectDefaultModelSelection?.instanceId, + ], + lockedProvider, + lockedInstanceId: + activeThread?.session?.providerInstanceId ?? activeThreadModelSelection?.instanceId, + }), + [ + activeProjectDefaultModelSelection?.instanceId, activeThread?.session?.providerInstanceId, activeThreadModelSelection?.instanceId, - activeProjectDefaultModelSelection?.instanceId, - ]; - for (const candidate of candidates) { - if (!candidate) continue; - const match = providerInstanceEntries.find( - (entry) => entry.instanceId === candidate && entry.enabled && entry.isAvailable, - ); - if (match) { - // When locked to a specific driver kind, ignore persisted instance - // ids from a different kind or continuation group. - if (lockedProvider && match.driverKind !== lockedProvider) continue; - if ( - lockedContinuationGroupKey && - match.continuationGroupKey !== lockedContinuationGroupKey - ) { - continue; - } - return match.instanceId; - } - } - const compatibleEntries = providerInstanceEntries.filter( - (entry) => - (!lockedProvider || entry.driverKind === lockedProvider) && - (!lockedContinuationGroupKey || entry.continuationGroupKey === lockedContinuationGroupKey), - ); - const requestedDriverEntries = compatibleEntries.filter( - (entry) => entry.driverKind === requestedDriverKind, - ); - return ( - resolveSelectableProviderInstanceEntry(requestedDriverEntries, undefined)?.instanceId ?? - resolveSelectableProviderInstanceEntry(compatibleEntries, undefined)?.instanceId ?? - NO_PROVIDER_MODEL_SELECTION.instanceId - ); - }, [ - activeProjectDefaultModelSelection?.instanceId, - activeThread?.session?.providerInstanceId, - activeThreadModelSelection?.instanceId, - composerDraft.activeProvider, - lockedContinuationGroupKey, - lockedProvider, - providerInstanceEntries, - requestedDriverKind, - ]); - - // Resolve the active instance's snapshot by `instanceId` so a custom - // instance gets its own slash commands, skills, and model list — not - // the first snapshot for the same driver kind. - const selectedProviderEntry = useMemo( - () => providerInstanceEntries.find((entry) => entry.instanceId === selectedInstanceId), - [providerInstanceEntries, selectedInstanceId], + selectedProviderByThreadId, + lockedProvider, + providerInstanceEntries, + ], ); + const selectedInstanceId = + selectedProviderEntry?.instanceId ?? NO_PROVIDER_MODEL_SELECTION.instanceId; const noProviderAvailable = selectedProviderEntry === undefined; + const providerSetupInstanceId = noProviderAvailable + ? (unavailableProviderInstanceId ?? + (lockedProvider === null + ? providerInstanceEntries.find((entry) => hasProviderSetup(entry.snapshot))?.instanceId + : undefined)) + : undefined; const resolvedCompactDisabledReason = compactDisabledReason ?? (noProviderAvailable ? "Compacting is unavailable right now" : null); // The driver kind follows the instance that will actually run the turn, @@ -1611,6 +1558,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) projectModelSelection: activeProjectDefaultModelSelection, settings, }); + const providerSendBlockReason = getAntigravitySendBlockReason( + selectedProviderEntry?.snapshot, + selectedModel, + ); + const sendDisabledReason = + externalSendDisabledReason ?? + (activePendingProgress ? null : (attachmentBlockReason ?? providerSendBlockReason)); + const isSendDisabled = sendDisabledReason !== null; const selectedProviderStatus = useMemo( () => selectedProviderEntry?.snapshot ?? null, [selectedProviderEntry], @@ -1707,17 +1662,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const selectedPromptEffort = composerProviderState.promptEffort; const selectedModelOptionsForDispatch = composerProviderState.modelOptionsForDispatch; - // Plan mode is a legacy feature behind Settings → Beta. With the flag off, - // ChatView forces the effective mode to "default", so hiding the toggle - // can't trap anyone in plan mode. - const planModeUiEnabled = settings.planModeEnabled; - const composerProviderControls = useMemo( - () => ({ - showInteractionModeToggle: - planModeUiEnabled && getProviderInteractionModeToggle(providerStatuses, selectedProvider), - }), - [planModeUiEnabled, providerStatuses, selectedProvider], - ); + const { enabled: planModeUiEnabled, interactionMode } = resolveComposerInteractionMode({ + planModeEnabled: settings.planModeEnabled, + provider: selectedProviderStatus, + interactionMode: requestedInteractionMode, + }); const selectedModelSelection = useMemo( () => createModelSelection(selectedInstanceId, selectedModel, selectedModelOptionsForDispatch), [selectedInstanceId, selectedModel, selectedModelOptionsForDispatch], @@ -1999,6 +1948,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const isComposerApprovalState = activePendingApproval !== null; const activePendingUserInput = pendingUserInputs[0] ?? null; + const isChoiceOnlyPendingQuestion = + activePendingProgress?.activeQuestion?.allowCustomAnswer === false; const showComposerTopDrawer = isComposerApprovalState || pendingUserInputs.length > 0 || @@ -2491,6 +2442,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ) => { expandComposerForEditorChange(); if (activePendingProgress?.activeQuestion && pendingUserInputs.length > 0) { + if (activePendingProgress.activeQuestion.allowCustomAnswer === false) return; setComposerCursor(nextCursor); setComposerTrigger( cursorAdjacentToMention ? null : detectComposerTrigger(nextPrompt, expandedCursor), @@ -2544,6 +2496,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) citationComment?: { start: number; sourceAnchor: AssistantCitationSourceAnchor }; }, ): boolean => { + if ( + activePendingUserInput && + activePendingProgress?.activeQuestion?.allowCustomAnswer === false + ) { + return false; + } const currentText = promptRef.current; const safeStart = Math.max(0, Math.min(currentText.length, rangeStart)); const safeEnd = Math.max(safeStart, Math.min(currentText.length, rangeEnd)); @@ -2668,6 +2626,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } return; } + if (!planModeUiEnabled) return; void handleInteractionModeChange(item.command === "plan" ? "plan" : "default"); const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), @@ -2714,7 +2673,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) return; } }, - [applyPromptReplacement, handleInteractionModeChange, resolveActiveComposerTrigger], + [ + applyPromptReplacement, + handleInteractionModeChange, + planModeUiEnabled, + resolveActiveComposerTrigger, + ], ); const onComposerMenuItemHighlighted = useCallback( @@ -2833,6 +2797,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) shouldBlurMobileComposerOnSubmit, ], ); + const submitCitationAndSend = useCallback(() => { + const intent = composerSubmissionIntentForEnter({ + isMobileViewport, + shiftKey: false, + modifierKey: true, + isDraftThread: routeKind === "draft", + }); + submitComposer(undefined, intent ?? "foreground"); + }, [isMobileViewport, routeKind, submitComposer]); const compactThreadContext = useCallback(() => { if ( compactDisabled || @@ -3541,8 +3514,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 @@ -3614,8 +3589,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) { @@ -3656,11 +3637,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; } @@ -3694,8 +3670,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(), }); @@ -3739,7 +3714,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) content: ( { + if (providerSetupInstanceId) { + onOpenProviderSetup(providerSetupInstanceId); + } + }} data-chat-provider-unavailable="true" className="shrink-0 gap-2 px-2 text-secondary-label sm:px-3" > - No provider available + {providerSetupInstanceId ? "Open provider settings" : "No provider available"} ) : ( <> @@ -3801,13 +3781,14 @@ 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!", ), } : {})} onOpenChange={setIsComposerModelPickerOpen} getModelDisabledReason={getModelDisabledReason} onInstanceModelChange={onProviderModelSelect} + onOpenProviderSetup={onOpenProviderSetup} /> {composerControlsCompact ? ( @@ -3815,7 +3796,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) provider={selectedProvider} interactionMode={interactionMode} runtimeMode={runtimeMode} - showInteractionModeToggle={composerProviderControls.showInteractionModeToggle} + showInteractionModeToggle={planModeUiEnabled} traitsMenuContent={providerTraitsMenuContent} onToggleInteractionMode={toggleInteractionMode} onRuntimeModeChange={handleRuntimeModeChange} @@ -3859,8 +3840,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) size="xs" hidden={hiddenRestingBlockIds.length === 0} showInteractionModeToggle={ - composerProviderControls.showInteractionModeToggle && - hiddenRestingBlockIds.includes("mode") + planModeUiEnabled && hiddenRestingBlockIds.includes("mode") } traitsMenuContent={ hiddenRestingBlockIds.includes("traits") ? providerTraitsMenuContent : undefined @@ -4280,7 +4260,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 ( @@ -4300,8 +4280,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)) { @@ -4431,7 +4410,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ); }, addTerminalContext: (selection: TerminalContextSelection) => { - if (!activeThread) return; + if (!activeThread || isChoiceOnlyPendingQuestion) return; const snapshot = composerEditorRef.current?.readSnapshot() ?? { value: promptRef.current, cursor: composerCursor, @@ -4476,10 +4455,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) selectedPromptEffort, selectedModelOptionsForDispatch, selectedModelSelection, - providerAvailable: !noProviderAvailable, + providerAvailable: !noProviderAvailable && providerSendBlockReason === null, selectedProvider, selectedModel, selectedProviderModels, + interactionMode, + interactionModeEnabled: planModeUiEnabled, }), validateProviderInput: (providerInput: string) => { const validationMessage = getComposerSubmissionValidationMessage({ @@ -4511,6 +4492,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) focusComposer, isConnecting, isComposerApprovalState, + isChoiceOnlyPendingQuestion, pendingUserInputs.length, projectSelectionRequired, applyPromptReplacement, @@ -4520,9 +4502,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) selectedModelOptionsForDispatch, selectedModelSelection, noProviderAvailable, + providerSendBlockReason, selectedPromptEffort, selectedProvider, selectedProviderModels, + interactionMode, + planModeUiEnabled, compactThreadContext, ], ); @@ -4536,6 +4521,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) onPointerDownCapture={(event) => { const target = event.target; if (isInsideRestingComposerControlScope(target)) return; + if (isInsideCollapsedComposerControls(target)) return; if (!(target instanceof Element)) return; const isInteractive = Boolean( target.closest('button, a, input, select, [role="button"], [role="menuitem"]'), @@ -4558,11 +4544,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) if (composerControlsInStrip && isInsideRestingComposerControlScope(activeElement)) { return; } - if ( - isComposerCollapsedMobile && - activeElement instanceof HTMLElement && - activeElement.closest('[data-chat-composer-collapsed-controls="true"]') - ) { + if (isInsideCollapsedComposerControls(activeElement)) { return; } // Focus returning from another window or tab lands on the element @@ -4687,54 +4669,61 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) onToggleOption={onSelectActivePendingUserInputOption} onAdvance={onAdvanceActivePendingUserInput} /> - -
- - {activePendingProgress?.activeQuestion?.multiSelect ? ( - - ) : null} -
-
+ {!isChoiceOnlyPendingQuestion ? ( + + ) : null} + {activePendingProgress?.activeQuestion?.multiSelect ? ( + + ) : null} +
+ + ) : null}
) : null} @@ -4800,12 +4789,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) : "text-placeholder", )} onPointerDown={(event) => event.preventDefault()} - onClick={expandMobileComposer} + onClick={isChoiceOnlyPendingQuestion ? undefined : expandMobileComposer} + disabled={isChoiceOnlyPendingQuestion} aria-label="Expand composer" > {activePendingProgress - ? activePendingProgress.customAnswer || - "Type your own answer, or leave this blank to use the selected option" + ? isChoiceOnlyPendingQuestion + ? "Choose an option above" + : activePendingProgress.customAnswer || + "Type your own answer, or leave this blank to use the selected option" : prompt.trim() || (noProviderAvailable ? "Enable a provider in Settings" : "Ask anything...")} @@ -5239,13 +5231,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) onPageScrollKeyDown={onPageScrollKeyDown} onPageScrollKeyUp={onPageScrollKeyUp} onPageScrollRelease={onPageScrollRelease} + onCitationSubmitAndSend={submitCitationAndSend} onPaste={onComposerPaste} placeholder={ isComposerApprovalState ? (activePendingApproval?.detail ?? "Resolve this approval request to continue") : activePendingProgress - ? "Type your own answer, or leave this blank to use the selected option" + ? isChoiceOnlyPendingQuestion + ? "Choose an option above" + : "Type your own answer, or leave this blank to use the selected option" : showPlanFollowUpPrompt && activeProposedPlan ? "Add feedback to refine the plan, or leave this blank to implement it" : projectSelectionRequired @@ -5256,7 +5251,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ? DISCONNECTED_COMPOSER_PLACEHOLDER : "Ask anything, @tag files/folders, $use skills, or / for commands" } - disabled={isConnecting || isComposerApprovalState || projectSelectionRequired} + disabled={ + isConnecting || + isComposerApprovalState || + projectSelectionRequired || + isChoiceOnlyPendingQuestion + } /> {isComposerResting ? collapsedComposerImagePreviews : null} {showMobilePendingAnswerActions ? ( diff --git a/apps/web/src/components/chat/ComposerPendingApprovalActions.test.tsx b/apps/web/src/components/chat/ComposerPendingApprovalActions.test.tsx index 16141270f6f4..a68ea539ebed 100644 --- a/apps/web/src/components/chat/ComposerPendingApprovalActions.test.tsx +++ b/apps/web/src/components/chat/ComposerPendingApprovalActions.test.tsx @@ -41,6 +41,31 @@ describe("ComposerPendingApprovalActions", () => { expect(markup).not.toContain("Always allow this session"); }); + it("marks an option that carries a provider warning", () => { + const markup = renderToStaticMarkup( + undefined} + />, + ); + + expect(markup).toContain( + 'aria-description="Untrusted files could re-run this action without asking."', + ); + expect(markup).toContain("text-warning"); + expect(markup).toContain("Allow for this thread"); + }); + it("limits provider-supplied approval labels so narrow rows can wrap", () => { const label = "Allow ".repeat(40).trim(); const markup = renderToStaticMarkup( diff --git a/apps/web/src/components/chat/ComposerPendingApprovalActions.tsx b/apps/web/src/components/chat/ComposerPendingApprovalActions.tsx index 7482f665bd11..33f5afe50d75 100644 --- a/apps/web/src/components/chat/ComposerPendingApprovalActions.tsx +++ b/apps/web/src/components/chat/ComposerPendingApprovalActions.tsx @@ -4,7 +4,9 @@ import { type ProviderApprovalOption, } from "@t3tools/contracts"; import { memo } from "react"; +import { TriangleAlertIcon } from "lucide-react"; import { Button } from "../ui/button"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; interface ComposerPendingApprovalActionsProps { requestId: ApprovalRequestId; @@ -32,24 +34,42 @@ export const ComposerPendingApprovalActions = memo(function ComposerPendingAppro }: ComposerPendingApprovalActionsProps) { return ( <> - {options.map((option) => ( - - ))} + {options.map((option) => { + const button = ( + + ); + // A provider caution, such as a prompt injection warning on "allow + // always", rides along as a tooltip so the row stays one line. + return option.warning ? ( + + + + {option.warning} + + + ) : ( + button + ); + })} ); }); diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx index 78e1a1d522bf..2a7df2488233 100644 --- a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx @@ -15,7 +15,7 @@ interface PendingUserInputPanelProps { respondingRequestIds: ApprovalRequestId[]; answers: Record; questionIndex: number; - onToggleOption: (questionId: string, optionLabel: string) => void; + onToggleOption: (questionId: string, optionValue: string) => void; onAdvance: () => void; } @@ -56,7 +56,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( isResponding: boolean; answers: Record; questionIndex: number; - onToggleOption: (questionId: string, optionLabel: string) => void; + onToggleOption: (questionId: string, optionValue: string) => void; onAdvance: () => void; }) { const progress = derivePendingUserInputProgress(prompt.questions, answers, questionIndex); @@ -65,7 +65,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( const onAdvanceRef = useRef(onAdvance); const [optimisticSingleSelect, setOptimisticSingleSelect] = useState<{ questionId: string; - optionLabel: string; + optionValue: string; } | null>(null); // Collapsing hides everything but the header so a tall prompt stops covering // the thread the user is trying to read. Scoped to a single question: the card @@ -90,7 +90,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( } if ( progress.customAnswer.trim().length === 0 && - progress.selectedOptionLabels.includes(optimisticSingleSelect.optionLabel) + progress.selectedOptionValues.includes(optimisticSingleSelect.optionValue) ) { setOptimisticSingleSelect(null); } @@ -98,7 +98,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( activeQuestion, optimisticSingleSelect, progress.customAnswer, - progress.selectedOptionLabels, + progress.selectedOptionValues, ]); // Clear auto-advance timer on unmount @@ -111,13 +111,13 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( }, []); const handleOptionSelection = useCallback( - (questionId: string, optionLabel: string) => { + (questionId: string, optionValue: string) => { if (activeQuestion?.multiSelect) { - onToggleOption(questionId, optionLabel); + onToggleOption(questionId, optionValue); return; } - setOptimisticSingleSelect({ questionId, optionLabel }); - onToggleOption(questionId, optionLabel); + setOptimisticSingleSelect({ questionId, optionValue }); + onToggleOption(questionId, optionValue); if (autoAdvanceTimerRef.current !== null) { window.clearTimeout(autoAdvanceTimerRef.current); } @@ -154,7 +154,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( const option = activeQuestion.options[optionIndex]; if (!option) return; event.preventDefault(); - handleOptionSelection(activeQuestion.id, option.label); + handleOptionSelection(activeQuestion.id, option.value ?? option.label); }; document.addEventListener("keydown", handler); return () => document.removeEventListener("keydown", handler); @@ -208,12 +208,13 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( ) : null}
{activeQuestion.options.map((option, index) => { + const optionValue = option.value ?? option.label; const isOptimisticallySelected = optimisticSingleSelect?.questionId === activeQuestion.id && - optimisticSingleSelect.optionLabel === option.label; + optimisticSingleSelect.optionValue === optionValue; const isSelected = isOptimisticallySelected || - (!customAnswerActive && progress.selectedOptionLabels.includes(option.label)); + (!customAnswerActive && progress.selectedOptionValues.includes(optionValue)); const shortcutKey = index < 9 ? index + 1 : null; const className = cn( "group flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left outline-none transition-colors duration-150 focus-visible:ring-1 focus-visible:ring-primary/25", @@ -246,11 +247,11 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( ); return (
- {viewedImage && threadRef ? ( + {expanded && viewedImage && threadRef ? (
{ - it.each(["error", "warning"] as const)( - "keeps only the active synthetic OpenCode row when the provider status is %s", + it.each(["ready", "error"] as const)( + "never offers the internal Antigravity default marker as a model when %s", (status) => { - const providerEntry = entry(status); - const activeInstanceId = ProviderInstanceId.make("opencode_work"); - const activeModel = "openrouter/kimi-k3"; + const providerEntry = entry(status, "antigravity"); + expect( + shouldIncludeModelPickerOption({ + entry: providerEntry, + option: { + slug: ANTIGRAVITY_DEFAULT_MODEL, + name: ANTIGRAVITY_DEFAULT_MODEL, + isUnavailable: true, + }, + activeInstanceId: providerEntry.instanceId, + activeModel: ANTIGRAVITY_DEFAULT_MODEL, + }), + ).toBe(false); + }, + ); + + it.each([ + ["opencode", "error"], + ["opencode", "warning"], + ["antigravity", "error"], + ["antigravity", "warning"], + ] as const)( + "keeps only the active synthetic %s row when the provider status is %s", + (driver, status) => { + const providerEntry = entry(status, driver); + const activeInstanceId = providerEntry.instanceId; + const activeModel = "missing-model"; expect( shouldIncludeModelPickerOption({ @@ -62,6 +95,121 @@ describe("shouldIncludeModelPickerOption", () => { activeModel, }), ).toBe(false); + expect( + shouldIncludeModelPickerOption({ + entry: providerEntry, + option: { slug: activeModel, name: activeModel, isUnavailable: true }, + activeInstanceId: ProviderInstanceId.make(`${driver}_personal`), + activeModel, + }), + ).toBe(false); }, ); }); + +describe("resolveModelPickerSelectedModel", () => { + it("follows the catalog default for the marker but keeps an explicit native model", () => { + const driverKind = ProviderDriverKind.make("antigravity"); + const previousOptions = [ + { slug: "gemini-fast", name: "Gemini Fast", aliases: [ANTIGRAVITY_DEFAULT_MODEL] }, + { slug: "gemini-pro", name: "Gemini Pro" }, + ]; + const nextOptions = [ + { slug: "gemini-fast", name: "Gemini Fast" }, + { slug: "gemini-pro", name: "Gemini Pro", aliases: [ANTIGRAVITY_DEFAULT_MODEL] }, + ]; + + expect( + resolveModelPickerSelectedModel({ + driverKind, + model: ANTIGRAVITY_DEFAULT_MODEL, + options: previousOptions, + })?.slug, + ).toBe("gemini-fast"); + expect( + resolveModelPickerSelectedModel({ + driverKind, + model: ANTIGRAVITY_DEFAULT_MODEL, + options: nextOptions, + })?.slug, + ).toBe("gemini-pro"); + expect( + resolveModelPickerSelectedModel({ + driverKind, + model: "gemini-fast", + options: nextOptions, + })?.slug, + ).toBe("gemini-fast"); + }); + + it("does not guess the default from the first model in a catalog", () => { + expect( + resolveModelPickerSelectedModel({ + driverKind: ProviderDriverKind.make("antigravity"), + model: ANTIGRAVITY_DEFAULT_MODEL, + options: [{ slug: "gemini-fast", name: "Gemini Fast" }], + }), + ).toBeUndefined(); + }); +}); + +describe("shouldOfferModelPickerSetup", () => { + const availableModel = { slug: "gemini-3.1-pro", name: "Gemini 3.1 Pro" }; + + it("offers setup before an Antigravity account has models", () => { + expect(shouldOfferModelPickerSetup(entry("error", "antigravity"), [])).toBe(true); + }); + + it("offers setup after sign-out even if a model remains cached", () => { + const providerEntry = entry("ready", "antigravity"); + expect( + shouldOfferModelPickerSetup( + { + ...providerEntry, + snapshot: { ...providerEntry.snapshot, auth: { status: "unauthenticated" } }, + }, + [availableModel], + ), + ).toBe(true); + }); + + it("offers setup when the only model is an unavailable saved selection", () => { + expect( + shouldOfferModelPickerSetup(entry("ready", "antigravity"), [ + { ...availableModel, isUnavailable: true }, + ]), + ).toBe(true); + }); + + it("does not offer setup for a ready account with available models", () => { + expect(shouldOfferModelPickerSetup(entry("ready", "antigravity"), [availableModel])).toBe( + false, + ); + }); + + it("does not restore a disabled provider while its status snapshot is stale", () => { + expect( + shouldOfferModelPickerSetup({ ...entry("error", "antigravity"), enabled: false }, []), + ).toBe(false); + }); + + it("keeps providers without integrated setup on their existing path", () => { + expect(shouldOfferModelPickerSetup(entry("error", "codex"), [])).toBe(false); + }); + + it("uses the environment's setup capability for other drivers", () => { + const providerEntry = entry("error", "custom_driver"); + expect( + shouldOfferModelPickerSetup( + { + ...providerEntry, + snapshot: { + ...providerEntry.snapshot, + setup: { canAuthenticate: true, canInstall: false }, + }, + }, + [], + ), + ).toBe(true); + }); +}); diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index 8d94fde1c0e8..8880369a18c9 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -1,4 +1,5 @@ import { + ANTIGRAVITY_DEFAULT_MODEL, type ProviderInstanceId, type ProviderDriverKind, type ResolvedKeybindingsConfig, @@ -9,6 +10,7 @@ import { memo, useMemo, useState, useCallback, useEffect, useLayoutEffect, useRe import { ChevronRightIcon, SearchIcon } from "lucide-react"; import { ModelListRow } from "./ModelListRow"; import { ModelPickerSidebar } from "./ModelPickerSidebar"; +import { getProviderStatusMessage, hasProviderSetup } from "./ProviderStatusBanner"; import { modelPickerLegacySectionKey, modelPickerModelKey, @@ -34,6 +36,7 @@ import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings" import { cn } from "~/lib/utils"; import { getVirtualizedScrollFadeClassName } from "../ui/scroll-area"; import { TooltipProvider } from "../ui/tooltip"; +import { Button } from "../ui/button"; import { isProviderInstancePickerReady, isProviderInstancePickerVisible, @@ -56,22 +59,57 @@ type ModelPickerItem = { isUnavailable?: boolean | undefined; }; +export function resolveModelPickerSelectedModel(input: { + driverKind: ProviderDriverKind | undefined; + model: string; + options: ReadonlyArray; +}) { + if (input.driverKind === "antigravity" && input.model === ANTIGRAVITY_DEFAULT_MODEL) { + const availableModels = input.options.filter( + (option) => option.slug !== ANTIGRAVITY_DEFAULT_MODEL && !option.isUnavailable, + ); + return ( + availableModels.find((option) => option.aliases?.includes(ANTIGRAVITY_DEFAULT_MODEL)) ?? + availableModels.find((option) => option.isDefault) + ); + } + return input.options.find((option) => option.slug === input.model); +} + export function shouldIncludeModelPickerOption(input: { readonly entry: ProviderInstanceEntry; readonly option: ModelEsque; readonly activeInstanceId: ProviderInstanceId; readonly activeModel: string; }): boolean { + if (input.entry.driverKind === "antigravity" && input.option.slug === ANTIGRAVITY_DEFAULT_MODEL) { + return false; + } if (isProviderInstancePickerReady(input.entry)) return true; return ( input.entry.enabled && - input.entry.driverKind === "opencode" && + (input.entry.driverKind === "opencode" || input.entry.driverKind === "antigravity") && input.entry.instanceId === input.activeInstanceId && input.option.slug === input.activeModel && input.option.isUnavailable === true ); } +export function shouldOfferModelPickerSetup( + entry: ProviderInstanceEntry, + options: ReadonlyArray, +): boolean { + return ( + entry.enabled && + entry.status !== "disabled" && + hasProviderSetup(entry.snapshot) && + (!isProviderInstancePickerReady(entry) || + !entry.installed || + entry.snapshot.auth.status === "unauthenticated" || + !options.some((option) => !option.isUnavailable)) + ); +} + const EMPTY_MODEL_JUMP_LABELS = new Map(); function ModelListSeparator() { @@ -107,6 +145,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { modelOptionsByInstance: ReadonlyMap>; terminalOpen: boolean; onRequestClose?: () => void; + onOpenProviderSetup?: (instanceId: ProviderInstanceId) => void; getModelDisabledReason?: (instanceId: ProviderInstanceId, model: string) => string | null; onInstanceModelChange: (instanceId: ProviderInstanceId, model: string) => void; }) { @@ -127,6 +166,16 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { const activeEntry = props.instanceEntries.find( (entry) => entry.instanceId === props.activeInstanceId, ); + const activeModel = resolveModelPickerSelectedModel({ + driverKind: activeEntry?.driverKind, + model: props.model, + options: modelOptionsByInstance.get(props.activeInstanceId) ?? [], + }); + const activeModelSlug = + activeModel?.slug ?? (props.model === ANTIGRAVITY_DEFAULT_MODEL ? "" : props.model); + const activeModelKey = activeModelSlug + ? modelPickerModelKey(props.activeInstanceId, activeModelSlug) + : null; const activeInstanceHasSelectableUnavailableModel = activeEntry !== undefined && (modelOptionsByInstance.get(props.activeInstanceId) ?? []).some((option) => @@ -134,15 +183,25 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { entry: activeEntry, option, activeInstanceId: props.activeInstanceId, - activeModel: props.model, + activeModel: activeModelSlug, }), ) && !isProviderInstancePickerReady(activeEntry); + const activeInstanceNeedsSetup = + props.onOpenProviderSetup !== undefined && + activeEntry !== undefined && + shouldOfferModelPickerSetup( + activeEntry, + modelOptionsByInstance.get(props.activeInstanceId) ?? [], + ); const [selectedInstanceId, setSelectedInstanceId] = useState( () => { - if (props.lockedProvider !== null || activeInstanceHasSelectableUnavailableModel) { - // When locked, prime the sidebar to the currently-active instance - // so jumping into the picker keeps the focused instance visible. + if ( + props.lockedProvider !== null || + activeInstanceHasSelectableUnavailableModel || + activeInstanceNeedsSetup + ) { + // Keep the active instance visible when it is locked or needs setup. return props.activeInstanceId; } return favorites.length > 0 ? "favorites" : props.activeInstanceId; @@ -153,7 +212,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { new Set( modelOptionsByInstance .get(props.activeInstanceId) - ?.some((model) => model.slug === props.model && model.isLegacy) + ?.some((model) => model.slug === activeModelSlug && model.isLegacy) ? [props.activeInstanceId] : [], ), @@ -221,11 +280,27 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { ); const selectableUnavailableInstanceIds = useMemo(() => { - if (!activeInstanceHasSelectableUnavailableModel) { - return undefined; + const instanceIds = new Set(); + if (activeInstanceHasSelectableUnavailableModel) { + instanceIds.add(props.activeInstanceId); } - return new Set([props.activeInstanceId]); - }, [activeInstanceHasSelectableUnavailableModel, props.activeInstanceId]); + if (props.onOpenProviderSetup) { + for (const entry of instanceEntries) { + if ( + shouldOfferModelPickerSetup(entry, modelOptionsByInstance.get(entry.instanceId) ?? []) + ) { + instanceIds.add(entry.instanceId); + } + } + } + return instanceIds.size > 0 ? instanceIds : undefined; + }, [ + activeInstanceHasSelectableUnavailableModel, + instanceEntries, + modelOptionsByInstance, + props.activeInstanceId, + props.onOpenProviderSetup, + ]); // Flatten models into a searchable array. One pass over the // instance-keyed map; each model carries its instance id + driver kind @@ -246,7 +321,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { entry, option: model, activeInstanceId: props.activeInstanceId, - activeModel: props.model, + activeModel: activeModelSlug, }) ) { continue; @@ -270,7 +345,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { } } return out; - }, [modelOptionsByInstance, entryByInstanceId, props.activeInstanceId, props.model]); + }, [modelOptionsByInstance, entryByInstanceId, props.activeInstanceId, activeModelSlug]); const isLocked = props.lockedProvider !== null; const isSearching = searchQuery.trim().length > 0; @@ -441,6 +516,23 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { ]; }, [filteredModels, legacySection]); + const selectedEntry = + selectedInstanceId === "favorites" ? undefined : entryByInstanceId.get(selectedInstanceId); + const providerSetupEntries = + !isSearching && props.onOpenProviderSetup + ? instanceEntries.filter( + (entry) => + matchesLockedProvider(entry) && + shouldOfferModelPickerSetup( + entry, + modelOptionsByInstance.get(entry.instanceId) ?? [], + ) && + (selectedEntry + ? entry.instanceId === selectedEntry.instanceId + : filteredModels.length === 0), + ) + : []; + const toggleLegacySection = useCallback((instanceId: ProviderInstanceId) => { setExpandedLegacyInstances((expanded) => { const next = new Set(expanded); @@ -666,7 +758,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { autoHighlight open virtualized - value={modelPickerModelKey(props.activeInstanceId, props.model)} + value={activeModelKey} onItemHighlighted={(modelKey, eventDetails) => { highlightedModelKeyRef.current = typeof modelKey === "string" ? modelKey : null; if (eventDetails.reason === "keyboard" && eventDetails.index >= 0) { @@ -799,9 +891,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { isFavorite={favoritesSet.has( providerModelKey(model.instanceId, model.slug), )} - isSelected={ - modelKey === modelPickerModelKey(props.activeInstanceId, props.model) - } + isSelected={modelKey === activeModelKey} showProvider preferShortName={!isLocked} useTriggerLabel={false} @@ -830,9 +920,34 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { />
- - No models found - + {providerSetupEntries.length > 0 ? ( +
+ {providerSetupEntries.map((entry) => ( +
+

+ {getProviderStatusMessage(entry.snapshot)} +

+ +
+ ))} +
+ ) : ( + + No models found + + )} diff --git a/apps/web/src/components/chat/ProviderModelPicker.test.tsx b/apps/web/src/components/chat/ProviderModelPicker.test.tsx index 769ffae3d091..b1bb8ba9c74a 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.test.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.test.tsx @@ -1,4 +1,9 @@ -import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; +import { + ANTIGRAVITY_DEFAULT_MODEL, + ProviderDriverKind, + ProviderInstanceId, + type ServerProvider, +} from "@t3tools/contracts"; import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vite-plus/test"; @@ -45,17 +50,54 @@ function renderPicker(input: { } describe("ProviderModelPicker", () => { - it("shows a missing model slug for a custom OpenCode instance", () => { - const markup = renderPicker({ - instanceId: "team_runtime", - driver: "opencode", - model: "openrouter/missing-model", - options: [{ slug: "openrouter/fallback", name: "Fallback model" }], - }); + it.each(["", ANTIGRAVITY_DEFAULT_MODEL])( + "shows a choice prompt before Antigravity has an account catalog for %s", + (model) => { + const markup = renderPicker({ + instanceId: "antigravity", + driver: "antigravity", + model, + options: [], + }); - expect(markup).toContain("openrouter/missing-model"); - expect(markup).not.toContain("Fallback model"); - }); + expect(markup).toContain("Choose model"); + expect(markup).not.toContain(ANTIGRAVITY_DEFAULT_MODEL); + }, + ); + + it.each([{ aliases: [ANTIGRAVITY_DEFAULT_MODEL] }, { isDefault: true }])( + "shows the actual default model for an Antigravity marker with %j", + (defaultMetadata) => { + const markup = renderPicker({ + instanceId: "google_work", + driver: "antigravity", + model: ANTIGRAVITY_DEFAULT_MODEL, + options: [ + { slug: "gemini-fast", name: "Gemini Fast" }, + { slug: "gemini-pro", name: "Gemini Pro", ...defaultMetadata }, + ], + }); + + expect(markup).toContain("Gemini Pro"); + expect(markup).not.toContain("Gemini Fast"); + expect(markup).not.toContain(ANTIGRAVITY_DEFAULT_MODEL); + }, + ); + + it.each(["opencode", "antigravity"])( + "keeps the selected model label when the %s account catalog does not contain it", + (driver) => { + const markup = renderPicker({ + instanceId: "team_runtime", + driver, + model: "missing-model", + options: [{ slug: "fallback", name: "Fallback model" }], + }); + + expect(markup).toContain("missing-model"); + expect(markup).not.toContain("Fallback model"); + }, + ); it.each(["codex", "claudeAgent", "cursor", "grok"])( "uses the first option label for a missing %s model", diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index 9e3f167efbc7..932754db1eca 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -1,4 +1,5 @@ import { + ANTIGRAVITY_DEFAULT_MODEL, type ProviderInstanceId, type ProviderDriverKind, type ResolvedKeybindingsConfig, @@ -10,7 +11,7 @@ import { buttonVariants } from "../ui/button"; import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { cn } from "~/lib/utils"; -import { ModelPickerContent } from "./ModelPickerContent"; +import { ModelPickerContent, resolveModelPickerSelectedModel } from "./ModelPickerContent"; import { ProviderInstanceIcon } from "./ProviderInstanceIcon"; import { ModelEsque, @@ -50,6 +51,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { triggerClassName?: string; triggerAriaLabel?: string; onOpenChange?: (open: boolean) => void; + onOpenProviderSetup?: (instanceId: ProviderInstanceId) => void; getModelDisabledReason?: (instanceId: ProviderInstanceId, model: string) => string | null; onInstanceModelChange: (instanceId: ProviderInstanceId, model: string) => void; }) { @@ -68,15 +70,24 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { const activeInstanceId = props.activeInstanceId; const selectedInstanceOptions = props.modelOptionsByInstance.get(activeInstanceId) ?? []; - // OpenCode can keep a model through a transient catalog refresh. Other - // providers keep the active instance's first option as their normal fallback. + // Account-specific catalogs must keep the selected model label while unavailable. const selectedModel = - selectedInstanceOptions.find((option) => option.slug === props.model) ?? - (activeEntry?.driverKind === "opencode" ? undefined : selectedInstanceOptions[0]); - const triggerTitle = selectedModel ? getTriggerDisplayModelName(selectedModel) : props.model; + resolveModelPickerSelectedModel({ + driverKind: activeEntry?.driverKind, + model: props.model, + options: selectedInstanceOptions, + }) ?? + (activeEntry?.driverKind === "opencode" || activeEntry?.driverKind === "antigravity" + ? undefined + : selectedInstanceOptions[0]); + const triggerTitle = selectedModel + ? getTriggerDisplayModelName(selectedModel) + : props.model === ANTIGRAVITY_DEFAULT_MODEL + ? "Choose model" + : props.model || "Choose model"; const triggerLabel = selectedModel ? `${getTriggerDisplayModelLabel(selectedModel)}${selectedModel.isUnavailable ? " (Unavailable)" : ""}` - : props.model; + : triggerTitle; const showInstanceBadge = activeEntry !== null && shouldShowInstanceBadge(activeEntry, props.instanceEntries); @@ -225,6 +236,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { modelOptionsByInstance={props.modelOptionsByInstance} terminalOpen={props.terminalOpen ?? false} onRequestClose={() => setIsMenuOpen(false)} + {...(props.onOpenProviderSetup ? { onOpenProviderSetup: props.onOpenProviderSetup } : {})} {...(props.getModelDisabledReason ? { getModelDisabledReason: props.getModelDisabledReason } : {})} diff --git a/apps/web/src/components/chat/ProviderStatusBanner.test.tsx b/apps/web/src/components/chat/ProviderStatusBanner.test.tsx index 90f28effc7dc..f27cda19d957 100644 --- a/apps/web/src/components/chat/ProviderStatusBanner.test.tsx +++ b/apps/web/src/components/chat/ProviderStatusBanner.test.tsx @@ -4,6 +4,7 @@ import { describe, expect, it } from "vite-plus/test"; import { getProviderStatusBannerKey, + getProviderStatusMessage, ProviderStatusBanner, shouldShowProviderStatusBanner, } from "./ProviderStatusBanner"; @@ -64,3 +65,55 @@ describe("ProviderStatusBanner", () => { expect(markup).toContain('aria-label="Dismiss Codex provider error"'); }); }); + +describe("getProviderStatusMessage", () => { + it("preserves the environment's authentication error", () => { + const message = "SUBSCRIPTION_REQUIRED: This Google account cannot use Antigravity."; + expect( + getProviderStatusMessage({ + ...warningProvider(), + driver: ProviderDriverKind.make("antigravity"), + status: "error", + auth: { status: "unauthenticated" }, + message, + }), + ).toBe(message); + }); + + it("points a signed-out Antigravity account to Google sign-in without a CLI command", () => { + expect( + getProviderStatusMessage({ + ...warningProvider(), + driver: ProviderDriverKind.make("antigravity"), + status: "error", + auth: { status: "unauthenticated" }, + message: "", + }), + ).toBe("Open provider setup to sign in with Google."); + }); + + it("requires installation on the environment before sign-in", () => { + expect( + getProviderStatusMessage({ + ...warningProvider(), + driver: ProviderDriverKind.make("antigravity"), + displayName: "Google work account", + installed: false, + status: "error", + auth: { status: "unauthenticated" }, + message: "", + }), + ).toBe("Open provider setup to install Antigravity on this environment."); + }); + + it("keeps CLI sign-in advice for a provider without integrated setup", () => { + expect( + getProviderStatusMessage({ + ...warningProvider(), + status: "error", + auth: { status: "unauthenticated" }, + message: "", + }), + ).toBe("Sign in via the CLI to authenticate again."); + }); +}); diff --git a/apps/web/src/components/chat/ProviderStatusBanner.tsx b/apps/web/src/components/chat/ProviderStatusBanner.tsx index 1c7571b962f9..2ae4bc8b58d4 100644 --- a/apps/web/src/components/chat/ProviderStatusBanner.tsx +++ b/apps/web/src/components/chat/ProviderStatusBanner.tsx @@ -1,4 +1,4 @@ -import { type ServerProvider } from "@t3tools/contracts"; +import { type ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; import { memo } from "react"; import { InfoIcon, XIcon } from "lucide-react"; import { cn } from "~/lib/utils"; @@ -20,11 +20,43 @@ export function shouldShowProviderStatusBanner( return bannerKey !== null && bannerKey !== dismissedBannerKey; } +export function hasProviderSetup(status: ServerProvider): boolean { + return ( + status.driver === "antigravity" || + status.setup?.canAuthenticate === true || + status.setup?.canInstall === true + ); +} + +/** Keep the environment's error intact in both the banner and model picker. */ +export function getProviderStatusMessage(status: ServerProvider): string { + if (status.message) return status.message; + const providerName = status.displayName?.trim() || formatProviderDriverKindLabel(status.driver); + if (!status.installed && hasProviderSetup(status)) { + return `Open provider setup to install ${formatProviderDriverKindLabel(status.driver)} on this environment.`; + } + if (status.auth.status === "unauthenticated") { + if (hasProviderSetup(status)) { + return status.driver === "antigravity" + ? "Open provider setup to sign in with Google." + : "Open provider setup to sign in."; + } + return "Sign in via the CLI to authenticate again."; + } + return status.status === "ready" + ? "No models are available for this provider." + : status.status === "error" + ? `${providerName} provider is unavailable.` + : `${providerName} provider has limited availability.`; +} + export const ProviderStatusBanner = memo(function ProviderStatusBanner({ onDismiss, + onOpenProviderSetup, status, }: { onDismiss: () => void; + onOpenProviderSetup?: (instanceId: ProviderInstanceId) => void; status: ServerProvider | null; }) { if (!status || status.status === "ready" || status.status === "disabled") { @@ -36,12 +68,7 @@ export const ProviderStatusBanner = memo(function ProviderStatusBanner({ const title = isUnauthenticated ? `${providerName} is unauthenticated` : `${providerName} provider status`; - const message = isUnauthenticated - ? "Sign in via the CLI to authenticate again." - : (status.message ?? - (status.status === "error" - ? `${providerName} provider is unavailable.` - : `${providerName} provider has limited availability.`)); + const message = getProviderStatusMessage(status); return (
@@ -66,6 +93,16 @@ export const ProviderStatusBanner = memo(function ProviderStatusBanner({ {message} + {onOpenProviderSetup && hasProviderSetup(status) ? ( + + ) : null}
} /> - -
- onQueryChange(event.currentTarget.value)} - placeholder={searchLabel} - aria-label={searchLabel} - size="compact" - /> + +
+
+
-
+ {isPending ? ( ) : error !== null ? ( @@ -110,18 +154,18 @@ export function PullRequestCandidatePicker({ {query.length > 0 ? noMatchLabel : emptyLabel}

) : ( - candidates.map((candidate) => ( - // Stays open on press: a change is confirmed by the row's own check turning over, - // and a second label or reviewer is usually wanted right after the first. - ( + onSelect(candidate)} className="min-h-0 py-1.5 text-xs sm:min-h-0 sm:text-xs" + contentClassName="flex min-w-0 items-center gap-2" > {children(candidate)} - + )) )} {truncated ? ( @@ -129,8 +173,8 @@ export function PullRequestCandidatePicker({ // list is rather than offering a search that would find nothing further.

{truncatedLabel}

) : null} -
- - + +
+ ); } diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 59aa1333896b..adce43dd3344 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -4,6 +4,7 @@ import { type EnvironmentId, type PullRequestAction, type PullRequestMergeMethod, + type PullRequestListEntry, type PullRequestUpdateMethod, type PullRequestRef, type PullRequestState, @@ -447,6 +448,7 @@ export function PullRequestDetailPanel({ environmentId, threadRef = null, reference, + listEntry = null, refreshToken: forcedRefreshToken = 0, onActed, onClose, @@ -463,6 +465,8 @@ export function PullRequestDetailPanel({ */ threadRef?: ScopedThreadRef | null; reference: PullRequestRef; + /** Row fields already loaded by the pull-request list, used while richer detail arrives. */ + listEntry?: PullRequestListEntry | null; /** * Bumped by whatever holds the panel when a reader asks for everything on screen to be read * again. The panel owns its own reads, so the page cannot refresh them for it — it says when, @@ -491,6 +495,12 @@ export function PullRequestDetailPanel({ composerDraftTarget?: ScopedThreadRef | DraftId; }) { const pullRequestKey = `${reference.projectId}:${reference.repository}#${reference.number}`; + const matchingListEntry = + listEntry?.projectId === reference.projectId && + listEntry.repository.toLowerCase() === reference.repository.toLowerCase() && + listEntry.number === reference.number + ? listEntry + : null; const [tab, setTab] = useState("summary"); const [timelineOrder, setTimelineOrder] = useState<"newest" | "oldest">("newest"); const [codeCommitScope, setCodeCommitScope] = useState<{ @@ -1296,10 +1306,10 @@ export function PullRequestDetailPanel({ ).length : 0; - // A reopen already has last time's title, author, and counts. Keep them on screen - // and let the live read replace fields — especially the diff counts — in place. + // The list already has the pull request's identity and summary. Keep them on screen + // and let the richer detail read replace the remaining placeholders in place. if (detailQuery.isPending && !detail) { - return ; + return ; } return ( @@ -1468,41 +1478,6 @@ export function PullRequestDetailPanel({ ) : null} - {workflowApprovalsRequired > 0 && can("approve-workflows") ? ( - - - - - } - /> - - {pendingAction === "approve-workflows" - ? "Approving..." - : "Approve workflows to run"} - - - ) : null} {/* Said where the Merge button is, because it is the answer to why nobody has pressed it: the merge is already asked for, and the host is holding it. */} {autoMergeArmed && primaryAction !== "auto-merge-armed" ? ( @@ -2155,20 +2130,58 @@ export function PullRequestDetailPanel({ ))} {tab === "summary" ? ( - - {checksState !== null ? ( - + + {workflowApprovalsRequired > 0 && can("approve-workflows") ? ( + + + + + } + /> + + {pendingAction === "approve-workflows" + ? "Approving..." + : "Approve workflows to run"} + + ) : ( - + + {checksState !== null ? ( + + ) : ( + + )} + {checksSummary} + )} - {checksSummary} ) : tab === "timeline" ? (
diff --git a/apps/web/src/components/pullRequest/PullRequestGhosts.tsx b/apps/web/src/components/pullRequest/PullRequestGhosts.tsx index 38a3ab70d642..f8c922356548 100644 --- a/apps/web/src/components/pullRequest/PullRequestGhosts.tsx +++ b/apps/web/src/components/pullRequest/PullRequestGhosts.tsx @@ -3,13 +3,23 @@ * and a detail panel opening — use bars in the geometry of the content they stand for, pulsing * on one composited layer. Diff loading uses the shared diff-panel skeleton instead. * - * Deliberately not the app's shimmer skeleton. The sweep is a `transform` animation per bar — - * compositor-safe, but a layer for every bar on screen — and its white highlight over the - * near-white `muted` base all but disappears in light mode. Here one `animate-ghost-pulse` on the - * container is a single opacity animation however many bars sit under it, and the bars take - * their tone from `muted-foreground` at low alpha, which reads on both themes. + * The bars share the app-wide `Skeleton` tone (`muted-foreground` at low alpha, which reads on + * both themes) and the single `animate-skeleton` pulse, applied once on the container so any + * number of bars costs one opacity animation. */ +import type { PullRequestListEntry } from "@t3tools/contracts"; +import { ArrowLeftIcon } from "lucide-react"; + import { cn } from "~/lib/utils"; +import { formatRelativeTimeLabel } from "~/timestampFormat"; + +import { pullRequestLabelColor } from "./pullRequestList.logic"; +import { + PullRequestActorLabel, + PullRequestDiffStat, + pullRequestChecksStatePresentation, + resolvePullRequestState, +} from "./pullRequestPresentation"; function GhostBar({ className }: { className?: string | undefined }) { return
; @@ -32,7 +42,7 @@ export function PullRequestListGhost({
{caption ? (

{caption}

@@ -62,18 +72,46 @@ export function PullRequestListGhost({ * boundaries in the ghost prevents the loaded pull request from replacing one layout with * another a moment later. */ -export function PullRequestDetailGhost() { +export function PullRequestDetailGhost({ seed }: { seed?: PullRequestListEntry | null }) { + const statePresentation = seed + ? resolvePullRequestState({ + state: seed.state, + isDraft: seed.isDraft, + }) + : null; + const checksPresentation = seed?.checksState + ? pullRequestChecksStatePresentation(seed.checksState) + : null; + return (
- - + {seed && statePresentation ? ( + <> + + {seed.repository} + + + #{seed.number} + + + ) : ( + <> + + + + )}
@@ -82,18 +120,58 @@ export function PullRequestDetailGhost() {
- + {seed ? ( +

{seed.title}

+ ) : ( + + )}
- - + {seed ? ( + <> + + + updated {formatRelativeTimeLabel(seed.updatedAt)} + + + ) : ( + <> + + + + )}
- - - + {seed ? ( + + {seed.baseBranch} + + {seed.headBranch} + + ) : ( + <> + + + + + )}
- + {seed ? ( + + ) : ( + + )}
@@ -104,7 +182,19 @@ export function PullRequestDetailGhost() {
- + {checksPresentation ? ( + + + {checksPresentation.label} + + ) : ( + + )}
@@ -127,8 +217,29 @@ export function PullRequestDetailGhost() {
- - + {seed ? ( + seed.labels.slice(0, 3).map((label) => { + const color = pullRequestLabelColor(label.color); + return ( + + + {label.name} + + ); + }) + ) : ( + <> + + + + )}
@@ -160,7 +271,11 @@ export function PullRequestDetailGhost() { /** People-shaped: an avatar and a name, in the reviewer picker's own row height. */ export function PullRequestPeopleGhost({ rows = 4 }: { rows?: number }) { return ( -
+
{Array.from({ length: rows }, (_, index) => (
@@ -174,7 +289,11 @@ export function PullRequestPeopleGhost({ rows = 4 }: { rows?: number }) { /** The timeline's own shape: dots on the rail, a line and a date to each. */ export function PullRequestTimelineGhost({ rows = 6 }: { rows?: number }) { return ( -
+
{Array.from({ length: rows }, (_, index) => (
@@ -194,7 +313,7 @@ export function PullRequestConversationGhost({ rows = 3 }: { rows?: number }) {
{Array.from({ length: rows }, (_, index) => (
diff --git a/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts index 08757b275b7e..b3fc1da8d0a3 100644 --- a/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts @@ -23,6 +23,7 @@ import { rankPullRequestMatches, rankPullRequestsByMergeReadiness, scorePullRequestMatch, + sortPullRequestGroups, retainVisiblePullRequestStatsBatches, withDiffStat, resolveProjectScope, @@ -328,8 +329,8 @@ describe("pull request grouping", () => { VIEWERS, ); expect(groups.map((group) => [group.key, group.entries.length])).toEqual([ - ["reviewRequested", 1], ["authored", 1], + ["reviewRequested", 1], ]); }); @@ -753,6 +754,63 @@ describe("default merge-readiness ranking", () => { rankPullRequestsByMergeReadiness([larger, unknown, smaller]).map((row) => row.number), ).toEqual([2, 1, 3]); }); + + it("keeps authored work first and ranks each group by readiness", () => { + const authoredWaiting = entry({ number: 1, checksState: "pending" }); + const authoredReady = entry({ + number: 2, + checksState: "passing", + reviewDecision: "approved", + }); + const otherReady = entry({ + number: 3, + checksState: "passing", + reviewDecision: "approved", + }); + const sorted = sortPullRequestGroups( + [ + { key: "authored", label: "Authored", entries: [authoredWaiting, authoredReady] }, + { key: "others", label: "Others", entries: [otherReady] }, + ], + "ready", + "", + ); + + expect(sorted.map((group) => group.key)).toEqual(["authored", "others"]); + expect(sorted.flatMap((group) => group.entries).map((row) => row.number)).toEqual([2, 1, 3]); + }); + + it.each([ + ["updated", [1, 2]], + ["newest", [2, 1]], + ["oldest", [1, 2]], + ["largest", [1, 2]], + ["smallest", [2, 1]], + ] as const)("keeps authored first while applying the %s sort inside groups", (sort, order) => { + const olderLarger = entry({ + number: 1, + additions: 20, + createdAt: "2026-07-01T00:00:00Z", + updatedAt: "2026-08-01T00:00:00Z", + }); + const newerSmaller = entry({ + number: 2, + additions: 2, + createdAt: "2026-08-01T00:00:00Z", + updatedAt: "2026-07-01T00:00:00Z", + }); + const sorted = sortPullRequestGroups( + [ + { key: "authored", label: "Authored", entries: [olderLarger, newerSmaller] }, + { key: "others", label: "Others", entries: [entry({ number: 3 })] }, + ], + sort, + "", + ); + + expect(sorted.map((group) => group.key)).toEqual(["authored", "others"]); + expect(sorted[0]!.entries.map((row) => row.number)).toEqual(order); + }); }); describe("line counts that arrive after the rows", () => { @@ -843,9 +901,9 @@ describe("partitioning with the hosts' own priority reads", () => { updatedAt: "2026-06-02T00:00:00Z", }); const groups = partitionPullRequestsWithPriority([], [both], [both, requestedOlder, requested]); - expect(groups.map((group) => group.key)).toEqual(["reviewRequested", "authored"]); - expect(groups[0]!.entries.map((item) => item.number)).toEqual([2, 3]); - expect(groups[1]!.entries.map((item) => item.number)).toEqual([1]); + expect(groups.map((group) => group.key)).toEqual(["authored", "reviewRequested"]); + expect(groups[0]!.entries.map((item) => item.number)).toEqual([1]); + expect(groups[1]!.entries.map((item) => item.number)).toEqual([2, 3]); }); it("lets the feed's copy of a partitioned row replace the partition's", () => { diff --git a/apps/web/src/components/pullRequest/pullRequestList.logic.ts b/apps/web/src/components/pullRequest/pullRequestList.logic.ts index 576f771e7e7e..af1bd6ab4fbe 100644 --- a/apps/web/src/components/pullRequest/pullRequestList.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestList.logic.ts @@ -18,6 +18,9 @@ import type { PullRequestListState, } from "@t3tools/contracts"; +import { toSortableTimestamp } from "../../lib/threadSort"; +import type { PullRequestListSort } from "./pullRequestListPreferences"; + /** * A listed change request with the environment that read it. Nothing on a row says which machine * it came from, and the page unions every connected one — so acting on a row, refreshing it, or @@ -415,7 +418,7 @@ export function groupPullRequestsByInvolvement( buckets.others.push(entry); } } - return (["reviewRequested", "authored", "others"] as const) + return (["authored", "reviewRequested", "others"] as const) .filter((key) => buckets[key].length > 0) .map((key) => ({ key, label: GROUP_LABELS[key], entries: buckets[key] })); } @@ -615,8 +618,8 @@ export function partitionPullRequestsWithPriority right.updatedAt.localeCompare(left.updatedAt); return ( [ - { key: "reviewRequested", entries: [...reviewByKey.values()].toSorted(byRecency) }, { key: "authored", entries: [...authoredByKey.values()].toSorted(byRecency) }, + { key: "reviewRequested", entries: [...reviewByKey.values()].toSorted(byRecency) }, { key: "others", entries: others }, ] as const ) @@ -1027,6 +1030,45 @@ export function rankPullRequestsByMergeReadiness( + groups: ReadonlyArray>, + sort: PullRequestListSort, + searchText: string, + hasMeasuredSize: (entry: Entry) => boolean = (entry) => entry.additions + entry.deletions > 0, +): ReadonlyArray> { + const sortWithinGroups = (rank: (entries: ReadonlyArray) => ReadonlyArray) => + groups.map((group) => ({ ...group, entries: rank(group.entries) })); + + if (sort === "ready") { + return searchText.trim().length === 0 + ? sortWithinGroups((entries) => rankPullRequestsByMergeReadiness(entries, hasMeasuredSize)) + : groups; + } + if (sort === "updated") return groups; + + const timestamp = (entry: Entry) => + toSortableTimestamp(entry.updatedAt) ?? toSortableTimestamp(entry.createdAt) ?? 0; + return sortWithinGroups((entries) => + entries.toSorted((left, right) => { + if (sort === "newest" || sort === "oldest") { + const leftCreated = toSortableTimestamp(left.createdAt); + const rightCreated = toSortableTimestamp(right.createdAt); + const measured = Number(rightCreated !== null) - Number(leftCreated !== null); + const dated = (leftCreated ?? 0) - (rightCreated ?? 0); + return ( + measured || (sort === "newest" ? -dated : dated) || timestamp(right) - timestamp(left) + ); + } + const measured = Number(hasMeasuredSize(right)) - Number(hasMeasuredSize(left)); + const sized = left.additions + left.deletions - (right.additions + right.deletions); + return ( + measured || (sort === "largest" ? -sized : sized) || timestamp(right) - timestamp(left) + ); + }), + ); +} + /** * A row with the line counts that arrived after it did. Only where the host left them out — a * listing that carried them is not second-guessed — and only where they have arrived, since a row diff --git a/apps/web/src/components/settings/BrowserImportWizard.tsx b/apps/web/src/components/settings/BrowserImportWizard.tsx new file mode 100644 index 000000000000..a18cb9173ff6 --- /dev/null +++ b/apps/web/src/components/settings/BrowserImportWizard.tsx @@ -0,0 +1,475 @@ +import type { BrowserImportSource } from "@t3tools/contracts"; +import { BROWSER_IMPORT_FAILURE_COPY } from "@t3tools/contracts"; +import { ArrowDownIcon, ArrowRightIcon, CheckIcon } from "lucide-react"; +import { useRef, useState } from "react"; + +import { cn, randomUUID } from "~/lib/utils"; + +import { Button } from "../ui/button"; +import { + Dialog, + DialogClose, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "../ui/dialog"; +import { Spinner } from "../ui/spinner"; +import { + initialWizardStep, + initialTargetSelection, + canCloseWizard, + isRetryableReason, + formatSkippedDomains, + outcomeToStep, + refreshedSourceProfileDirectory, + refreshedSourceStep, + resolveWizardTarget, + type ImportOutcome, + type WizardTarget, + type WizardTargetProfile, + type WizardTargetSelection, + type WizardStep, +} from "./browserImportWizard.logic"; + +export type { WizardTarget } from "./browserImportWizard.logic"; + +interface BrowserImportWizardProps { + readonly source: BrowserImportSource; + /** Captured when the wizard opens so destination copy and writes stay stable. */ + readonly destinationEnvironmentName: string; + /** Existing profiles the import can go into. Incognito is excluded upstream. */ + readonly targetProfiles: ReadonlyArray; + /** Whether a new profile can still be created (profile cap). */ + readonly canCreateProfile: boolean; + /** + * Runs the import and returns how it went. For a new target the caller only + * registers the profile once the import succeeds, so a blocked attempt never + * leaves an empty profile behind. + */ + readonly onImport: (input: { + readonly sourceProfileDirectory: string; + readonly target: WizardTarget; + }) => Promise; + /** Re-checks the source's availability after the user quits the browser. */ + readonly onRefreshSource: () => Promise; + readonly onClose: () => void; +} + +/** + * Guides one browser's cookies into a profile. + * + * Every state the import can be in — the browser is open, a profile has to be + * chosen, the read failed — is a screen the user can move forward from, rather + * than a disabled row that only says no. + */ +export function BrowserImportWizard({ + source: initialSource, + destinationEnvironmentName, + targetProfiles, + canCreateProfile, + onImport, + onRefreshSource, + onClose, +}: BrowserImportWizardProps) { + const [source, setSource] = useState(initialSource); + const [step, setStep] = useState(() => initialWizardStep(initialSource)); + const [sourceProfileDirectory, setSourceProfileDirectory] = useState( + () => initialSource.profiles[0]?.directory ?? "", + ); + const [target, setTarget] = useState(() => + initialTargetSelection(canCreateProfile, targetProfiles), + ); + const [targetError, setTargetError] = useState(); + // Stable across retries so a keychain re-approval lands in one profile, not + // a new one each time. + const newProfileId = useRef(`profile-${randomUUID()}`); + // A second Import click before React has left the configure screen would + // start a second run; the parent refuses it, and applying that refusal here + // would drop the wizard out of the importing step while the first write is + // still going. The ref settles synchronously where state does not. + const importInFlight = useRef(false); + + const runImport = () => { + if (importInFlight.current) return; + const chosen = resolveWizardTarget(target, newProfileId.current, targetProfiles); + if (chosen === undefined) { + setTargetError("That profile is no longer available. Choose where to import these cookies."); + setStep({ step: "configure" }); + return; + } + setTargetError(undefined); + importInFlight.current = true; + setStep({ step: "importing" }); + void onImport({ sourceProfileDirectory, target: chosen }) + .then((outcome) => setStep(outcomeToStep(outcome))) + .catch(() => setStep({ step: "blocked", reason: "readFailed" })) + .finally(() => { + importInFlight.current = false; + }); + }; + + const recheckAfterQuit = () => { + setStep({ step: "checking" }); + void onRefreshSource() + .then((refreshed) => { + if (refreshed) { + setSource(refreshed); + setSourceProfileDirectory((current) => + refreshedSourceProfileDirectory(current, refreshed), + ); + } + setStep(refreshedSourceStep(refreshed)); + }) + .catch(() => setStep({ step: "blocked", reason: "readFailed" })); + }; + + return ( + (open || !canCloseWizard(step) ? undefined : onClose())}> + + {step.step === "quit" ? ( + + ) : step.step === "importing" ? ( + + ) : step.step === "checking" ? ( + + ) : step.step === "done" ? ( + + ) : step.step === "blocked" ? ( + + ) : ( + { + setTarget(selection); + setTargetError(undefined); + }} + targetError={targetError} + onCancel={onClose} + onImport={runImport} + /> + )} + + + ); +} + +function QuitStep({ + source, + onCancel, + onRechecked, +}: { + readonly source: BrowserImportSource; + readonly onCancel: () => void; + readonly onRechecked: () => void; +}) { + return ( + <> + + Quit {source.name} to import + + {source.name} is open, so its cookies can’t be read yet. Quit it, then continue. + + + + + + + + ); +} + +/** "5,065 cookies", or "no cookies", or nothing when the store is unreadable. */ +function cookieCountLabel(count: number | undefined): string | undefined { + if (count === undefined) return undefined; + if (count === 0) return "no cookies"; + return `${count.toLocaleString()} ${count === 1 ? "cookie" : "cookies"}`; +} + +function cookieResultCount(count: number): string { + return `${count.toLocaleString()} ${count === 1 ? "cookie" : "cookies"}`; +} + +type ConfigureStepProps = { + readonly source: BrowserImportSource; + readonly destinationEnvironmentName: string; + readonly targetProfiles: ReadonlyArray; + readonly canCreateProfile: boolean; + readonly sourceProfileDirectory: string; + readonly onSourceProfileChange: (directory: string) => void; + readonly target: WizardTargetSelection; + readonly targetError: string | undefined; + readonly onTargetChange: (target: WizardTargetSelection) => void; + readonly onCancel: () => void; + readonly onImport: () => void; +}; + +function ConfigureStep({ + source, + destinationEnvironmentName, + targetProfiles, + canCreateProfile, + sourceProfileDirectory, + onSourceProfileChange, + target, + targetError, + onTargetChange, + onCancel, + onImport, +}: ConfigureStepProps) { + const targetMissing = + target.kind === "existing" && + !targetProfiles.some((profile) => profile.id === target.profileId); + // The "New profile" tile is unrendered once the cap is reached, so a target + // chosen before that leaves nothing selected in "Into" — say so, the same + // way a vanished existing target is explained. + const targetUncreatable = target.kind === "new" && !canCreateProfile; + const targetFeedback = + targetError ?? + (targetMissing + ? "That profile is no longer available. Choose where to import these cookies." + : targetUncreatable + ? "You've reached the profile limit. Choose an existing profile to import into." + : undefined); + return ( + <> + + Import from {source.name} + + Choose which cookies to import for {destinationEnvironmentName}. + + + + {/* Side by side when the dialog has room, stacked when it doesn't. */} +
+
+

+ From +

+ {source.profiles.map((profile) => ( + onSourceProfileChange(profile.directory)} + /> + ))} +
+
+ + +
+
+

+ Into +

+ {canCreateProfile ? ( + onTargetChange({ kind: "new" })} + /> + ) : null} + {targetProfiles.map((profile) => ( + onTargetChange({ kind: "existing", profileId: profile.id })} + /> + ))} +
+
+ {targetFeedback ? ( +

+ {targetFeedback} +

+ ) : null} +
+ + + + + + ); +} + +/** One selectable option: a name, an optional detail line, and a check. */ +function SelectableTile({ + selected, + title, + subtitle, + onSelect, +}: { + readonly selected: boolean; + readonly title: string; + readonly subtitle?: string | undefined; + readonly onSelect: () => void; +}) { + return ( + + ); +} + +function ImportingStep() { + return ( + <> + + Importing cookies + This may take a moment. + + + + Importing… + + + ); +} + +function CheckingStep({ sourceName }: { readonly sourceName: string }) { + return ( + <> + + Checking {sourceName} + Checking whether the browser has closed. + + + + Checking… + + + ); +} + +function DoneStep({ + imported, + skipped, + skippedDomains, + targetName, + destinationEnvironmentName, + onClose, +}: { + readonly imported: number; + readonly skipped: number; + readonly skippedDomains: ReadonlyArray; + readonly targetName: string; + readonly destinationEnvironmentName: string; + readonly onClose: () => void; +}) { + return ( + <> + + + {imported > 0 + ? `Imported ${cookieResultCount(imported)}` + : skipped > 0 + ? `Skipped ${cookieResultCount(skipped)}` + : "No cookies found"} + + + {imported > 0 + ? `Added to ${targetName} for ${destinationEnvironmentName}.${skipped > 0 ? ` ${cookieResultCount(skipped)} skipped.` : ""}` + : skipped > 0 + ? `No cookies were imported for ${destinationEnvironmentName}.` + : `There were no cookies to import for ${destinationEnvironmentName}.`} + + + {skippedDomains.length > 0 ? ( + +

+ Skipped +

+

{formatSkippedDomains(skippedDomains)}

+
+ ) : null} + + } onClick={onClose}> + Done + + + + ); +} + +function BlockedStep({ + source, + reason, + onClose, + onRetry, +}: { + readonly source: BrowserImportSource; + readonly reason: keyof typeof BROWSER_IMPORT_FAILURE_COPY; + readonly onClose: () => void; + readonly onRetry: (() => void) | undefined; +}) { + return ( + <> + + Couldn’t import from {source.name} + {BROWSER_IMPORT_FAILURE_COPY[reason]} + + + + {onRetry ? : null} + + + ); +} diff --git a/apps/web/src/components/settings/IntegrationsSettings.logic.test.ts b/apps/web/src/components/settings/IntegrationsSettings.logic.test.ts index 26eef9536b6f..1c839f0cfdf8 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.logic.test.ts +++ b/apps/web/src/components/settings/IntegrationsSettings.logic.test.ts @@ -1,7 +1,11 @@ import { describe, expect, it, vi } from "vite-plus/test"; import type { EnvironmentId } from "@t3tools/contracts"; -import { browserProfileRemovalAvailable, clearBrowserProfileData } from "./IntegrationsSettings"; +import { + browserProfileRemovalAvailable, + clearBrowserProfileData, + importFailureReason, +} from "./IntegrationsSettings"; const environmentId = "environment-a" as EnvironmentId; const secondEnvironmentId = "environment-b" as EnvironmentId; @@ -72,3 +76,26 @@ describe("browserProfileRemovalAvailable", () => { expect(browserProfileRemovalAvailable(false, true, 1)).toBe(false); }); }); + +// Mirrors `BrowserImportFailedError.message`, which IPC flattens to a string +// before the renderer sees it. +const failure = (reason: string) => ({ + message: `Importing cookies from safari failed: ${reason}.`, +}); + +describe("importFailureReason", () => { + it("recovers the reason token from the flattened message", () => { + expect(importFailureReason(failure("browserRunning"))).toBe("browserRunning"); + expect(importFailureReason(failure("readFailed"))).toBe("readFailed"); + expect(importFailureReason(failure("keychainUnavailable"))).toBe("keychainUnavailable"); + // A settings write that fails after the cookies landed is its own case, + // not a read failure over a database that was in fact read. + expect(importFailureReason(failure("profileNotSaved"))).toBe("profileNotSaved"); + expect(importFailureReason(failure("profileLimitReached"))).toBe("profileLimitReached"); + }); + + it("falls back to readFailed for anything it cannot classify", () => { + expect(importFailureReason(new Error("something else entirely"))).toBe("readFailed"); + expect(importFailureReason(undefined)).toBe("readFailed"); + }); +}); diff --git a/apps/web/src/components/settings/IntegrationsSettings.tsx b/apps/web/src/components/settings/IntegrationsSettings.tsx index 803e694c061d..b2b51f5bf1a2 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.tsx +++ b/apps/web/src/components/settings/IntegrationsSettings.tsx @@ -7,6 +7,7 @@ * @module IntegrationsSettings */ import { + BrowserImportFailureReason, BROWSER_PROFILE_MAX_COUNT, type BrowserLinkTarget, type BrowserProfile, @@ -29,21 +30,32 @@ import { findBrowserProfile, isBuiltInBrowserProfileId, resolveBrowserProfiles, + type BrowserImportSource, type PreviewAppearancePreference, type PreviewViewportSetting, } from "@t3tools/contracts"; import { PREVIEW_VIEWPORT_PRESETS } from "@t3tools/shared/previewViewport"; -import { InfoIcon, Plus as PlusIcon, Trash2 as Trash2Icon } from "lucide-react"; -import { useState } from "react"; -import type { ReactNode } from "react"; +import { InfoIcon, MoreVertical, Plus as PlusIcon } from "lucide-react"; +import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; import { ScreenRotationIcon } from "~/browser/ScreenRotationIcon"; +import { resolveEnvironmentOptionLabel } from "~/components/BranchToolbar.logic"; import { previewBridge } from "~/components/preview/previewBridge"; import { cn, randomUUID } from "~/lib/utils"; -import { useEnvironments } from "~/state/environments"; +import { useEnvironments, usePrimaryEnvironment } from "~/state/environments"; import { isElectron } from "../../env"; import { Badge } from "../ui/badge"; +import { + Menu, + MenuGroup, + MenuGroupLabel, + MenuItem, + MenuPopup, + MenuSeparator, + MenuTrigger, +} from "../ui/menu"; +import { toastManager } from "../ui/toast"; import { AlertDialog, AlertDialogClose, @@ -69,6 +81,7 @@ import { Switch } from "../ui/switch"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { getClientSettings, + persistClientSettingsUpdate, useClientSettings, useClientSettingsHydrated, usePrimarySettings, @@ -81,8 +94,9 @@ import { SettingsRow, SettingsSection, } from "./settingsLayout"; -import { ITEM_ROW_INNER_CLASSNAME } from "./itemRows"; import { searchableSetting } from "./settingsSearch"; +import { BrowserImportWizard, type WizardTarget } from "./BrowserImportWizard"; +import type { ImportOutcome } from "./browserImportWizard.logic"; const FILL_VALUE = "fill"; const RESPONSIVE_VALUE = "responsive"; @@ -133,6 +147,27 @@ const APPEARANCE_LABELS: Readonly> = const zoomLabel = (zoomFactor: number) => `${Math.round(zoomFactor * 100)}%`; +/** + * IPC flattens the failure to its message, so the reason token travels inside + * it. Anything unrecognised reads as a plain read failure rather than leaking + * the raw message into a toast. + */ +/** Thrown from the post-import settings updater when the cap was hit meanwhile. */ +class ProfileLimitReachedError extends Error { + constructor() { + super("Browser profile limit reached."); + this.name = "ProfileLimitReachedError"; + } +} + +export const importFailureReason = (cause: unknown): BrowserImportFailureReason => { + const message = String((cause as { message?: unknown } | undefined)?.message ?? ""); + return ( + BrowserImportFailureReason.literals.find((reason) => message.includes(`failed: ${reason}.`)) ?? + "readFailed" + ); +}; + const viewportSelectValue = (viewport: PreviewViewportSetting): string => { if (viewport._tag === "fill") return FILL_VALUE; if ( @@ -560,7 +595,7 @@ function BrowserAutoShowFloatingPreviewSetting({ disabled }: { readonly disabled return ( settings.browserProfiles); + const defaultProfileId = useClientSettings((settings) => settings.browserDefaultProfileId); const settingsHydrated = useClientSettingsHydrated(); const updateSettings = useUpdatePrimarySettings(); const { environments, isReady: environmentsReady } = useEnvironments(); + const primaryEnvironment = usePrimaryEnvironment(); + const [sources, setSources] = useState | null>(null); + const [importSession, setImportSession] = useState<{ + readonly source: BrowserImportSource; + readonly environmentId: EnvironmentId; + readonly environmentName: string; + } | null>(null); const [profilePendingRemoval, setProfilePendingRemoval] = useState(null); const [profileRemovalError, setProfileRemovalError] = useState(null); const [profileRemovalInFlight, setProfileRemovalInFlight] = useState(false); @@ -633,25 +685,36 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { environmentsReady, environments.length, ); + const importInFlightRef = useRef(false); + const [importInFlight, setImportInFlight] = useState(false); const profileWritesDisabled = disabled || !settingsHydrated; - const addProfile = () => { - if (!settingsHydrated) return; + const profiles = resolveBrowserProfiles(userProfiles); + // Incognito is deliberately not a row — it holds nothing to manage — so the + // default has to resolve against the list that renders. A stored + // `browserDefaultProfileId` of "incognito" would otherwise leave the section + // with no Default badge at all. + const listedProfiles = profiles.filter((profile) => profile.kind !== "incognito"); + const resolvedDefaultId = + findBrowserProfile(listedProfiles, defaultProfileId)?.id ?? DEFAULT_BROWSER_PROFILE_ID; + + const createProfile = (baseName: string) => { + if (!settingsHydrated || importInFlightRef.current) return undefined; const currentProfiles = getClientSettings().browserProfiles; - if (currentProfiles.length >= BROWSER_PROFILE_MAX_COUNT) return; - const taken = new Set(resolveBrowserProfiles(currentProfiles).map((profile) => profile.name)); - let name = "New profile"; - for (let index = 2; taken.has(name); index += 1) name = `New profile ${index}`; - updateSettings({ - browserProfiles: [ - ...currentProfiles, - { id: `profile-${randomUUID()}`, name, kind: "persistent" as const }, - ], - }); + // Checked against the live settings, not the rendered list: two clicks + // before a re-render would otherwise both pass the disabled control. + if (currentProfiles.length >= BROWSER_PROFILE_MAX_COUNT) return undefined; + const resolvedProfiles = resolveBrowserProfiles(currentProfiles); + const taken = new Set(resolvedProfiles.map((profile) => profile.name)); + let name = baseName; + for (let index = 2; taken.has(name); index += 1) name = `${baseName} ${index}`; + const profile = { id: `profile-${randomUUID()}`, name, kind: "persistent" as const }; + updateSettings({ browserProfiles: [...currentProfiles, profile] }); + return profile; }; const renameProfile = (id: string, next: string) => { - if (!settingsHydrated) return; + if (!settingsHydrated || importInFlightRef.current) return; const name = next.trim().slice(0, BROWSER_PROFILE_NAME_MAX_LENGTH); if (name === "") return; const currentProfiles = getClientSettings().browserProfiles; @@ -662,8 +725,31 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { }); }; + const clearProfileData = (id: string, name: string) => { + if (!settingsHydrated || importInFlightRef.current) return; + if (!previewBridge || !environmentsReady || environments.length === 0) { + toastManager.add({ + type: "error", + title: `Could not clear ${name}'s data`, + description: "You're not connected to a server yet.", + }); + return; + } + void clearBrowserProfileData( + previewBridge, + environments.map((environment) => environment.environmentId), + id, + ) + .then(() => { + toastManager.add({ type: "success", title: `Cleared ${name}'s cookies and cache` }); + }) + .catch(() => { + toastManager.add({ type: "error", title: `Could not clear ${name}'s data` }); + }); + }; + const removeProfile = async (id: string) => { - if (!settingsHydrated) return; + if (!settingsHydrated || importInFlightRef.current) return; if (!removalAvailable) { setProfileRemovalError("Connect to an environment before removing this profile."); return; @@ -695,92 +781,331 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { setProfilePendingRemoval(null); }; + // A browser that is not on this machine is left out rather than listed as a + // dead row: there is nothing to act on, and the menu is a list of things you + // can import from. An unsupported one is left out for the same reason — the + // blocked wizard step can't be fixed from here. Every other unavailable + // reason stays, since each names a step the user can take. + const importableSources = (sources ?? []).filter( + (source) => + source.unavailable !== "notInstalled" && source.unavailable !== "unsupportedPlatform", + ); + + // Refreshed without blanking the last result: the menu shows the cached list + // straight away so it doesn't reflow on open, and the source list is stable + // (names only) since choosing what to import happens in the wizard, not here. + const loadSources = useCallback(() => { + if (!previewBridge) return; + void previewBridge + .listBrowserImportSources() + .then(setSources) + .catch(() => setSources((previous) => previous ?? [])); + }, []); + + // Loaded once so the first open is instant instead of flashing a spinner. + useEffect(() => { + loadSources(); + }, [loadSources]); + + // Runs one import for the wizard. A new profile is registered only once the + // import succeeds — the cookies land in its partition first — so a blocked + // attempt never leaves an empty profile behind. + const runWizardImport = async ( + source: BrowserImportSource, + environmentId: EnvironmentId, + input: { readonly sourceProfileDirectory: string; readonly target: WizardTarget }, + ): Promise => { + if (!previewBridge) return { kind: "blocked", reason: "sessionUnavailable" }; + if (!settingsHydrated) return { kind: "blocked", reason: "sessionUnavailable" }; + if ( + input.target.kind === "existing" && + !resolveBrowserProfiles(getClientSettings().browserProfiles).some( + (profile) => profile.id === input.target.profileId, + ) + ) { + return { kind: "blocked", reason: "readFailed" }; + } + if (importInFlightRef.current) return { kind: "blocked", reason: "readFailed" }; + importInFlightRef.current = true; + setImportInFlight(true); + try { + const result = await previewBridge.importBrowserCookies({ + environmentId, + sourceId: source.id, + sourceProfileDirectory: input.sourceProfileDirectory, + targetProfileId: input.target.profileId, + }); + if ( + input.target.kind === "existing" && + !resolveBrowserProfiles(getClientSettings().browserProfiles).some( + (profile) => profile.id === input.target.profileId, + ) + ) { + return { kind: "blocked", reason: "readFailed" }; + } + let targetName: string; + if (input.target.kind === "new") { + // Registered only when something actually came over: an import that + // found no cookies should not leave a new, empty profile behind. + if (result.imported > 0) { + try { + const persisted = await persistClientSettingsUpdate((current) => { + const existing = current.browserProfiles.find( + (profile) => profile.id === input.target.profileId, + ); + if (existing) return current; + // The wizard refuses a new target at the cap, but the cap can be + // reached while the import runs; the updater sees the newest + // settings, so this is the check that holds. + if (current.browserProfiles.length >= BROWSER_PROFILE_MAX_COUNT) { + throw new ProfileLimitReachedError(); + } + const taken = new Set( + resolveBrowserProfiles(current.browserProfiles).map((profile) => profile.name), + ); + let name = source.name; + for (let index = 2; taken.has(name); index += 1) name = `${source.name} ${index}`; + return { + ...current, + browserProfiles: [ + ...current.browserProfiles, + { id: input.target.profileId, name, kind: "persistent" as const }, + ], + }; + }); + targetName = + persisted.browserProfiles.find((profile) => profile.id === input.target.profileId) + ?.name ?? source.name; + } catch (cause) { + // This target id belongs only to the attempted new profile. Clear + // its partition so a failed registration cannot strand imported + // cookies behind a profile that disappears on restart. + await clearBrowserProfileData( + previewBridge, + [environmentId], + input.target.profileId, + ).catch(() => undefined); + // Not a read failure: the cookies came over and were cleared again + // because the profile could not be kept. Name that, in the same + // token form `importFailureReason` recovers from a bridge error. + const reason = + cause instanceof ProfileLimitReachedError ? "profileLimitReached" : "profileNotSaved"; + throw new Error(`Importing cookies from ${source.id} failed: ${reason}.`, { cause }); + } + } else { + targetName = source.name; + } + } else { + targetName = input.target.name; + } + return { + kind: "imported", + imported: result.imported, + skipped: result.skipped, + skippedDomains: result.skippedDomains, + targetName, + }; + } catch (cause) { + return { kind: "blocked", reason: importFailureReason(cause) }; + } finally { + importInFlightRef.current = false; + setImportInFlight(false); + } + }; + + // Re-checks a source's availability after the user quits the browser, and + // keeps the cached list in step so the menu reflects it too. + const refreshImportSource = async ( + sourceId: BrowserImportSource["id"], + ): Promise => { + if (!previewBridge) return undefined; + try { + const latest = await previewBridge.listBrowserImportSources(); + setSources(latest); + return latest.find((source) => source.id === sourceId); + } catch { + return undefined; + } + }; + + const atProfileLimit = userProfiles.length >= BROWSER_PROFILE_MAX_COUNT; + return ( = BROWSER_PROFILE_MAX_COUNT} - onClick={addProfile} - > - - Add profile - + open && loadSources()}> + + } + > + + Add profile + + + createProfile("New profile")} + > + Blank profile + + {atProfileLimit ? ( + You’ve reached the profile limit + ) : null} + + + Import from + {sources === null ? ( + Looking for browsers… + ) : importableSources.length === 0 ? ( + No supported browsers found + ) : ( + // Every source is a plain row — running, needs-permission and + // ready all look the same here. The wizard picks up whatever + // state the source is in and walks the user forward from there. + <> + {importableSources.map((source) => ( + { + if (!settingsHydrated || primaryEnvironment == null) return; + setImportSession({ + source, + environmentId: primaryEnvironment.environmentId, + environmentName: resolveEnvironmentOptionLabel({ + isPrimary: true, + environmentId: primaryEnvironment.environmentId, + runtimeLabel: primaryEnvironment.label, + }), + }); + }} + > + {source.name} + + ))} + {primaryEnvironment == null ? ( + Connect to an environment to import cookies + ) : null} + + )} + + + } > {/* - Each profile is its own bounded row, and the list carries the bottom - spacing `SettingsRow` leaves to its children (`pt-3 pb-1`). Bare rows - stack on narrow viewports with a larger gap inside a row than between - rows, which reads as the remove button belonging to the profile below. + The bordered container groups rows unambiguously at any width, and + carries the bottom spacing `SettingsRow` leaves to its children + (`pt-3 pb-1`). */} -
- {resolveBrowserProfiles(userProfiles).map((profile) => { +
+ {listedProfiles.map((profile, index) => { const builtIn = isBuiltInBrowserProfileId(profile.id); + const isDefault = profile.id === resolvedDefaultId; return (
0 && "border-t border-border/60", )} > - {builtIn ? ( - // Dimmed here rather than on the list, which is the only - // content in the row without a disabled treatment of its own: - // a wrapper-level dim would stack with the rename field's and - // the remove button's, landing them near 0.41 while every - // other disabled control in the block sits at 0.64. - - {profile.name} - - {profile.kind === "incognito" ? "Ephemeral" : "Built-in"} - - - ) : ( - renameProfile(profile.id, next)} - /> - )} - {builtIn ? null : ( - - - - - } + + {builtIn ? ( + // Dimmed here rather than on the table: a wrapper-level dim + // stacks with the rename field's and the row menu button's + // own, landing them near 0.41 while every other disabled + // control in the block sits at 0.64. + + {profile.name} + + ) : ( + renameProfile(profile.id, next)} /> - - {removalAvailable - ? "Remove profile and its data" - : "Connect to an environment to remove this profile"} - - - )} + )} + {/* + Dimmed with the rest of the row: a `Badge` has no disabled + treatment of its own, so a solid `bg-primary` pill would + otherwise sit at full strength beside a name, rename field + and menu button that are all at 0.64. + */} + {isDefault ? ( + Default + ) : null} + + + + } + > + + + + { + if (settingsHydrated) { + updateSettings({ browserDefaultProfileId: profile.id }); + } + }} + > + Set as default + + clearProfileData(profile.id, profile.name)} + > + Clear cookies and cache + + {builtIn ? null : ( + { + if (settingsHydrated) setProfilePendingRemoval(profile); + }} + > + Remove profile and data + + )} + {!removalAvailable ? ( + <> + + + {environmentsReady + ? "Connect to an environment to clear profile data" + : "Checking environments…"} + + + ) : null} + +
); })} @@ -798,8 +1123,8 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { Remove “{profilePendingRemoval?.name}”? - Its cookies, logins, and cache are deleted with it. Tabs already open in this profile - stay open until you close them. + Its cookies and logins are deleted. Tabs already open in this profile stay open until + you close them. {profileRemovalError ? (

@@ -833,86 +1158,29 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { + {importSession ? ( + ({ id: profile.id, name: profile.name }))} + canCreateProfile={settingsHydrated && !atProfileLimit} + onImport={(input) => + runWizardImport(importSession.source, importSession.environmentId, input) + } + onRefreshSource={() => refreshImportSource(importSession.source.id)} + onClose={() => setImportSession(null)} + /> + ) : null} ); } -function BrowserDefaultProfileSetting({ disabled }: { readonly disabled: boolean }) { - const userProfiles = useClientSettings((settings) => settings.browserProfiles); - const defaultProfileId = useClientSettings((settings) => settings.browserDefaultProfileId); - const settingsHydrated = useClientSettingsHydrated(); - const updateSettings = useUpdatePrimarySettings(); - const profileWritesDisabled = disabled || !settingsHydrated; - // Incognito is deliberately absent: as a default it would open every tab - // into storage that is discarded on close. - const profiles = resolveBrowserProfiles(userProfiles).filter( - (profile) => profile.kind !== "incognito", - ); - const selected = findBrowserProfile(profiles, defaultProfileId) ?? profiles[0]; - - return ( - { - if (settingsHydrated) { - updateSettings({ browserDefaultProfileId: DEFAULT_BROWSER_PROFILE_ID }); - } - }} - /> - ) : null - } - control={ - - } - /> - ); -} - export function IntegrationsSettingsPanel() { // Client-local preview defaults are editable only where the preview exists. const previewDefaultsDisabled = !isElectron; const previewDefaults = ( <> - diff --git a/apps/web/src/components/settings/ProjectIconPickerDialog.test.tsx b/apps/web/src/components/settings/ProjectIconPickerDialog.test.tsx index 9098b359d1ea..2280395ecf40 100644 --- a/apps/web/src/components/settings/ProjectIconPickerDialog.test.tsx +++ b/apps/web/src/components/settings/ProjectIconPickerDialog.test.tsx @@ -39,13 +39,13 @@ vi.mock("../ui/toggle-group", () => ({ import { ProjectIconPickerDialog } from "./ProjectIconPickerDialog"; describe("ProjectIconPickerDialog", () => { - it("shows emoji first and selects it for an automatic project", () => { + it("shows icons first and selects them for an automatic project", () => { const markup = renderToStaticMarkup( {}} onSelect={() => {}} />, ); - expect(markup).toContain('data-current="emoji"'); - expect(markup.indexOf(">Emoji<")).toBeLessThan(markup.indexOf(">Icons<")); - expect(markup).toContain("Or paste any emoji"); + expect(markup).toContain('data-current="lucide"'); + expect(markup.indexOf(">Icons<")).toBeLessThan(markup.indexOf(">Emoji<")); + expect(markup).toContain('aria-label="Icon color"'); }); }); diff --git a/apps/web/src/components/settings/ProjectIconPickerDialog.tsx b/apps/web/src/components/settings/ProjectIconPickerDialog.tsx index 4ecdb0f653c5..7fce7a4fbb5b 100644 --- a/apps/web/src/components/settings/ProjectIconPickerDialog.tsx +++ b/apps/web/src/components/settings/ProjectIconPickerDialog.tsx @@ -45,7 +45,7 @@ export function ProjectIconPickerDialog({ readonly onSelect: (icon: ProjectIconOverride) => void; }) { const [mode, setMode] = useState<"lucide" | "emoji">( - current?.kind === "lucide" ? "lucide" : "emoji", + current?.kind === "emoji" ? "emoji" : "lucide", ); const [iconName, setIconName] = useState( current?.kind === "lucide" ? (current.name as IconName) : DEFAULT_ICON, @@ -60,7 +60,7 @@ export function ProjectIconPickerDialog({ useEffect(() => { if (open && !previousOpenRef.current) { - setMode(current?.kind === "lucide" ? "lucide" : "emoji"); + setMode(current?.kind === "emoji" ? "emoji" : "lucide"); setIconName(current?.kind === "lucide" ? (current.name as IconName) : DEFAULT_ICON); setColor(current?.kind === "lucide" ? current.color : DEFAULT_COLOR); setEmoji(current?.kind === "emoji" ? current.emoji : "💻"); @@ -84,7 +84,7 @@ export function ProjectIconPickerDialog({ Choose project icon - Pick an emoji, or choose any Lucide icon and color. + Pick any Lucide icon and color, or use an emoji. - Emoji Icons + Emoji {mode === "lucide" ? ( diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index 891e219eb598..de65eccc6264 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -907,6 +907,12 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { modelOptionsByInstance={modelOptionsByInstance} triggerVariant="outline" triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME} + onOpenProviderSetup={(instanceId) => { + void navigate({ + to: "/settings/providers", + search: { environmentId: representative.environmentId, instanceId }, + }); + }} onInstanceModelChange={(instanceId, model) => { setDefaultModel(createModelSelection(instanceId, model)); }} diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index 48a8a4dbfc56..256db96843b3 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -373,6 +373,7 @@ interface ProviderInstanceCardProps { * omit it. */ readonly headerAction?: ReactNode | undefined; + readonly setup?: ReactNode; readonly hiddenModels: ReadonlyArray; readonly favoriteModels: ReadonlyArray; readonly modelOrder: ReadonlyArray; @@ -415,6 +416,7 @@ export function ProviderInstanceCard({ onUpdate, onDelete, headerAction, + setup, hiddenModels, favoriteModels, modelOrder, @@ -479,7 +481,8 @@ export function ProviderInstanceCard({ : null; const visibleTab = driverOption === undefined ? "configuration" : activeTab; - const customModels = readConfigStringArray(instance.config, "customModels"); + const customModels = + instance.driver === "antigravity" ? [] : readConfigStringArray(instance.config, "customModels"); // Server-returned models may lag behind settings writes. Treat probe // models as the source for built-ins only; custom rows come directly // from the current instance config so add/remove reflects immediately. @@ -848,6 +851,7 @@ export function ProviderInstanceCard({ className="lg:h-full" hidden={visibleTab !== "configuration"} > + {setup ?

{setup}
: null}
{ + if (driverKind === "antigravity") return; const normalized = normalizeCustomModelSlug(input); if (!normalized) { setError("Enter a model slug."); @@ -505,7 +506,7 @@ export function ProviderModelsSection({ })}
- {isAdding ? ( + {driverKind === "antigravity" ? null : isAdding ? (
)} - {error ?

{error}

: null} + {driverKind !== "antigravity" && error ? ( +

{error}

+ ) : null}
); } diff --git a/apps/web/src/components/settings/ProviderSettingsForm.test.ts b/apps/web/src/components/settings/ProviderSettingsForm.test.ts index af460824c171..7dc13fa4f25c 100644 --- a/apps/web/src/components/settings/ProviderSettingsForm.test.ts +++ b/apps/web/src/components/settings/ProviderSettingsForm.test.ts @@ -37,6 +37,29 @@ describe("ProviderSettingsForm helpers", () => { }); }); + it("derives a select control with its choices for the Antigravity sign-in method", () => { + const antigravity = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("antigravity")]; + expect(antigravity).toBeDefined(); + + const fields = deriveProviderSettingsFields(antigravity!); + expect(fields.map((field) => field.key)).toEqual([ + "authMethod", + "apiKey", + "gcpProject", + "gcpLocation", + "binaryPath", + ]); + const authMethod = fields.find((field) => field.key === "authMethod"); + expect(authMethod).toMatchObject({ control: "select", clearWhenEmpty: "omit" }); + expect(authMethod?.options?.map((option) => option.value)).toEqual([ + "oauth-personal", + "oauth-business", + "gemini-api-key", + "agent-platform", + ]); + expect(fields.find((field) => field.key === "apiKey")?.control).toBe("password"); + }); + it("shows the auto-compaction threshold for Claude providers", () => { const claude = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("claudeAgent")]; expect(claude).toBeDefined(); diff --git a/apps/web/src/components/settings/ProviderSettingsForm.tsx b/apps/web/src/components/settings/ProviderSettingsForm.tsx index 02f40fd6d5c9..c94b7da9d34f 100644 --- a/apps/web/src/components/settings/ProviderSettingsForm.tsx +++ b/apps/web/src/components/settings/ProviderSettingsForm.tsx @@ -6,12 +6,14 @@ import * as Schema from "effect/Schema"; import type { ProviderSettingsFormAnnotation, ProviderSettingsFormControl, + ProviderSettingsFormOption, ProviderSettingsFormSchemaAnnotation, } from "@t3tools/contracts"; import { cn } from "../../lib/utils"; import { DraftInput } from "../ui/draft-input"; import { Input } from "../ui/input"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; import { Switch } from "../ui/switch"; import { Textarea } from "../ui/textarea"; import type { ProviderClientDefinition } from "./providerDriverMeta"; @@ -24,6 +26,8 @@ export interface ProviderSettingsFieldModel { readonly placeholder?: string | undefined; readonly clearWhenEmpty: "omit" | "persist"; readonly defaultBooleanValue?: boolean | undefined; + /** Choices for a `select` control. The first entry is the default. */ + readonly options?: ReadonlyArray | undefined; } function titleizeFieldKey(key: string): string { @@ -106,6 +110,9 @@ export function deriveProviderSettingsFields( ...(formAnnotation.control === "switch" ? { defaultBooleanValue: readFieldBooleanDefault(fieldSchema) } : {}), + ...(formAnnotation.control === "select" && formAnnotation.options + ? { options: formAnnotation.options } + : {}), } satisfies ProviderSettingsFieldModel, ]; }); @@ -168,6 +175,48 @@ interface ProviderSettingsFormProps { readonly onChange: (nextConfig: Record | undefined) => void; } +/** Stores the default choice as an omitted key so unchanged configs stay small. */ +function ProviderSettingsSelect({ + field, + value, + inputId, + size, + className, + onChange, +}: { + readonly field: ProviderSettingsFieldModel; + readonly value: unknown; + readonly inputId: string; + readonly size: "sm" | "xs"; + readonly className?: string | undefined; + readonly onChange: ProviderSettingsFormProps["onChange"]; +}) { + const options = field.options ?? []; + const fallback = options[0]?.value ?? ""; + const current = readProviderConfigString(value, field.key) || fallback; + const label = options.find((option) => option.value === current)?.label ?? current; + return ( + + ); +} + function FieldFrame(props: { readonly variant: ProviderSettingsFormProps["variant"]; readonly children: ReactNode; @@ -229,6 +278,15 @@ function ProviderSettingsFieldRow({ aria-describedby={descriptionId} /> + ) : field.control === "select" ? ( + ) : field.control === "textarea" ? (